This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Development
Dev environment
nix develop
# or, with direnv: direnv allow
Provides Packer, QEMU, the Python toolchain opnseed needs (matching what's
installed on the guest via pkg — see flake.nix), OpenTofu, Ansible, and
debugging tools (VNC, guestfish on demand). packer is BUSL-licensed; the
flake whitelists only that package via allowUnfreePredicate rather than a
blanket allowUnfree.
Repo layout
base/config.xml known-good OPNsense config.xml, extracted from a
real build -- opnseed always merges into this
guest/ files installed onto the guest filesystem
(rc.syshook wrapper)
opnseed/ the Python library + CLI (host and guest)
src/opnseed/
nocloud.py parses user-data/meta-data/network-config
extension.py parses the opnsense: extension block
spec.py internal representation (dataclasses)
merge.py loads base/config.xml, runs the transformer pipeline
transformers/ system, users, interfaces, apikeys
firstboot.py guest entrypoint (rc.syshook)
cli.py host entrypoint (validate/render/iso/diff)
tests/
examples/ worked NoCloud seed directories, used by tests
packer/ builds the base qcow2 image
terraform/ OpenTofu module + example deployments
iso/ pinned installer ISO + checksum
Building the base image
make iso # fetch + verify the pinned OPNsense DVD ISO
make image # unattended install under QEMU, produces build/opnsense-*.qcow2
# and base/config.xml
make image runs packer/generate-build-key.sh (ephemeral build-only SSH
keypair, never shipped), then packer build. Takes several minutes; most of
it is the unattended installer running under QEMU with no way to poll for
completion, so boot_command waits are fixed and intentionally generous
(see Installer automation below).
Import the resulting qcow2 into Proxmox as a template:
qm importdisk <vmid> build/opnsense-*.qcow2 <storage>
qm set <vmid> --scsi0 <storage>:vm-<vmid>-disk-0 --boot order=scsi0
qm set <vmid> --ide2 <storage>:cloudinit
qm template <vmid>
Running tests
make test # everything below
make test-opnseed # pytest (requires base/config.xml to exist for most cases)
make test-terraform # tofu validate, module + examples
make test-packer # packer validate (no ISO download needed)
make test-shellcheck # shellcheck over every shell script
opnseed's own CLI is also useful standalone against a seed directory,
without touching a VM:
cd opnseed
python3 -m opnseed.cli validate ../examples/wan-lan-dhcp
python3 -m opnseed.cli render ../examples/wan-lan-dhcp -o /tmp/config.xml
python3 -m opnseed.cli diff base/config.xml /tmp/config.xml
End-to-end verification without Proxmox
cloud-localds (in the dev shell) builds a local NoCloud seed ISO from any
examples/ directory, for testing against a plain QEMU VM before touching
real infrastructure:
make seed-example # build/seed.iso from examples/wan-lan-dhcp
qemu-system-x86_64 -m 2048 -smp 2 -enable-kvm \
-drive file=build/opnsense-base.qcow2,if=virtio,format=qcow2 \
-cdrom build/seed.iso \
-netdev user,id=n0 -device virtio-net-pci,netdev=n0,mac=bc:24:11:00:00:01 \
-netdev user,id=n1 -device virtio-net-pci,netdev=n1,mac=bc:24:11:00:00:02
MACs must match the example's network-config for interface resolution to
work. Check /var/log/opnseed.log on the guest console.
Installer automation
The Packer boot_command in packer/opnsense.pkr.hcl scripts the OPNsense
installer's TUI blind (headless QEMU, no monitor socket on a Packer-managed
process to screenshot). Screen-by-screen reference, captured from a manual
walkthrough (images in images/):
- Live boot (
images/01-boot.png,02-boot.png) — the importer's "press any key" prompt appears here with a narrow (~7s) window; any keystroke landing inside it derails everything that follows. Login asroot(notinstaller, whose login shell execs the installer directly) so a config can be dropped onto the live/conffirst. - Console menu (not a shell) — select
8(Shell). - Network — the live default is static LAN, no route to Packer's HTTP
server;
dhclient vtnet0first. - Fetch + install —
install.shwritesconfig.xmlonto the live/conf, then execsopnsense-installer— the installer's "Cloning current system" step copies/confonto the target disk, carrying the config along (the open-images/opnsense trick, seeTHIRD_PARTY_NOTICES.md). - Keymap (
04-after-password.png,05-after-keymap.png) — default, Enter. - Installer main menu (
05-after-keymap.png) — Down to "Install (UFS)", Enter. ZFS is pre-selected. - RAM warning — skipped entirely by giving the build VM ≥3072MB (installer warns below ~3000MB).
- Disk select (
07-after-proceed.png,08-after-disk-select.png) —vtbd0pre-selected (plainvirtio, notvirtio-scsi— the latter sorts after the install media and leaves the wrong entry highlighted). Enter. - Destroy confirmation —
[YES]pre-selected, Enter. - Install progress (
09–11-install-done.png) — no signal to poll; fixed wait. - Final configuration (
14-post-install3.png) — Down to "Complete Install" (password/key already correct from the cloned config), Enter. - Reboot (
17-after-complete.png) — Packer must not re-attach the ISO on this boot, or it loops back into the install media.
Known gotchas
qemuargsreplaces the plugin's default NIC, not adds to it. The build VM only ever gets one NIC; a second is added toconfig.xmlafterwards over SSH (provisioners/15-fix-interfaces.sh), not via a secondqemuargsNIC.- OPNsense relabels a lone NIC from
wantolan. On the installed system's first real boot,rc.bootupsilently relabels the sole present interface if nolanis declared at all — happens before any provisioner script runs.15-fix-interfaces.shre-asserts both viawrite_config()(a plain file write, not subject to the same relabeling) afterwards. config.incneedsutil.increquired explicitly first — it assumes a caller's normal request/rc.* bootstrap already loaded it;write_config()needsshell_safe()from there.- root's login shell is
csh, notsh— Packer's defaultexecute_commanduses sh-style syntax; provisioners route throughenv- explicit
shinstead.
- explicit
<down>inboot_commandneeds real margin — each installer screen is a freshly exec'dbsddialogprocess that appears to discard stdin arriving before its own read loop starts. Widen waits before suspecting a different root cause.- pkg package names are version-pinned to the Python ABI OPNsense
ships (
py313-lxml, not a genericpython3-lxml) — checkpkg.opnsense.org's listing when bumping the OPNsense version; see Testing-New-Releases. - No
pydanticpackage in OPNsense's pkg repo at all (stable or snapshots) —spec.pyuses stdlibdataclassesinstead.