gsadmin de49257c8d feat: Separate authorised resolvers from what they may forward towards
Modelled on the operator's production configuration, which was supplied as a
sample. Name resolution was one permit and one blanket deny, which said who may
be queried but had nothing to say about what a resolver is then allowed to do,
and pinned the appliance to whatever name server the machine generating the
document happened to be using.

Two aliases now carry the policy. The internal alias holds the resolvers a client
may query, which is the appliance itself by default, and the upstream alias holds
what an authorised resolver may forward towards. Eight rules follow, four per
transport, because each transport carries a different set of ports.

The deny inverts its destination rather than being a blanket deny placed after
the permits. That states the policy as it is actually meant, which is that name
resolution towards anything unauthorised is refused, and it stays true as the
alias is edited instead of depending upon the deny sitting at a later sequence
than every permit.

The rule permitting an authorised resolver to forward upstream changes nothing
while the internal alias holds only the appliance, because traffic the appliance
originates is not evaluated by an inbound rule. It is not redundant for that
reason. It is what makes the alias worth having: adding a real resolver to the
internal alias gives it both directions at once, reached by clients through one
rule and forwarding upstream through the other, with nothing further to write.

Nothing about name resolution is read from the machine that generates the
document any more. A resolver that machine uses is a fact about that machine
rather than about the network the appliance joins, and seeding it wrote a site
specific address into every document produced. The upstream alias defaults to
public resolvers and is overridable, and the host inspection that remains is only
for the networks it is attached to, which the parent network selection excludes.

The appliance resolves through its own resolver, which is what allows a local
record to be defined and answered authoritatively for the networks behind it, and
takes its upstream from the wide area network lease. Three settings carry that:
no static name server address is written, the lease is permitted to populate the
system list, and the local host is preferred so the appliance asks itself first.

The resolver is also switched to forwarding. Left recursing it would query the
root servers directly and the upstream the joined network expects would never be
consulted, so the appliance would resolve correctly while ignoring its own
network's resolver, which is not what taking the upstream from the lease is meant
to mean. The local zone stays transparent, so a local record is still answered
before anything is forwarded.

Verified that the document carries the eight rules in the intended order with the
deny inverted, that the aliases hold the appliance address on every planned
network and the public upstreams, that an explicit upstream list is honoured, that
no address belonging to this host appears anywhere in the output, and that every
alias named by any rule still resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 12:29:42 -04:00

Invoke-OPNSenseVMDeployment

Idempotently deploys a fully preconfigured OPNsense virtual firewall into a Hyper-V lab.

One execution installs the hypervisor if it is missing, plans the lab addressing, downloads and verifies the installation media, generates a complete OPNsense config.xml, hands that document to the appliance on a second virtual hard disk, builds the virtual switches and the virtual machine, and starts it. Every stage checks the current state first, so running it again changes only what still needs to change.


Quick start

# Deploy with every default. The script elevates itself when it is not already running elevated.
powershell.exe -ExecutionPolicy Bypass -NoProfile -File .\Invoke-OPNSenseVMDeployment.ps1

# Produce only the configuration document, for import into another appliance or hypervisor.
powershell.exe -ExecutionPolicy Bypass -NoProfile -File .\Invoke-OPNSenseVMDeployment.ps1 -SkipHypervisorConfiguration

# Repeatable addressing. The same parent network every time, on any host.
powershell.exe -ExecutionPolicy Bypass -NoProfile -File .\Invoke-OPNSenseVMDeployment.ps1 -ParentNetwork "10.130.240.0/20"

# Eight networks instead of sixteen, picked at random out of the 172.16.0.0/12 base network.
powershell.exe -ExecutionPolicy Bypass -NoProfile -File .\Invoke-OPNSenseVMDeployment.ps1 -BaseNetwork "172.16.0.0/12" -ParentNetworkMaskLength 21

# Remove everything a previous execution created, including the cached disc image.
powershell.exe -ExecutionPolicy Bypass -NoProfile -File .\Invoke-OPNSenseVMDeployment.ps1 -RemoveExistingDeployment -RemoveCachedMedia

