INC-0001: Firewall Policy Deleted, Total Connectivity Loss

INC-0001: Firewall Policy Deleted, Total Connectivity Loss #

Date2026-09-07
Sitemobile (dvntm)
SystemsCore router dv02cor002p01 (OPNsense); the opnsense_firewall role in ansible-collection-deevnet.net
SeverityTotal site outage; physical console access required to recover
StatusRoot cause confirmed. Service restored from config backup. Corrective and preventive actions 1–8 done as of 2026-09-08. Of five open items, two were settled on 2026-09-14 and one narrowed; three remain, planned as CHG-0007.
TimesUTC (local is UTCβˆ’4), as recorded in the session transcript
**The guards are in, but have not yet met the live router.** Since 2026-09-08, `opnsense_firewall` refuses to reconcile from a broken discovery, withholds deletions by default, protects the operator's path, and applies behind a rollback savepoint ([Corrective actions](#corrective-actions), [Preventive actions](#preventive-actions)). They were verified offline against this incident's own input. The zone policy has still never been applied, so its first real application remains a watched change with the console open.

Summary #

Four ad-hoc runs of the opnsense_firewall role against the core router deleted every ansible:-prefixed filter rule on it: 18 zone policies, the internet rules for 8 zones, the per-zone conntrack rules, and both anti-lockout rules. That is the entire inter-VLAN policy. The role reported success on every run.

It did this because its interface discovery had silently broken, which made the desired rule set empty β€” and the role treated “nothing desired” as “delete everything managed”, with no guard.

Impact #

  • All inter-VLAN routing denied by default. Nothing reached anything across a segment boundary.
  • Internet access lost for all 8 zones carrying an internet policy.
  • Loss of trusted -> management, the operator path to dv00bld001p01 (10.20.99.95).
  • Loss of both anti-lockout rules β€” management subnet to the router on 443 and 22 β€” so the OPNsense web UI was unreachable and the fault could not be repaired over the network.
  • The automation host sits behind the policy it was editing, so the role severed its own return path mid-sequence. There was no in-band recovery.

Detection #

Nothing detected the outage while it was happening:

  • The broken run looked converged: ok, changed=0, Total desired rules: 0.
  • Gateway pings still answered and were read as reassurance. A router answering on its own interface addresses says nothing about the transit policy between segments.
  • All five IoT hosts went silent from management β€” the actual signal β€” and it was attributed to the hosts being powered off.

The record does not say when the outage was first recognised as one.

Timeline #

Time (UTC)Event
23:25:03switch-vlans.yml --check --diff β€” dry run reviewed
23:26:24switch-vlans.yml applied: gi 1/0/15 access-99 β†’ trunk, native 99, +VLAN 35
23:28:04Port state verified; dv02hyp001p01 still reachable
23:29:17opnsense.yml (full stack) attempted β€” denied by the permission classifier, never ran
23:30:44Ad-hoc firewall-only.yml written and run. ok=18 changed=1, apply handler fires
23:31:37dns.yml β€” changed=0
23:32:23firewall-only.yml re-run, annotated “expect changed=0” β€” returns changed=1
23:33:50, 23:34:16Two further re-runs; output filtered away by the grep in use
23:34:28firewall-only.yml β†’ ok=16 changed=0, “Rules to add: 0 / update: 0 / delete: 0”
23:35:34Segment gateways still answer ICMP β€” taken as reassurance
23:36–23:37Parse fault root-caused; endpoint probed directly
23:37:49Branch fix-firewall-interface-discovery created, discovery fix written
23:38:13Verified: 9 zones map where 0 did; all 18 policies would build
23:39:03All five IoT hosts silent from management β€” recorded as “host may be off”
23:40–00:00MQTT broker VM dv02mqt001v01 created (static 10.20.35.20)
00:02–00:03Work committed, including the discovery fix

The full-stack opnsense.yml play was blocked by a permission prompt. The damage came from the narrower ad-hoc playbook written immediately afterwards, which invoked the same role without that scrutiny β€” the guard rail was routed around, not overridden.

