Reported symptoms
On Tuesday an engineer closed an audit finding across the whole border fleet.
The finding was that management services accepted connections from any source;
the change added an explicit source list to the SSH and HTTPS API accept rules
in each router’s input filter. Fourteen routers, one window, commit-confirm on
every one, all fourteen confirmed and saved.
On Thursday morning the on-call engineer needs to look at edge3 during an
unrelated partner circuit problem and cannot get in.
The picture that greets the bridge call is contradictory in every direction:
- SSH to
edge3,edge4andedge5from the EU-South jump host hangs and eventually times out. It is not refused — there is no immediate error, just silence. - All three routers answer ICMP from the same jump host. Their BGP sessions are Established. Traffic through the region is completely unaffected, and the partner circuit problem is real and now unfixable.
- Monitoring is green for all three, including the management-plane checks.
- The other eleven routers, which got the identical change in the same window, are reachable normally.
Three routers, one region, everything else healthy. The first hour goes to the transit provider, because that is what a regional problem looks like.
Two more facts arrive and make it worse rather than better. The engineer who
made the change is certain they verified it, and their session log backs them
up: eleven unbroken minutes of connectivity after the commit, then confirm,
then save. And the out-of-band runbook names a console server for the site
whose hostname no longer resolves.
Somebody requests a remote power cycle. It is performed. Nothing changes.
Evidence provided
ops@jump-eu-south:~$ ssh -v edge3.eu-south.example.comOpenSSH_9.2p1 Debian-2, OpenSSL 3.0.11
debug1: Connecting to edge3.eu-south.example.com [203.0.113.9] port 22.
ssh: connect to host edge3.eu-south.example.com port 22: Connection timed outIllustrative output
ops@jump-eu-south:~$ ping -c 3 edge3.eu-south.example.comPING edge3.eu-south.example.com (203.0.113.9) 56(84) bytes of data.
64 bytes from 203.0.113.9: icmp_seq=1 ttl=61 time=8.42 ms
64 bytes from 203.0.113.9: icmp_seq=2 ttl=61 time=8.39 ms
64 bytes from 203.0.113.9: icmp_seq=3 ttl=61 time=8.44 ms
--- edge3.eu-south.example.com ping statistics ---
3 packets transmitted, 3 received, 0% packet lossIllustrative output
ops@collector01:~$ ssh vyos@edge3.eu-south.example.com 'show version | head -2'Version: VyOS 1.5-rolling
Release train: circinusIllustrative output
vyos@edge3:~$ show firewall ipv4 input filterRule Action Protocol Source Packets Bytes
10 accept all 0.0.0.0/0 4812390 1204M state established
11 accept all 0.0.0.0/0 21744 3021K state related
15 drop all 0.0.0.0/0 12 768 state invalid
20 accept tcp 198.51.100.0/24 1904 221K dport 22
25 accept tcp 198.51.100.0/24 840 96K dport 443
30 accept icmp 0.0.0.0/0 112044 9M
default drop 36219 2174KIllustrative output
vyos@edge3:~$ show configuration commands | match 'input filter rule 2'set firewall ipv4 input filter rule 20 action 'accept'
set firewall ipv4 input filter rule 20 protocol 'tcp'
set firewall ipv4 input filter rule 20 destination port '22'
set firewall ipv4 input filter rule 20 source address '198.51.100.0/24'
set firewall ipv4 input filter rule 25 action 'accept'
set firewall ipv4 input filter rule 25 protocol 'tcp'
set firewall ipv4 input filter rule 25 destination port '443'
set firewall ipv4 input filter rule 25 source address '198.51.100.0/24'Illustrative output
ops@collector01:~$ getent hosts jump-eu-south.example.com203.0.113.71 jump-eu-south.example.comIllustrative output
vyos@edge3:~$ show system commit | head -40 2026-08-18 14:07:52 by netops via cli SEC-1180 restrict mgmt sources
1 2026-06-02 10:44:19 by netops via cli bgp timers per SEC-0994
2 2026-05-19 08:16:03 by netops via cli syslog target change
3 2026-04-28 16:30:41 by netops via cli interface description tidyIllustrative output
Work the evidence before reading on
The router is refusing you and accepting somebody else at the same instant.
- SSH from the jump host times out; SSH from the collector succeeds; ping works from both. What is different between those two clients, and which field in rule 20 is it?
- Rule 20’s packet counter is not zero. Something is matching it. Whose traffic is that, and does a non-zero counter mean the rule is doing what it was written to do?
- Rule 10 accepts established connections and sits above rule 20. The engineer held a session through the commit and for eleven minutes afterwards. Which rule was carrying that session, and what would the change have had to do to interrupt it?
commit-confirm,confirmandsavewere all used, in that order, by the book. Trace what each of them could have detected on Tuesday afternoon, given the answer to question 3.- The power cycle did not help. What does
savedo, and what would a reboot have done ifsavehad not been typed?
Then the question that resolves it: what test could the engineer have run on Tuesday that would have failed?
Root cause
1. The source list came from a register that has been wrong since March
The change is well built. An explicit source list on the management accept rules is exactly what the audit asked for, and it was rolled to the whole fleet in one window with a confirm timer on every router.
The source list came from the network register, which is the estate’s system of record for management ranges, and which says the EU-South jump host is in 198.51.100.0/24. It moved to a new colo in March and was renumbered into 203.0.113.64/26. DNS was updated in the same change. The register was not.
Eleven routers are managed from jump hosts that never moved, so eleven routers were unaffected. The three that were affected are all in one region because the stale entry is regional, which is precisely why the fault impersonated a regional network problem for the first hour.
The rule is doing exactly what it was told. Rule 20’s counter is non-zero because 198.51.100.0/24 is still a real range with real hosts in it — the central monitoring collector is one of them — so the rule looks alive, and its liveness is unrelated to whether it lets the right people in.
2. The change is invisible to every connection that already exists
This is the half that turns a routine mistake into a two-day outage.
The input filter accepts established connections in rule 10, above the management rules. That is the standard build and it is correct: a stateful firewall classifies each packet against the connection table first, and a packet belonging to a flow that already has state is accepted there and never reaches the rules further down.
The engineer’s SSH session was established before the commit. After the commit it kept matching rule 10, exactly as it had before, and nothing about it changed. It would have survived any source-list value at all, including one with no overlap with reality whatsoever.
So the change applies only to connections that do not exist yet, and the only connection available to test it with was one that could not be affected by it. The eleven minutes of unbroken connectivity in the session log are real, and they are evidence of nothing.
3. Three safety practices ran correctly and none of them could see it
commit-confirm schedules a rollback if the operator does not confirm. It
protects against a change whose damage is visible inside the window. Here the
window elapsed with a healthy session as the only signal.
confirm was the correct action given that signal. save persisted a
configuration that had been tested as far as anybody knew how to test it.
And save is why the power cycle failed. Without it, a reboot would have
brought the router up on the last saved configuration — the one from June — and
restored access, which is why “reboot it” is a genuine recovery path on VyOS and
why it is a genuine recovery path exactly once, before somebody saves.
4. The recovery path that was supposed to catch this had been removed
The out-of-band runbook names a console server for the site. It was decommissioned in May in a cost-saving exercise, and the runbook was not updated, so the estate believed it had console access to these routers and did not.
Nobody discovered that on a quiet afternoon. They discovered it on a bridge call, forty minutes in, which is the worst possible moment to learn that a control does not exist.
Resolution
- Get in from a source the rule still permits, before reaching for anything heavier. The routers are running and accepting management connections from 198.51.100.0/24, which is where the central monitoring collector lives. That host is the way in, and it is available immediately.
- Stop the power-cycle request. A reboot cannot help: the configuration was saved, so the router boots straight back into the rule that is refusing you, and the reboot costs a forwarding outage the region does not currently have.
- Confirm the diagnosis from the router before changing it.
show firewall ipv4 input filterand the jump host's actual address take under a minute together, and they distinguish this from an SSH daemon problem, a routing problem or a transit problem — all of which are still live theories on the bridge. - Widen the rule with the security owner, not around them. This control exists to close an audit finding, so adding a range to it is a change to a security control. That conversation is short, and skipping it is how a lockout incident becomes a compliance incident.
- Apply the correction under
commit-confirmfrom the permitted host: add 203.0.113.64/26 to rules 20 and 25. One router first, not all three, so that a mistake costs one router rather than the region. - Verify from a NEW connection from the jump host before confirming. Not the session you are holding, not a reconnect from the collector — a fresh connection from the client that could not get in. This is the step whose absence caused the incident, so it is the step that closes it.
confirm, thensave, in that order and not before the new connection succeeded. Then repeat on the remaining two routers and verify each one individually; they were changed as a batch and can be wrong as a batch.- Correct the network register in the same ticket. Adding the range without fixing the source of truth leaves the identical trap armed for the next jump host that moves, and the change that moved this one updated DNS correctly, so the register is the part of the process that failed.
- Raise the console server separately and treat it as a finding in its own right. The estate believed it had out-of-band access to these routers for three months and did not, which is a gap in the recovery plan for every device behind it, not a detail of this incident.
- Check the eleven working routers for the same latent dependency rather than filing them as unaffected. They are working because their jump hosts have not moved yet.
Verification
- A new SSH connection from the EU-South jump host, opened after the change, succeeds. Not the session you are holding and not a reconnect from the host you fixed it from — the client that could not get in.
- The HTTPS API answers from the same client if the API is part of the allowlist. Two services were restricted and only one of them is the one everybody tested.
- The counters agree with the intent.
show firewall ipv4 input filtershows the management accept rules incrementing as you connect, and thedefault-action dropcounter no longer climbing in step with management attempts. - The control is still a control. Connect from an address that is not on the list and confirm it is refused. A rule widened to 0.0.0.0/0 by accident would also make every check above pass.
- All three routers pass individually. They were changed as a batch, so infer nothing about the second and third from the first.
- The running and saved configurations agree, so a reboot restores access rather than removing it. This is the property that made the power cycle useless, and it is worth confirming deliberately rather than assuming.
- The nightly configuration backup succeeds for all three routers. That job failed on Tuesday night and again on Wednesday night, thirty-six hours before anybody noticed, and it is the off-router signal that this class of fault produces first.
Prevention
- Never verify a management-path change on the connection you are already holding. The established-state rule accepts your session regardless of what you just changed, so the test is a new connection from the intended client, ideally opened by somebody who is not you.
- Make the second session an explicit step in the remote-change procedure, and
size the
commit-confirmwindow around how long that takes rather than around how long the commit takes. A step that is implicit is a step that is skipped under pressure. - Keep
saveout of the confirm window. The sequence is commit-confirm, test from a new session, confirm, save. Asavetyped early turns a self-reverting change into a permanent one and removes the reboot as a recovery path, which is two safety nets lost to one keystroke of muscle memory. - Treat out-of-band access as a control to exercise, not a hostname in a runbook. Connect to it on a schedule. A console server decommissioned in May is a change to the recovery plan for every router behind it.
- Build source allowlists from an inventory validated against reality, not from a register updated by convention. The renumbering here updated DNS and not the register, and the review that approved the change checked it against the register, so every step of the process was internally consistent and wrong.
- Read the signals you already collect. The configuration backup failed the same night, for exactly the right reason, into a digest nobody opens.