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.
- Design specification: .ai/specification/OPNSense-VM-Deployment-Design-Specification.md
- Full parameter reference: docs/Invoke-OPNSenseVMDeployment.md
- Function reference index: docs/README.md
- Screenshot walkthrough of a real run: docs/screenshots/README.md
- Sanitised transcript of a real run: samples/Invoke-OPNSenseVMDeployment-Sample.log
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 | RFC1918 → NOT 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 (RFC1918 → PARENT_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:
- Warns, naming the adapter it is about to bind.
- Waits
-NetworkInterruptionWarningDelaySeconds(default10) so an operator can interrupt it. Pass0for unattended runs. - Creates the switch with
-AllowManagementOS, so the host keeps its connectivity through it. - Waits up to
-ConnectivitySettleTimeoutSeconds(default90) forvEthernet (<switch>)to come up with a non-APIPA IPv4 address, logging how long it took. Pass0to 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
- Start the VM and open its console. It boots from the attached disc image.
- 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. - When it asks for the device, enter
da1- the 4 GB disk. - 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
- At the login prompt, log in as
installerwith passwordopnsense. That account belongs to the installation media, so the root password from the imported configuration does not apply to it. - Accept the default keymap.
- Choose Install (UFS). ZFS also works but buys nothing in a single-disk lab.
- Select
da0, the 32 GB disk, as the destination. - Confirm the destructive format. This is the point of no return for that disk - it is destroyed
and repartitioned. Check you are on
da0and notda1before confirming. - Accept the default swap size.
- 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.
- 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, pickda0, 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 hostnamepxe-server-001. - That address populates
PXE_SERVERS_001and becomesnext_server/tftp_server_namein every DHCP scope, so any network can netboot from it. - A floating rule permits
RFC1918to 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