<feed xmlns='http://www.w3.org/2005/Atom'>
<title>9line.git/fw/doc/todo.md, 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>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>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>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>fw: fix a bypass, a broken round-trip, and eleven others from review</title>
<updated>2026-08-18T23:32:56+00:00</updated>
<author>
<name>Calvin Morrison</name>
<email>calvin@pobox.com</email>
</author>
<published>2026-08-18T23:32:56+00:00</published>
<link rel='alternate' type='text/html' href='https://git.ceux.org/9line.git/commit/?id=b758d92ca80b25c0391dce4c7df73ef93aeeec99'/>
<id>urn:sha1:b758d92ca80b25c0391dce4c7df73ef93aeeec99</id>
<content type='text'>
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-&gt;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 &lt;nil&gt;; 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 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fw: a firewall, at a card, between two networks, or in front of a namespace</title>
<updated>2026-08-18T21:01:49+00:00</updated>
<author>
<name>Calvin Morrison</name>
<email>calvin@pobox.com</email>
</author>
<published>2026-08-18T21:01:49+00:00</published>
<link rel='alternate' type='text/html' href='https://git.ceux.org/9line.git/commit/?id=0f922552ad8cc73c0c3c3674d484c3d78dd8c557'/>
<id>urn:sha1:0f922552ad8cc73c0c3c3674d484c3d78dd8c557</id>
<content type='text'>
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 &lt;side&gt; &lt;side&gt;      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 &lt;noreply@anthropic.com&gt;
</content>
</entry>
</feed>
