Skip to content

OPNsense Owl: IaC Configuration Coverage Audit

Initial audit: 2026-02-09 Last updated: 2026-02-22 (radvd RDNSS/DNSSL IaC, users 4-bug prevention, system-identity Rosa-Luxemburg conversion) Source: config-pre-ansible-20260209.xml vs Ansible opnsense role

Coverage Summary

Category Manual Config IaC Managed Coverage
Firewall rules 14 12 86% (ZT tightened, SSH rate-limited)
Firewall aliases 1 1 100%
DHCP reservations 28 28 100% (migrated to Dnsmasq)
DHCP general (v4+v6) Kea DHCPv4, Dnsmasq IPv6 Dnsmasq unified 100% (Kea→Dnsmasq migration complete)
DNS forwarding 8 8 100%
DNS host overrides 19 19 100% (dedup on identity)
DNS-over-TLS upstream not configured idempotent module (unbound_dot) 100% (upgraded to dedicated module)
DNSBL blocklists 5 lists 5 lists (26.1 raw API) 100%
ZeroTier config 1 network 1 network 100%
GIF tunnel params 1 tunnel 1 tunnel (partial) ~50%
SNMP full config full config 100%
Packages/plugins 9 installed 9 installed + 2 removed 100%
IDS/Suricata enabled, no rules raw API (general) + ids_ruleset (rulesets) 100% (26.1 raw API workaround)
Remote syslog not configured idempotent module (syslog) 100% (upgraded to dedicated module)
Unbound advanced full logging + privacy full logging + privacy 100%
System identity hostname/domain/tz config.xml via SSH (Rosa-Luxemburg) 100% (converted from puzzle.opnsense)
SSH hardening KEX/ciphers/MACs config.xml via SSH (Rosa-Luxemburg) 100% (new — ssh-hardening tag)
Web GUI binding HTTPS, all interfaces skipped (no API) 0% (security reminder)
2FA/TOTP not enabled N/A (manual only) 0% (security reminder)
Users/groups 3 users, 1 group group + user modules (no API keys) ~80% (users tag; all 4 SSH keys in inventory prevents 26.1 local_user_set() PUT; repair task in SSH play as fallback)
Sudo configuration admins=NOPASSWD config.xml via SSH (Rosa-Luxemburg) 100% (new — sudo tag)
Sysctl tunables 11 custom values config.xml via SSH (Rosa-Luxemburg) 100% (new — sysctl tag)
Gateways 2 (WAN, HE_v6) gateway module (WAN_GW + HE_TUNNELV6) 100% (new — gateways tag)
Interface assignments 7 interfaces read-only verify 0%
NAT hybrid mode not managed 0%
Certificates 1 (web GUI) not managed 0%
Monit 4 services, 11 tests monit_alert + monit_test + monit_service 100% (new — monit tag)
Node Exporter enabled, 7 collectors bind + enable 100% (new)
Router Advertisements radvd on LAN RDNSS + DNSSL via Rosa-Luxemburg (radvd.yml) ~50% (RA interval, prefix = manual)
DHCPv6 / dnsmasq SLAAC range on LAN not managed 0%
NTP 4 US pool servers not managed 0%
Power management powerd + amdtemp not managed 0%
Backup (git) github.com/scandora/opnsense-owl not managed 0%
Backup (Google Drive) stale GCP SA config not managed 0% (cleanup reminder)
DynDNS (ddclient) plugin enabled, no accounts not managed 0%
WireGuard removed removed via IaC 100% (cleanup)
Interface offloading checksum/TSO/LRO disabled not managed 0%

By the Numbers

Metric Value
Total customized features ~36 distinct subsystems
Fully managed by IaC 26 subsystems
Partially managed 2 (GIF tunnel — params yes, assignment no; Users — no API keys)
Documented with reminders 4 (Web GUI, 2FA, GDrive cleanup, legacy FW rules)
Attempted but failing 0
Not managed at all ~4 subsystems
Estimated coverage ~84% of customized config

Changes Since Initial Audit

Incident Response + IaC Hardening (2026-02-21/22)

Change Type Files
radvd RDNSS/DNSSL: Rosa-Luxemburg pattern manages nameserver + search domain in RA New subsystem (partial) roles/opnsense/tasks/radvd.yml
4 SSH keys added to both inventories — prevents local_user_set() PUT trigger Bug prevention inventory/owl.yml, inventory/opnsense-dev.yml
users-repair.yml (new): idempotent repair task detects nologin + nobody group Defense-in-depth roles/opnsense/tasks/users-repair.yml, playbooks/opnsense.yml
Explicit SSH key for root subprocess (ansible_ssh_private_key_file) Bug fix inventory/owl.yml, playbooks/opnsense.yml
OPNsense 26.1 local_user_set() bug fully understood: 4 bugs on any API PUT Root cause

