Wheelhouse docs

Build a seed that installs the replacement#

You will end up with a small ISO image — a seed — that you attach to the new machine as a second disk or write to a USB stick. Boot the Wheelhouse image with the seed present, and the installer finds it, installs to the machine's other disk, applies the configuration the seed carries and reboots into a configured router with nobody at the keyboard.

This is the fastest way to stand up a replacement for an OPNsense box, and the only practical way to install more than two or three machines the same way.

Before you start#

  • A Wheelhouse ISO for the machine, and the machine itself, with UEFI boot and one disk that is not the boot medium.
  • Either an OPNsense config.xml to import, or a cloud-config you wrote yourself.
  • xorrisofs, genisoimage or mkisofs if you want an ISO. On Debian: apt install xorriso. If you have none of them, --out-dir writes the files instead and you copy them onto a stick you formatted and labelled yourself.
  • Two passwords: one for the Wheelhouse admin account, and one for the router's console accounts. The installer creates two — wheelhouse, the one you use, and vyos, kept for recovery — and gives both the same password, so there is one thing to remember and no way to be locked out of the recovery account.

Step 1 — Put the passwords in files#

bash
umask 077
printf '%s' 'the web UI password'  > admin.pw
printf '%s' 'the console password' > console.pw

printf rather than echo so no trailing newline creeps into the password. The tool reads the file and strips whitespace, but the habit is worth keeping.

If you leave both out and are on a terminal, the tool prompts twice for each and refuses if the two entries differ. With no terminal and no files it stops rather than guessing.

Step 2 — Build the seed from the OPNsense configuration#

bash
tools/wheelhouse-seed.py --opnsense config.xml --map lan=eth0,wan=eth1 \
    --admin-password-file admin.pw --console-password-file console.pw \
    --ssh-key ~/.ssh/id_ed25519.pub \
    --include-source \
    --out seed.iso
what it prints
wrote ./out/config.commands (147 commands)
wrote ./out/report.md
translated 31, differently 3, not at all 9
wrote seed.iso (372 KiB, label CIDATA, built with xorrisofs)
attach it as a disk (or write it to a USB stick with dd) and boot the Wheelhouse image;
the installer will find it.
it holds the passwords in the clear: wipe it once the machine is up.

The tool runs the importer for you and puts three things on the seed: config.commands, the report as import-report.md, and a user-data cloud-config that ties them together. It also reads the host name and time zone out of the imported commands, so the new box keeps the old router's name unless you pass --hostname.

--include-source puts the original config.xml on the seed as opnsense-config.xml, which is what makes verifying the migration a two-command job months later.

--ssh-key puts a public key on both console accounts, so the machine is reachable the moment it boots without a password living on a stick any longer than it has to. Both, because a key that opens only the recovery account would be a trap and one that opens only the product's account would leave the recovery account key-less. SSH still has to be enabled in the configuration — see Turn SSH on, and restrict it.

The firewall answer is decided for you, deliberately#

The tool reads the commands it just generated and writes one of two lines into the seed:

yaml
  firewall: false      # the imported configuration brings its own
yaml
  firewall: true       # nothing came across (see import-report.md),
                       # so the installer writes its default-deny one

Two writers would number the same rules differently and the router would refuse the result. No writer at all would put a new box on the internet with nothing in front of it. So the seed states the answer rather than leaving it to a default.

Step 3 — Or write the cloud-config yourself#

For a machine that is not a migration, write seed.yaml and build from that:

bash
tools/wheelhouse-seed.py --user-data seed.yaml --out seed.iso

Anything named config.commands, config.boot or authorized_keys sitting beside seed.yaml is copied onto the seed too.

seed.yaml
#cloud-config
wheelhouse:
  disk: auto                  # auto = the one disk that is not the boot medium
  hostname: edge
  timezone: Europe/Berlin
  wan:
    interface: eth1
    addressing: dhcp          # or static
    address: 203.0.113.2/24   # static only
    gateway: 203.0.113.1      # static only
    dns: [9.9.9.9, 1.1.1.1]   # static only
  lan:
    interface: eth0
    address: 192.0.2.1/24
    dhcp_server: true         # hand out addresses and answer DNS
    nat: true                 # share the WAN address
  firewall: true              # default-deny on the WAN, both address families
  management_source: 203.0.113.0/24   # who may reach the web UI on a box with no LAN
  passwords:
    admin: "the web UI password"
    console: "the console password"
  config:
    commands: config.commands # a file on the seed, a URL, or inline text
    boot: config.boot         # or a whole VyOS configuration to load
  ssh_authorized_keys:
    - ssh-ed25519 AAAA... you@laptop
  reboot: true                # false stops after installing
  poweroff: false             # true powers off instead of rebooting
  keep_previous: false
  overwrite: false            # reinstall over a disk that already has a system
  on_error: halt              # halt | poweroff | menu | shell | exit

Everything is optional except the two passwords and a disk. A seed that only sets config.commands gets its addresses from the restored configuration, so the wan: and lan: sections can be left out entirely — which is exactly what the OPNsense path does.

Two keys are commonly put in the wrong place.

