Wheelhouse docs

Turn a config.xml into VyOS commands#

You will end up with two files: config.commands, a list of VyOS set lines in the order a person would type them, and report.md, which says what came across, what came across differently, and what could not come across at all. Neither file touches a router. Applying them is a separate step — a seed during an install, or a load at the console on a box that is already running.

The importer is tools/opnsense-import.py: Python 3 with no third-party imports, so it runs on the laptop you exported the configuration to. On a Wheelhouse router the same script is installed as wheelhouse-opnsense-import.

Before you start#

  • The OPNsense config.xml from the router you are replacing. System → Configuration → Backups → Download configuration. Do not encrypt it — the importer parses XML, not a password-wrapped blob.
  • A decision about which cable goes in which socket on the new box. That is what --map is for, and it is the one answer nothing in the file can give you.
  • Python 3 on whatever machine you run this on.

Step 1 — Name the ports#

FreeBSD calls a port igc0; Linux calls it eth0. Nothing in the configuration file records which physical socket that was, so the importer either takes your mapping or assigns ports in the order the old router listed them and says so in the report.

Map by OPNsense role, which is what most people mean:

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

Or by the FreeBSD device name, when the roles are confusing and the sockets are not:

bash
tools/opnsense-import.py config.xml --map igc0=eth0,igc1=eth1,igc2=eth2 --out ./out

Without --map, the ports the old router lists — filtered to real Ethernet devices, matched on igc, em, ix, re, bge, vtnet, eth or en followed by a digit — become eth0, eth1, eth2 and so on in that order, and each one gets a line in the report's Translated differently section naming the substitution.

Step 2 — Run it#

bash
tools/opnsense-import.py config.xml --map lan=eth0,wan=eth1 --out ./out
what it prints
wrote ./out/config.commands (147 commands)
wrote ./out/report.md
translated 31, differently 3, not at all 9

With no --out the commands go to standard output and no report is written, which is useful for a quick look and nothing else. The four --no- switches turn off a whole area when you would rather write it yourself:

FlagEffect
--map lan=eth0,wan=eth1Interface mapping, by role or by device name
--hostname edgeOverride the host name from the file
--out ./outWrite config.commands and report.md into this directory
--answers ./out/answers.confAlso write an installer answers file (see Build a seed)
--no-firewallDo not write a ruleset. The report records that you asked for this.
--no-dhcpSkip the DHCP server
--no-dnsSkip DNS forwarding
--no-qosSkip the shaper
--check-against live.txtCompare instead of generating — see Verify a migration

If the file is not an OPNsense configuration the importer stops before doing anything: it checks that the root element is <opnsense> and reports the element it actually found.

Step 3 — Read what it wrote#

config.commands is grouped by area, with a comment line before each group, in the order the importer runs them: interfaces, system, static routes, NAT, firewall, the DHCP server, DNS forwarding, QoS. Interfaces come first because several later sections need the LAN address to work out a network to allow, a router to hand out, or an address to listen on.

config.commands, abridged
# ===== interfaces =====
set interfaces ethernet eth0 address 192.0.2.1/24
set interfaces ethernet eth0 description LAN
set interfaces ethernet eth1 address dhcp
set interfaces ethernet eth1 description WAN

# ===== system =====
set system host-name edge
set system domain-name example.com
set system name-server 9.9.9.9
set system time-zone Europe/Berlin
set system config-management commit-revisions 100

# ===== NAT =====
set nat source rule 100 description 'LAN to WAN'
set nat source rule 100 outbound-interface name eth1
set nat source rule 100 source address 192.0.2.0/24
set nat source rule 100 translation address masquerade
set nat destination rule 110 description 'web'
set nat destination rule 110 inbound-interface name eth1
set nat destination rule 110 protocol tcp
set nat destination rule 110 destination port 443
set nat destination rule 110 translation address 192.0.2.10

Read report.md next, and read all of it. That page explains what each of its three sections means and what to do about the entries in the third.

What the importer translates, in detail#

Interfaces#

Every enabled Ethernet port becomes set interfaces ethernet <name> address … plus a description taken from the old router's label, or the role in upper case when there is none. dhcp comes across as dhcp; a static address is joined with its subnet value, defaulting to /24 when the file does not carry one.

Ports that are not Ethernet — an OpenVPN interface, a WireGuard interface, tailscale0 — are named in the report as not translated, because those are configured by their own service and not by an interface line. Loopback is skipped silently. A port that was disabled on the old router is skipped and reported.