Execution flow

flowchart TD
    A["Start Invoke-OPNSenseVMDeployment.ps1"] --> B["Elevate if required"]
    B --> C["Load Toolkit.ps1 - logging, transcript, error handling"]
    C --> D["Save-ToolkitModule - Indented.Net.IP and Indented.Net.Dns"]
    D --> E{"RemoveExistingDeployment?"}
    E -- Yes --> E1["Remove-OPNSenseDeployment - marker scoped teardown"]
    E1 --> Z["Exit 0"]
    E -- No --> F{"SkipHypervisorConfiguration?"}
    F -- No --> G["Install-HyperVPlatform and Test-PendingReboot"]
    G --> H{"Hypervisor usable?"}
    H -- No --> H1["Report reason and exit 3010 - restart required"]
    H -- Yes --> I["Get-HostUpstreamDNSConfiguration"]
    F -- Yes --> I
    I --> J["New-OPNSenseNetworkPlan - resolve parent network, count the VLAN tags"]
    J --> K["New-RandomPassword and New-OPNSensePasswordHash"]
    K --> L["New-OPNSenseConfigurationDocument - interfaces, VLANs, Kea, Unbound, aliases, rules"]
    L --> M["Save-OPNSenseConfigurationDocument - UTF-8 without BOM"]
    M --> N{"SkipHypervisorConfiguration?"}
    N -- Yes --> Y["Report summary"]
    N -- No --> O["Get-OPNSenseInstallationMedia - prefer cached ISO, verify checksum"]
    O --> P["Get-HyperVStorageLocation"]
    P --> Q["New-OPNSenseConfigurationDisk - FAT32 VHDX holding conf/config.xml"]
    Q --> R["Initialize-OPNSenseVirtualSwitch - WAN external, LAN internal, rename host adapters"]
    R --> S["New-OPNSenseVirtualMachine - generation 2, secure boot off, trunk"]
    S --> T["Start the virtual machine"]
    T --> Y
    Y --> Z

What gets built

Addressing

A parent network is divided into /24 networks. The first is the untagged one that the appliance uses as its lan interface; each of the rest gets a VLAN tag counted from 2 upwards.

You get the parent network one of two ways:

What happens
Default - nothing specified A /20 is picked at random out of -BaseNetwork, skipping anything the host is already attached to. The summary prints the exact -ParentNetwork value to reuse
-ParentNetwork 10.130.240.0/20 That network is used verbatim. Same input, same plan, every time, on any host
Parent Network: 10.147.208.0/20 [16 network(s)] [Selected at random]
Reproduce This Plan: Specify -ParentNetwork 10.147.208.0/20 to produce this exact addressing again.

Copy that line and you have a repeatable build. Nothing to look up or calculate.

-BaseNetwork is one of the three RFC 1918 ranges (10.0.0.0/8 default, 172.16.0.0/12, 192.168.0.0/16) and only bounds the random pick - an explicit -ParentNetwork is not required to sit inside it.

Two mask lengths decide how many networks you get. The parent is divided into as many subnets as fit inside it:

Parameter Default What it is
Parent -ParentNetworkMaskLength 20 How big the whole lab range is
Subnet -ParentNetworkSubnetMaskLength 24 How big each network inside it is
Parent Subnet Networks
/20 /24 (default) 16
/20 /23 8
/20 /22 4
/19 /24 32
/21 /24 8

-ParentNetworkSubnetMaskLength always applies. It describes how the parent is divided, not how the parent is chosen, so it works the same whether you passed -ParentNetwork or let the script pick one. Only -ParentNetworkMaskLength is ignored when you pass an explicit -ParentNetwork, because that value carries its own mask length.

The only rule is that the subnet mask length has to be longer than the parent mask length. Networks beyond the 16 named roles fall back to Network_Zone_NN.

VLAN tags

Tags are counted, not derived from the addressing:

Parameter Default What it does
-StartingVlanId 2 Tag of the first tagged network
-VlanIdIncrement 1 How much each subsequent tag advances by

