| Age | Commit message (Collapse) | Author |
|
/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 <noreply@anthropic.com>
|
|
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 <noreply@anthropic.com>
|
|
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 <noreply@anthropic.com>
|
|
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 <noreply@anthropic.com>
|
|
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 <noreply@anthropic.com>
|
|
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 <noreply@anthropic.com>
|
|
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>
|
|
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 <noreply@anthropic.com>
|
|
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>
|
|
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>
|
|
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>
|
|
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>
|
|
if(! test -d /mnt/fw)
mntgen /mnt/fw
mount(2) needs its mount point to exist, so mntgen cannot create
/mnt/fw -- and this ran it only when /mnt/fw was missing, which is
exactly the case where it fails:
mntgen: mount /tmp/mg/fw: file does not exist: '/tmp/mg'
So on a machine that had never had a /mnt/fw, the control directories
never appeared, and fw's own "not touching the card until the mountpoint
exists" check then refused every card in the file. The script has never
worked on a fresh machine. It has also never been run by the suite,
which is the other half of why nobody noticed.
Now the directory first, then mntgen only if it is not already there --
under mntgen every name exists, which is the test.
Both mntgen and fw leave a server behind, and a server started from a
shell keeps that shell's descriptors, so at boot they sit on the
console's input and it reads as a wedged terminal. This is the trap
CLAUDE.md documents; the script was walking into it. They get
/dev/null.
Three checks, and fwstart now gets run at all: a control directory
appears, a card that is not there is reported and skipped, and nothing
left running holds the descriptors we started it with. The first fails
against the old script. The third does not, because the old script
never started anything for want of the directory -- it guards the fix
from here, not the bug that was there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
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>
|
|
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>
|
|
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>
|
|
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>
|
|
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>
|
|
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>
|
|
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>
|
|
/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>
|
|
The suite passed the first time and failed the second, always on "a
permitted connection crosses, and is tracked". A comment blamed timing
and told the reader that a failure of that check alone was not evidence
of a fault. It was.
Each run left six fw processes alive with their pkt interfaces still
bound. After four runs #I22 looked like this:
0: device pkt0 maxtu 1500 ... pktout 1826 | 10.9.9.1 /120
1: device pkt1 maxtu 1500 ... pktout 0 | 10.9.9.1 /120
2: device pkt2 maxtu 1500 ... pktout 0 | 10.9.9.1 /120
3: device pkt3 maxtu 1500 ... pktout 0 | 10.9.9.1 /120
Four interfaces, one address, and the stack routes out the first, so the
current fw sees nothing and its flows file is empty. pktout 1826 into a
wire whose far end died two runs ago is the trap design.md already
records costing an afternoon. Measured, not guessed: kill every fw,
run once, 22 passed; run again immediately, 21 passed with that check
failing.
fw cannot be stopped by pid -- it daemonizes, so the shell's $apid is
gone before the server exists, and ps shows it no arguments -- and "kill
fw" would be wrong on a machine running a real one. So stopfw takes the
interfaces away instead and fw follows: the relay's read fails and
threadexitsall takes the rest down. That doubles as a live test of the
fail-closed path, since a relay that goes back to dying quietly now
shows up in the two new checks at the end, which count fw processes and
bound interfaces and would have caught this on the day.
Two other ways a check could pass without meaning anything. A "refused"
result was returned for any failure at all, so a check for a hole went
green on a kernel that never had the hole -- gre raw is refused says
nothing if there is no /net/gre. Each such check is now paired with one
asking, outside the sandbox, whether the thing being refused exists.
And the diagnostic could not be read: a failed > is reported by rc
itself and escapes any >[2] around it, so wr does the same create(2)
with cp, whose error lands on its own standard error and is printed
when a check fails.
Finally the port is derived from the pid. A devip Conv is never freed,
so a fixed port made one run's leftovers into the next run's "address in
use".
29 checks, and 29 pass twice in a row with no cleanup between. With the
stopfw calls disabled the two new ones report 6 processes and 4
interfaces left behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Every check is a bug that once shipped, which is the only reason to have
any of them. Two would have caught real ones early: a rule set
containing ip= edited through ctl (the %M bug, where both paths were
tested but never together), and a rule set written in two writes (each
Twrite replaced the whole set).
Runs against two IP stacks it makes for itself, so it needs no network
and does not disturb the machine's. Card mode is deliberately not
covered: it takes the card away, and a test that can leave you with no
network is a test nobody runs.
The harness had two bugs of its own worth recording. Counters kept in
variables reported one pass out of seventeen, because every check runs
inside an @{} that needs its own namespace and an assignment there never
reaches the parent; results go to a file now. And a failed redirect is
reported by the outer shell rather than the block, so the message cannot
be captured from inside - the checks test whether a write was refused,
not what it said.
One check is timing-sensitive and marked as such: it passes standalone
and fails here intermittently.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
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>
|
|
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>
|