System#

Host name, domain name, every <dnsserver> and the time zone, plus one line the old router had no equivalent for:

set system config-management commit-revisions 100

That is VyOS' configuration archive — the history behind the Restore buttons on the System page. A hundred revisions is what the importer asks for.

SSH comes across only if it was enabled, as service ssh port <n>. If the old router answered SSH on the LAN interface, the importer binds it to the LAN address rather than every interface. It never binds SSH to the WAN.

Local user accounts do not come across. Password hashes do not carry between FreeBSD and Linux, and the report says so with a count. The installer sets the console password and the web UI admin password; every other person needs an account of their own.

Static routes#

Each enabled route becomes set protocols static route <network> next-hop <address>. OPNsense names its gateways and refers to them by name, so the importer reads OPNsense/Gateways and resolves the name to the address behind it. A gateway with no address of its own — a dynamic one, or one bound to an interface — cannot be resolved, and the route is reported rather than guessed at.

NAT#

Outbound NAT comes across when the old router's outbound mode was automatic or hybrid, as one masquerade rule:

set nat source rule 100 description 'LAN to WAN'
set nat source rule 100 outbound-interface name eth1
set nat source rule 100 source address 192.0.2.0/24
set nat source rule 100 translation address masquerade

A hand-written outbound ruleset (manual mode) is not translated. Those rules exist because somebody had a reason, and a machine guessing at the reason is worse than a line in the report.

Port forwards become nat destination rules numbered from 110 in steps of 10, sorted by destination port. Sorting by port rather than by the old router's order means the table reads sensibly and a second import of the same file produces the same rule numbers, which is what makes --check-against useful later.

Host and port aliases are resolved: an alias of type host or network holding exactly one value becomes that value, and a port alias becomes its list, joined with commas. OPNsense writes port ranges with a colon; VyOS wants a hyphen, and the importer converts.

A forward whose destination interface was not the WAN is still imported, as arriving on the WAN, with a line in Translated differently naming the interface it actually matched on.

Firewall#

The importer does not translate the old ruleset. It writes a new default-drop one that admits exactly what the rest of the import needs, on both address families:

what it writes, with a LAN on eth0 and a DHCP WAN on eth1
set firewall global-options state-policy established action accept
set firewall global-options state-policy related action accept
set firewall global-options state-policy invalid action drop

set firewall ipv4 input filter default-action drop
set firewall ipv4 input filter rule 10 action accept          # from the LAN
set firewall ipv4 input filter rule 20 action accept          # from lo
set firewall ipv4 input filter rule 30 action accept          # udp/68, the DHCP client
set firewall ipv4 input filter rule 40 action accept          # icmp
set firewall ipv4 forward filter default-action drop
set firewall ipv4 forward filter rule 10 action accept        # LAN out
set firewall ipv4 forward filter rule 20 action accept        # to a forwarded host
set firewall ipv4 forward filter rule 21 action accept        # to the next one

set firewall ipv6 input filter default-action drop
set firewall ipv6 input filter rule 10 action accept          # from the LAN
set firewall ipv6 input filter rule 20 action accept          # from lo
set firewall ipv6 input filter rule 30 action accept          # icmpv6, all of it
set firewall ipv6 input filter rule 40 action accept          # udp/546, DHCPv6
set firewall ipv6 forward filter default-action drop
set firewall ipv6 forward filter rule 10 action accept        # LAN out
set firewall ipv6 forward filter rule 20 action accept        # icmpv6 through

Four things about that ruleset are decisions rather than translations, and the report records them.

  1. The old router's rules are not read. Not the IPv4 ones and not the IPv6 ones. What you get is a posture, not a port of your policy. Everything you allowed beyond a port forward — a management VLAN reaching another segment, a printer reachable from guests — you re-create yourself, from the Firewall page or with Use groups instead of literals.
  2. IPv6 gets the same default-deny even though nothing was read from the old configuration. A router filtered on one family and open on the other is not filtered, and it is the family nobody looks at that gets left open. If the provider brings IPv6 up later, the box is closed rather than surprised.
  3. All of ICMPv6 is accepted on input, not just echo. Neighbour discovery and path MTU discovery are ICMPv6; a router that drops them loses its own default route and then breaks every large packet it forwards.
  4. One forward-chain accept per forwarded host, numbered from 20 upwards in steps of one. The destination NAT has already rewritten the address by the time the packet is filtered, so the accept matches the inside address, not the public one. This is the same pairing the publish a service guide describes.