firewall: is top level, not under lan:. The firewall follows the WAN, not the LAN: a machine with an uplink and no LAN was the one case that used to get no ruleset at all while the web UI listened on every address. lan: { firewall: … } is still read, for seeds written before the move, and the top-level key wins. A seed that carries config.commands or config.boot defaults firewall to false, because that configuration is the thing deciding what the firewall is; every other seed defaults it to true.

management_source: is the prefix allowed to reach the web UI on a box that has no LAN to reach it from. Without it, a WAN-only machine with the default-deny ruleset is one you cannot sign in to.

A seed can also be plain KEY=VALUE lines instead of YAML, which is the format wheelhouse-install --answers has always taken and what the importer's --answers flag writes:

DISK=auto
HOSTNAME=edge
TIMEZONE=Europe/Berlin
WAN_IFACE=eth1
WAN_ADDRESSING=dhcp
LAN_IFACE=eth0
LAN_ADDRESS=192.0.2.1/24
LAN_DHCP=no
NAT=no
FIREWALL=no
MGMT_SOURCE=203.0.113.0/24
CONFIG_COMMANDS=config.commands
ADMIN_PASSWORD=
CONSOLE_PASSWORD=
REBOOT=yes
ON_ERROR=halt

Without ON_ERROR, a seeded install halts the machine and a hand-run --answers install exits, which is what each one wants. VYOS_PASSWORD is the old name of CONSOLE_PASSWORD — from before the console account was named after the product — and a seed written against it still works; CONSOLE_PASSWORD wins if a file carries both.

Serving a seed over the network#

A seed URL on the kernel command line is fetched over https:// only. Plain http:// and ftp:// are refused, with the reason on screen: whoever is on the network path would otherwise be the one setting this router's passwords. Two escape hatches, both explicit and both on the kernel command line, where only the person at the keyboard can put them:

Kernel parameterEffect
wheelhouse.insecure=1Says the network is trusted and means it. The install proceeds over plain HTTP and logs a warning saying why it did.
wheelhouse.autoinstall.sha256=<hex>Pins the seed's content. The installer hashes what it fetched and stops if it does not match — which is what makes an unauthenticated fetch safe rather than merely permitted.

Neither is an answers key, deliberately: a seed that arrived over the network must not be able to say that the network it arrived over is trustworthy.

Step 4 — Get the seed onto the machine#

A VM: attach seed.iso as a second CD or disk.

Hardware: write it to a USB stick.

bash
sudo dd if=seed.iso of=/dev/sdX bs=4M status=progress conv=fsync

Check /dev/sdX twice. lsblk before and after plugging the stick in is the way to be sure.

No ISO tool: build the files instead and copy them onto a filesystem you labelled CIDATA or WHEELHOUSE yourself.

bash
tools/wheelhouse-seed.py --opnsense config.xml --map lan=eth0,wan=eth1 \
    --admin-password-file admin.pw --console-password-file console.pw \
    --out-dir /media/stick

Step 5 — Boot the Wheelhouse image with the seed present#

The installer looks for a seed in this order and stops at the first one it finds:

  1. The kernel command line: wheelhouse.autoinstall=<value>, where the value is a URL, a device (/dev/sdb1), label:NAME, a path on the live image, auto for the normal search, or off to insist on a person at the keyboard.
  2. A labelled filesystem: any partition labelled WHEELHOUSE or CIDATA — cloud-init's NoCloud name, which is why the tool uses it by default — holding wheelhouse.conf, wheelhouse.yaml or user-data. Case does not matter.
  3. cloud-init's own seed, when cloud-init has already found one: /var/lib/cloud/seed/nocloud, nocloud-net, or the current instance.
  4. /usr/share/wheelhouse/autoinstall.conf, for images built to install a fleet the same way every time.

Two rules keep a seed from turning into a machine that erases itself repeatedly.

At most one unattended install per boot. If it cannot finish, the machine does what on_error says and the next start of the installer shows the menu instead of erasing a disk again.

A seed will not reinstall a machine that is already installed. If the target disk holds a system and the seed does not say overwrite: true, the installer says so and leaves the disk alone — which is what you want the first time somebody reboots a new router with the USB stick still in it.

Check it worked#

Watch the console. Each step is printed, and everything lands in /var/log/wheelhouse-install.log on the live system. On failure the installer names that log in its own error message.

To try a seed on a live system without rebooting, or to ask which seed would be used:

bash
sudo wheelhouse-autoinstall             # just say which seed would be used
sudo wheelhouse-install --seed          # find a seed and use it
sudo wheelhouse-install --answers /path/to/answers.conf

To see the set commands a set of answers produces without touching a disk:

bash
sudo wheelhouse-install --answers /tmp/answers --commands

That is worth doing before you hand a seed to a fleet.

After the reboot, the console banner prints the web UI address. Sign in as admin with the password from the seed, and then:

Undoing it#

An install is not undoable: the disk is written. If the result is wrong, fix the seed and install again with overwrite: true. Nothing on the old router has been touched, which is the reason to keep it powered off but intact until the new one has carried traffic for a week.

See also#


Checked against tools/wheelhouse-seed.py · tools/opnsense-import.py · docs/unattended-install.md · packaging/wheelhouse-autoinstall

Updated 2026-09-02 migration seed unattended install