#!/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 # where the scripts are, likewise lib=$3 if(~ $#lib 0) lib=/lib/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 24 25) # 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 '== a peer is checked when there is one to check' # An announce names no peer - nobody has called yet - so a rule naming # one was skipped, and "deny=in ip=..." did nothing here while doing # something at the packet layer. Both rule sets below permit the # announce by port; only the second permits the caller. The machine's # own address stands in for a peer, since it has no loopback. myip=`{awk '/4u$/ {print $1}' /net/ipselftab | sed 1q} if(~ $#myip 0) echo ' skip no IPv4 address on this machine to call' if not { { echo 'deny=in proto=tcp ip='^$myip^' log=yes' echo 'allow=in proto=tcp lport='^$port echo 'allow=out proto=tcp ip='^$myip^' port='^$port } > $tmp/peerno.ndb { echo 'allow=in proto=tcp lport='^$port^' log=yes' echo 'allow=out proto=tcp ip='^$myip^' port='^$port } > $tmp/peeryes.ndb # listentest : announce, have somebody call, and let fw # decide. What it decided is in the log; the announce is logged # too, so the checks look for "listen" and not for the verb alone. fn listentest { @{ rfork n $fw $1 >[2]/dev/null @{ conv=`{cat /fd/0} echo -n 'announce '^$myip^'!'^$port >[1=0] @{ cat /net/tcp/$conv/listen } >/dev/null >[2]/dev/null } <>[0] /net/tcp/clone >/dev/null & lpid=$apid sleep 2 wr /net/tcp/clone 'connect '^$myip^'!'^$port >/dev/null sleep 3 @{ echo kill > /proc/$lpid/note } >[2]/dev/null } } # differences, not totals: the caller's port is ephemeral, so there # is nothing in the line that belongs to this run, and /sys/log/fw # keeps what earlier runs put there before=`{grep -c 'deny tcp listen '^$myip /sys/log/fw} listentest $tmp/peerno.ndb sleep 1 after=`{grep -c 'deny tcp listen '^$myip /sys/log/fw} r=`{echo $before $after | awk '{print $2 - $1}'} check 'a rule naming the caller refuses the connection' 1 $"r before=`{grep -c 'allow tcp listen '^$myip /sys/log/fw} listentest $tmp/peeryes.ndb sleep 1 after=`{grep -c 'allow tcp listen '^$myip /sys/log/fw} r=`{echo $before $after | awk '{print $2 - $1}'} check 'and one permitting it lets the program have it' 1 $"r } echo '== logging and accounting' # /sys/log/fw is fw's only durable output. syslog(2) does not create # it, so make it if it is not there and take it away again if we did. madelog=no if(! test -f /sys/log/fw){ >/sys/log/fw chmod 666 /sys/log/fw madelog=yes } @{ rfork n { echo 'deny=out proto=tcp ip=127.0.0.2 log=yes' echo 'allow=out proto=tcp ip=127.0.0.1 port='^$port^' log=yes' } > $tmp/log.ndb $fw $tmp/log.ndb >[2]/dev/null wr /net/tcp/clone 'connect 127.0.0.2!'^$port >/dev/null wr /net/tcp/clone 'connect 127.0.0.1!'^$port >/dev/null } sleep 1 # the port is this run's, so the whole file can be searched r=`{grep -c 'deny tcp connect 127.0.0.2!'^$port^': denied by rule 1' /sys/log/fw} check 'a denied connection is logged, with the rule that said so' 1 $"r r=`{grep -c 'allow tcp connect 127.0.0.1!'^$port /sys/log/fw} check 'and a permitted one, when the rule asks' 1 $"r if(~ $madelog yes) rm -f /sys/log/fw 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 # A rule set used to be formatted into 64K and clipped, and since # every ctl edit works by formatting the set out, editing the text # and parsing it back, editing one rule past the limit deleted # every rule after it. This set is about 104K. awk 'BEGIN{for(i = 0; i < 2000; i++) printf "allow=out\tproto=tcp\tip=10.9.0.0/24\tport=%d\n", 1000+i}' \ > $tmp/big.ndb r=`{wr $mtpt/ctl 'reload '^$tmp/big.ndb} check 'a rule set larger than 64K loads' ok $"r r=`{grep -c . $mtpt/rules} check 'and all of it comes back' 2000 $"r r=`{wr $mtpt/ctl 'append deny=out proto=udp'} check 'and survives an edit' ok $"r r=`{grep -c . $mtpt/rules} check 'with nothing lost off the end' 2001 $"r r=`{wr $mtpt/ctl 'save '^$tmp/saved.ndb} check 'and saves' ok $"r r=`{grep -c . $tmp/saved.ndb} check 'all of itself' 2001 $"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 # A rule edit rebuilds the set, so every count starts at zero; what # runs next is revalidate, re-checking the live flow against the new # rules. That is not traffic. Counting it made "how often has this # rule decided something" answer a different question after every # edit. echo -n 'append deny=out proto=udp' > $mtpt/ctl sleep 1 r=`{awk '/allow=in/ {print $1}' $mtpt/stats} check 'rechecking flows after an edit is not a decision' 0 $"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 '== a fragmented datagram crosses' # Only the first fragment carries the transport header, so the rest # match no port and a rule set written in ports denied them: the first # fragment crossed and the receiver waited for the others until it gave # up. 3000 bytes over a 1500 mtu is three fragments. @{ rfork n echo 'allow=in proto=udp lport='^$port > $tmp/frag.ndb bind -a '#I24' $nA bind -a '#I25' $nB $fw -m $mtpt $tmp/frag.ndb $nA^'!'^10.9.9.1^'!'^/24 $nB^'!'^10.9.9.2^'!'^/24 >[2]/dev/null & sleep 3 dd -if /dev/zero -of $tmp/big -bs 3000 -count 1 >[2]/dev/null # a difference, not a total: an IP stack outlives the run that made # it, and so does its count of datagrams before=`{awk '/^InDatagrams/ {print $2}' $nB/udp/stats} @{ conv=`{cat /fd/0} echo -n 'announce 10.9.9.2!'^$port >[1=0] sleep 12 } <>[0] $nB/udp/clone & sleep 2 @{ conv=`{cat /fd/0} echo -n 'connect 10.9.9.2!'^$port >[1=0] dd -if $tmp/big -of $nA/udp/$conv/data -bs 3000 -count 1 >[2]/dev/null } <>[0] $nA/udp/clone sleep 3 r=`{awk '/^passed/ {print $2}' $mtpt/stats} check 'every fragment of it is passed' 3 $"r r=`{awk '/^dropped/ {print $2}' $mtpt/stats} check 'and none of them dropped' 0 $"r # one decision, not three: the first fragment consults the rules # and the rest of the train inherits what it decided r=`{awk '/allow=in/ {print $1}' $mtpt/stats} check 'the rules are consulted once for the datagram' 1 $"r # the far stack's own count, which does not depend on us reading it after=`{awk '/^InDatagrams/ {print $2}' $nB/udp/stats} r=`{echo $before $after | awk '{print $2 - $1}'} check 'and the far side reassembles it' 1 $"r stopfw $nA $nB } echo '== fwstart' if(test -f $lib/fwstart) @{ rfork n # /mnt as it is at boot: a real directory, with no fw in it yet. # -c because without it the bound directory forbids creation and # fwstart's mkdir fails for a reason the real /mnt would not give. mkdir -p $tmp/mnt bind -c $tmp/mnt /mnt >$tmp/marker echo 'fw=nosuchether rules='^$tmp/empty.ndb > $tmp/fwcfg @{ rc $lib/fwstart $tmp/fwcfg } <$tmp/marker >$tmp/fwout >[2=1] sleep 1 # mount(2) needs the mount point to exist, so the old # "mntgen /mnt/fw only if /mnt/fw is missing" ran it exactly when # it could not work, and every card's control directory failed r=`{if(test -d /mnt/fw/whatever) echo yes; if not echo no} check 'a control directory appears for each card' yes $"r r=`{grep -c nosuchether $tmp/fwout} check 'a card that is not there is reported and skipped' 1 $"r # anything it leaves running holds the descriptors it was started # with, and on a console that is the console r=`{cat /proc/*/fd >[2]/dev/null | grep -c $tmp/marker} check 'and nothing it started kept our descriptors' 0 $"r } if not echo ' skip no '^$lib^'/fwstart' 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 ''