The default of 2 leaves tag 1 alone, since switches commonly use it as their own default VLAN and colliding with that is awkward to diagnose. A larger increment leaves gaps, which is handy if you want tags to carry meaning - -VlanIdIncrement 10 gives you 2, 12, 22, 32 ….

Counting rather than deriving means the tags stay distinct no matter how the parent is divided, so any subnet mask length works. The plan refuses up front if the range would run past 4094.

Interface VLAN Network Gateway DHCP pool Name
wan untagged DHCP from upstream - - WAN
lan untagged 10.218.176.0/24 10.218.176.1 .2 - .254 LAN
opt1 2 10.218.177.0/24 10.218.177.1 .2 - .254 2_Management
opt2 3 10.218.178.0/24 10.218.178.1 .2 - .254 3_Infrastructure
opt3 4 10.218.179.0/24 10.218.179.1 .2 - .254 4_DMZ
opt4 5 10.218.180.0/24 10.218.180.1 .2 - .254 5_VPN
opt5 6 10.218.181.0/24 10.218.181.1 .2 - .254 6_Guest
opt6 - opt10 7 - 11 ... ... ... 7_Servers_Zone_001 through 11_Servers_Zone_005
opt11 - opt15 12 - 16 ... ... ... 12_Clients_Zone_001 through 16_Clients_Zone_005

The DHCP pool of each network spans every usable address it contains - 253 of them in a /24. Only the network address, the broadcast address, and the address of the firewall itself are excluded. When the firewall does not sit at the first host address, the pool is emitted as two ranges either side of it. Narrow a pool with -DHCPPoolStartHostOffset and -DHCPPoolEndHostOffset if you want room for statics outside of DHCP.

The five server zones and five client zones are paired by index, so Clients_Zone_003 is granted access to Servers_Zone_003. Change the layout with -NetworkRoleList; any role named Servers_Zone_<nnn> or Clients_Zone_<nnn> is paired automatically, and any role beginning with Management becomes the management network.

Firewall aliases

Alias Type Contents
RFC1918 network 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
CGNAT network 100.64.0.0/10
PARENT_NETWORK_001 network The parent network the lab was subnetted out of, e.g. 10.130.240.0/20
AUTHORIZED_DNS_SERVERS_INTERNAL host The address of the firewall on every planned network. Add your own resolver here to authorise it
AUTHORIZED_DNS_SERVERS_UPSTREAM host What an authorised resolver may forward to. 1.1.1.1, 1.0.0.1 by default
AUTHORIZED_DNS_PORTS_TCP port 53, 853
AUTHORIZED_DNS_PORTS_UDP port 53, 853, 5353
BASTION_SERVERS_001 host The administrative jump hosts. Set with -BastionServerAddressList
BASTION_PORTS_001 port 22, 80, 443, 3389, 5985, 5986
PXE_SERVERS_001 host The reserved address of the PXE server (§ below)
INBOUND_<speed> ×10 network Empty. Add an address or CIDR to shape its inbound traffic
OUTBOUND_<speed> ×10 network Empty. Add an address or CIDR to shape its outbound traffic

Firewall policy

Every rule is a quick rule, so the lowest sequence value that matches wins and the ordering is the policy. The rules that would otherwise be identical on every interface are floating - one rule applied to all interfaces rather than sixteen copies.

Sequence Scope Action Rule
10 floating pass The management network reaches every destination
20 floating pass BASTION_SERVERS_001 reaches everything over BASTION_PORTS_001
30 floating pass RFC1918 reaches PXE_SERVERS_001
40 WAN pass RFC1918 reaches PARENT_NETWORK_001, so a private source upstream can reach the lab (§ Reaching the lab from the host)
100-101 floating pass AUTHORIZED_DNS_SERVERS_INTERNAL may forward to AUTHORIZED_DNS_SERVERS_UPSTREAM
110-111 floating pass RFC1918 may resolve against AUTHORIZED_DNS_SERVERS_INTERNAL
120-121 floating pass RFC1918 may resolve against (self), the firewall's own resolver
200-201 floating block RFC1918 to NOT AUTHORIZED_DNS_SERVERS_INTERNAL - leak prevention
300+ per interface pass Each client zone reaches the server zone carrying the same index
500-519 floating pass One pair per shaping profile, each through its own pipe
900+ per interface pass Everything else originating from that network