If no LAN interface came across, the importer refuses to write a ruleset at all and says why: a default-drop ruleset with nothing to admit is a locked door with the key inside. --no-firewall has the same effect and is also recorded.

Rules the old router had on an interface that is not part of this import — an OPT interface you did not map — are named in the report one by one.

DHCP#

The LAN's DHCP scope becomes a VyOS shared network called LAN:

set service dhcp-server shared-network-name LAN subnet 192.0.2.0/24 subnet-id 1
set service dhcp-server shared-network-name LAN subnet 192.0.2.0/24 option default-router 192.0.2.1
set service dhcp-server shared-network-name LAN subnet 192.0.2.0/24 option name-server 192.0.2.1
set service dhcp-server shared-network-name LAN subnet 192.0.2.0/24 option domain-name example.com
set service dhcp-server shared-network-name LAN subnet 192.0.2.0/24 lease 7200
set service dhcp-server shared-network-name LAN subnet 192.0.2.0/24 range 0 start 192.0.2.100
set service dhcp-server shared-network-name LAN subnet 192.0.2.0/24 range 0 stop 192.0.2.200
set service dhcp-server shared-network-name LAN subnet 192.0.2.0/24 static-mapping printer mac 00:00:5e:00:53:01
set service dhcp-server shared-network-name LAN subnet 192.0.2.0/24 static-mapping printer ip-address 192.0.2.10

The default router falls back to the LAN address when the old scope did not name one, the resolvers fall back to the router itself, and the lease time falls back to 7200 seconds. Domain search lists come across entry by entry. Every reservation comes across, named from its host name (or its client identifier, or its MAC with the colons removed), sanitised to letters, digits and hyphens, with -x appended if two reservations would otherwise collide.

PXE comes across as option bootfile-server, option tftp-server-name and option bootfile-name, preferring the UEFI file name (filename64) over the legacy one. If the old router handed out a different loader per client architecture, VyOS has one per subnet, so one file name is used for every client and the report says which. Needing the architecture matrix is what the dnsmasq guide is for.

Resolvers pinned to an individual reservation are dropped and reported: VyOS hands out resolvers per subnet, not per reservation.

DNS#

If the old router ran dnsmasq or Unbound, the forwarder comes across:

set service dns forwarding listen-address 192.0.2.1
set service dns forwarding allow-from 192.0.2.0/24
set service dns forwarding name-server 9.9.9.9
set service dns forwarding cache-size 10000

Static host entries become system static-host-mapping host-name <fqdn> inet <address>. Two things do not come across and both are named in the report: wildcard records, which the VyOS forwarder does not answer, and names for DHCP clients, because the forwarder does not register leases the way dnsmasq does. Both have the same workaround — run dnsmasq as an app — and the second has a simpler one: give the hosts that need names a reservation and a static host mapping.

QoS#

The upload pipe from the old traffic shaper becomes a CAKE policy on the WAN's egress:

set qos policy cake WAN-EGRESS bandwidth 70mbit
set qos policy cake WAN-EGRESS rtt 100
set qos policy cake WAN-EGRESS flow-isolation dual-src-host
set qos interface eth1 egress WAN-EGRESS

The importer picks the enabled pipe whose description mentions "up", or the first enabled pipe if none does. The download pipe is deliberately not translated, and the report says why: shaping inbound traffic on the router only drops what has already crossed the uplink. See Shape a link with CAKE for what the QoS editor can and cannot express.

Check it worked#

Nothing has been applied yet, so the check is on the files.

bash
# The interface mapping is the first thing in the file, and the thing to get right.
sed -n '/# ===== interfaces/,/# ===== system/p' out/config.commands

# Count the port forwards against the old router's.
grep -c 'translation address' out/config.commands

# Nothing should be left unquoted that has a space in it.
grep "description '" out/config.commands | head

# And read the report, all three sections of it.
less out/report.md

If you have a Wheelhouse router already running and want to see what the commands do before committing to them, paste them into the Config tree page's search to find the subtrees they touch, or stage a handful at a time from a shell and read the Commit Bar's diff.

Undoing it#

The importer writes files. Delete the output directory. If you have already applied the commands to a running router, rollback 1 — from System → History or Undo the last commit — puts the configuration back to the commit before them, provided they were applied as one commit.

See also#


Checked against tools/opnsense-import.py · docs/unattended-install.md · ui/src/pages/Nat.tsx · ui/src/pages/Firewall.tsx

Updated 2026-09-02 migration opnsense importer