<feed xmlns='http://www.w3.org/2005/Atom'>
<title>9line.git/fw, 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-19T17:00:43+00:00</updated>
<entry>
<title>doc: 91 checks, and a wire</title>
<updated>2026-08-19T17:00:43+00:00</updated>
<author>
<name>Calvin Morrison</name>
<email>calvin@pobox.com</email>
</author>
<published>2026-08-19T17:00:43+00:00</published>
<link rel='alternate' type='text/html' href='https://git.ceux.org/9line.git/commit/?id=5ef4699d05bc919255449a9af780f216a0589a72'/>
<id>urn:sha1:5ef4699d05bc919255449a9af780f216a0589a72</id>
<content type='text'>
Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>lib: the host rule set three files were already pointing at</title>
<updated>2026-08-19T17:00:26+00:00</updated>
<author>
<name>Calvin Morrison</name>
<email>calvin@pobox.com</email>
</author>
<published>2026-08-19T17:00:26+00:00</published>
<link rel='alternate' type='text/html' href='https://git.ceux.org/9line.git/commit/?id=e8d83b79247f25095346041d4af65c3e0dc3da16'/>
<id>urn:sha1:e8d83b79247f25095346041d4af65c3e0dc3da16</id>
<content type='text'>
/lib/ndb/fw names it, fw(8) uses it in two examples, and the service
file passes it as the rule file.  Nothing provided it, so following any
of them got you a firewall that would not start.

Written for the qemu NAT segment this is developed on: the local /24
first, since the gateway and the resolver both live there, then name
resolution and the web, icmp so there is something to test with that
does not need a name resolved first, and one port answered on.

Calvin's; committed so the tree stops referring to a file that is not
in it.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>svc: /mnt/fw/ether0 needs an mntgen of its own</title>
<updated>2026-08-19T17:00:26+00:00</updated>
<author>
<name>Calvin Morrison</name>
<email>calvin@pobox.com</email>
</author>
<published>2026-08-19T17:00:26+00:00</published>
<link rel='alternate' type='text/html' href='https://git.ceux.org/9line.git/commit/?id=4ecaf11844d819c587d9440358df66d8259809d2'/>
<id>urn:sha1:4ecaf11844d819c587d9440358df66d8259809d2</id>
<content type='text'>
The service file said -m /mnt/fw/ether0 and nothing made that name.
mntgen invents names one level deep, so the one over /mnt gives /mnt/fw
and stops there:

	one mntgen over /mnt makes /mnt/fw                        yes
	but not /mnt/fw/ether0, which is where fw is told to mount no
	a second one over /mnt/fw does                            yes

So a second mntgen over /mnt/fw, as its own service, and every fw needs
it.  It is a file server like the rest -- posts its name, lets the
process that started it exit -- so ready=srv:mntfw, the same shape fw
uses.

fwstart already does this imperatively, which is why the boot path
worked and the supervised path would not have.  Four checks now pin the
premise both rest on, ending with fw actually mounting its control
files under the second mntgen.

The finding and the service file are Calvin's; the checks are mine.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>test: put fw on a wire, with another machine on the other end</title>
<updated>2026-08-19T16:54:29+00:00</updated>
<author>
<name>Calvin Morrison</name>
<email>calvin@pobox.com</email>
</author>
<published>2026-08-19T16:54:29+00:00</published>
<link rel='alternate' type='text/html' href='https://git.ceux.org/9line.git/commit/?id=841d1b31f575aef87a65e425fdc87b004c53cf0a'/>
<id>urn:sha1:841d1b31f575aef87a65e425fdc87b004c53cf0a</id>
<content type='text'>
Everything in fwtest.rc happens on one machine, which is as far as it
goes: it can take a spare card and watch what fw does to the
interfaces, but it cannot make a neighbour send anything.  Four things
were listed as never tested for that reason.  Three of them now are.

run.sh -gw and -lan join two VMs into a point-to-point segment, so the
peer is a plain overlay of the base with an address and nothing else.

ARP first, since it is the part with no other explanation: the stack
that owns 10.9.9.1 has a pkt interface and no ethernet, so nothing else
on that segment can answer for it.  The peer's arp table:

	ether  OK   10.9.9.1     52540087c8c1     10.9.9.2

which is the firewall's card.  The first ping takes about a second and
the rest are sub-millisecond -- fw ARPing for the peer before it can
reply, and dropping the first one while it asks, which is the drop no
rule caused in fw(8) BUGS, until now only reasoned about.

Then filtering as against forwarding, which cannot be seen from inside
at all.  With allow=in proto=icmp alone, a TCP connect from the peer
sits for 290 seconds and times out; prepend a rule through ctl and the
same connect is refused in 2.  Refused is the far stack's RST, so the
packet arrived; timed out is fw dropping it in silence.