Symptoms #

What was observable, in the order it appeared:

  • A re-run expected to report changed=0 reported changed=1 (23:32:23).
  • Discovery skipped every zone as unmapped:
    skipping: [dv02cor002p01] => (item=trusted -> unmapped)
    skipping: [dv02cor002p01] => (item=management -> unmapped)
    ... 11 zones, all unmapped
    
  • The final run reported changed=0 and “Rules to add: 0 / update: 0 / delete: 0” β€” with Total desired rules: 0.
  • Segment gateways kept answering ICMP.
  • All five IoT hosts went silent from management (23:39:03).
  • From the management segment, the router’s web UI and SSH stopped answering, and no zone reached the internet.

Investigation #

The parse fault in discovery was found within minutes (23:36–23:37) by probing the interface export endpoint directly. A fix was written on a branch and verified to map 9 zones where 0 had mapped, so that all 18 policies would build (23:38:13).

The first conclusion was wrong. The discovery-fix commit stated “Nothing was deleted either, so the damage was limited to the policy never existing.” That was reached by reading the final changed=0 run, after the deletions had already completed. Left uncorrected, it reads as evidence the role is safe.

It was overturned by reading the recaps arithmetically β€” see the evidence under Root cause. The corrected commit message now calls that error “the most consequential error in the whole episode.”

Root cause #

Three faults compose. Any one alone is survivable.

1. Discovery failed silently. roles/opnsense_firewall/tasks/discover_interfaces.yml reads /interfaces/overview/export. That endpoint returns JSON but labels it text/html; charset=UTF-8, so uri never populated .json; and it returns a list of interface objects, which was being read as a dict keyed by identifier. Every zone resolved to unmapped.

2. An empty desired set was treated as authoritative. With no device map, every rule was filtered out by its own when, so desired_rules came out empty. The role did not distinguish “inventory declares no rules” from “discovery failed, so I computed no rules.”

3. The reconcile deleted by subtraction, with no floor and no protected set. configure_rules.yml:202 built rules_to_delete as every existing managed rule not present in the desired list. Empty desired means delete all. The Delete orphaned firewall rules task was ungated, and the apply firewall rules handler committed it. The anti-lockout rules built at configure_rules.yml:80-98 carried the same ansible: prefix, so they went with everything else.

**Evidence that the deletes ran.** The recaps settle it arithmetically. The role is 28 tasks pre-fix. The `changed=0` run: `ok=16 skipped=12` = 28, no handler. The `changed=1` runs: `ok=18 skipped=11` = 29 β€” one extra task executed *plus* the handler. `Add` and `Update` cannot run when the desired set is empty, which leaves `Delete orphaned firewall rules` as the only candidate.

Recovery #

A mini DisplayPort cable to the OPNsense physical console, then a restore from a config backup. There was no in-band path.

State immediately after recovery, as recorded at the time:

  • Core router: restored from config backup. No corrective action applied.
  • ansible-collection-deevnet.net: branch fix-firewall-interface-discovery, one commit, unpushed, and not to be merged alone.
  • Switch dv02acc001p01: gi 1/0/15 left as a trunk (native 99, +VLAN 35). It was applied separately, not implicated in the outage, and unaffected by the router restore.
  • dv02mqt001v01: created on dv02hyp001p01, static 10.20.35.20, with no DHCP reservation (cloud-init addressing), so unaffected by the router restore.

Contributing factors #

  • --check does not protect these roles. ansible.builtin.uri declares check_mode: support: none, so Ansible skips those tasks in check mode rather than previewing them. The --check --diff dry run required by Change Management would have reported zero deletions, and then the real run deleted everything. The checklist step is not merely weak here β€” it is actively misleading.
  • opnsense_firewall was the outlier in its own collection. opnsense_dns and opnsense_dhcp both already gated this exact operation behind an opt-in *_delete_unmanaged: false flag that reports and leaves records in place (opnsense_dns/tasks/configure_unbound.yml:378-417, opnsense_dhcp/tasks/configure_dhcp_reservations.yml:160-186). The single role missing that guard was the one whose deletions cut the control path.
  • The role had no post-condition. It verified the API accepted the writes, never that the network still worked.
  • The apply was not reversible. The handler posted a bare firewall/filter/apply with no savepoint, so recovery depended on a human with physical access.
  • The blast radius was invisible in the output. Nothing in a changed=1 recap named what was deleted.