That is 28 rules on a 16 network plan rather than the 87 the same policy took when every rule was bound to an interface. Descriptions are generic, so a rule reads the same wherever it applies.

The 900 series is what makes the lab permissive by default. Supply -EnableZoneIsolation to omit it, and the management, jump host, name resolution, and zone pairing rules become the entire policy.

Name resolution

Two aliases, one for resolvers clients may use and one for what those resolvers may forward to:

clients ──► AUTHORIZED_DNS_SERVERS_INTERNAL ──► AUTHORIZED_DNS_SERVERS_UPSTREAM
            (the firewall, by default)          (1.1.1.1, 1.0.0.1 by default)
Seq Action Rule
100-101 pass An authorised resolver may forward upstream
110-111 pass Anything in RFC1918 may query an authorised resolver
120-121 pass Anything in RFC1918 may query (self)
200-201 block RFC1918NOT an authorised resolver

The block uses an inverted destination rather than a blanket deny sitting after the permits. It states the policy directly - DNS to anything that is not authorised is refused - and stays true as you edit the alias, instead of depending on sequence ordering.

To authorise your own resolver, add its address to AUTHORIZED_DNS_SERVERS_INTERNAL. Rules 110/111 then let clients reach it and 100/101 already let it forward upstream - no new rules needed.

How the firewall itself resolves

system/dnsserver empty - nothing is baked in
dnsallowoverride 1 - the WAN lease supplies the upstream
dnslocalhost 1 - the firewall asks its own resolver first
Unbound enabled, forwarding mode, local_zone_type: transparent

The firewall resolves through Unbound, so you can define local records and have them answered authoritatively; anything Unbound can't answer is forwarded to whatever the WAN lease handed over. Forwarding mode is what makes that lease matter - left recursing, Unbound would query the root servers directly and the upstream your network expects would never be consulted.

Nothing is read from the machine that generates the config. A resolver that host happens to use is a fact about that host, not about the network the appliance lands on, so seeding it would bake a site-specific address into every deployment. Override with -UpstreamDNSServerList if you want specific upstreams in the alias.

Reaching the lab from the host

The LAN switch type decides this, and the shipped default makes it a non-issue. Two topologies get you to the same place:

LAN switch Host adapter behind the firewall How the host reaches the lab
Internal (default) Yes - a vEthernet adapter Directly. Nothing to do
Private No One static route towards the appliance's WAN address

Pass -LANSwitchType Private to build the second one. An existing switch is reused as-is either way, because changing a switch's type would detach everything already connected to it.

Why anyone would want private: the internal switch's host adapter takes a lease from Kea on the parent network, and that lease carries a default gateway. The host then holds two, and which wins is a matter of interface metrics rather than intent. A private switch keeps the host out of the lab entirely, and the single route is the only way in.

The private-switch route

route add 10.159.32.0 mask 255.255.240.0 <appliance WAN address> metric 9999

The exact command, with the supernet and mask of the block that was selected, is printed in the deployment summary. One route covers the whole /20, so every network in the plan is reachable and no future one needs adding. The high metric makes it the lowest-priority claim on that prefix; being more specific than the default route is what gets it chosen, so the metric only arbitrates against something else claiming the same /20.

The route alone will not get you in. It only decides where the host sends the packet - three things on the appliance decide whether it survives arrival, and all three are configured for you:

What Why it is needed
blockpriv is disabled on WAN - this allows traffic to ingress from the host The private-network block discards any packet whose source is in the private ranges, and it runs ahead of the rule set. Your host is on the upstream network, so it has a private address itself - with the block on, every packet it sent here would be discarded on arrival and no rule could save it, because the packet would never reach the rules. Disabling the block is what lets those packets in to be evaluated
Rule 40 is enabled Getting past the block only gets the packet to the rule set. Rule 40 (RFC1918PARENT_NETWORK_001) is what actually admits it. Without it the packet arrives and is dropped
reply-to is disabled on rule 40 OPNsense adds reply-to automatically to any rule naming an interface and a direction, which forces replies to the interface's gateway instead of following the routing table. Since your host is on the same segment as WAN, that would send every reply out to the upstream router just for it to turn around and deliver it back to the segment it came from. Disabled, replies go straight back

blockbogons stays on - the bogon list does not include the private ranges, so it costs nothing here.

Worth knowing: rule 40 is enabled in every deployment, not just the private-switch one, so any private source on the upstream network can reach the lab over WAN. Its destination is PARENT_NETWORK_001 rather than all of RFC1918, so it is scoped to this lab's own supernet and does not additionally permit whatever else the appliance happens to route towards. For a lab behind your own router that is the intent - it is what makes the appliance reachable for management. If you ever put this somewhere the upstream network is not trusted, that rule is the first thing to disable.

So the route is the only step left for you to do. The address cannot be printed ahead of time, because WAN is configured for DHCP and holds no lease until the appliance boots. Read it from the console banner (WAN (hn1) -> v4/DHCP4 -> …) and substitute it.

Note that on a private LAN switch the web interface address printed in the summary is also unreachable until the route is in place, since it is the firewall's address on the parent network.

When the route cannot work

The next hop has to be a single hop away, which holds when the WAN switch is external and the management OS still shares the adapter - the host and the appliance then lease from the same upstream network. That is the ordinary arrangement, because an external WAN switch is what gives the machines behind the firewall a path to the internet in the first place.

If the LAN switch is private and that is not true, the summary says so explicitly rather than printing a command that would fail on an unreachable gateway.

Traffic shaping

Shaping is applied at layer 3 on the firewall rather than at the hypervisor switch. Two things follow from that, both of them intended:

  • Traffic between two hosts on the same VLAN is switched and never reaches the firewall, so it cannot be shaped. This matches how a real network behaves - a switch only slows traffic when it is overwhelmed.
  • Traffic between VLANs and traffic to the internet is routed, so it can be shaped with full layer 3 context: source, destination, port, protocol.

The point is to imitate constrained links between networks behind the router, not the WAN uplink itself - one router standing in for what would otherwise be several. That is what lets you say this client reaching that server is slow, while those clients reaching that other server are fast.

Where the shaping is attached

Not to an interface. OPNsense has two ways to shape, and this uses the second:

Mechanism Matches on Aliases?
Shaper → Rules (ipfw) interface + a literal address No - NetworkField takes an address or any
Firewall → Rules shaper1/shaper2 anything a firewall rule can match Yes - NetworkAliasField

A pipe is just a bandwidth bucket - it has no interface and no notion of who it applies to. A firewall rule decides what goes into the bucket. Because the rules here are floating, they are not tied to an interface either: any routed traffic matching the alias is shaped, whichever VLAN it entered or left by. Shaper → Rules is left empty on purpose.

The profiles

Ten scenarios, each a matched set of two pipes, two aliases and two rules, named so the intent is obvious in an alphabetical alias list:

512KBPS   1MBPS   5MBPS   10MBPS   25MBPS   45MBPS   100MBPS   250MBPS   500MBPS   1000MBPS

giving INBOUND_45MBPS / OUTBOUND_45MBPS and so on - 20 pipes, 20 aliases, 20 rules, all enabled.

To impose a scenario, add an address or a CIDR to the matching alias - Firewall → Aliases → INBOUND_45MBPS. Nothing else needs touching. Every alias starts empty, which produces an empty table that matches nothing, so the rules are live but inert until then.

Two rules per profile rather than one, because a pipe carries traffic in one direction only:

Rule Direction Matches Shapes
even (500, 502, …) in source in OUTBOUND_<speed> What the host sends
odd (501, 503, …) out destination in INBOUND_<speed> What the host receives