And frames addressed to somebody else, which is the commit that shipped
saying it could not be tested without a second machine.  A stack only
addresses frames to the mac it resolved, so rawether.c forges one.  A
promiscuous reader on the firewall machine sees both frames, which had
to be confirmed first -- a frame that never arrived looks exactly like
one that was filtered, and my first attempt at this drew the wrong
conclusion from precisely that.  Same rig, same frames, same rules:

	                 to fw's card   to nobody's
	no check              +3            +3
	with the check        +2             0

test/wire.md is the procedure, including the two ways I wasted time:
fw's control files need -s to be reachable from another shell, and
anything that leaves fw holding a pipe waits forever for a program that
has already detached.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>test: count what was skipped, and say how to stop skipping it</title>
<updated>2026-08-19T16:31:10+00:00</updated>
<author>
<name>Calvin Morrison</name>
<email>calvin@pobox.com</email>
</author>
<published>2026-08-19T16:31:10+00:00</published>
<link rel='alternate' type='text/html' href='https://git.ceux.org/9line.git/commit/?id=d1fc2a8047d722f90d289fafe41be0029c6e39ff'/>
<id>urn:sha1:d1fc2a8047d722f90d289fafe41be0029c6e39ff</id>
<content type='text'>
The suite reported 73 passed, 0 failed on a machine where it had been
reporting 87.  Nothing had regressed: the VM had been booted without a
second card, so the fourteen card-mode checks skipped.  A skip printed
one line among ninety and the total moved, and a total that moves is
not a signal anybody reads -- 73 passed looks exactly as good as 87
unless you happen to know which to expect.

So skips are counted with the rest:

	87 passed, 0 failed, 0 skipped

and the one that matters says what to do about it rather than only what
is missing: boot with run.sh -gw and the spare card is there.  The
header says the same, since that is where someone looks first.

Also finishes the proof the last commit could not.  /sys/log/fw now has
a line for every port the suite can pick, so the collision that made
the old check fail by chance is certain: 3600 poison lines, and the
delta version passes 87 while the total version fails both logging
checks with 4 and 3 where 1 is right.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>test: the log is not this run's, so do not ask it for a total</title>
<updated>2026-08-19T14:57:54+00:00</updated>
<author>
<name>Calvin Morrison</name>
<email>calvin@pobox.com</email>
</author>
<published>2026-08-19T14:57:54+00:00</published>
<link rel='alternate' type='text/html' href='https://git.ceux.org/9line.git/commit/?id=5bf7e1f5ea541af3275e287939485fa2b4149e2c'/>
<id>urn:sha1:5bf7e1f5ea541af3275e287939485fa2b4149e2c</id>
<content type='text'>
The suite failed on the second run of a pair, on the two checks that
read /sys/log/fw:

	FAIL  a denied connection is logged, with the rule that said so
	      want: 1  got: 2

Not a firewall bug.  The checks counted matching lines in the whole
log, on the stated grounds that "the port is this run's, so the whole
file can be searched".  The port is 17000 + pid%900 and /sys/log/fw
keeps every run there has ever been, so the port is this run's only
until two runs pick the same one.  Thirty entries over nine hundred
ports is a two-in-five chance of a collision, and there it was:

	ports that appear more than once:
	   2 17538

So they are differences now, like the fragment count and the listen
log before them.  That is three checks in this suite that have had to
learn the same thing: a check that reads a file which outlives the run
must read it twice and subtract, because every other reading is a
measurement of the machine's history.

Verified passing; the stronger proof -- poisoning the log with a line
for every port the suite can pick, so the collision is certain rather
than likely -- did not finish, because the VM it was running on was
shut down partway through.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<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>doc: fragments cross, callers are checked, and the open list is shorter</title>
<updated>2026-08-19T03:22:58+00:00</updated>
<author>
<name>Calvin Morrison</name>
<email>calvin@pobox.com</email>
</author>
<published>2026-08-19T03:22:58+00:00</published>
<link rel='alternate' type='text/html' href='https://git.ceux.org/9line.git/commit/?id=1575b83f102060eab99248c8e7660e9f61df1389'/>
<id>urn:sha1:1575b83f102060eab99248c8e7660e9f61df1389</id>
<content type='text'>
man/fw said a fragmented datagram does not cross.  IPv4 ones now do:
the first piece decides and the train inherits.  IPv6 fragments still
do not, because the extension header they live in is not walked, and
that is what the paragraph says now.

The altitude paragraph said request filtering cannot stop an inbound
connection before the handshake.  Still true, and still worth saying,
but it now refuses the connection to the program and hangs it up, which
is the difference between a rule that is late and one that does
nothing.  fwrules(6) gains the other half of that: an in rule naming an
ip cannot decide an announce, and decides the connection instead.

todo.md loses the six items that are now fixed and gains a third round.
One of them is a correction rather than a fix: the interface tables
being read into fixed buffers was reported as losing the default route,
and it was not -- routes come out sorted and 0.0.0.0 sorts first, so it
was always on the first line.  Worth removing the limits anyway; not
worth having claimed it broke something.

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>
</feed>