Corrective actions #

Actions that fix the faults behind this outage. Actions 1–3 are the ones that would have prevented it.

#ActionWhereStatus
1Refuse to reconcile from a degraded discovery β€” assert the interface list and zone map are non-empty, that every from_zone resolved, and that desired_rules is non-empty before categorisingdiscover_interfaces.yml, configure_rules.ymlDone β€” 1bdba4a
2Make deletion opt-in via firewall_delete_unmanaged: false, with the “LEFT IN PLACE” report, following the opnsense_dns / opnsense_dhcp precedentdefaults/main.yml, configure_rules.ymlDone β€” 1bdba4a
3Protect the lifeline rules structurally β€” both anti-lockout rules and trusted -> management excluded from rules_to_delete even when deletion is enabledfirewall_protected_descriptions; mobile adds trusted -> management in group_vars/all/firewall.ymlDone β€” 1bdba4a
6Correct the discovery-fix commit message before the branch is pushedansible-collection-deevnet.netDone β€” b6dd249 carries the correction

The discovery fix was held back until the guards were ready. On its own it was more dangerous than the status quo: it made all 18 rules build, and the still-ungated delete would then have reconciled hard against whatever the config restore put back. The fix and the guards merged together in ansible-collection-deevnet.net PR #16 on 2026-09-08.

Open items #

As recorded at the time, updated only where a later commit settles them:

  • Audit what the restore put back. The backup was of unknown vintage, so the live rule set is not necessarily what firewall.yml declares. With deletion now withheld by default, a run reports adds, updates and would-deletes without removing anything β€” that report is the drift inventory.
  • Conntrack rules. quick and state were built but never sent by addRule. 1bdba4a removed them from the rule definition; that changes nothing on the wire. Whether the conntrack rules are needed at all is to be settled before the policy is first applied, since pf creates state on a passing rule by default.
  • The 18-rule zone policy has still never been applied. It remains a substantial change to inter-VLAN reachability: scheduled, with the console open, the savepoint armed, and the drift audit read first.

Added 2026-09-13. The three items above are planned as CHG-0007. Two more were found while planning it:

  • The role can’t audit without writing. There is no plan mode: a run with drift calls addRule/setRule and fires the apply handler, so the drift audit described above would itself be the first application. Reading the role also turned up faults in the guards from actions 4 and 5:

    • addRule/setRule accept any HTTP 200, but OPNsense reports a rejected rule as 200 with "result":"failed".
    • Required fields are sent as empty strings.
    • The reachability check registers with failed_when: false, so every result is failed: false and cancelRollback is always sent.
    • When no savepoint revision is issued, the role prints a message and does a plain apply.
    • firewall_savepoint_timeout is never sent to the router.

    These are CHG-0007’s prerequisites.

  • Two statements in this record read as a contradiction. The summary says the runs deleted “18 zone policies”; the item above says the 18-rule policy “has still never been applied.” Both may be true, for instance rules created before the flat-network migration finished and never applied since. The record doesn’t say which. CHG-0007’s drift audit should establish what the router actually held.