So a host in OUTBOUND_5MBPS and INBOUND_45MBPS gets an asymmetric 45/5 link. Put it in one alias only and it is shaped one way.

Each pipe is a shared bucket

No mask is applied, so every member of a profile contends for the same bandwidth - which is what a link is.

This matters more than it looks. A mask does not subdivide a pipe; it instantiates a separate copy of it for every distinct value of the masked field:

1 host 5 hosts Models
mask=none (used here) 20 Mbit 20 Mbit shared A link everyone sits behind
mask=src-ip 20 Mbit 100 Mbit aggregate Each host on its own 20 Mbit link

Masking by address would make a 20 Mbit profile carry 100 Mbit across five members, and the "constrained" link would get faster the more hosts you put behind it.

A consequence worth knowing: everything in one profile shares one bucket, so two independent slow paths need two different profiles. Ten profiles give ten independent buckets per direction.

An address should appear in at most one inbound and one outbound alias. The rules are quick and the profiles are ordered slowest first, so if an address is in two, the slower one wins.

The pipes use the default FIFO queue rather than an active queue discipline. One greedy flow can therefore fill the queue and starve the others, which is bufferbloat - and is a fair imitation of a genuinely poor link. Set a pipe's scheduler to fq_codel in the UI if you would rather the members of a profile share the link fairly.

-SkipTrafficShaping omits the pipes, the aliases and the rules entirely.

Virtual machine

Setting Value
Generation 2, with secure boot disabled, which FreeBSD requires
Processors 25 percent of the host logical processors, minimum 2, maximum 8
Memory 4 GB static, above the live image's ~3 GB floor. -EnableDynamicMemory switches to 2 - 8 GB dynamic
Disks <VMName>-Boot.vhdx at 32 GB (da0), and <VMName>-Data.vhdx at 4 GB (da1) holding the configuration. Both dynamically expanding
Adapter 1 LAN, internal switch, trunk carrying VLANs 1-4094, MAC address spoofing on
Adapter 2 WAN, external switch, untagged, DHCP
Boot order Boot disk, disc drive, then the LAN adapter. The firmware falls through an empty disk to the installer, then boots the installed system by itself

The LAN adapter is added first so the appliance enumerates it as hn0, matching the generated configuration. The trunk carries the whole VLAN range by default so a downstream lab virtual machine can be placed onto any tag without reconfiguring the trunk first.

External switch selection and the network interruption

An existing external switch is always reused. When one has to be created, the physical adapter is chosen automatically out of the adapters that are operational, report an ethernet or wireless media type, and are not already bound to another virtual switch:

Rank Adapter
0 Ethernet carrying a default gateway
1 Ethernet
2 Wireless carrying a default gateway
3 Wireless

Ties break on link speed, then interface index. Ethernet is always preferred over wireless, and a default gateway is preferred within each class because that adapter is the one providing upstream connectivity today. Override the choice with -WANNetAdapterName.

Binding a physical adapter to an external switch interrupts host connectivity for several seconds, because its protocol bindings are torn down and recreated. A remote session running over that adapter can drop. A wireless adapter is attached through a bridge, so its interruption is usually longer.

The script therefore:

  1. Warns, naming the adapter it is about to bind.
  2. Waits -NetworkInterruptionWarningDelaySeconds (default 10) so an operator can interrupt it. Pass 0 for unattended runs.
  3. Creates the switch with -AllowManagementOS, so the host keeps its connectivity through it.
  4. Waits up to -ConnectivitySettleTimeoutSeconds (default 90) for vEthernet (<switch>) to come up with a non-APIPA IPv4 address, logging how long it took. Pass 0 to skip the wait. If it times out the deployment continues and a warning is logged rather than failing outright.

Embedded teaming is only used on a server operating system, only across ethernet adapters, and never when the selected adapter is wireless.


After the deployment

The rest is interactive by design - OPNsense will not import a configuration or install itself unattended. Connect to the VM console in Hyper-V Manager and work through the two phases below.

