1 Development
Louis Raymond edited this page 2026-08-19 13:48:46 -04:00
This file contains ambiguous Unicode characters

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/):

  1. 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 as root (not installer, whose login shell execs the installer directly) so a config can be dropped onto the live /conf first.
  2. Console menu (not a shell) — select 8 (Shell).
  3. Network — the live default is static LAN, no route to Packer's HTTP server; dhclient vtnet0 first.
  4. Fetch + installinstall.sh writes config.xml onto the live /conf, then execs opnsense-installer — the installer's "Cloning current system" step copies /conf onto the target disk, carrying the config along (the open-images/opnsense trick, see THIRD_PARTY_NOTICES.md).
  5. Keymap (04-after-password.png, 05-after-keymap.png) — default, Enter.
  6. Installer main menu (05-after-keymap.png) — Down to "Install (UFS)", Enter. ZFS is pre-selected.
  7. RAM warning — skipped entirely by giving the build VM ≥3072MB (installer warns below ~3000MB).
  8. Disk select (07-after-proceed.png, 08-after-disk-select.png) — vtbd0 pre-selected (plain virtio, not virtio-scsi — the latter sorts after the install media and leaves the wrong entry highlighted). Enter.
  9. Destroy confirmation[YES] pre-selected, Enter.
  10. Install progress (0911-install-done.png) — no signal to poll; fixed wait.
  11. Final configuration (14-post-install3.png) — Down to "Complete Install" (password/key already correct from the cloned config), Enter.
  12. 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

  • qemuargs replaces the plugin's default NIC, not adds to it. The build VM only ever gets one NIC; a second is added to config.xml afterwards over SSH (provisioners/15-fix-interfaces.sh), not via a second qemuargs NIC.
  • OPNsense relabels a lone NIC from wan to lan. On the installed system's first real boot, rc.bootup silently relabels the sole present interface if no lan is declared at all — happens before any provisioner script runs. 15-fix-interfaces.sh re-asserts both via write_config() (a plain file write, not subject to the same relabeling) afterwards.
  • config.inc needs util.inc required explicitly first — it assumes a caller's normal request/rc.* bootstrap already loaded it; write_config() needs shell_safe() from there.
  • root's login shell is csh, not sh — Packer's default execute_command uses sh-style syntax; provisioners route through env
    • explicit sh instead.
  • <down> in boot_command needs real margin — each installer screen is a freshly exec'd bsddialog process 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 generic python3-lxml) — check pkg.opnsense.org's listing when bumping the OPNsense version; see Testing-New-Releases.
  • No pydantic package in OPNsense's pkg repo at all (stable or snapshots) — spec.py uses stdlib dataclasses instead.