Wheelhouse docs

opnsense-import.py#

Reads an OPNsense config.xml and writes two files: config.commands, a file of set lines for the router, and report.md, which says what came across, what came across differently, and what did not come across at all. Nothing is dropped silently.

It ships on the router as /usr/bin/wheelhouse-opnsense-import, so a migration can be finished on the box itself and checked long afterwards.

bash
tools/opnsense-import.py config.xml --map lan=eth0,wan=eth1 --out ./out

Options#

OptionDefaultMeaning
config(required)The config.xml to read.
--maplan=eth0,wan=eth1, or by device: igc0=eth0. Without it, ports are taken in the order the source router lists them.
--hostnamefrom the fileOverride the host name.
--outstdoutA directory for config.commands and report.md.
--answersAlso write a Wheelhouse answer file here.
--no-firewalloffSkip the firewall entirely.
--no-dhcpoffSkip the DHCP server.
--no-dnsoffSkip DNS forwarding.
--no-qosoffSkip traffic shaping.
--check-against FILECompare a file of live set lines with what this import expects, and report the difference. Exits non-zero if anything is missing.

A file whose root element is not opnsense is refused by name, as is one that is not valid XML.

--map is the option that matters#

FreeBSD calls a port igc0; Linux calls it eth0; nothing in the configuration file says which cable is in which socket. Map it either by role or by device:

bash
--map lan=eth0,wan=eth1
--map igc0=eth0,igc1=eth1

Get it wrong and the new router puts the LAN address on the uplink.

What it translates#

In this order, because several sections need the LAN address:

SectionWhat comes across
InterfacesEach enabled interface's device mapping and address.
SystemHost name, domain, resolvers, time zone. SSH, when the source had it.
RoutesStatic routes, with each gateway name resolved to its next hop.
NATOutbound masquerade for the LAN out of the WAN, and every enabled port forward, with host and port aliases resolved.
FirewallA default-drop input and forward posture that admits exactly what those forwards need, the LAN outbound, and an IPv6 mirror of the same posture.
DHCPThe server, its pool, its options, the PXE handoff, and every reservation.
DNSForwarding, with its static host entries.
QoSThe WAN egress shaper.

What it will not translate#

The report's third section names each one with a reason. The recurring categories:

Not translatedWhy
Local user accountsThey do not carry across; create them on the new box.
Enabled FreeBSD-only pluginsCaptive portal, the caching proxy, ntopng, monit, the UniFi helper, and the routing daemons — the last of which the base already has as FRR.
Plugins with a native equivalentSuricata, WireGuard, OpenVPN, IPsec, dynamic DNS and Tailscale are each named with where they live here instead.
Virtual IPsAdd them as extra addresses on the interface if you still need them.
NAT reflectionNamed per forward.
Per-interface firewall rules the posture does not coverNamed with the interface and the rule's description.
Disabled anythingSkipped as disabled on the router.
Outbound NAT in a mode other than masqueradeNamed with the mode.
A port forward with no destination portThere is nothing to match on.

The report also has a middle section — translated differently — for things that came across with a change worth knowing about, such as an interface whose device name moved, or the router's own IPv6 rules.

--answers#

Writes an answer file alongside, so the import can drive an unattended install directly. The two passwords are left empty and must be filled in before use.

Its FIREWALL line is decided by what the import produced: when the commands carry a default-drop input filter, the answer is FIREWALL=no, because the imported configuration is the thing deciding what the firewall is. When no ruleset came across — no LAN mapped, or --no-firewall — the answer is FIREWALL=yes, so the installer's own default-deny is written rather than leaving the new box open. Each is written with a comment saying which case it is.

LAN_DHCP=no and NAT=no are always written, because the imported configuration brings its own.

--check-against#

The verification half, and the reason to keep the tool after the migration:

bash
# On the router, over SSH — show configuration commands is a shell function of the
# operational CLI, so it needs the wrapper:
/opt/vyatta/bin/vyatta-op-cmd-wrapper show configuration commands > /tmp/live

wheelhouse-opnsense-import config.xml --map lan=eth0,wan=eth1 --check-against /tmp/live
missing: set nat destination rule 10 translation address 192.0.2.10
also set: set service dhcp-server shared-network-name LAN subnet 192.0.2.0/24 lease 3600
42 expected, 1 missing, 1 set beyond the import in the areas it covers
LineMeaning
missing:The import expects it; the router does not have it.
also set:The router has it, the import does not — and it touches an area the import has an opinion about.

Two deliberate narrowings keep the output readable:

  • Quoting is ignored. A router quotes the values it prints; the commands file quotes only what has to be quoted. The comparison is on words, not punctuation.
  • Extras are only reported in areas the import covers, and the settings the router writes for itself on every interface — offload, hw-id, mtu, duplex, speed — are excluded as noise.

Exit 1 when anything is missing, 0 otherwise, which makes it a reasonable thing to run from a cron job after a migration until you trust it.

On a seeded machine#

wheelhouse-seed.py --include-source puts the original config.xml on the seed as opnsense-config.xml, so the installed machine can check itself long after the migration with nothing else to fetch:

bash
sudo mount /dev/sr1 /mnt
/opt/vyatta/bin/vyatta-op-cmd-wrapper show configuration commands > /tmp/live
sudo wheelhouse-opnsense-import /mnt/opnsense-config.xml --map lan=eth1,wan=eth0 \
    --hostname "$(hostname)" --check-against /tmp/live

What it is not#

See also#

Checked against#

tools/opnsense-import.py (main, Importer.run, Report, do_interfaces, do_system, do_routes, do_nat, do_firewall, do_firewall_v6, do_dhcp, do_dns, do_qos, do_unsupported, parse_map), tools/tests/test_opnsense_import.py, tools/wheelhouse-seed.py, docs/unattended-install.md "Coming from OPNsense".

Updated 2026-09-02 tools opnsense migration import