Identifying the disks

The VM is built with two disks, sized so they are impossible to confuse in the installer:

Device Size Purpose
da0 32 GB The boot disk. This is the install target.
da1 4 GB The configuration disk holding \conf\config.xml. Never install onto this.

Both are dynamically expanding, so neither consumes its declared size on the host. Adjust with -SystemDiskSizeBytes and -ConfigurationDiskSizeBytes.

Phase 1 - import the configuration

  1. Start the VM and open its console. It boots from the attached disc image.
  2. Early in the boot, watch for Press any key to start the configuration importer. Press a key. This window is short - if it passes, reset the VM and try again.
  3. When it asks for the device, enter da1 - the 4 GB disk.
  4. Boot continues into the live environment, already running the generated configuration. Interfaces, VLANs, DHCP scopes and firewall rules are all live at this point, from memory.

You now have a working throwaway router before anything has been installed, which is worth using to sanity-check the addressing and rules before committing them to disk.

Phase 2 - install to disk

  1. At the login prompt, log in as installer with password opnsense. That account belongs to the installation media, so the root password from the imported configuration does not apply to it.
  2. Accept the default keymap.
  3. Choose Install (UFS). ZFS also works but buys nothing in a single-disk lab.
  4. Select da0, the 32 GB disk, as the destination.
  5. Confirm the destructive format. This is the point of no return for that disk - it is destroyed and repartitioned. Check you are on da0 and not da1 before confirming.
  6. Accept the default swap size.
  7. When the installer offers to set a root password at the end, skip it to keep the password from the imported configuration. Setting one here overwrites it.
  8. Choose the option to reboot when the install completes.

The installed system comes back up running the same configuration, now persistent, and the web interface answers on the LAN gateway address printed in the deployment summary.

Each of these steps is captured in the screenshot walkthrough, taken during a real deployment.

The disc image can stay attached. The boot order is boot disk first, disc drive second, and UEFI firmware falls through a disk that carries no boot loader - so the installer boots while the disk is empty, and the installed system takes precedence by itself from then on. Detach the media only if you want to be certain:

Set-VMDvdDrive -VMName 'vFW-HQ-00001' -Path $null

Exact menu wording varies between OPNsense releases. The sequence - import, log in as installer, pick UFS, pick da0, confirm the destroy, reboot - is stable across them.

The credentials are printed in the deployment summary. The stock root / opnsense password is retained by default, since it is typed at a console and the appliance prompts to change it anyway. Supply -RootPassword for a specific one, or -GenerateRootPassword for a random one.

Memory

The VM defaults to 4 GB. The live image runs entirely from memory before it is installed and refuses to start with less than roughly 3 GB available, so a smaller allocation fails during phase 1 rather than later. Override with -MemoryStartupBytes once installed, if you want to reclaim some.

DHCP

Kea is the DHCP server. It ships in the OPNsense base system and is the current default; ISC DHCP was moved out to a plugin and is not present on a fresh install, so there is nothing to disable there. dnsmasq is present and a stock installation hands it the address pool, which matters because two services cannot bind the same DHCP socket - whichever loses the race is the one that fails to start. The generated configuration therefore leaves dnsmasq with no DHCP interfaces and emits no legacy dhcpd section at all, so Kea owns DHCP outright.

Kea is also given service_sockets_max_retries and service_sockets_retry_wait_time (the UI calls these Socket retries and Socket retry wait time, on the same page as but distinct from Affinity lifetime and Reclamation delay, which belong to lease expiration and are left alone). A VLAN interface is frequently not ready at the instant the service starts, and without a bounded retry the service start can sit there trying to bind.

The socket type stays raw, which is what OPNsense ships. udp has a documented weakness where a client that holds no address yet can fail to obtain a lease - precisely the PXE case - so it is available through -DHCPSocketType udp but is not the default.

PXE

