#!/bin/rc # fwtest [fw] - check fw against the things that have broken before. # # Every check here is a bug that once shipped. Run it after touching # anything; it needs no network of its own and does not disturb the # machine's, because the packet checks run between two IP stacks it # makes for itself. # # Card mode is not covered: it takes the machine's card away, and a # test that can leave you with no network is a test nobody runs. # # Run it twice in a row. It used to pass the first time and fail the # second, because it left its firewalls running and their interfaces # bound, and the next run's stack routed out the dead one; the failure # was blamed on timing in a comment that told the next person to # disbelieve a real result. The last two checks exist so that cannot # happen again quietly. rfork ne fw=$1 if(~ $#fw 0) fw=/bin/fw if(! test -x $fw){ echo fwtest: no $fw >[1=2] exit nofw } # where the source is, for the build check; skipped if it is not here src=$2 if(~ $#src 0) src=/sys/src/cmd/fw tmp=/tmp/fwtest.$pid mkdir -p $tmp nA=$tmp/nA nB=$tmp/nB mkdir -p $nA $nB mtpt=$tmp/ctl mkdir -p $mtpt # The stacks the packet checks build for themselves. Fixed numbers are # fine because they are unbound again at the end of each block, and the # last check proves it. stacks=(20 21 22 23) # A port of our own. Conversations outlive the run that made them - a # devip Conv is never freed - so a fixed port makes one run's leftovers # into the next run's "address in use". port=`{echo $pid | awk '{print 17000 + ($1 % 900)}'} # Results go to a file, not to variables: every check below runs inside # an @{} that needs its own namespace, and an assignment in there never # reaches the parent. Counting in variables silently reported one pass # out of seventeen. res=$tmp/results >$res >$tmp/why # check fn check { if(~ $2 $3){ echo ok >> $res echo ' ok ' $1 } if not { echo FAIL >> $res echo ' FAIL ' $1 echo ' want: '$2 echo ' got: '$3 if(test -s $tmp/why) echo ' said: '`{cat $tmp/why} } >$tmp/why } # Did that write succeed? # # Not with rc's own redirection: a failed > is reported by the shell # itself and escapes any >[2] put around it, so there is no way to see # what went wrong. cp does the same create(2) and reports it on its # own standard error, where a failing check can print it. fn wr { echo -n $2 > $tmp/in >$tmp/why if(@{ cp $tmp/in $1 } >[2] $tmp/why) echo ok if not echo refused } fn rd { >$tmp/why if(@{ cat $1 >/dev/null } >[2] $tmp/why) echo ok if not echo refused } # Is it there at all, on the real /net? # # Every "refused" check below is also passed by a path that does not # exist, which is how a check for a hole can go green on a kernel that # never had the hole to begin with. So each one is paired with this, # asked outside the sandbox: the thing being refused has to be a thing. fn have { if(test -e $1) echo yes if not echo no } # Is it gone from the served tree? # # A stat, not a read. Reading /net/log, or an interface's data or snoop # file, blocks until traffic arrives, so a check that read them would # hang rather than fail on exactly the build that still serves them - # and a test that hangs on a regression is worse than no test. fn gone { if(test -e $1) echo there if not echo gone } # Stop the firewalls a packet check started. # # Take the interfaces away and fw follows: the relay's read fails and # threadexitsall takes the rest of it down. Nothing else can find # these processes - fw daemonizes, so the shell's $apid is gone before # the server exists, and ps shows fw no arguments to match on - and # "kill fw" would be wrong on a machine running a real one. Doing it # this way also exercises the fail-closed path: if one relay ever goes # back to dying quietly, the leak checks at the end catch it. fn stopfw { for(d in $*) for(i in `{ls -p $d/ipifc | grep '^[0-9]'}) @{ echo -n unbind > $d/ipifc/$i/ctl } >[2]/dev/null sleep 2 } # fw processes belonging to anyone, counted the same way twice fn nfw { ps | awk '$NF == "fw"' | wc -l } fwbefore=`{nfw} echo '== it builds without complaint' # A warning is a finding nobody had to look for. Two stood in fw.c for # as long as the program has existed, which is how long it has been # since anyone read the output of mk. if(test -d $src){ if(@{ cd $src && mk clean && mk all } >$tmp/build >[2=1]) r=ok if not r=failed check 'the source still builds' ok $"r grep warning $tmp/build > $tmp/why r=`{grep -c warning $tmp/build} check 'and the compiler has nothing to say about it' 0 $"r } if not echo ' skip no '^$src^', not checking the build' echo '== rules: parsing' cat > $tmp/bad.ndb <<'!' allow=out prot=tcp ! r=`{$fw $tmp/bad.ndb >[2=1] | sed 's/.*: //' | sed 1q} check 'a mistyped attribute is fatal' 'unknown attribute' $"r cat > $tmp/empty.ndb <<'!' # nothing ! echo '== namespace mode: what is refused is there to refuse' for(p in /net/udp/clone /net/gre/clone /net/ndb /net/log /net/ipifc/0/data \ /net/ipifc/0/snoop /net/tcp/trans /net/ether0/clone /net/ipmux/clone){ r=`{have $p} check 'the real /net has '^$p yes $"r } echo '== namespace mode' @{ rfork n $fw $tmp/empty.ndb >[2]/dev/null r=`{wr /net/tcp/clone 'connect 10.0.0.1!80'} check 'an empty rule set denies connect' refused $"r r=`{wr /net/udp/clone 'headers'} check 'headers is refused (it sends without connect)' refused $"r r=`{wr /net/gre/clone 'raw'} check 'gre raw is refused' refused $"r r=`{wr /net/tcp/clone 'ttl 32'} check 'ttl is allowed' ok $"r r=`{wr /net/ndb 'x'} check '/net/ndb is not writable' refused $"r # trans installs a kernel address translation and devip gates it # with iseve() -- which is fw's identity through here, not the # caller's, so on a machine where fw runs as eve there was no gate # at all. Opening it with truncation also flushed the table. r=`{gone /net/tcp/trans} check 'a protocol trans file is not served' gone $"r r=`{wr /net/tcp/trans '10.9.9.9 80 10.9.9.8 1234 10.9.9.7 5678'} check 'and cannot be written' refused $"r # reading log is the leak, not writing it: turn it on elsewhere and # it traces every connection on the machine r=`{gone /net/log} check '/net/log is not served at all' gone $"r r=`{gone /net/ipifc/0/data} check 'an interface data file is a wire, and is not served' gone $"r r=`{gone /net/ipifc/0/snoop} check 'nor is its snoop file, which is the same wire' gone $"r r=`{rd /net/ipifc/0/status} check 'but its status still is' ok $"r r=`{gone /net/ether0/clone} check 'a card is not served, clone file or no' gone $"r r=`{gone /net/ipmux/clone} check 'nor is ipmux' gone $"r # The mount driver splits a path before it sends it, so this only # asks that the obvious way out is shut; a compound name arriving # as one walk element needs a client speaking 9P straight to the # server, and splitpath is what refuses that. r=`{gone '/net/tcp/../../adm/keys'} check 'nothing outside /net resolves through it' gone $"r # what a program still needs r=`{wr /net/cs 'tcp!10.9.9.9!80'} check 'cs still translates' ok $"r r=`{rd /net/arp} check 'arp is still readable' ok $"r r=`{rd /net/ndb} check 'ndb is still readable' ok $"r r=`{rd /net/iproute} check 'iproute is still readable' ok $"r r=`{ls -p /net | grep -c '^(log|ipmux|ether0|ether1)$'} check 'none of them are even listed' 0 $"r r=`{ls -p /net/tcp | grep -c '^trans$'} check 'and trans is not listed either' 0 $"r r=`{ls -p /net/tcp | grep -c '^clone$'} check 'while clone still is' 1 $"r } @{ rfork n echo 'allow=in proto=tcp lport='^$port > $tmp/lport.ndb $fw $tmp/lport.ndb >[2]/dev/null r=`{wr /net/tcp/clone 'announce '^$port} check 'announce matches lport, not port' ok $"r r=`{wr /net/tcp/clone 'announce 17098'} check 'a different port is denied' refused $"r } echo '== rules: round-trip through ctl' cat > $tmp/ip.ndb <<'!' allow=out proto=tcp ip=10.9.0.0/24 port=80 deny=* log=yes ! @{ rfork n bind -a '#I20' $nA bind -a '#I21' $nB $fw -m $mtpt $tmp/ip.ndb $nA^'!'^10.9.9.1^'!'^/24 $nB^'!'^10.9.9.2^'!'^/24 >[2]/dev/null & sleep 3 r=`{grep -c '%M' $mtpt/rules} check 'a mask prints as a mask, not %M%' 0 $"r r=`{wr $mtpt/ctl 'append deny=out proto=udp'} check 'a rule set with ip= survives a ctl edit' ok $"r r=`{grep -c . $mtpt/rules} check 'the appended rule is there' 3 $"r # two writes, one open: the fragmented case @{ echo 'allow=out proto=tcp port=80' echo 'deny=* log=yes' } > $mtpt/rules sleep 1 r=`{grep -c . $mtpt/rules} check 'a rule set written in two writes is not truncated' 2 $"r r=`{wr $mtpt/ctl 'reload '^$tmp/bad.ndb} check 'reloading a bad file is refused' refused $"r r=`{grep -c . $mtpt/rules} check 'and leaves the old rules alone' 2 $"r r=`{wr $mtpt/ctl 'delete 0'} check 'delete 0 is refused' refused $"r stopfw $nA $nB } echo '== packets, between two stacks' @{ rfork n echo 'allow=in proto=tcp lport='^$port > $tmp/wire.ndb bind -a '#I22' $nA bind -a '#I23' $nB $fw -m $mtpt $tmp/wire.ndb $nA^'!'^10.9.9.1^'!'^/24 $nB^'!'^10.9.9.2^'!'^/24 >[2]/dev/null & sleep 3 r=`{cat $nA/ipifc/0/status | sed 1q | awk '{print $4}'} check 'the pkt interface does not claim a 4096 mtu' 1500 $"r @{ echo -n 'announce '^$port; sleep 15 } > $nB/tcp/clone & sleep 3 @{ echo -n 'connect 10.9.9.2!'^$port; sleep 12 } > $nA/tcp/clone & sleep 6 r=`{grep -c $port $mtpt/flows} check 'a permitted connection crosses, and is tracked' 1 $"r # one rule, both directions: state, not a second rule r=`{grep -c . $mtpt/rules} check 'it took one rule to do that' 1 $"r echo -n 'prepend deny=in proto=tcp lport='^$port > $mtpt/ctl sleep 1 r=`{grep -c $port $mtpt/flows} check 'blocking a port drops the live connection' 0 $"r stopfw $nA $nB } echo '== the test cleans up after itself' r=`{nfw} check 'no fw processes are left running' $"fwbefore $"r >$tmp/ifcs for(s in $stacks) @{ rfork n mkdir -p $tmp/z bind -a '#I'^$s $tmp/z for(i in `{ls -p $tmp/z/ipifc | grep '^[0-9]'}) sed 1q $tmp/z/ipifc/$i/status >> $tmp/ifcs } r=`{grep -c 'device pkt' $tmp/ifcs} check 'no interfaces are left bound, so the next run is clean' 0 $"r echo npass=`{grep -c '^ok' $res} nfail=`{grep -c '^FAIL' $res} echo $"npass' passed, '$"nfail' failed' rm -rf $tmp if(! ~ $"nfail 0) exit failed exit ''