Reported symptoms
A firewall hardening play runs at 14:00 across 200 hosts. At 14:03 the operator notices that hosts are dropping out of the run as UNREACHABLE and stops it.
By then 94 hosts are unreachable and stay unreachable.
The confusing part is that nothing is down:
pingsucceeds to every one of them.- The application endpoint on each returns a normal response.
- Monitoring is entirely green; the monitoring collectors reach the hosts on a different network path that is still permitted.
- The hosts have not rebooted and their uptime is unchanged.
So the first framing is “Ansible has a connectivity problem”, and half an hour goes into the controller, the bastion and the automation network - all of which are healthy.
The 106 hosts that still work are simply the ones the play had not reached.
Evidence provided
$ ansible app041 -i inventory -m ansible.builtin.pingapp041 | UNREACHABLE! => {
"changed": false,
"msg": "Failed to connect to the host via ssh: ssh: connect to host 192.0.2.41 port 22: Connection timed out",
"unreachable": true
}$ ping -c2 192.0.2.41; curl -s -o /dev/null -w '%{http_code}\n' http://192.0.2.41:8080/health2 packets transmitted, 2 received, 0% packet loss
200$ grep -n -B2 -A12 'Harden the input chain' roles/firewall/tasks/main.yml18:- name: Harden the input chain
19: ansible.builtin.iptables:
20: chain: INPUT
21: policy: DROP
22:
23:- name: Permit the allowed sources
24: ansible.builtin.iptables:
25: chain: INPUT
26: protocol: tcp
27: destination_port: "22"
28: source: "{{ item }}"
29: jump: ACCEPT
30: loop: "{{ ssh_allowed_sources | default([]) }}"$ grep -rn 'ssh_allowed_sources' group_vars/group_vars/web.yml:11:ssh_allowed_sources: ['198.51.100.0/24']$ ansible-inventory -i inventory --graph app | head -4@app:
|--app001
|--app002
|--app003$ ansible app101 -i inventory -b -m ansible.builtin.command -a 'iptables -S INPUT' | head -3-P INPUT ACCEPT
-A INPUT -p tcp -m tcp --dport 22 -j ACCEPTWork the evidence before reading on
The play succeeded. Every task it ran did exactly what it was written to do, on every host it reached.
- Read the two tasks in order. What is the state of the host between the first task completing and the second task completing?
ssh_allowed_sourceshas adefault([]). What does the second task do when the list is empty?- Which group are the 94 hosts in, and which group has the variable?
Before continuing: which connection was Ansible using to apply the first task, and what did the first task do to it?
Root cause
1. The policy was tightened before the accept rules existed
The play sets the input chain policy to DROP, then adds accept rules from a list.
Between those two tasks the host drops every inbound packet that no existing rule permits. On these hosts there was no pre-existing rule permitting SSH from the automation network, so the moment the policy changed, the connection Ansible was using was severed - by the task Ansible had just executed.
Ordering is the entire difference between a working hardening play and an unrecoverable one. Accept rules first, then policy, and the window never opens.
2. The accept list rendered empty
ssh_allowed_sources is defined in group_vars/web.yml and nowhere
else. The 94 affected hosts are in the app group, which has no such
variable.
The loop is guarded by default([]), so on those hosts it iterates over
an empty list, adds no rules, reports ok, and moves on without
complaint. That default was added at some point to stop the play failing
on hosts where the variable was missing, which is precisely the failure
that would have prevented this incident.
A default([]) on a list that controls access is the same defect as a
default('') on a required template value: it converts a loud error
into a silently wrong result.
3. Nothing asserted that the rule survived
There is no guard anywhere in the play requiring that a rule permitting the automation network exists before the policy is tightened. The play trusts that the variable is populated, and the variable is the only thing standing between the play and a fleet-wide lockout.
Resolution
- Stop the play if it is still running, and disable any schedule that would run it again. The 106 hosts that still work are only working because the play has not reached them.
- Establish the out-of-band path before touching anything. Serial console, management processor, hypervisor or cloud console - whichever exists, confirm it works on one host before planning for 94.
- Recover one host first and confirm the recovery procedure end to end, including that SSH from the automation network works afterwards. Do not start a 94-host recovery on an unproven procedure.
- Restore the management accept rule on each host through the out-of-band path. Add the accept rule before touching the policy, so the host is never in the locked-out state again during recovery.
- Verify reachability from the automation network after each host, not at the end. A host you believe you fixed and cannot reach is indistinguishable from one you have not fixed.
- Correct the missing variable, but do not stop there. The variable is the trigger; the structural fault is a play that can tighten a policy without the rule that keeps you connected.
- Restructure the play so the rule set is applied atomically, or so accepts precede the policy, and add a guard that asserts the management rule is present in the rendered result.
- Add a timed rollback to the play, then canary the corrected play on one host and verify reachability before going anywhere near the fleet.
Verification
- Ansible can reach every recovered host.
ansible <host> -m ansible.builtin.pingsucceeds from the automation network, which is the exact path that was lost and therefore the only meaningful check. - The guard can fail. Run the corrected play against a scratch host with the management variable deliberately undefined and confirm it refuses to start. A guard that has never rejected anything is why this incident happened.
- The rule ordering is correct. Read the rendered rule set on a recovered host and confirm the management accept rule precedes any drop or restrictive policy.
- The intended restrictions are actually in force. From a network position that should be denied, confirm the connection is refused - a fix that restores access by permitting everything is not a fix.
- The timed rollback works. On a scratch host, apply the change and do not confirm it; the host must return to its previous rule set on its own.
- The out-of-band path is documented and tested for every host in the estate, including the ones that were not affected. This incident happened to reach 94; the next one may reach all of them.
- A canary run on one host is clean and reachable before the fleet is touched, and that step is now part of the runbook rather than a decision somebody makes.
Prevention
- Guard on the rendered result, not on the input. Assert that a rule permitting the management network exists in what is about to be applied:
- name: Refuse to harden without a management accept rule
ansible.builtin.assert:
that:
- ssh_allowed_sources is defined
- ssh_allowed_sources | length > 0
- management_network in ssh_allowed_sources
fail_msg: >-
{{ inventory_hostname }} would be hardened with no rule permitting
{{ management_network }}. Refusing to continue.
- Never use
default([])on a list that controls access. The failure it prevents is the one you want. - Apply rule sets atomically. Render the whole rule set and load it in one operation so no intermediate state exists on the host.
- Add a timed rollback to any change that can remove your own access. A scheduled revert that fires unless confirmed turns a lockout into a short outage.
- Canary to one host and verify reachability from the automation network before the fleet. One host is a survivable mistake; 94 is a week.
- Maintain and test an out-of-band access path for every host, and record it where somebody under pressure will find it. The time to discover that the console password is wrong is not while 94 hosts are unreachable.
- Treat firewall changes with the same procedure as authorisation changes. Both can remove the means of repair, and both deserve a written pre-flight rather than a review.