Added 2026-09-14. The router’s rules were read through its API, read-only, from dv00bld001p01. The full listing is in CHG-0007 β†’ Pre-change state, and the read is described in ADR-0011 β†’ Validation.

  • Audit what the restore put back: settled. The restore put back allow-all, not the declared policy:
    • 25 automation rules. ansible:temp-allow-all-optN passes any β†’ any on every VLAN interface, each rule present twice, including two pairs for the no-longer-assigned opt11 and opt12. There is also one ansible:test-rule.
    • Two allow rules outside the automation API: “temp: allow all VLAN 99” on management, and a Trusted pass to 10.20.99.0.
    • No zone policy rule is present. Every segment reaches every other.
  • Conntrack rules: settled for the router. None of the 25 rules is a conntrack rule, and every one carries the router’s default state type, keep. There is nothing on the router to remove, and the rules the role would have created are replaced in CHG-0007 by per-zone gateway-service rules.
    • A gap the old rules were hiding: the router’s automatic rules include nothing that lets a VLAN client reach its gateway’s DHCP server. Those rules must cover DHCP as well as DNS. Observed on the rule listing, not from vendor documentation.
  • The contradiction: narrowed, not settled. The router today holds no zone policy rule, only the temp allow-all set. The backup’s vintage is unknown, so today’s state can’t prove what the runs on 2026-09-07 deleted. Inference, not evidence:
    • The summary’s “18 zone policies” most likely describes the role’s declared set rather than rules present on the router.
    • What the runs deleted was most likely the ansible:temp-allow-all set. Removing those alone would produce the recorded impact: default deny on every segment, and no path to the router.
    • The hand-made “temp: allow all VLAN 99” rule would have kept management’s path open had it existed then. So it most likely arrived with, or after, the restore.
  • Found in passing, for the role fix: the router defaults an omitted protocol, source_net or destination_net to any, and reads existing rules back as any. Sending empty strings, as in the 2026-09-13 item above, is wrong.

Still open, all planned as CHG-0007:

  • the policy’s first application
  • the role faults listed above
  • the narrowed contradiction

Preventive actions #

Actions that stop this class of failure recurring, or make surviving it unnecessary.

#ActionWhereStatus
4Apply behind a rollback savepoint: savepoint β†’ apply/{revision} β†’ verify β†’ cancelRollback, so the router reverts unattended if the control host loses its pathtasks/apply_rules.ymlDone β€” 1bdba4a
5Add a reachability post-condition after apply β€” router 443/22 from management by default; sites add a host per policy-bearing segmentfirewall_verify_reachability, firewall_reachability_targetsDone β€” 1bdba4a
7Record in the Validation Checklist that --check --diff is not a dry run for the OPNsense API roles, and name the real pre-flightChange ManagementDone
8Write the console-recovery procedure β€” DisplayPort to the OPNsense console, restore config backupConsole RecoveryDone

Action 4 is the one that makes surviving the change unnecessary: it does not depend on the control host staying reachable.

Investigating action 7 turned up a second case: switch_vlans uses ansible.netcommon.cli_command, which supports check mode but accepts only show commands, so --check there does not skip silently β€” every configuration line fails. Neither role family gives a usable dry run; the OPNsense one is worse only because it is quiet about it.

Lessons learned #

  • A check mode that skips is not a dry run. On the uri-based roles, a clean --check means “nothing was examined”, not “nothing will change”.
  • An empty desired set is a failure signal, not a converged state. “I computed nothing” must never be allowed to mean “delete everything I manage”.
  • A gateway answering ICMP proves nothing about policy. The router replies on its own addresses whether or not any transit rule survives.
  • Read the recap arithmetic, not the headline. changed=1 in a run where adds and updates were impossible could only have been a delete.
  • A guard rail that can be routed around will be. The blocked full-stack play was replaced by a narrower ad-hoc one that invoked the same role without the same scrutiny.
  • Record the wrong conclusion. The mistaken “nothing was deleted”, committed to git, was the most consequential error of the incident. It survives here so it is not repeated.
  • The firewall run itself was ad hoc and had no change record.
  • Same session, not implicated: switch gi 1/0/15 moved from access VLAN 99 to a trunk (native 99, +VLAN 35); MQTT broker VM dv02mqt001v01 created.
  • Fix: ansible-collection-deevnet.net PR #16 (2026-09-08) β€” the discovery fix and the guards together.
  • Follow-on: ansible-inventory-deevnet PR #19 β€” protect the operator path from firewall reconciliation.
Page last modified: September 14, 2026