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.
tools/opnsense-import.py config.xml --map lan=eth0,wan=eth1 --out ./outOptions#
| Option | Default | Meaning |
|---|---|---|
config | (required) | The config.xml to read. |
--map | — | lan=eth0,wan=eth1, or by device: igc0=eth0. Without it, ports are taken in the order the source router lists them. |
--hostname | from the file | Override the host name. |
--out | stdout | A directory for config.commands and report.md. |
--answers | — | Also write a Wheelhouse answer file here. |
--no-firewall | off | Skip the firewall entirely. |
--no-dhcp | off | Skip the DHCP server. |
--no-dns | off | Skip DNS forwarding. |
--no-qos | off | Skip traffic shaping. |
--check-against FILE | — | Compare 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:
--map lan=eth0,wan=eth1
--map igc0=eth0,igc1=eth1Get 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:
| Section | What comes across |
|---|---|
| Interfaces | Each enabled interface's device mapping and address. |
| System | Host name, domain, resolvers, time zone. SSH, when the source had it. |
| Routes | Static routes, with each gateway name resolved to its next hop. |
| NAT | Outbound masquerade for the LAN out of the WAN, and every enabled port forward, with host and port aliases resolved. |
| Firewall | A default-drop input and forward posture that admits exactly what those forwards need, the LAN outbound, and an IPv6 mirror of the same posture. |
| DHCP | The server, its pool, its options, the PXE handoff, and every reservation. |
| DNS | Forwarding, with its static host entries. |
| QoS | The WAN egress shaper. |
What it will not translate#
The report's third section names each one with a reason. The recurring categories:
| Not translated | Why |
|---|---|
| Local user accounts | They do not carry across; create them on the new box. |
| Enabled FreeBSD-only plugins | Captive 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 equivalent | Suricata, WireGuard, OpenVPN, IPsec, dynamic DNS and Tailscale are each named with where they live here instead. |
| Virtual IPs | Add them as extra addresses on the interface if you still need them. |
| NAT reflection | Named per forward. |
| Per-interface firewall rules the posture does not cover | Named with the interface and the rule's description. |
| Disabled anything | Skipped as disabled on the router. |
| Outbound NAT in a mode other than masquerade | Named with the mode. |
| A port forward with no destination port | There 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:
# 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/livemissing: 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| Line | Meaning |
|---|---|
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:
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/liveWhat it is not#
See also#
wheelhouse-seed.py— putting the result on a seed.- Answer-file keys — what
--answerswrites. - The commands Wheelhouse runs
- Tools
- Migrate from OPNsense
- Read the report
- Verify a migration
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".