<feed xmlns='http://www.w3.org/2005/Atom'>
<title>9line.git/fw/src/fw.c, branch master</title>
<subtitle>random plan9 experiements
</subtitle>
<id>https://git.ceux.org/9line.git/atom?h=master</id>
<link rel='self' href='https://git.ceux.org/9line.git/atom?h=master'/>
<link rel='alternate' type='text/html' href='https://git.ceux.org/9line.git/'/>
<updated>2026-08-19T14:41:03+00:00</updated>
<entry>
<title>fw: a dead firewall leaves the network down, and can be restarted into it</title>
<updated>2026-08-19T14:41:03+00:00</updated>
<author>
<name>Calvin Morrison</name>
<email>calvin@pobox.com</email>
</author>
<published>2026-08-19T14:41:03+00:00</published>
<link rel='alternate' type='text/html' href='https://git.ceux.org/9line.git/commit/?id=f05383bb3447f7fbd44c9051c4e273caeb33352f'/>
<id>urn:sha1:f05383bb3447f7fbd44c9051c4e273caeb33352f</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fw: make the /srv name worth watching, and say how to supervise it</title>
<updated>2026-08-19T14:00:34+00:00</updated>
<author>
<name>Calvin Morrison</name>
<email>calvin@pobox.com</email>
</author>
<published>2026-08-19T14:00:34+00:00</published>
<link rel='alternate' type='text/html' href='https://git.ceux.org/9line.git/commit/?id=dee31d66b705734be68c1bb21ff5dfc7bdaae23b'/>
<id>urn:sha1:dee31d66b705734be68c1bb21ff5dfc7bdaae23b</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>ether: a frame addressed to somebody else is not ours</title>
<updated>2026-08-19T03:21:41+00:00</updated>
<author>
<name>Calvin Morrison</name>
<email>calvin@pobox.com</email>
</author>
<published>2026-08-19T03:21:41+00:00</published>
<link rel='alternate' type='text/html' href='https://git.ceux.org/9line.git/commit/?id=8f5fed654d54a9ae9c4eb95425d857e9524b803c'/>
<id>urn:sha1:8f5fed654d54a9ae9c4eb95425d857e9524b803c</id>
<content type='text'>
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 &amp;&amp; !multi &amp;&amp; !f-&gt;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 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fw: read the interface tables a line at a time, and test card mode at all</title>
<updated>2026-08-19T03:20:19+00:00</updated>
<author>
<name>Calvin Morrison</name>
<email>calvin@pobox.com</email>
</author>
<published>2026-08-19T03:20:19+00:00</published>
<link rel='alternate' type='text/html' href='https://git.ceux.org/9line.git/commit/?id=1d2e70b303ee08c17a7f59fa8a1e667b709e9dc2'/>
<id>urn:sha1:1d2e70b303ee08c17a7f59fa8a1e667b709e9dc2</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fw: a fragmented datagram crosses</title>
<updated>2026-08-19T02:23:00+00:00</updated>
<author>
<name>Calvin Morrison</name>
<email>calvin@pobox.com</email>
</author>
<published>2026-08-19T02:23:00+00:00</published>
<link rel='alternate' type='text/html' href='https://git.ceux.org/9line.git/commit/?id=38f289588d7f17f86a20a3372390257e3214155b'/>
<id>urn:sha1:38f289588d7f17f86a20a3372390257e3214155b</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fw: the gateway relay decides the same way the card does</title>
<updated>2026-08-19T02:10:27+00:00</updated>
<author>
<name>Calvin Morrison</name>
<email>calvin@pobox.com</email>
</author>
<published>2026-08-19T02:10:27+00:00</published>
<link rel='alternate' type='text/html' href='https://git.ceux.org/9line.git/commit/?id=ed972197544a12d2527cfe6d9ac0c0d9beb399b0'/>
<id>urn:sha1:ed972197544a12d2527cfe6d9ac0c0d9beb399b0</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>rules: a rule set is not 64 kilobytes long</title>
<updated>2026-08-19T02:05:20+00:00</updated>
<author>
<name>Calvin Morrison</name>
<email>calvin@pobox.com</email>
</author>
<published>2026-08-19T02:05:20+00:00</published>
<link rel='alternate' type='text/html' href='https://git.ceux.org/9line.git/commit/?id=2f408deee4d88ebd2ee87bbf5fe227ff764e0e6d'/>
<id>urn:sha1:2f408deee4d88ebd2ee87bbf5fe227ff764e0e6d</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>rules: matchrule stops returning things whose lifetime it does not own</title>
<updated>2026-08-19T02:01:24+00:00</updated>
<author>
<name>Calvin Morrison</name>
<email>calvin@pobox.com</email>
</author>
<published>2026-08-19T02:01:24+00:00</published>
<link rel='alternate' type='text/html' href='https://git.ceux.org/9line.git/commit/?id=330c2a27a998a2a0d8b31af10edf087558ea7050'/>
<id>urn:sha1:330c2a27a998a2a0d8b31af10edf087558ea7050</id>
<content type='text'>
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, ..., &amp;rule);
	if(rule != nil &amp;&amp; rule-&gt;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 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>doc: say what fragments do, and stop listing fixed items as open</title>
<updated>2026-08-19T01:11:11+00:00</updated>
<author>
<name>Calvin Morrison</name>
<email>calvin@pobox.com</email>
</author>
<published>2026-08-19T01:11:11+00:00</published>
<link rel='alternate' type='text/html' href='https://git.ceux.org/9line.git/commit/?id=35f13d1f583d9e9b8e00011c625444ce6b5f1d9f'/>
<id>urn:sha1:35f13d1f583d9e9b8e00011c625444ce6b5f1d9f</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>ether: keep the frame buffer off the proc stack, and log the v6 drop</title>
<updated>2026-08-19T01:07:59+00:00</updated>
<author>
<name>Calvin Morrison</name>
<email>calvin@pobox.com</email>
</author>
<published>2026-08-19T01:07:59+00:00</published>
<link rel='alternate' type='text/html' href='https://git.ceux.org/9line.git/commit/?id=24ae4160d6b82aa409ec8f2a80d4b59166c19972'/>
<id>urn:sha1:24ae4160d6b82aa409ec8f2a80d4b59166c19972</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
</content>
</entry>
</feed>
