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_keys ≠ config.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 usersre-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 (block→ips 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_dotmodule (Cloudflare + Quad9, idempotent) - IDS/Suricata — raw API (general settings) +
ids_rulesetmodule (11 rulesets;ids_generalbroken on 26.1) - Remote syslog —
syslogmodule (forward to dumbo, idempotent) - Gateway definitions —
gatewaymodule (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.opnsensedisabled 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 —
groupmodule (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_keysmust be included in inventory orlocal_user_set()wipesauthorized_keys - System identity —
puzzle.opnsensedisabled (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:
- Sysctl tunables — 11 custom values managed via config.xml XPath editing
- SSH hardening — KEX algorithms, ciphers, MACs, root login, listen interfaces
- Sudo configuration —
sudo_allow_group=admins+sudo_allow_wheel=2, withconfigctl template reload OPNsense/Authto 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.