summaryrefslogtreecommitdiff
path: root/fw/src
AgeCommit message (Collapse)Author
4 daysfw: make the /srv name worth watching, and say how to supervise itCalvin Morrison
todo.md said fw daemonizing meant svc could not supervise it, and that it needed a foreground mode. Both wrong. svc has had the shape from the start. ready=srv: watches the /srv name rather than the pid, and its own manual says why: "Use this for a service that posts to /srv and lets the process you started exit, which many Plan 9 file servers do on purpose. For these the /srv file is watched and the process is not, so the process exiting is normal and never causes a restart." init.c agrees -- reap() returns early for Ksrv with that comment on it. Nor is detaching unusual. 42 commands under /sys/src/cmd use postmountsrv or threadpostmountsrv; five call srv() directly, and every one of those is a stdio server (ramfs -i, ext4srv -s, skelfs, hjfs, wacom) speaking 9P on file descriptors it was handed. That is not a foreground service, it is a pipe server: no /srv, no mount, nothing to supervise. A foreground mode for fw would buy a pid to watch, and the /srv name is the better signal -- it survives the process that made it. What was true underneath the wrong diagnosis: the name was not honest. Taking the control filesystem away ends the server proc, and the relays carried on filtering: procs: 3 ... take the ctl filesystem away ... procs after: 2 still filtering? pkt interface: pkt0 A firewall nobody can reach, stop, or notice, and the /srv name gone while it runs. Srv.end now takes the whole thing down, which is the answer the relays already gave when their wire failed. Same test after: three procs become none and the interface goes with them. So: no flag, an example service file in lib/svc, and a SUPERVISION section in fw(8) that says what init should watch and what restarting will and will not fix. Restart=always brings fw back; it does not undo taking the card, so the new fw has no address to read. Supervision works, recovery does not, and that stays open as item 1. Two checks. Against the previous fw.c they fail with two orphaned procs and a pkt interface still bound -- and so do the leak checks at the end of the run, which is what they were built for. 77 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 daysether: a frame addressed to somebody else is not oursCalvin Morrison
fw asks the card for promiscuous mode and then hands the protected stack every IP frame that arrives on it, whoever it was for. On a switched network that is mostly nothing; on anything else it is the neighbours' traffic, judged against the rules, counted in stats, and entered in the flow table as conversations that were never ours. A flow created that way outlives the packet that made it and will let traffic past that no rule was asked about. Promiscuous is still needed. The stack behind fw joins multicast groups on a pkt interface, which has no way to tell a card about them, so without it the groups would never be received at all. What it costs is the filter ethermux would otherwise have applied: if(!tome && !multi && !f->prom) continue; Frames addressed to this card, plus broadcast and multicast, would have arrived for nothing. So etherin puts that test back itself: the destination is ours, or it is a group address, or the frame is not ours to look at. No test. This is on the wire side of card mode, and the suite can take a spare card but cannot make a neighbour send to it. Reproducing it needs a second machine on the same segment, which is on the list of things never tested and stays there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 daysfw: read the interface tables a line at a time, and test card mode at allCalvin Morrison
learnaddr, reclaim and takecard each read a status or route file into a fixed 1024-byte buffer and split it into at most eight lines. I called this a defect that could lose the default route. It could not: routes come out sorted, and 0.0.0.0 sorts first, so the default route is on the first line of the table and both limits are reached long after it has been found. The report was wrong about the consequence. The limits are still worth removing, and one thing in there was a real mistake: learnaddr and takecard looked for the device name anywhere in the status text, addresses included, rather than in the field that holds it. An interface whose address contained the name of the device being looked for would have matched. Contrived, but there is no reason to be searching a blob for something that has a place of its own. Bio reads line by line, the device is matched against the device field, and nothing has a length limit any more. More to the point, none of this had ever been run. Card mode is outside the suite because taking the machine's card away is how you end up with no network -- but a second card that nothing is using can be taken safely. The suite now binds #l1 into /net, puts it in an IP stack of its own with an address and a default route, and hands it to fw with no -a and no -g: fw: /net/ether1 has 10.9.9.1/120, gateway 10.9.9.254 fw: took /net/ether1 away from .../ipifc/0 fw: protected .../ipifc/0 addr 10.9.9.1 /120 fw: default route via 10.9.9.254 Six checks on that: the address and gateway are read off the interface rather than repeated on the command line, the card is taken, what replaces it is a pkt interface at the card's mtu holding the same address, and the route the card carried is put back. Skipped when there is no spare card, which is why it says so rather than passing quietly. 72 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 daysnetfs: check the caller when there is one to checkCalvin Morrison
An announce names no peer -- at that moment nobody has called -- so matchrule skipped every rule naming an address, and "deny=in ip=1.2.3.4" did nothing at all in namespace mode while doing something real at the packet layer. The code said so and called it right: A rule naming a peer therefore cannot apply to an announce, which is right: at this point there is no peer to name. Right about the announce, wrong about the connection. A rule that silently does nothing is the failure this program refuses to accept from a mistyped attribute -- fw will not start rather than run with "prot=tcp" ignored -- and it should not accept it from itself. So the peer is asked about at listen time, when there is one. The fd that listen yields is the new conversation's ctl file; its number reads out of it at offset 0, so the program's own read, the one listen(2) makes to learn the same number, still sees it. remote and local give the peer and the port announced. If the rules refuse, the connection is hung up and the open fails, and the program never has it. The handshake has already happened by then: the kernel answered before listen returned, and no filter at this altitude can prevent that. That is the difference between a rule that is late and a rule that is decorative, and it is worth the distinction. Two checks, on the same pair of rule sets, differing only in whether the caller is refused; both fail against the previous netfs.c. They read /sys/log/fw as a difference rather than a total: the caller's port is ephemeral, so nothing in the line belongs to this run, and the log keeps what earlier runs put there. The machine's own address stands in for a peer, since this one has no loopback configured -- announcing 127.0.0.1 gets "not a local IP address", and announcing a bare port binds to :: and never sees a v4 call at all. 66 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 daysfw: a fragmented datagram crossesCalvin Morrison
Only the first fragment of a datagram carries the transport header, so every later one matched no port, and a rule set written in ports -- which is every rule set in fwrules(6) -- denied it. Measured, 3000 bytes of UDP over a 1500 mtu, against a rule permitting the port: passed 1 dropped 2 The first fragment crossed and the receiver waited for the rest until it gave up. The alternative this replaced was worse: reading ports out of a later fragment lets one whose payload bytes happen to look like an open connection through, which is a firewall evasion older than most firewalls. So the first fragment decides and the rest of the train inherits. The train is what the receiving stack reassembles on -- protocol, addresses, identification -- and lasts about as long as that stack will hold the pieces. A train whose head we never saw is judged on its addresses alone, and so is normally denied: it is either an attack or the tail of a datagram we already refused. Same measurement after: passed 3 dropped 0 and one rule decision for the datagram rather than one per fragment. IPv6 fragments live in an extension header, which fw does not walk, so none of this reaches them; that stays in BUGS. Four checks, three of which fail against the previous fw.c. The fourth -- the far stack's own InDatagrams -- is read as a difference across the exchange, not a total: an IP stack outlives the run that made it, and reading the total made the check pass on a build that had dropped two thirds of the datagram. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 daysfw: the gateway relay decides the same way the card doesCalvin Morrison
relay() and permitted() were the same twenty lines twice: parse, work out which end is the peer, consult the flow table, consult the rules, log, count, remember. Two copies that had already drifted -- relay printed "(state)" and "(new)" under -d and permitted printed neither, and the two spelled the drop reason differently -- and every fix since has had to be made in both, which is how the copies drift further. Now relay reads a packet, asks permitted, and writes it or does not. The debug line permitted was missing is added rather than dropped, so -d still distinguishes a packet the flow table let through from one the rules did. No new checks: the point is that nothing changes. The gateway tests already cover this path -- a connection crossing, one rule serving both directions, a rule change killing a live flow -- and all 60 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 daysrules: a rule set is not 64 kilobytes longCalvin Morrison
fmtrules formatted into a 64K buffer with seprint, which clamps, and returned how much it had written. Nothing looked at whether that was everything. Since prepend, append and delete all work by formatting the whole set out, editing the text and parsing it back -- deliberately, so that a rule typed at ctl and a rule in a file go through one parser -- editing a set past the limit did not truncate the display, it truncated the rules. Measured with 2000 rules, about 104K formatted: and all of it comes back want: 2000 got: 1214 and survives an edit want: ok got: refused with nothing lost off the end got: 1214 786 rules gone from the running firewall, and the only sign is that the edit after it failed. save wrote the same short file, so reload would then have made the loss permanent. Now sized and allocated to fit. The bound is per rule -- the fixed attributes at their longest, plus the protocol, which is the only part whose length is ndb's choice rather than ours -- summed under the same lock that formats, so an install cannot get between the two passes. flows had the identical cap and gets the identical fix; on a busy firewall it is the file most likely to reach it. Rulebuf is gone. Six checks: a 2000-rule set loads, comes back whole, survives an edit, and saves whole. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 daysrules: matchrule stops returning things whose lifetime it does not ownCalvin Morrison
Three findings, one interface. matchrule handed back a Rule* and a pointer into a static char[128], both read by the caller after it had released rulelock: e = matchrule(verb, ..., &rule); if(rule != nil && rule->log) /* freed? */ syslog(0, "fw", "... %s", e); /* whose? */ A rule set installed between the return and those two lines frees the Rule under them, which is a narrow window but this is a firewall, and two procs deciding at once overwrite each other's reason -- in a program whose entire output is the reason. netfs.c ran multi-proc from the first blocking open and had its own static err with the same problem. Neither is a race you can test for; both stop existing if the answer lives in the caller's frame, so it does. Seven positional arguments become named fields while the signature is being rewritten anyway. The third is that revalidate could not ask without being counted. A rule edit rebuilds the set, so every hit count starts at zero, and then revalidate re-checks each live flow against the new rules and charged every one of them to the rule that matched. So a rule that had decided nothing since the edit reported one decision per live connection, and stats answered a different question after every edit. count says whether this is traffic. Also: the log said "deny tcp connect 127.0.0.2" for a connection to a port it never named. getfields writes over the separators it splits on, so f[1] afterwards is only what precedes the first "!". The address is copied before it is taken apart. Four checks. Two exercise the log path end to end, denied and permitted, matching the full address and the rule number in /sys/log/fw -- which they create if it is missing and remove again if they made it. One reads the hit count after an edit: with count put back to 1 in revalidate it reports 1 where 0 is right. 51 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 daysdoc: say what fragments do, and stop listing fixed items as openCalvin Morrison
todo.md had three items in both halves of itself. Control files being world-writable, delete 0, and a dead relay leaving half a firewall were all in "Fixed since the review" as items 10, 11 and 12, and all three were still sitting in "Worth fixing" underneath. Anyone working the open list would have redone them. Now an item appears in one half or the other, and the first round's entries are one line each, since the detail is in the commit and the value of this file is the part that is still true. Fragments got a sentence that described the mechanism and not the consequence. "Later fragments now match on addresses and protocol only" reads as though they would pass under an address rule; what actually happens is that every rule set in fwrules(6) is written in ports, later fragments match no port, and the datagram does not cross. That is the right trade against reading ports out of them, which let a crafted fragment through, but it is a hole in what works and belongs in BUGS rather than in a changelog line. man/fw also now says what the served /net contains, which changed underneath it and was never written down; and it still said the card was "not undone reliably", when nothing undoes it at all. design.md gains the whitelist decision next to the others, because the argument for it is the same one the rule parser already makes and the next person to add a file to the served tree should meet it. The mtu comment claimed a card as its reason while sitting in code both modes use. Between two stacks there is no card; 1500 is still right, for a reason worth one sentence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 daysether: keep the frame buffer off the proc stack, and log the v6 dropCalvin Morrison
Maxframe was Ehdrlen + 16K, an automatic in etherwriteip, which runs on a proc created with a 32K stack. It fits, and the previous fix was right that 64K did not, but half a stack for a buffer that -- since the pkt interface is now told "mtu 1500" -- can never hold more than 1514 bytes is a number waiting to be wrong again, and libthread allocates that stack with malloc, so being wrong means quietly corrupting the heap rather than faulting. So the buffer belongs to the caller, with its size, and the guard is against that size. etherout allocates it once, from the same Maxpkt it sizes its read buffer with, which is the only place that knows how much can arrive. ether.c no longer has a length of its own to drift. The IPv6 message now also goes to syslog. fw daemonizes, so a message on file descriptor 2 goes wherever the shell that started it was pointing, which for a firewall started at boot is nowhere. No test. etherwriteip is reached only in card mode, which the suite stays out of on purpose because a test that can leave the machine with no network is a test nobody runs. A unit harness for ether.c -- point efd at a pipe, call etherwriteip, read the frame back -- would cover this and the broadcast mapping that todo.md still records as written but never observed. Worth doing; not done here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 daysfw: build clean, and check that it stays that wayCalvin Morrison
Two warnings have stood in fw.c since the program was written: warning: fw.c:732 auto declared and not used: buf warning: fw.c:1286 set and not used: m Neither matters on its own -- an unused array in fsread, and an m = nil that the next line overwrites -- but a build that always prints two warnings is a build whose output nobody reads, which is how the next one that does matter goes unnoticed. Both are the sort of thing kencc tells you for free. So the suite now builds the source from clean and asks the compiler whether it had anything to say. Reintroducing the unused array makes it fail with the warning printed under the check, which is what a finding nobody had to look for should look like. It also checks that mk succeeded, since a build that does not run produces no warnings either. Skipped if the source is not on the machine being tested. 48 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 daysfw: delete the card-restore code that never ranCalvin Morrison
The last round removed the two call sites and said the code was gone. It was not: putback, notehandler and the five back* globals were still there with no callers, and the comment that was meant to be moved back to reclaim() was copied instead, so the file carried the same twelve lines twice in a row above a function they do not describe. kencc does not warn about an unused static function, so nothing objected. Dead code that reads like a safety net is worse than none. Anyone finding putback() would reasonably conclude the card is restored on the way out; it is not, and the reason is in the comment that now stands where the duplicate was, so the next person to look does not have to re-derive atexit's pid matching to find out why. Also moves netfs.c's "connect takes addr!port" comment down to checkctl, which it describes, from above the okverbs table, which it does not. No test: the deletion is invisible at runtime, which is the whole complaint about it. The suite still passes 46, and card mode -- the only thing putback ever touched -- is deliberately outside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 daysnetfs: name what is served instead of listing what is hiddenCalvin Morrison
/net/tcp/trans, /net/udp/trans and /net/icmp/trans install kernel address translations. devip gates them with iseve() (devip.c:406), and through this server that is fw's identity, not the caller's -- fw does every open with its own credentials and never looks at the client's. On a machine where fw runs as eve, which is the ordinary case, there was no gate at all. Demonstrated in a sandbox with an empty rule set: === baseline: real /net, no fw === echo: write error: local ip not found === inside the sandbox === connect: refused (as expected) append via fw: local ip not found create via fw: bad process or channel control request Both errors come from transwrite itself, so the open succeeded and fw imposed nothing; and the second proves the OTRUNC path is reachable, which runs transwrite(p, nil, 0, 0) and flushes the whole table before the write is even parsed. /net/log was half closed: the write was refused so a program could not turn tracing on, but reading it was the leak, and anything an administrator turns on elsewhere is then readable from inside the sandbox. ipifc data was refused rather than hidden, against the principle stated ten lines above it for ether and ipmux, and its snoop file is the same wire and was not mentioned at all. The pattern is the problem. A list of things to deny has now been wrong twice, in the same way the ctl filter was, and the answer is the one that worked there: nothing is served unless it is named. Protocol directories come from a list of names rather than from "has a clone file", because devether has one of those too and #l bound into /net would have become a protocol; a protocol missing from the list is one nobody can reach, which is the safe way to be out of date. Within one, only clone, stats and the conversation files, and for ipifc not clone, not data, not snoop. In the root, only cs and dns writable and arp, bootp, iproute, ipselftab and ndb readable. Splitting the path also disposes of a name like "tcp/../.." arriving as a single walk element from a client speaking 9P straight to the server: more than three components, or an empty one, is not a path this server handed out, so it is not one it will honour. Sixteen new checks. Against the previous netfs.c six of them fail -- trans served, log served, ipifc data and snoop served, and both listing checks -- while cs, arp, ndb, iproute, ipifc status, clone and connect filtering all still pass, which is the half that matters. They ask by stat rather than by read: reading log or a data file blocks until traffic arrives, so reading would hang on exactly the build that still serves them, and a test that hangs on a regression is worse than none. 46 pass, twice in a row with no cleanup between. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 daysfw: fix a bypass, a broken round-trip, and eleven others from reviewCalvin Morrison
The serious one is that the ctl filter was a blacklist. checkctl looked at connect and announce and passed everything else, but udpctl takes "headers", and udpcreate gives a conversation a live write queue at clone time: c->wq = qbypass(udpkick, c); So three writes -- clone, "headers", a header-prefixed datagram to data -- sent a packet anywhere, with no connect for a rule to match. rudp and icmpv6 have the same verb, gre has raw and forward. none.ndb did not mean "no network at all", though the manual said it did. It is now a whitelist of control messages that cannot reach the network by themselves, which is the argument this code already made about ndb attributes it does not recognise, applied where it was not. Also blocking: %M was never installed, so fmtrules emitted ipmask=%M% and every ctl edit on a rule set containing ip= failed, while save wrote a file reload would reject. Tests had exercised the ctl path and the ip= path but never together. parserules built the new list in the globals with no lock, so for the length of a reload the relay procs walked a list that was empty and then half built -- exactly what installrules' comment promised could not happen. etherwriteip put a 64KB frame on a 32KB proc stack, the same bug design.md records learning and fixing in relay(). putback and notehandler were dead code: atexit matches on the registering pid and _exits never runs the handlers, which is why the cleanup "did not fire" rather than being flaky. Nothing puts the card back, and the docs that said otherwise are corrected. The rest: expired flows kept matching and refreshing themselves; the pkt interface claimed a 4096 MTU from a 1514-byte card; ports were read out of non-first fragments; /net/ndb and /net/log were writable and ipifc/*/data readable through the filter; control files were world-writable, and owning them as a user called "fw" locked out the administrator instead; delete 0 appended a rule reading <nil>; a dead relay left one direction unfiltered with nothing to notice; and IPv6 unicast under -e was dropped in silence when it is simply not implemented. All three modes regression tested after: a namespace refusing headers and port 22 while allowing 443, a card passing https and then blocking it live, and the machine's network restored afterwards. doc/todo.md says which of these were reproduced and which were read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 daysfw: a firewall, at a card, between two networks, or in front of a namespaceCalvin Morrison
One program with three modes, sharing one rule engine and one ndb rule language. Which mode it is depends on what you point it at, and it says so at startup rather than choosing silently. fw -e /net/ether0 rules.ndb a card: every packet in or out fw rules.ndb <side> <side> two networks: everything crossing fw rules.ndb one namespace: what programs ask for The first two filter packets on a wire, using the pkt medium: the stack gives up its card and gets a synthetic one with fw on the other end, so nothing reaches it that fw did not pass. Since the stack no longer has ethernet, fw answers ARP for the address it stands in for. The third serves a filtered /net and matches connect and announce before they reach the kernel, so a refusal comes back out of dial(2) with a reason. That is only a boundary if the program also loses #I, which /dev/drivers does and cannot be undone; fw.rc does it in the right order. Rules are ndb, matched top to bottom, first match wins, no match denies. Connections are tracked, so permitting traffic one way permits the replies. A rule change drops connections the new rules forbid rather than letting them finish: a block blocks. Logging is per rule, to /sys/log/fw. Tested on the init-test VM in all three modes: a page fetched through a real card, a TCP handshake across two networks, request filtering with the escape routes closed, live rule changes killing established connections, and one rule file working unchanged at both altitudes. doc/todo.md has what is not done. Item 1 is the one that matters: a fw that dies takes the card's address with it, so the machine loses its network and fw cannot restart unaided. That also blocks svc supervision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>