Key finding: The bug fires when inventory ssh_keysconfig.xml. Primary group → nobody (bug 2) is the actual SSH lockout mechanism — it violates AllowGroups wheel in /usr/local/etc/ssh/sshd_config. Prevention: keep all SSH keys complete in inventory. Recovery: power cycle (boot-time code path is correct).

Production Deployment Status (2026-02-22):

  • --tags users re-run: changed=0, all 4 keys match ✅
  • --tags sudo,sysctl,ssh-hardening: dev-validated but NOT yet deployed to production ⚠️

DHCP Migration: Kea → Dnsmasq (2026-02-11)

Change Type Files
Created idempotent migration playbook (Kea → Dnsmasq) New workflow playbooks/migrate-dhcp-kea-to-dnsmasq.yml
Tested in opnsense-dev: 5 static hosts + dynamic pool (10.7.254.200-250) Validation
Migrated production owl from Kea DHCPv4 to unified Dnsmasq (IPv4+IPv6) Production change
Updated dhcp role to use dnsmasq-dhcp instead of kea Code update roles/opnsense/tasks/dhcp.yml
Removed os-isc-dhcp plugin (EOL since 2022, moved to plugin in 26.1) Cleanup roles/opnsense/defaults/main.yml

Rationale: Dnsmasq is the OPNsense 26.1 default DHCP server, handles IPv4+IPv6 in one daemon, integrates with Unbound DNS, and receives active security updates. ISC DHCP has been EOL since 2022. Kea lacks dynamic DNS integration.

Golden Image Rebuild + Validation (2026-02-11)

Change Type Files
Golden image rebuilt with latest bootstrap code; full 18-tag validation passed Validation build-golden-image.sh
Build script: CPU platform → Cascade Lake, ephemeral IP for builder, quickstart.sh upload Bug fixes build-golden-image.sh
ssh_keys added to joe user in both inventories (prevents authorized_keys wipe) Bug fix opnsense-dev.yml, owl.yml
puzzle.opnsense system-identity task disabled for 26.1 compatibility Bug fix system-identity.yml
Idempotency verified: ok=71, changed=12 (all known raw POST false-positives) Validation

Config.xml SSH Play Expansion (~70% → ~78%)

Change Type Files
Sysctl tunables: Rosa-Luxemburg fetch-edit-push for 11 tunable values New subsystem config-xml-sysctl.yml, defaults/main.yml, owl.yml
SSH hardening: config.xml KEX, ciphers, MACs, root login, listen interfaces New subsystem config-xml-ssh-hardening.yml, defaults/main.yml, owl.yml
Sudo config: config.xml sudo_allow_group + sudo_allow_wheel, configd template reload New subsystem config-xml-sudo.yml, defaults/main.yml, owl.yml, opnsense-dev.yml
Golden image bootstrap: virsh-create-apikey.py --bootstrap pre-bakes joe + sudo + SSH Dev workflow virsh-create-apikey.py, opnsense-dev-autoinstall.sh, build-golden-image.sh
OPNsense local_user_set() 26.1 bugs discovered and worked around Bug workaround virsh-create-apikey.py

Dedicated Module Migration + New Subsystems (~53% → ~70%)

Change Type Files
IDS: replaced 12+ raw tasks with raw API (general) + ids_ruleset (rulesets) Cleanup + partial idempotency ids.yml, defaults/main.yml
IDS: ids_general module broken on 26.1 (blockips renamed to mode) Bug workaround ids.yml
IDS: cron dedup (6 raw tasks) eliminated — simplified to single raw set Simplification ids.yml
Syslog: replaced raw search/add with syslog module Idempotency upgrade syslog.yml, defaults/main.yml
DNS-over-TLS: replaced raw addDot with unbound_dot module Idempotency upgrade dns.yml
Gateways: new gateway module for WAN_GW and HE_TUNNELV6 New subsystem gateways.yml, defaults/main.yml, owl.yml
Users/groups: new group + user modules (password from 1Password) New subsystem users.yml, defaults/main.yml, owl.yml, run-opnsense.sh
Monit: new monit_alert + monit_test + monit_service (1 alert, 11 tests, 4 services) New subsystem monit.yml, defaults/main.yml
System identity: puzzle.opnsense via SSH play for hostname/domain/timezone New subsystem system-identity.yml, opnsense.yml (SSH play)
Grafana MCP server added to .mcp.json Tooling .mcp.json

Security Best-Practices Review (previous)

The following changes were made during the security best-practices review:

