From f05383bb3447f7fbd44c9051c4e273caeb33352f Mon Sep 17 00:00:00 2001 From: Calvin Morrison Date: Wed, 19 Aug 2026 10:41:03 -0400 Subject: fw: a dead firewall leaves the network down, and can be restarted into it Two things can happen when a firewall dies: the traffic it was filtering carries on unfiltered, or it stops. Only the second is defensible. A machine briefly off the network is a machine somebody notices and fixes; a machine briefly on the network with no rules is the thing the firewall was installed to prevent, and nobody notices it at all. fw already does the second, in all three modes, and by mechanism rather than by care. Measured rather than assumed: before fw device: /net/ether1 addr: 10.9.9.1 route: 10.9.9.254 fw running device: pkt0 addr: 10.9.9.1 route: 10.9.9.254 fw killed device: addr: route: pktmedium is unbindonclose, so the interface and the address go when fw's fds close, and the card is left bound to nothing with nothing reading it. In a namespace it is harder still: /net answers "i/o on hungup channel" and bind -a '#I' /net answers "mount/attach disallowed", because the device mask was dropped before the program started. So todo item 1 -- "a dead fw takes the network with it", open since the first commit -- was the requirement written down as a defect. It is now design.md and a FAILURE section in fw(8), and the tests assert it, which is the point: this is exactly the property a later helpful change reverses without meaning to. putback() was that change, written and never run; deleting it removed a fail-open path, not just dead code. What was actually broken is recovery, and in a way nobody had reached: a fw that dies leaves its control filesystem mounted, and a corpse of a mount fails everything asked of it -- including the access() check fw makes before touching a card, which then refuses the restart: fw: /tmp/rdbg/ctl: clone failed; not touching /net/ether1 until it exists That check exists so fw does not take a card it cannot then serve, and it was keeping fw from ever coming back. Now the dead mount is cleared first, the same way reclaim() clears the pkt interface the same dead fw left behind: both are its own wreckage. With that and -a/-g in the service file -- so a restart does not need the address it just lost -- the whole cycle works and never passes through open: crash, network down, restart, network up and filtered. Ten checks, three of which fail against the previous fw.c. 87 pass. Co-Authored-By: Claude Opus 5 --- fw/test/fwtest.rc | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) (limited to 'fw/test') diff --git a/fw/test/fwtest.rc b/fw/test/fwtest.rc index 3f80242..bd7423d 100755 --- a/fw/test/fwtest.rc +++ b/fw/test/fwtest.rc @@ -547,10 +547,71 @@ echo '== a card' r=`{awk '$1 == "0.0.0.0" {print $3}' $tmp/nC/iproute | sed 1q} check 'and the route that went with the card' 10.9.9.254 $"r + # The question a firewall has to answer about its own death: + # does the network come back open, or not at all. A note is + # a fair stand-in for a crash - what matters is the kernel + # closing what fw held, which happens either way. No other + # fw is running here; the leak checks at the end say so. + kill fw | rc + sleep 3 + r=`{ps | awk '$NF == "fw"' | wc -l} + check 'killing one part kills the firewall' 0 $"r + r=`{awk '/4u$/ {print $1}' $tmp/nC/ipselftab} + check 'a dead fw leaves the stack no address' '' $"r + r=`{awk '$1 == "0.0.0.0" {print $3}' $tmp/nC/iproute | sed 1q} + check 'and no route: the network is down, not open' '' $"r + # "device" with nothing after it: the pkt interface is gone + # and the card was never given back + r=`{sed 1q $tmp/nC/ipifc/0/status | awk '{print $2}'} + check 'and nothing bound to hand the card back' maxtu $"r + + # recovery, without ever passing through open. The dead fw + # also left its control filesystem mounted, and a corpse of a + # mount fails every operation on it - including the check fw + # makes before touching a card. So a restart has that to get + # past too. + @{ $fw -n $tmp/nC -e /net/ether1 -m $mtpt $tmp/empty.ndb } >[2]/dev/null + if(~ $status '') + r=started + if not + r=refused + check 'it will not restart on a guess' refused $"r + + $fw -n $tmp/nC -e /net/ether1 -a 10.9.9.1/24 -g 10.9.9.254 \ + -m $mtpt $tmp/empty.ndb >[2] $tmp/rerr + sleep 2 + r=`{grep -c 'cleared '^$mtpt $tmp/rerr} + check 'the control mount the dead one left is cleared away' 1 $"r + r=`{awk '/4u$/ {print $1}' $tmp/nC/ipselftab} + check 'but does when told the address the card no longer has' 10.9.9.1 $"r + r=`{awk '$1 == "0.0.0.0" {print $3}' $tmp/nC/iproute | sed 1q} + check 'route and all' 10.9.9.254 $"r + stopfw $tmp/nC } } +echo '== a dead firewall leaves a sandbox with no network either' +@{ + rfork n + $fw $tmp/empty.ndb >[2]/dev/null + # the drop is per process group and this block has its own, so it + # reaches this shell and its children and nothing else + echo 'chdev' '&~' 'Iluσ' > /dev/drivers + kill fw | rc + sleep 3 + if(@{ ls /net/tcp/clone } >/dev/null >[2]/dev/null) + r=there + if not + r=gone + check 'the filtered /net dies with it' gone $"r + if(@{ bind -a '#I' /net } >[2]/dev/null) + r=allowed + if not + r=refused + check 'and the real one cannot be bound back in its place' refused $"r +} + echo '== supervision' # fw daemonizes, like every other Plan 9 file server: the process you # exec posts to /srv, forks the server and exits. A supervisor watches -- cgit v1.2.3