The hypervisor records the next dynamic MAC address it will hand out. That address is read from the registry and used to build a Kea reservation before the VM that will carry it exists, so the PXE server has a predictable address from the start:

  • The reservation lands on the network whose role is Infrastructure (-PXEServerNetworkRole) at host offset .10 (-PXEServerHostOffset), with hostname pxe-server-001.
  • That address populates PXE_SERVERS_001 and becomes next_server / tftp_server_name in every DHCP scope, so any network can netboot from it.
  • A floating rule permits RFC1918 to reach it.

Build the PXE server VM next and it takes that MAC, hence that address. It is a prediction rather than a guarantee - the hypervisor issues that MAC to whichever VM asks first - so confirm the VM's adapter MAC matches the reservation once it exists. -SkipPXEConfiguration omits all of it.


Repository layout

Path Contents
Invoke-OPNSenseVMDeployment.ps1 The entry point
Toolkit/Toolkit.ps1 Dot-sourced bootstrap: logging, transcript, error handling, loaders
Toolkit/Functions/ One function per file, loaded automatically by the toolkit
Toolkit/Libraries/ BCrypt.Net-Next, used to generate the appliance password hash
Toolkit/Modules/ Save-Module target for Indented.Net.IP and Indented.Net.Dns
Toolkit/Tools/X64/ 7z.exe, required because the bundled tar.exe cannot read a raw bzip2 stream
ISOs/ Installation media, downloaded here by default. Git-ignored
Configurations/ Generated OPNsense configuration documents. Git-ignored, created on first run
Logs/ Transcripts, written here by the toolkit. Git-ignored, created on first run
samples/ A sanitised transcript of a real run, for reference
docs/ Generated reference documentation
.ai/rules/ Authoring standards
.ai/specification/ Design specification

Function reference

Function Purpose
Save-ToolkitModule Idempotently saves the required modules into the toolkit
Install-HyperVPlatform Detects and installs Hyper-V, reporting whether a restart is required
Test-PendingReboot Reports the operating system restart markers
Expand-CompressedFile Expands an archive through the first available provider
Get-OPNSenseInstallationMedia Resolves, caches, verifies and expands the disc image
Get-HyperVStorageLocation Determines where virtual machines and disks are stored
Get-HostUpstreamDNSConfiguration Determines the upstream resolvers and the networks the host already uses
New-RandomPassword Generates a password from a secure random number generator
New-OPNSensePasswordHash Produces the blowfish crypt hash the appliance stores
New-OPNSenseNetworkPlan Resolves the parent network and plans the networks, tags, names and pools
New-OPNSenseConfigurationDocument Builds the configuration document
Save-OPNSenseConfigurationDocument Writes the document without a byte order mark
New-OPNSenseConfigurationDisk Builds the FAT32 disk that carries the document
Initialize-OPNSenseVirtualSwitch Resolves or creates the virtual switches
New-OPNSenseVirtualMachine Creates and reconciles the virtual machine
Remove-OPNSenseDeployment Removes everything a previous execution created
Start-ProcessWithOutput Executes a process and captures its output in memory

Idempotency and removal

Every object this script creates records a marker in its notes property:

Created by Invoke-OPNSenseVMDeployment [Created: yyyy/MM/dd HH:mm:ss UTC]

-RemoveExistingDeployment powers off and removes the virtual machine, deletes its virtual hard disks and the generated document, and removes the virtual switches only when they carry that marker. A switch an operator created by hand is always left alone, and the hypervisor itself is never uninstalled.


Exit codes

Code Meaning
0 The deployment completed successfully
3010 A restart is required before the deployment can continue
2000 - 2999 An error occurred. The specific value is allocated by the toolkit error handler
6000 The toolkit failed to load

Requirements

  • Windows 10 or 11, or Windows Server, with hardware virtualisation available
  • PowerShell 5.1 or 7.x, running elevated. The script relaunches itself elevated when it is not
  • Internet access on the first execution, to acquire the modules and the disc image
S
Description
Automatically and idepotently deploys a OPNSense virtual machine in Hyper-V and places the internal/private switch into trunk mode.
Readme 11 MiB
Languages
PowerShell 100%