Change Severity Type Files
Added Suricata IDS config (12 ET Open rulesets, cron, syslog) HIGH New subsystem ids.yml, defaults/main.yml, main.yml
Added remote syslog to dumbo (192.168.194.131:514 UDP) MEDIUM New subsystem syslog.yml, defaults/main.yml, main.yml, owl.yml
Added DNS-over-TLS upstream (Cloudflare + Quad9) MEDIUM Enhancement dns.yml, defaults/main.yml, owl.yml
Tightened ZT firewall: 4 any→any rules → 1 ZT→LAN rule MEDIUM Hardening firewall.yml
Added DHCPv6 HA disable (matches DHCPv4 fix) MEDIUM Bug fix dhcp.yml
Added security reminders: Web GUI binding, 2FA/TOTP HIGH Documentation system.yml
Added cleanup reminder: stale GDrive backup config LOW Documentation system.yml
Added SSH rate-limiting (pf overload tables) to WAN + HE SSH rules HIGH Security fix firewall.yml
Added DNS host override dedup (delete-then-recreate, match on identity) MEDIUM Dedup fix dns.yml
Added ZT firewall rule guards (when: opn_zerotier_network_id) MEDIUM Bug fix firewall.yml
Added legacy firewall rule cleanup reminder (15 rules) MEDIUM Documentation firewall.yml
Added stale package removal (os-isc-dhcp, os-wireguard) LOW Cleanup packages.yml, defaults/main.yml
Added ZeroTier disabled-duplicate network cleanup LOW Dedup fix zerotier.yml
Added stale GIF tunnel cleanup (gif1) LOW Dedup fix ipv6-tunnel.yml
Added IDS cron job dedup (keep first, delete extras) LOW Dedup fix ids.yml
Added node_exporter config (bind to ZT IP, not 0.0.0.0) MEDIUM Security fix monitoring.yml

Tier Breakdown

Tier 1: Fully Managed (can reproduce from scratch)

These can be recreated entirely from Ansible if Owl were wiped:

  • Firewall rules + aliases (12 rules, 1 GeoIP alias, savepoint pattern, SSH rate-limiting)
  • DHCP subnet + 28 static reservations (Dnsmasq unified IPv4+IPv6, integrated with Unbound DNS)
  • DNS general + advanced settings
  • DNS forwarding (8 zones to PowerDNS)
  • DNS host overrides (19 records: 6 IPv4, 6 IPv6, 7 ZeroTier; dedup on hostname+domain+type)
  • DNS-over-TLS upstream — unbound_dot module (Cloudflare + Quad9, idempotent)
  • IDS/Suricata — raw API (general settings) + ids_ruleset module (11 rulesets; ids_general broken on 26.1)
  • Remote syslog — syslog module (forward to dumbo, idempotent)
  • Gateway definitions — gateway module (WAN_GW + HE_TUNNELV6, idempotent)
  • Monit monitoring — monit_alert + monit_test + monit_service (1 alert, 11 tests, 4 services)
  • System identity — hostname, domain, timezone via config.xml SSH play (Rosa-Luxemburg pattern; puzzle.opnsense disabled for 26.1 compatibility)
  • Sysctl tunables — config.xml via SSH (Rosa-Luxemburg pattern, 11 custom values)
  • SSH hardening — config.xml via SSH (KEX, ciphers, MACs, root login, listen interfaces)
  • Sudo configuration — config.xml via SSH (sudo_allow_group=admins, sudo_allow_wheel=2, configd template reload)
  • ZeroTier join + local config (disabled duplicate network cleanup)
  • SNMP (community, location, contact, ZT-only bind)
  • Node Exporter (enabled, bound to ZT IP 192.168.194.10:9100)
  • Unbound advanced settings (prefetch, QNAME min, full logging)
  • Plugin management (9 packages installed, 2 stale packages removed)
  • Groups — group module (admins group with root + joe, page-all privilege)

