summaryrefslogtreecommitdiff
path: root/fw/doc
diff options
context:
space:
mode:
Diffstat (limited to 'fw/doc')
-rw-r--r--fw/doc/design.md171
-rw-r--r--fw/doc/todo.md103
2 files changed, 274 insertions, 0 deletions
diff --git a/fw/doc/design.md b/fw/doc/design.md
new file mode 100644
index 0000000..d6f3e55
--- /dev/null
+++ b/fw/doc/design.md
@@ -0,0 +1,171 @@
+# fw
+
+**A firewall for 9front. One program, filtering at a network card, between
+two networks, or in front of one namespace.**
+
+Status: working and tested on the init-test VM, in all three modes. Not
+yet fit to run on a machine you care about — see `todo.md`, item 1.
+
+## Why
+
+9front has no firewall. The usual answer is that it does not need one:
+few services run, and they are listed in `/rc/bin/service`. That answers
+inbound and says nothing about outbound, which is the question that
+matters now — not "who can reach this machine" but "who can *this
+program* reach".
+
+The manifesto asks for:
+
+> a software-level firewall implementing a /net interface that sits
+> between network interfaces; it can be used as a global firewall or
+> placed in front of a namespace
+
+Both halves of that sentence are now one program, and the same rule file
+drives either.
+
+## The three modes
+
+ 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; the third filters requests before
+any packet exists. The mode is what you point it at, printed at startup,
+never inferred silently.
+
+### Filtering a card
+
+A card cannot stay attached to the IP stack and be filtered — packets
+would reach the stack whatever the rules said. So `fw` takes the card
+and hands the stack a `pkt` interface instead:
+
+ network ---- ether0 [ fw ] stack ---- programs
+
+`pktmedium` is the whole trick, and it is three lines of kernel:
+
+ static void
+ pktbwrite(Ipifc *ifc, Block *bp, int, uchar*, Routehint*)
+ {
+ qpass(ifc->conv->rq, bp);
+ }
+
+Reading `/net/ipifc/N/data` gives packets the stack wants to send;
+writing injects packets as if received. No hardware, no wire — the
+reader *is* the wire. That is why filtering works there and not at
+`/net/ether0`, where reading gives you a copy and the stack gets the
+packet anyway.
+
+Because the stack no longer has ethernet, nothing is doing ARP for its
+address any more, so `fw` does: it answers requests for the address it is
+standing in for and resolves the next hop itself.
+
+An ether `bypass` connection looks like the right primitive and is not:
+it intercepts only the stack's transmissions, and `etheriq` discards what
+arrives from the wire while it is set. Measured, not assumed.
+
+### Filtering a namespace
+
+`fw` serves a filtered view of `/net` and mounts it back. Almost
+everything passes through; a write of `connect` or `announce` to a
+protocol ctl file is matched against the rules first. A refusal fails the
+write, and the text comes back out of `dial(2)` — which is a far better
+diagnostic than a dropped packet.
+
+Because it proxies the *assembled* `/net` rather than synthesising a
+tree, `cs` and `dns` come along for free.
+
+The real `/net` needs no second name and must not have one: any surviving
+path to it is a way around the filter. lib9p forks the server with
+`RFNAMEG` (`postsrv`, `/sys/src/lib9p/post.c`), so the server keeps a
+private copy of the namespace from before the mount. Inside, `/net` is
+real; outside, it is `fw`.
+
+## What makes the namespace mode binding
+
+Filtering `/net` achieves nothing on its own, because
+
+ bind -a '#I' /net
+
+puts the real stack back. 9front already has the answer, and it is
+better than the design needed. Each process group carries a mask of
+permitted device drivers, written through `/dev/drivers`:
+
+ echo chdev '&~' 'Iluσ' >/dev/drivers
+
+Two properties make it right: `devmask` in `pgrp.c` only ever ORs bits
+in, so access can only be removed; and the mask is copied into every new
+process group with the comment *"always inherit devmask
+unconditionally"*. So a process that has lost `#I` cannot regain it and
+cannot fork a child that has it.
+
+`RFNOMNT` is the same mechanism with a fixed whitelist and is too blunt —
+it also forbids all mounting. Note the trap: `chdev &` permits *only*
+what is named, so trying to regain a device with it silently drops
+everything else instead.
+
+## Decisions, and why
+
+**ndb for rules.** The house format, and it deleted a hand-rolled
+parser. Read with `ndbparse`, which returns entries in file order, so it
+stays an ordered list and not a lookup. An unrecognised attribute is
+fatal: silently ignoring `prot=tcp` would leave a rule matching every
+protocol, and a typo that fails open is not something a firewall may do.
+
+**First match wins, default deny.** Firewall matching is a solved
+interface. Being different about it would be cost for its own sake.
+
+**`in` and `out`, not `connect` and `announce`.** Direction, not layer,
+so one rule file works at both altitudes unchanged. Getting this right
+exposed a real bug: `announce 443` was matched by `port=` at the request
+layer and `lport=` at the packet layer, so each spelling silently never
+matched in the other mode.
+
+**Stateful by default.** Without it a rule set is unwritable: permitting
+a connection out would mean permitting every reply back in, which means
+opening every ephemeral port and calling it strict.
+
+**A block blocks.** When the rules change, live connections the new rules
+forbid are dropped. pf and iptables leave them running until they time
+out; that is a wart everyone has to learn, and "I blocked it, why is the
+transfer still going" is the wrong thing to discover at 3am.
+
+**Per-rule logging to `/sys/log/fw`.** Global logging either floods a
+disk or tells you nothing. `syslog(2)` does not create its file, so `fw`
+says so at startup rather than dropping the lines silently.
+
+**No NAT.** It is not a firewall feature, it is a workaround for IPv4
+address scarcity, and it is needed in exactly one configuration:
+non-Plan 9 clients behind an IPv4 gateway with one address. 9front
+clients import `/net` instead, which gets NAT's effect with none of its
+code and per-client rules as a bonus. Keeping translation out of
+filtering is also just correct design — it is why iptables has separate
+`filter` and `nat` tables.
+
+## Things learned the hard way
+
+- **`bind '#l' /net` brings in only `ether0`.** A second card is attached
+ by the kernel but invisible until `bind -a '#l1' /net`. The boot
+ messages show `#l0` and `#l1` in a font where `l` looks like `1`.
+- **`@{...}` in rc does not fork the namespace** —
+ `rfork(RFPROC|RFFDG|RFREND)`, no `RFNAMEG`. A mount inside it lands in
+ the calling shell and stays. Over a long-lived shell this accumulates
+ and reads exactly like a regression in whatever you last changed.
+- **IP stacks outlive the programs that configure them.** A stale
+ interface on `#I1` from an earlier run pointed routes at a dead wire
+ and cost an afternoon.
+- **`proccreate` stacks are small.** A 64KB packet buffer on one
+ corrupted the data segment and surfaced as a *string constant* reading
+ back wrong, not as a crash.
+- **Ethernet has a 60-byte minimum** and devether enforces it: 42-byte
+ ARP frames were refused with "read or write too small". Small IP
+ packets would have failed the same way.
+- **Anything backgrounded from the serial shell inherits `/dev/eia0`**
+ and will eat the console's input.
+
+## What it cannot do
+
+It filters connections and packets, not flows over time: no rate limits,
+no fragment logic, no ICMP type matching, and IPv6 extension headers are
+not walked. Forwarded traffic is invisible to the namespace mode by
+construction — it never becomes a ctl write, because no local program
+asked for it.
diff --git a/fw/doc/todo.md b/fw/doc/todo.md
new file mode 100644
index 0000000..97d1d71
--- /dev/null
+++ b/fw/doc/todo.md
@@ -0,0 +1,103 @@
+# fw: open items
+
+Ranked. Item 1 is the only thing between this and running it on a
+machine you care about.
+
+## 1. A dead fw takes the network with it
+
+**Severity: blocks use.**
+
+Taking a card is destructive and is not undone. The `pkt` interface that
+replaces it is `unbindonclose`, so when `fw` stops the interface goes and
+the address goes with it. The machine is left with a card bound to
+nothing and no network.
+
+Worse, `fw` cannot restart unaided: the address it would read off the
+card is the address that just vanished. So it exits, and a supervisor
+with `restart=always` would spin.
+
+`fw` tries to put the card back as it exits (`putback`, via `atexit` and
+`threadnotify`). That covers an orderly stop. **It did not fire on a kill
+in testing** and I did not chase it further — a cleanup that works
+sometimes is worse than none, because you would trust it.
+
+The fix is probably not more note handling. Whatever restarts `fw` has to
+be able to configure the card first, which means the address has to
+survive somewhere `fw` does not own. That is a supervisor's job.
+
+This is also what blocks `svc` supervision (item 3), so one fix, two
+payoffs.
+
+## 2. Broadcast handling is written but unwitnessed
+
+Broadcast and multicast are addressed directly rather than resolved:
+`255.255.255.255` and the subnet broadcast to `ff:ff:ff:ff:ff:ff`,
+`224/4` to `01:00:5e:...`, `ff00::/8` to `33:33:...`. Without this a
+DHCP renewal would be ARPed for the gateway and unicast there, and the
+lease would quietly never renew.
+
+The mapping is the standard one and normal traffic is unaffected, but
+**I never managed to get a broadcast to cross `fw` to confirm it.**
+Reviewed, not observed.
+
+Related and unfixed: `fw` reads the address once at startup, so a lease
+that *changes* the address goes unnoticed until restart.
+
+## 3. fw daemonizes, so svc cannot supervise it
+
+The process you exec returns immediately and leaves the server behind.
+`svc` would see an instant exit and, with `restart=always`, spin.
+
+Needs a foreground mode where the process started is the process that
+stays. `svc`'s `ready=srv:name` fits: `fw -s fw.ether0` already posts to
+`/srv`.
+
+`fwstart` is the wrong shape and should probably go. `svc` already does
+dependency ordering and per-service supervision; `fwstart` re-implements
+the loop in rc and then exits, so `svc` would be supervising a process
+that has already gone. One service per card is the right shape.
+
+## 4. One card per fw, and rules cannot name a card
+
+Two cards means two `fw`s with two rule files. Tested and it works, but
+a rule cannot say `ifc=ether0`, so one file cannot express different
+policy for different cards. Wants repeatable `-e` and an `ifc=`
+attribute, and those go together.
+
+## 5. Tflush is not implemented
+
+A request `fw` is blocked on cannot be abandoned, so killing a program
+that is waiting for an inbound connection does not reach `fw`. Only
+affects the namespace mode; nothing in packet mode blocks indefinitely.
+
+Doable: record the worker's pid against the Req, post an `interrupt`
+note on flush, catch it with `threadnotify` so the syscall returns
+`interrupted` rather than killing the proc. Maybe 80 lines. There is a
+race that cannot be fully closed — between the syscall returning and the
+handler clearing its entry, a note may already be in flight and land on
+a worker that has moved on, failing an unrelated request. Rare,
+unreproducible, and the reason to do it deliberately.
+
+## 6. Positional delete renumbers
+
+`delete n` counts lines of `rules`, so numbers shift after each delete
+and `delete 3` twice removes two different rules. Inherent to positional
+deletion — iptables has it too — but it should be said out loud.
+
+## 7. Untested at the edges
+
+- The gateway has only been tested between two synthetic stacks on one
+ machine. `run.sh` has `-gw`/`-lan` for a two-VM test; the client VM
+ was never built.
+- No IPv6 traffic has been pushed through any mode. The code paths
+ exist and parse v6, but nothing has exercised them.
+- No test with a real second NIC carrying real traffic.
+
+## Deliberately not doing
+
+**NAT.** See design.md. A 9front client imports `/net`; anything else
+behind an IPv4 gateway with one address is the only case that needs it,
+and that case can wait for someone who actually has it.
+
+**Rate limiting, fragment logic, ICMP type matching, deep IPv6.** Scope,
+not difficulty. The useful firewall is the one that ships.