Tier 2: Partially Managed (need manual follow-up)

  • GIF tunnel — API sets tunnel parameters, but interface assignment (OPT3), LAN IPv6 address, radvd, and DHCPv6 all need manual steps
  • DNSBL — Ansible attempts but fails on 26.1; must configure via web UI
  • Users — password, shell, email, groups managed; API keys are NOT managed (must be created via web UI or PHP shell). Critical: ssh_keys must be included in inventory or local_user_set() wipes authorized_keys
  • System identitypuzzle.opnsense disabled (doesn't support 26.1). Hostname/domain/timezone must be set manually or converted to Rosa-Luxemburg config.xml pattern

Tier 2.5: Documented with Security Reminders

These have no API but Ansible prints actionable reminders during each run:

  • Web GUI interface binding — HIGH: restrict to LAN + ZeroTier only
  • 2FA/TOTP on admin accounts — HIGH: enable for root and joe
  • Legacy firewall rules — MEDIUM: 15 duplicate rules in Firewall → Rules must be deleted via web UI
  • Stale GDrive backup config — LOW: clear legacy fields in config.xml

Tier 3: Not Managed (manual-only, exists only in config.xml)

Feature Complexity Risk if Lost
~~Sysctl tunables (11 custom)~~ — (promoted to Tier 1)
Interface assignments (7 interfaces) Low High (done at bootstrap)
Router Advertisements (radvd) Medium Medium (IPv6 SLAAC breaks)
DHCPv6 / dnsmasq Medium Medium (IPv6 addressing breaks)
NTP servers Low Low
Power management / thermal Low Low
Backup configs (git) Medium Medium (no auto-backup)
NAT mode (hybrid) Low Low (hybrid is default)
Certificate (self-signed web GUI) Low Low (auto-generated)
Interface offloading flags Low Low
~~WireGuard~~ — (removed via IaC)
~~Users/groups~~ — (promoted to Tier 1/2)
~~Gateway definitions~~ — (promoted to Tier 1)
~~Monit~~ — (promoted to Tier 1)

Highest-Value Gaps to Close

Prioritized by risk if lost x feasibility to automate:

Priority Feature API Available? Effort Status
1 ~~SSH hardening (KEX/ciphers/MACs)~~ config.xml via SSH (Rosa-Luxemburg) Closed
2 ~~Users/groups~~ Yes (user + group modules) Closed
3 ~~Sysctl tunables (11 custom)~~ config.xml via SSH (Rosa-Luxemburg) Closed
4 ~~Gateway definitions~~ Yes (gateway module) Closed
5 radvd + DHCPv6 No (not in 26.1 API) Medium Open
6 Backup configs (git-backup) Partial (git-backup plugin has some API) Low Open
7 ~~Monit~~ Yes (monit_alert + monit_test + monit_service) Closed

The remaining gaps (#5, #6) lack REST API support. The Rosa-Luxemburg pattern (fetch config.xml via SSH, edit locally, push back) unblocked SSH hardening and sysctl tunables that were previously stuck on "no API".

Ansible Tag Reference

Tag File Subsystems
system system.yml SSH reminders, security reminders
interfaces interfaces.yml WAN/LAN verification
packages packages.yml Plugin installation
zerotier zerotier.yml ZeroTier join + config
firewall firewall.yml Rules, aliases, savepoint
dhcp dhcp.yml Dnsmasq DHCP (IPv4+IPv6), static reservations, dynamic pools
dns dns.yml Unbound, forwards, hosts, DoT, DNSBL
ipv6 ipv6-tunnel.yml HE 6in4 tunnel parameters
ids ids.yml Suricata IDS/IPS (ids_general + ids_ruleset, auto-managed cron)
syslog syslog.yml Remote log forwarding (syslog module)
monitoring monitoring.yml SNMP, Node Exporter
gateways gateways.yml Gateway definitions (WAN_GW, HE_TUNNELV6)
users users.yml User accounts and groups
monit monit.yml Monit local health monitoring (alerts, tests, services)
system-identity system-identity.yml Hostname, domain, timezone (SSH play, puzzle.opnsense)
sysctl config-xml-sysctl.yml Sysctl tunables (SSH play, Rosa-Luxemburg config.xml)
ssh-hardening config-xml-ssh-hardening.yml SSH KEX, ciphers, MACs, root login (SSH play, Rosa-Luxemburg)
sudo config-xml-sudo.yml sudo_allow_group, sudo_allow_wheel (SSH play, Rosa-Luxemburg)

Bottom Line

Coverage progression: ~37% → ~47% → ~53% → ~70% → ~78%

The latest round introduced the Rosa-Luxemburg pattern (fetch config.xml via SSH, edit locally with community.general.xml, push back, reload) to manage settings that have no REST API:

  1. Sysctl tunables — 11 custom values managed via config.xml XPath editing
  2. SSH hardening — KEX algorithms, ciphers, MACs, root login, listen interfaces
  3. Sudo configurationsudo_allow_group=admins + sudo_allow_wheel=2, with configctl template reload OPNsense/Auth to regenerate /usr/local/etc/sudoers.d/20-opnsense

Additionally, virsh-create-apikey.py --bootstrap now pre-bakes joe user + sudo + SSH into the golden image, reducing quickstart time to ~35 seconds and eliminating the --tags users bootstrap prerequisite.

The remaining ~22% unmanaged consists of: interface assignments (bootstrap-only), IPv6 stack (radvd, DHCPv6), NTP, power management, backup config, NAT, certificates, and interface offloading. Most are set-once-at-bootstrap items or lack API support.

The config.xml backup (git-backed to github.com/scandora/opnsense-owl) remains the safety net for everything in Tier 3.