From 0f922552ad8cc73c0c3c3674d484c3d78dd8c557 Mon Sep 17 00:00:00 2001 From: Calvin Morrison Date: Tue, 18 Aug 2026 17:01:49 -0400 Subject: fw: a firewall, at a card, between two networks, or in front of a namespace 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 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 --- fw/doc/design.md | 171 +++++++ fw/doc/todo.md | 103 ++++ fw/lib/example.ndb | 42 ++ fw/lib/fw.ndb | 13 + fw/lib/fw.rc | 27 + fw/lib/fwstart | 61 +++ fw/lib/local.ndb | 7 + fw/lib/none.ndb | 3 + fw/lib/web.ndb | 7 + fw/man/fw | 482 ++++++++++++++++++ fw/man/fwrules | 224 +++++++++ fw/src/ether.c | 374 ++++++++++++++ fw/src/fw.c | 1391 ++++++++++++++++++++++++++++++++++++++++++++++++++++ fw/src/mkfile | 9 + fw/src/netfs.c | 555 +++++++++++++++++++++ fw/src/rules.c | 440 +++++++++++++++++ fw/src/rules.h | 57 +++ 17 files changed, 3966 insertions(+) create mode 100644 fw/doc/design.md create mode 100644 fw/doc/todo.md create mode 100644 fw/lib/example.ndb create mode 100644 fw/lib/fw.ndb create mode 100755 fw/lib/fw.rc create mode 100755 fw/lib/fwstart create mode 100644 fw/lib/local.ndb create mode 100644 fw/lib/none.ndb create mode 100644 fw/lib/web.ndb create mode 100644 fw/man/fw create mode 100644 fw/man/fwrules create mode 100644 fw/src/ether.c create mode 100644 fw/src/fw.c create mode 100644 fw/src/mkfile create mode 100644 fw/src/netfs.c create mode 100644 fw/src/rules.c create mode 100644 fw/src/rules.h (limited to 'fw') 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 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. diff --git a/fw/lib/example.ndb b/fw/lib/example.ndb new file mode 100644 index 0000000..60f5119 --- /dev/null +++ b/fw/lib/example.ndb @@ -0,0 +1,42 @@ +# +# every attribute fw understands. an attribute that is absent does +# not constrain, so there is never a "*" to write; it is accepted, but +# leaving the attribute out says the same thing more quietly. +# +# allow= the rule permits. dir is in, out or * +# deny= the rule refuses +# proto= a protocol: tcp, udp, icmp, ... +# port= the port at the far end +# lport= the port at this end +# ip= the address at the far end, optionally with a /mask +# ipmask= the mask, if you would rather write it separately +# +# in and out are directions, not layers: the same rule means the same +# thing whether fw is matching it against a packet on a wire or against +# a connection a program asked for. connect and announce are accepted +# as older spellings of out and in. +# +# rules are matched top to bottom and the first one that matches wins. +# nothing matching means deny, so a file with no rules permits nothing. +# + +# deny before allow, since the first match wins +deny=out ip=1.1.1.1 +deny=out ip=8.8.8.8 + +# the two spellings of a masked address are the same rule +allow=out ip=10.0.2.0/24 +allow=out ip=192.168.0.0 ipmask=/16 + +# v6 needs no distinguishing: it is the same attribute +allow=out ip=2001:db8::/32 + +# an entry may also be spread over indented continuation lines +allow=out + proto=tcp + port=443 + +# answer on one port, but never call out on it. lport is our end, so +# this is the port we serve; port would be the caller's, which we do not +# get to know until they call. +allow=in proto=tcp lport=17019 diff --git a/fw/lib/fw.ndb b/fw/lib/fw.ndb new file mode 100644 index 0000000..0292f53 --- /dev/null +++ b/fw/lib/fw.ndb @@ -0,0 +1,13 @@ +# +# Which cards are firewalled, and with what. Read by fwstart(8) at +# boot. The address is not named here: fw reads it from the card it +# takes over, so this file and ipconfig(8) cannot drift apart. +# +# One entry per card. Everything the card sends or receives is +# filtered; see fwrules(6) for the rule files themselves. +# + +fw=ether0 rules=/lib/fw/host.ndb + +# a second card, if this machine has one +#fw=ether1 rules=/lib/fw/lan.ndb diff --git a/fw/lib/fw.rc b/fw/lib/fw.rc new file mode 100755 index 0000000..09f2960 --- /dev/null +++ b/fw/lib/fw.rc @@ -0,0 +1,27 @@ +#!/bin/rc +# fw.rc - run a command behind a firewall of its own. +# +# fw.rc rules.ndb cmd [arg ...] +# +# Order matters. The namespace is made private first, the firewall is +# mounted second, and the devices are dropped last: once dropped they +# cannot be regained by this process or any child, so fw has to +# already be serving by then. +# +# The devices dropped are the ones that reach a network without going +# through /net: +# I the IP stack itself - bind -a '#I' /net undoes everything +# l ethernet +# u usb, which can carry an ether device +# σ shr, where nusb publishes usbnet +# Relax that set only if you know the machine has no other way out. +rfork ne +if(~ $#* 0 1){ + echo usage: fw.rc rules.ndb cmd [arg ...] >[1=2] + exit usage +} +rules=$1 +shift +fw $rules || exit 'fw failed' +echo chdev '&~' 'Iluσ' >/dev/drivers || exit 'cannot drop devices' +exec $* diff --git a/fw/lib/fwstart b/fw/lib/fwstart new file mode 100755 index 0000000..00b4d3a --- /dev/null +++ b/fw/lib/fwstart @@ -0,0 +1,61 @@ +#!/bin/rc +# fwstart [cfg] - start a firewall for each card named in /lib/ndb/fw. +# +# Run this after the network is configured and before anything dials. +# fw reads each card's address from the card itself, so the addresses +# have to be there already; and a program that connects before fw is up +# is a program that was never filtered. +rfork e + +cfg=/lib/ndb/fw +if(! ~ $#* 0) + cfg=$1 +if(! test -f $cfg){ + echo fwstart: no $cfg, nothing to do >[1=2] + exit +} + +# the cards named in the config +fn cards { + awk ' + /^[ \t]*#/ { next } + { for(i = 1; i <= NF; i++) if($i ~ /^fw=/) print substr($i, 4) } + ' $cfg +} + +# the rule file for one card +fn rulesfor { + awk -v 'want='^$1 ' + /^[ \t]*#/ { next } + { + dev = ""; rules = "" + for(i = 1; i <= NF; i++){ + if($i ~ /^fw=/) dev = substr($i, 4) + if($i ~ /^rules=/) rules = substr($i, 7) + } + if(dev == want && rules != ""){ print rules; exit } + } + ' $cfg +} + +# each card gets its own control directory; mntgen makes them appear +if(! test -d /mnt/fw) + mntgen /mnt/fw + +for(name in `{cards}){ + dev=/net/$name + rules=`{rulesfor $name} + if(! test -e $dev) + echo fwstart: no $dev, skipped >[1=2] + if not if(~ $#rules 0) + echo fwstart: no rules given for $name, skipped >[1=2] + if not if(! test -f $rules) + echo fwstart: $rules missing, $name skipped >[1=2] + if not { + fw -m /mnt/fw/$name -e $dev $rules + if(~ $status '') + echo fwstart: $name filtered by $rules + if not + echo fwstart: $name failed to start >[1=2] + } +} diff --git a/fw/lib/local.ndb b/fw/lib/local.ndb new file mode 100644 index 0000000..2b846c4 --- /dev/null +++ b/fw/lib/local.ndb @@ -0,0 +1,7 @@ +# +# the local network, and one port to answer on. +# +allow=out ip=10.0.2.0/24 +allow=out ip=192.168.0.0/16 +allow=out ip=fe80::/10 +allow=in proto=tcp lport=17019 diff --git a/fw/lib/none.ndb b/fw/lib/none.ndb new file mode 100644 index 0000000..c12c0aa --- /dev/null +++ b/fw/lib/none.ndb @@ -0,0 +1,3 @@ +# +# no rules. the default is deny, so this program gets no network at all. +# diff --git a/fw/lib/web.ndb b/fw/lib/web.ndb new file mode 100644 index 0000000..709df98 --- /dev/null +++ b/fw/lib/web.ndb @@ -0,0 +1,7 @@ +# +# a browser: name resolution and the web, nothing else. +# +allow=out proto=udp port=53 +allow=out proto=tcp port=53 +allow=out proto=tcp port=80 +allow=out proto=tcp port=443 diff --git a/fw/man/fw b/fw/man/fw new file mode 100644 index 0000000..34a9d19 --- /dev/null +++ b/fw/man/fw @@ -0,0 +1,482 @@ +.TH FW 8 +.SH NAME +fw \- firewall +.SH SYNOPSIS +.B fw +.RB [ -dSW ] +.RB [ -m +.IR ctlmtpt ] +.RB [ -n +.IR net ] +.RB [ -s +.IR srv ] +.br +.RB [ -e +.I ether +.B -a +.IR addr / mask +.RB [ -g +.IR gateway ]] +.br +.I rules +.RI [ outside +.IR inside ] +.SH DESCRIPTION +.I Fw +is a network firewall. +It can filter a network card, the traffic crossing between two networks, +or the traffic of a single namespace. +Rules are written in an +.IR ndb (6) +file; see +.B RULES +below. +.PP +The three ways of running it follow. +The first two filter +.IR packets , +as they cross a wire; the third filters +.IR requests , +before any packet exists. +.SS Filtering a network card +.IP +.EX +fw -e /net/ether0 -a 10.0.2.15/24 -g 10.0.2.2 /lib/fw/host.ndb +.EE +.PP +Everything sent or received through that card is filtered, whoever sent +it. +One card to one +.IR fw , +so a machine with two cards to filter runs two of them. +.PP +The card cannot stay attached to the IP stack, or packets would reach it +whatever the rules said. +So +.I fw +takes the card and hands the stack a +.I pkt +interface instead - a card with +.I fw +on the other end of it: +.IP +.EX +network ---- ether0 [ fw ] stack ---- your programs +.EE +.PP +The stack keeps the address it had, given as +.BI -a " addr/mask"\f1, +and +.B -g +names the gateway. +Nothing is left doing ARP for an address whose card has been taken away, +so +.I fw +answers for it and resolves the next hop itself. +.SS Gateway, between two networks +.IP +.EX +fw /lib/fw/gate.ndb /net.wan!203.0.113.2!/24 /net.lan!10.0.0.1!/24 +.EE +.PP +.I Fw +sits between two networks and decides, packet by packet, what may cross: +.IP +.EX +internet ---- ether0 [ fw ] ether1 ---- your machines + outside inside +.EE +.PP +The side facing the untrusted network is the +.IR outside , +the side facing the machines you are protecting is the +.IR inside . +Rules are written in those terms, so which side is which matters. +.PP +Every packet between the two networks goes through +.IR fw , +so one the rules forbid is thrown away before the far side ever sees it. +Nothing is opened, nothing replies, and the sender is told nothing. +A side is written +.IB net ! addr ! mask\f1, +naming the mountpoint of an IP stack and the address to give the +interface +.I fw +creates on it. +.SS Filtering one namespace +.IP +.EX +fw.rc /lib/fw/web.ndb mothra +.EE +.PP +Given neither of those, +.I fw +serves a synthetic +.B /net +in place of +.IR ip (3), +and every program in that namespace reaches the network through it. +A write of +.B connect +or +.B announce +to a protocol ctl file is matched against the rules before it reaches +the kernel. +A refused write fails, and the diagnostic is what +.IR dial (2) +reports to the program that tried it. +.PP +Because the policy lives in a namespace, each program can have its own. +Filtering a program's requests is not a boundary by itself - see +.B CONTAINMENT +below - so to sandbox one, run +.B /lib/fw/fw.rc +rather than +.I fw +directly: it builds the namespace, mounts the filter, drops the devices +that would go around it, and only then runs the program. +.PP +The difference is one of altitude. +Packet filtering sees everything, including traffic no local program +asked for, but cannot tell one program from another. +Request filtering knows exactly who asked, but only ever sees intentions, +so it cannot stop an inbound connection before the handshake and cannot +see traffic that is merely passing through. +.PP +The options are: +.TP +.B -W +require wire (packet) filtering; exit rather than fall back to filtering +requests. +.TP +.B -S +do not track connections. +Every packet is then matched against the rules, and a rule permitting +traffic one way does not permit the replies. +.TP +.B -d +report every packet and every decision on standard error. +.TP +.BI -m " ctlmtpt" +mount the control files here, default +.BR /mnt/fw . +Packet filtering only. +.TP +.BI -e " ether" +take this card and filter the packets of this machine, eg +.BR /net/ether0 . +.TP +.BI -a " addr/mask" +the address the stack should keep once its card has been taken. +Without it +.I fw +reads the address and mask off the card it is taking, which is almost +always what you want; give it only to use something other than the +address the card already has. +.TP +.BI -g " gateway" +the next hop for anything off this network. +Without it +.I fw +takes the default route the stack was already using; if there was none, +only the local network is reachable. +.TP +.BI -n " net" +filter this stack rather than +.BR /net . +.TP +.BI -s " srv" +post the served filesystem on +.BI /srv/ srv +as well as mounting it. +Which filesystem that is depends on the mode, and so does the risk. +Filtering packets it is the control files, and anyone who can open them +can rewrite the rules; filtering requests it is the synthetic +.BR /net , +and anyone who can mount it has the network the rules were meant to +ration. +Either way, do not post it anywhere the traffic being filtered can reach +.BR /srv . +.SH RULES +Rules live in an +.IR ndb (6) +file, matched from the top, first match deciding, and traffic matching +nothing denied. +Connections are tracked, so permitting traffic one way permits the +replies. +The file format, the attributes, and worked rule sets are in +.IR fwrules (6). +.SH CONTROL +When filtering packets, +.I fw +serves four files, by default under +.BR /mnt/fw . +There are none when filtering requests: that policy is chosen when the +namespace is built and lasts as long as it does, so to change it, build +the namespace again. +.TP +.B ctl +Read it for the commands it takes. +Writing a rule installs it at once. +A rule that will not parse fails the write and leaves the running rules +alone. +.TP +.B rules +The current rules, as ndb, one rule per line and numbered from one in +the order shown - so rule +.I n +in a diagnostic is line +.I n +here, which is what +.B delete +counts. +What it prints can be written back unchanged. +.IP +Writing it replaces the whole set. +Writes accumulate and are applied when the file is closed, so a set +arriving in several messages is still installed in one step and no packet +is matched against half of it. +A clunk cannot report an error, so a set that will not parse is noted in +.B /sys/log/fw +and the running rules are kept; +.B ctl +is the way in if you want to be told. +.TP +.B flows +The connections being tracked. +.TP +.B stats +Packets passed and dropped, then each rule with the number of decisions +it has made. +A rule at zero is either dead or waiting for something that has not +happened, and it is worth knowing which. +.PP +The commands accepted by +.B ctl +are +.BR prepend , +.BR append , +.B delete +.IR n , +.BR flush , +.B reload +.RI [ file ], +.B save +.RI [ file ], +and +.BR flushflows . +.B Reload +and +.B save +with no argument use the file +.I fw +was started with. +.PP +There are three things called the rules: the file, what is running, and +what has been typed at +.BR ctl . +.B Reload +makes the running rules match the file, discarding anything typed; +.B save +makes the file match the running rules, keeping it. +Neither happens on its own. +.SH CONTAINMENT +Filtering +.B /net +achieves nothing on its own, because +.IP +.EX +bind -a '#I' /net +.EE +.PP +puts the real stack back. +A filtered program must also be denied the devices that reach a network +directly, through +.B /dev/drivers +(see +.IR cons (3)): +.IP +.EX +echo chdev '&~' 'Iluσ' >/dev/drivers +.EE +.PP +That mask can only ever have bits added, and every new process group +inherits it, so nothing below can undo it. +Note that the +.B & +form permits +.I only +the devices named, so using it to try to regain one silently drops +everything else instead. +.PP +.B /lib/fw/fw.rc +does this in the right order: private namespace, mount, drop, exec. +.SH BOOT +.B /lib/fw/fwstart +starts a firewall for each card named in +.BR /lib/ndb/fw , +which is an +.IR ndb (6) +file of one entry per card: +.IP +.EX +fw=ether0 rules=/lib/fw/wan.ndb +fw=ether1 rules=/lib/fw/lan.ndb +.EE +.PP +No address appears there. +.I Fw +reads each card's address, mask and gateway from the card it is taking +over, so this file cannot drift out of step with +.IR ipconfig (8). +Control files land under +.BI /mnt/fw/ ether0 +and so on, one directory per card. +.PP +Run it after the network is configured and before anything dials. +Too early and there is no address to read; too late and something has +already connected unfiltered. +A card named here that does not exist, or a rule file that is missing, +is reported and skipped rather than stopping the rest. +.SH EXAMPLES +Filter this machine's card, keeping the address it already has: +.IP +.EX +fw -e /net/ether0 -a 10.0.2.15/24 -g 10.0.2.2 /lib/fw/host.ndb +.EE +.PP +Filter two cards, one +.I fw +each: +.IP +.EX +fw -m /mnt/fw/ether0 -e /net/ether0 -a 198.51.100.7/24 -g 198.51.100.1 \ + /lib/fw/wan.ndb +fw -m /mnt/fw/ether1 -e /net/ether1 -a 10.0.0.1/24 /lib/fw/lan.ndb +.EE +.PP +Sit between two networks: +.IP +.EX +fw /lib/fw/gate.ndb /net.wan!203.0.113.2!/24 /net.lan!10.0.0.1!/24 +.EE +.PP +Sandbox one program, which may resolve names and speak https: +.IP +.EX +fw.rc /lib/fw/web.ndb mothra +.EE +.PP +Give a program no network whatever. +An empty rule file permits nothing: +.IP +.EX +fw.rc /lib/fw/none.ndb troff -ms doc +.EE +.PP +Block a port on a running firewall, at once, including anything already +connected: +.IP +.EX +echo prepend deny=in proto=tcp lport=80 >/mnt/fw/ctl +.EE +.PP +Edit the rule file and apply it, or keep what was typed at +.BR ctl : +.IP +.EX +echo reload >/mnt/fw/ctl +echo save >/mnt/fw/ctl +.EE +.PP +See what a rule has actually done: +.IP +.EX +cat /mnt/fw/stats +.EE +.SH FILES +.TP +.B /lib/fw +rule sets +.TP +.B /lib/fw/fwstart +starts one firewall per card at boot +.TP +.B /lib/ndb/fw +which cards are filtered, and with what +.TP +.B /mnt/fw +control files +.TP +.B /sys/log/fw +where rules marked +.B log=yes +are recorded +.SH SOURCE +.B /sys/src/cmd/fw +.SH "SEE ALSO" +.IR ip (3), +.IR cons (3), +.IR fwrules (6), +.IR ndb (6), +.IR dial (2), +.IR syslog (2), +.IR fork (2), +.IR ipconfig (8) +.SH BUGS +There is no address translation. +A machine behind a +.I fw +gateway needs a routable address, or must import +.B /net +rather than route through it. +.PP +Packet filtering drops silently, so a refused connection is discovered +by timing out. +Request filtering fails +.IR dial (2) +immediately with a reason, which is much easier to diagnose. +.PP +IPv6 extension headers are not walked; such packets are matched on their +addresses and next-header protocol alone. +ICMP has no type or code matching. +.PP +Taking a card is destructive and is not undone reliably. +The interface that replaces it is unbound when +.I fw +stops, and the address goes with it, so a +.I fw +that is killed leaves the card bound to nothing and the machine with no +network. +It cannot be restarted unaided either: the address it would have read +off the card is the address that has just been lost, so it must be told +one with +.BR -a , +or the card configured again with +.IR ipconfig (8) +first. +.I Fw +tries to put the card back as it exits, which covers an orderly stop but +not a kill. +Whatever restarts +.I fw +should be prepared to configure the card first. +.PP +The first packet to an unresolved next hop is dropped while +.I fw +asks for its ethernet address, exactly as any other stack would, so a +run of +.I fw +begins with one drop that no rule caused. +.PP +.I Fw +does not implement +.BR Tflush , +so a request it is blocked on cannot be abandoned. +While filtering requests this affects waiting for an inbound connection, +which is the one operation that blocks indefinitely: giving up on it does +not reach +.IR fw , +which stays waiting until a connection arrives. + + diff --git a/fw/man/fwrules b/fw/man/fwrules new file mode 100644 index 0000000..143ac08 --- /dev/null +++ b/fw/man/fwrules @@ -0,0 +1,224 @@ +.TH FWRULES 6 +.SH NAME +fwrules \- firewall rule files +.SH DESCRIPTION +.IR Fw (8) +decides what may cross a network by matching traffic against a file of +rules. +The file is an +.IR ndb (6) +file: one entry is one rule. +.PP +A rule set for a machine that may look up names and fetch pages, and do +nothing else: +.IP +.EX +allow=out proto=udp port=53 +allow=out proto=tcp port=53 +allow=out proto=tcp port=80 +allow=out proto=tcp port=443 + +deny=* log=yes +.EE +.PP +Rules are matched from the top, the first one that matches decides, and +traffic matching none of them is denied. +A file with no rules therefore permits nothing, and the last rule above +changes nothing about what is allowed - it exists so that the refusals +are written down instead of happening silently. +.PP +There is no rule permitting the replies to any of this, and none is +needed: see +.B STATE +below. +.SH ACTION +Every rule begins with an action, whose value is the direction it +governs: +.TP +.BI allow= dir +permit. +.TP +.BI deny= dir +refuse. +.PP +.I Dir +is +.BR out , +.BR in , +or +.B * +for either. +.B Out +is traffic begun from the side being protected; +.B in +is traffic begun toward it. +That holds wherever the rule is enforced: on a gateway the protected +side is the inside, and in a namespace it is the program, whose +.B connect +is +.B out +and whose +.B announce +is +.BR in . +.B Connect +and +.B announce +are accepted as older spellings of +.B out +and +.BR in . +.SH ATTRIBUTES +The rest of a rule says what it matches. +An attribute that is absent does not constrain, so there is never a +.B * +to write: +.TP +.BI proto= name +a protocol: +.BR tcp , +.BR udp , +.BR icmp , +and so on. +.TP +.BI port= n +the port at the far end. +.TP +.BI lport= n +the port at this end. +A program announcing a port is naming this end, so +.B announce 17019 +is matched by +.BR lport=17019 , +and by +.B port= +never - at that moment nobody has called, so there is no far end. +.TP +.BI ip= address +the address at the far end, optionally carrying a +.BI / mask +suffix. +IPv4 and IPv6 are written the usual way and need no distinguishing. +.TP +.BI ipmask= mask +the mask, written separately, as +.B /24 +or in full. +.TP +.B log=yes +note every match of this rule in +.BR /sys/log/fw . +.PP +An unrecognised attribute is an error, and +.I fw +refuses to start rather than run with it ignored: a mistyped constraint +would otherwise silently widen the rule it was meant to narrow. +.PP +An entry may be spread over indented continuation lines, as any +.IR ndb (6) +entry may: +.IP +.EX +allow=out + proto=tcp + port=443 +.EE +.SH ORDER +The first match decides, so a rule carving an exception out of a +broader rule must come above it. +This is right: +.IP +.EX +deny=out ip=1.1.1.1 +allow=out proto=tcp port=443 +.EE +.PP +and this is not, because 1.1.1.1:443 matches the allow first and the +deny is never reached: +.IP +.EX +allow=out proto=tcp port=443 +deny=out ip=1.1.1.1 +.EE +.PP +Rules are numbered from one in the order they appear, and that is the +number a refusal names in +.BR /sys/log/fw . +.SH STATE +Connections are tracked, so a rule permitting traffic one way permits +the replies without a second rule. +A permitted packet records the protocol and both addresses and ports; +anything matching that, either way round, passes without consulting the +rules again. +.PP +UDP has no connections, so a flow is that same tuple and lasts 60 +seconds after the last packet. +TCP lasts 300 seconds, everything else 30. +ICMP has no ports, so its flows are the two addresses alone, which is +enough for a reply to an echo to be recognised, but does not tie an +ICMP error to the connection it is about. +.PP +When the rules change, connections the new rules forbid are dropped +rather than left to finish: a block blocks. +.SH EXAMPLES +A gateway. +The machines behind it may reach the web, one host is refused outright, +and the only thing the internet may reach is a web server: +.IP +.EX +# the exception first, or the allows below would match +# 1.1.1.1:443 before this was ever reached +deny=out ip=1.1.1.1 + +# out: what the machines behind me may reach +allow=out proto=udp port=53 +allow=out proto=tcp port=53 +allow=out proto=tcp port=80 +allow=out proto=tcp port=443 + +# in: what the internet may reach here +allow=in proto=tcp lport=443 + +# and note anything else that tries, either way +deny=* log=yes +.EE +.PP +On a gateway facing the internet that last rule will log a great deal, +since the internet knocks on every door constantly. +Narrow it to +.B deny=out +if only the traffic from your own machines is worth recording. +.PP +A program that may resolve names and fetch pages over TLS, and nothing +else: +.IP +.EX +allow=out proto=udp port=53 +allow=out proto=tcp port=443 +.EE +.PP +A service that answers on one port and never calls out: +.IP +.EX +allow=in proto=tcp lport=17019 +.EE +.PP +No network at all. +An empty file permits nothing, so this is a complete rule set: +.IP +.EX +# nothing +.EE +.SH FILES +.TP +.B /lib/fw +rule sets +.TP +.B /lib/fw/example.ndb +every attribute, with comments +.SH "SEE ALSO" +.IR fw (8), +.IR ndb (6) +.SH BUGS +A rule cannot name which network card it applies to, so a machine +filtering two cards needs a file for each. diff --git a/fw/src/ether.c b/fw/src/ether.c new file mode 100644 index 0000000..54f5068 --- /dev/null +++ b/fw/src/ether.c @@ -0,0 +1,374 @@ +/* + * fw, the ethernet side. + * + * To filter a real machine, fw has to be the wire: the card cannot + * stay attached to the IP stack, or packets reach it whatever we + * decide. So fw opens /net/etherN itself and the protected stack gets + * a pkt interface instead - a fake card with fw on the other end. + * + * ether0 ---- fw ---- pkt ---- the stack ---- programs + * + * The stack no longer has ethernet, so nobody is doing ARP for it any + * more, and fw has to: answer requests for the address we are + * protecting, and resolve the next hop for anything we send. That is + * all the ethernet fw knows about; everything else it treats as an IP + * packet and hands to the rules. + * + * An ether "bypass" connection looks like it should serve instead, but + * it only intercepts the stack's transmissions - etheriq drops what + * arrives from the wire while bypass is set - so it cannot filter + * inbound at all. + */ +#include +#include +#include +#include +#include +#include "rules.h" + +extern int etherdebug; + +enum +{ + Eaddrlen = 6, + Ehdrlen = 14, + Etip4 = 0x0800, + Etarp = 0x0806, + Etip6 = 0x86DD, + + Arplen = 28, + Eminlen = 60, /* ethernet minimum frame, devether enforces it */ + Arpreq = 1, + Arpreply = 2, + + Narp = 64, + Arplife = 300, +}; + +typedef struct Arpent Arpent; +struct Arpent +{ + uchar ip[IPaddrlen]; + uchar mac[Eaddrlen]; + long when; +}; + +static Arpent arptab[Narp]; +static Lock arplock; + +static uchar ourmac[Eaddrlen]; +static uchar ouraddr[IPaddrlen]; +static uchar gateway[IPaddrlen]; +static int haveg; +static int efd = -1; + +static uchar bcast[Eaddrlen] = { 0xff,0xff,0xff,0xff,0xff,0xff }; + +/* + * Open a card for raw frames of every type. Promiscuous because we + * are answering for an address the card does not believe is its own + * once the stack has let go of it. + */ +int +etheropen(char *dev, uchar *mac) +{ + char path[128], buf[64]; + int cfd, dfd, n, conn; + + snprint(path, sizeof path, "%s/clone", dev); + if((cfd = open(path, ORDWR)) < 0) + sysfatal("open %s: %r", path); + if((n = read(cfd, buf, sizeof buf - 1)) <= 0) + sysfatal("read %s: %r", path); + buf[n] = '\0'; + conn = atoi(buf); + + if(fprint(cfd, "connect -1") < 0) + sysfatal("%s: connect -1: %r", dev); + if(fprint(cfd, "promiscuous") < 0) + sysfatal("%s: promiscuous: %r", dev); + + snprint(path, sizeof path, "%s/%d/data", dev, conn); + if((dfd = open(path, ORDWR)) < 0) + sysfatal("open %s: %r", path); + + /* the card's own address, which we answer with */ + snprint(path, sizeof path, "%s/addr", dev); + if((n = open(path, OREAD)) < 0) + sysfatal("open %s: %r", path); + if(read(n, buf, 12) != 12) + sysfatal("read %s: %r", path); + close(n); + buf[12] = '\0'; + if(parseether(mac, buf) < 0) + sysfatal("%s: unparseable address %s", dev, buf); + + efd = dfd; + memmove(ourmac, mac, Eaddrlen); + return dfd; +} + +void +ethersetaddr(uchar *ip, uchar *gw, int haveit) +{ + ipmove(ouraddr, ip); + if(haveit) + ipmove(gateway, gw); + haveg = haveit; +} + +static void +arpput(uchar *ip, uchar *mac) +{ + Arpent *a, *old; + long now; + int i; + + now = time(0); + lock(&arplock); + old = &arptab[0]; + for(i = 0; i < Narp; i++){ + a = &arptab[i]; + if(a->when != 0 && ipcmp(a->ip, ip) == 0){ + memmove(a->mac, mac, Eaddrlen); + a->when = now; + unlock(&arplock); + return; + } + if(a->when < old->when) + old = a; + } + ipmove(old->ip, ip); + memmove(old->mac, mac, Eaddrlen); + old->when = now; + unlock(&arplock); +} + +static int +arpget(uchar *ip, uchar *mac) +{ + Arpent *a; + long now; + int i, r; + + r = 0; + now = time(0); + lock(&arplock); + for(i = 0; i < Narp; i++){ + a = &arptab[i]; + if(a->when != 0 && ipcmp(a->ip, ip) == 0){ + if(now - a->when <= Arplife){ + memmove(mac, a->mac, Eaddrlen); + r = 1; + } + break; + } + } + unlock(&arplock); + return r; +} + +/* + * Broadcast and multicast are not resolved, they are addressed by rule. + * Without this a DHCP renewal, which goes to 255.255.255.255, would be + * sent to whatever the gateway's ethernet address happened to be, and + * the lease would quietly never renew. + */ +static int +groupmac(uchar *dst, uchar *mask, uchar *mac) +{ + uchar net[IPaddrlen], all[IPaddrlen]; + int i; + + if(isv4(dst)){ + /* 255.255.255.255 */ + for(i = IPv4off; i < IPaddrlen; i++) + if(dst[i] != 0xff) + break; + if(i == IPaddrlen){ + memmove(mac, bcast, Eaddrlen); + return 1; + } + /* the broadcast address of our own network */ + maskip(ouraddr, mask, net); + for(i = 0; i < IPaddrlen; i++) + all[i] = net[i] | ~mask[i]; + if(ipcmp(dst, all) == 0){ + memmove(mac, bcast, Eaddrlen); + return 1; + } + /* 224.0.0.0/4 */ + if(dst[IPv4off] >= 224 && dst[IPv4off] < 240){ + mac[0] = 0x01; + mac[1] = 0x00; + mac[2] = 0x5e; + mac[3] = dst[IPv4off+1] & 0x7f; + mac[4] = dst[IPv4off+2]; + mac[5] = dst[IPv4off+3]; + return 1; + } + return 0; + } + /* ff00::/8 */ + if(dst[0] == 0xff){ + mac[0] = 0x33; + mac[1] = 0x33; + memmove(mac+2, dst+12, 4); + return 1; + } + return 0; +} + +static void +puthdr(uchar *f, uchar *dst, int type) +{ + memmove(f, dst, Eaddrlen); + memmove(f + Eaddrlen, ourmac, Eaddrlen); + f[12] = type >> 8; + f[13] = type; +} + +/* ask who has ip; the answer arrives later and goes in the cache */ +static void +arpask(uchar *ip) +{ + uchar f[Eminlen]; + + if(!isv4(ip)) + return; + memset(f, 0, sizeof f); + puthdr(f, bcast, Etarp); + hnputs(f + 14, 1); /* ethernet */ + hnputs(f + 16, Etip4); + f[18] = Eaddrlen; + f[19] = 4; + hnputs(f + 20, Arpreq); + memmove(f + 22, ourmac, Eaddrlen); + memmove(f + 28, ouraddr + IPv4off, 4); + memmove(f + 38, ip + IPv4off, 4); + if(etherdebug) + fprint(2, "arp: who has %I? (asking)\n", ip); + if(write(efd, f, sizeof f) != sizeof f) + fprint(2, "arp: write failed: %r\n"); +} + +/* + * An arp frame from the wire. Learn from it either way, and answer a + * request for the address we are standing in for. + */ +static void +arpin(uchar *f, int n) +{ + uchar sip[IPaddrlen], tip[IPaddrlen], r[Eminlen]; + int op; + + if(n < Ehdrlen + Arplen) + return; + if(nhgets(f + 16) != Etip4 || f[18] != Eaddrlen || f[19] != 4) + return; + op = nhgets(f + 20); + v4tov6(sip, f + 28); + v4tov6(tip, f + 38); + + if(ipcmp(sip, IPnoaddr) != 0){ + arpput(sip, f + 22); + if(etherdebug) + fprint(2, "arp: learned %I is %E (op %d)\n", sip, f+22, op); + } + if(op != Arpreq || ipcmp(tip, ouraddr) != 0) + return; + + memset(r, 0, sizeof r); + puthdr(r, f + 22, Etarp); + hnputs(r + 14, 1); + hnputs(r + 16, Etip4); + r[18] = Eaddrlen; + r[19] = 4; + hnputs(r + 20, Arpreply); + memmove(r + 22, ourmac, Eaddrlen); + memmove(r + 28, ouraddr + IPv4off, 4); + memmove(r + 32, f + 22, Eaddrlen); + memmove(r + 38, f + 28, 4); + write(efd, r, sizeof r); +} + +int +etherisarp(uchar *f, int n) +{ + if(n < Ehdrlen) + return 0; + if(nhgets(f + 12) != Etarp) + return 0; + arpin(f, n); + return 1; +} + +int +etherisip(uchar *f, int n) +{ + int t; + + if(n < Ehdrlen) + return 0; + t = nhgets(f + 12); + return t == Etip4 || t == Etip6; +} + +/* + * Send an IP packet out the card. The next hop is the destination if + * it is on our own network, otherwise the gateway. If we do not know + * its ethernet address yet we ask and drop this one; the sender will + * try again, which is what every other stack does too. + */ +int +etherwriteip(uchar *p, int n, uchar *mask) +{ + uchar f[Ehdrlen + 64*1024], dst[IPaddrlen], hop[IPaddrlen]; + uchar net[IPaddrlen], ournet[IPaddrlen], mac[Eaddrlen]; + int type, len; + + if(n < 20 || n > 64*1024 - Ehdrlen) + return -1; + switch(p[0] >> 4){ + case 4: + type = Etip4; + v4tov6(dst, p + 16); + break; + case 6: + type = Etip6; + ipmove(dst, p + 24); + break; + default: + return -1; + } + + if(groupmac(dst, mask, mac)) + goto Send; + + maskip(dst, mask, net); + maskip(ouraddr, mask, ournet); + if(ipcmp(net, ournet) == 0) + ipmove(hop, dst); + else if(haveg) + ipmove(hop, gateway); + else + ipmove(hop, dst); + + if(!arpget(hop, mac)){ + if(etherdebug) + fprint(2, "arp: no entry for %I, dropping and asking\n", hop); + arpask(hop); + return 0; + } +Send: + puthdr(f, mac, type); + memmove(f + Ehdrlen, p, n); + len = Ehdrlen + n; + if(len < Eminlen){ + memset(f + len, 0, Eminlen - len); + len = Eminlen; + } + if((len = write(efd, f, len)) < 0) + fprint(2, "ether: write failed: %r\n"); + return len; +} diff --git a/fw/src/fw.c b/fw/src/fw.c new file mode 100644 index 0000000..4f94090 --- /dev/null +++ b/fw/src/fw.c @@ -0,0 +1,1391 @@ +/* + * fw - a firewall between two networks. + * + * Each side is an interface bound to the pkt medium, whose data file is + * a raw IP wire: reading gives packets that stack wants to transmit, + * writing injects packets as if they had arrived. fw sits between + * two of them and copies packets across, or doesn't. + * + * LAN --ether-- stack --pkt-- fw --pkt-- stack --ether-- WAN + * + * Because nothing crosses without passing through here, a dropped + * packet is dropped before the far stack allocates anything or answers + * anything: this is filtering at the gate, in both directions, which is + * what the request-filtering half cannot do. + * + * pktmedium is unbindonclose, so the ctl fd of each interface has to be + * held for as long as the interface should exist. That is why the + * interfaces are created here rather than by a script. + */ +#include +#include +#include +#include +#include +#include +#include +#include <9p.h> +#include "rules.h" + +typedef struct Wire Wire; +struct Wire +{ + char *net; /* mountpoint of this side's stack */ + char *addr; + char *mask; + char *side; /* "outside" or "inside", for messages */ + int ifc; + int cfd; /* held open: unbindonclose */ + int dfd; /* the raw IP wire */ +}; + +typedef struct Pkt Pkt; +struct Pkt +{ + int ok; + int proto; + uchar src[IPaddrlen]; + uchar dst[IPaddrlen]; + int sport; + int dport; + int verb; +}; + +static int debug; +static int stateless; +static int nallow, ndeny; + +/* + * Connection tracking. + * + * Without this a rule set is unwritable: permitting a connection out + * would mean separately permitting every reply back in, which in + * practice means opening every ephemeral port and calling it strict. + * So a packet the rules permit creates a flow, and anything belonging + * to that flow - in either direction - passes without consulting the + * rules again. This is what "keep state" means in pf and what every + * sysadmin already assumes is happening. + * + * ICMP has no ports, so its flows are keyed on the addresses alone; + * that is enough for ping to work. ICMP errors *about* a flow are not + * yet recognised as related to it. + */ +typedef struct Flow Flow; +struct Flow +{ + int proto; + uchar src[IPaddrlen]; + uchar dst[IPaddrlen]; + int sport; + int dport; + int verb; /* direction that created it */ + long last; + Flow *next; +}; + +enum { Nflow = 257, Maxpkt = 16*1024 }; + +static Flow *flowtab[Nflow]; +static Lock flowlock; +static int sweep; + +static int +flowtimeout(int proto) +{ + switch(proto){ + case 6: /* tcp */ + return 300; + case 17: /* udp */ + return 60; + default: + return 30; + } +} + +static uint +flowhash(int proto, uchar *src, uchar *dst, int sport, int dport) +{ + uint h; + int i; + + h = proto*31 + sport*7 + dport; + for(i = 0; i < IPaddrlen; i++) + h = h*33 + src[i]*3 + dst[i]; + return h % Nflow; +} + +static int +flowis(Flow *f, int proto, uchar *src, uchar *dst, int sport, int dport) +{ + return f->proto == proto && f->sport == sport && f->dport == dport + && ipcmp(f->src, src) == 0 && ipcmp(f->dst, dst) == 0; +} + +static int +flowlook(int proto, uchar *src, uchar *dst, int sport, int dport, long now) +{ + Flow *f; + uint h; + + h = flowhash(proto, src, dst, sport, dport); + for(f = flowtab[h]; f != nil; f = f->next) + if(flowis(f, proto, src, dst, sport, dport)){ + f->last = now; + return 1; + } + return 0; +} + +/* does this packet belong to a flow we already permitted, either way round? */ +static int +flowseen(Pkt *p) +{ + long now; + int r; + + if(stateless) + return 0; + now = time(0); + lock(&flowlock); + r = flowlook(p->proto, p->src, p->dst, p->sport, p->dport, now) + || flowlook(p->proto, p->dst, p->src, p->dport, p->sport, now); + unlock(&flowlock); + return r; +} + +static void +reapbucket(int i, long now) +{ + Flow *f, **pp; + + for(pp = &flowtab[i]; (f = *pp) != nil; ){ + if(now - f->last > flowtimeout(f->proto)){ + *pp = f->next; + free(f); + }else + pp = &f->next; + } +} + +/* + * Re-check every live flow against the current rules and drop the ones + * the rules no longer permit. Called whenever the rule set changes. + * + * pf and iptables do not do this: there, blocking a port stops new + * connections and leaves established ones running until they time out. + * That is a wart everybody has to learn. A block should block, so a + * rule change kills the traffic it now forbids. + */ +int +revalidate(void) +{ + Flow *f, **pp; + uchar *peer; + int i, port, lport, n; + + n = 0; + lock(&flowlock); + for(i = 0; i < Nflow; i++) + for(pp = &flowtab[i]; (f = *pp) != nil; ){ + if(f->verb == Vout){ + peer = f->dst; + port = f->dport; + lport = f->sport; + }else{ + peer = f->src; + port = f->sport; + lport = f->dport; + } + if(matchrule(f->verb, protonum2name(f->proto), peer, 0, port, lport, nil) != nil){ + *pp = f->next; + free(f); + n++; + }else + pp = &f->next; + } + unlock(&flowlock); + return n; +} + +static void +flowadd(Pkt *p) +{ + Flow *f; + uint h; + long now; + + if(stateless) + return; + now = time(0); + h = flowhash(p->proto, p->src, p->dst, p->sport, p->dport); + lock(&flowlock); + reapbucket(h, now); + /* and sweep one other bucket, so idle flows do not accumulate */ + sweep = (sweep + 1) % Nflow; + reapbucket(sweep, now); + + f = emalloc(sizeof *f); + f->proto = p->proto; + ipmove(f->src, p->src); + ipmove(f->dst, p->dst); + f->sport = p->sport; + f->dport = p->dport; + f->verb = p->verb; + f->last = now; + f->next = flowtab[h]; + flowtab[h] = f; + unlock(&flowlock); +} + +void servenet(char*, char*, char*); +int etheropen(char*, uchar*); +void ethersetaddr(uchar*, uchar*, int); +int etherisarp(uchar*, int); +int etherisip(uchar*, int); +int etherwriteip(uchar*, int, uchar*); + +enum { Ehdrlen = 14 }; + +static int efd = -1; +int etherdebug; +static uchar ethermask[IPaddrlen]; + +static void +usage(void) +{ + fprint(2, "usage: fw [-dSW] [-m ctl] [-s srv] [-n net]\n"); + fprint(2, " [-e ether -a addr/mask [-g gateway]]\n"); + fprint(2, " rules.ndb [outside inside]\n"); + fprint(2, " -e: filter packets on a card, for this machine\n"); + fprint(2, " two sides: filter packets between them (a gateway)\n"); + fprint(2, " neither: filter requests on /net (one namespace)\n"); + fprint(2, " a side is net!addr!mask, eg /net.alt!10.9.9.1!255.255.255.0\n"); + exits("usage"); +} + +static Wire* +parseside(char *spec, char *side) +{ + char *f[4]; + Wire *w; + + w = emalloc(sizeof *w); + w->side = side; + if(getfields(estrdup(spec), f, nelem(f), 0, "!") != 3) + sysfatal("%s: want net!addr!mask", spec); + w->net = f[0]; + w->addr = f[1]; + w->mask = f[2]; + return w; +} + +static void +wireup(Wire *w) +{ + char path[128], buf[64]; + int n; + + snprint(path, sizeof path, "%s/ipifc/clone", w->net); + if((w->cfd = open(path, ORDWR)) < 0) + sysfatal("open %s: %r", path); + if((n = read(w->cfd, buf, sizeof buf - 1)) <= 0) + sysfatal("read %s: %r", path); + buf[n] = '\0'; + w->ifc = atoi(buf); + + if(fprint(w->cfd, "bind pkt") < 0) + sysfatal("%s: bind pkt: %r", w->net); + if(fprint(w->cfd, "add %s %s", w->addr, w->mask) < 0) + sysfatal("%s: add %s %s: %r", w->net, w->addr, w->mask); + + snprint(path, sizeof path, "%s/ipifc/%d/data", w->net, w->ifc); + if((w->dfd = open(path, ORDWR)) < 0) + sysfatal("open %s: %r", path); + + fprint(2, "fw: %s %s/ipifc/%d addr %s %s\n", + w->side, w->net, w->ifc, w->addr, w->mask); +} + +/* + * Enough of an IP header to make a decision. IPv6 extension headers + * are not walked: a packet carrying them is reported as its next-header + * protocol and matched on addresses only, which is conservative given + * default deny but is a real gap worth closing. + */ +static void +parsepkt(uchar *b, int n, Pkt *p) +{ + uchar *t; + int hl; + + memset(p, 0, sizeof *p); + p->sport = p->dport = -1; + if(n < 1) + return; + + switch(b[0] >> 4){ + case 4: + if(n < 20) + return; + hl = (b[0] & 0xF) * 4; + if(hl < 20 || n < hl) + return; + p->proto = b[9]; + v4tov6(p->src, b + 12); + v4tov6(p->dst, b + 16); + t = b + hl; + n -= hl; + break; + case 6: + if(n < 40) + return; + p->proto = b[6]; + ipmove(p->src, b + 8); + ipmove(p->dst, b + 24); + t = b + 40; + n -= 40; + break; + default: + return; + } + + if((p->proto == 6 || p->proto == 17) && n >= 4){ + p->sport = nhgets(t); + p->dport = nhgets(t + 2); + } + p->ok = 1; +} + +/* + * One direction. "verb" says which way this is, and the rule's ip and + * port always refer to the peer - the far end - so a rule reads the + * same whichever direction it governs. + */ +int revalidate(void); + +static void +relay(Wire *from, Wire *to, int verb) +{ + uchar *buf; + char *e; + uchar *peer; + Rule *rule; + Pkt p; + int n, port, lport; + + /* + * On the heap, not the stack: these run as libthread procs with a + * small stack, and pktmedium's maxtu is 4k anyway. + */ + buf = emalloc(Maxpkt); + for(;;){ + if((n = read(from->dfd, buf, Maxpkt)) <= 0){ + fprint(2, "fw: %s: read failed: %r\n", from->side); + syslog(0, "fw", "%s: read failed: %r", from->side); + return; + } + + parsepkt(buf, n, &p); + if(!p.ok){ + ndeny++; + if(debug) + fprint(2, "drop %s: unparseable, %d bytes\n", from->side, n); + continue; + } + + p.verb = verb; + if(verb == Vout){ + peer = p.dst; + port = p.dport; + lport = p.sport; + }else{ + peer = p.src; + port = p.sport; + lport = p.dport; + } + + if(flowseen(&p)){ + nallow++; + if(debug) + fprint(2, "pass %s %s %I!%d -> %I!%d (state)\n", + verb == Vout ? "out" : "in", + protonum2name(p.proto), + p.src, p.sport, p.dst, p.dport); + if(write(to->dfd, buf, n) != n) + fprint(2, "fw: write %s wire: %r\n", to->side); + continue; + } + + e = matchrule(verb, protonum2name(p.proto), peer, 0, port, lport, &rule); + if(e != nil){ + if(rule != nil && rule->log) + syslog(0, "fw", "drop %s %s %I!%d -> %I!%d: %s", + verb == Vout ? "out" : "in", + protonum2name(p.proto), + p.src, p.sport, p.dst, p.dport, e); + ndeny++; + if(debug) + fprint(2, "drop %s %s %I!%d -> %I!%d: %s\n", + verb == Vout ? "out" : "in", + protonum2name(p.proto), + p.src, p.sport, p.dst, p.dport, e); + continue; + } + + nallow++; + if(rule != nil && rule->log) + syslog(0, "fw", "pass %s %s %I!%d -> %I!%d", + verb == Vout ? "out" : "in", + protonum2name(p.proto), + p.src, p.sport, p.dst, p.dport); + flowadd(&p); + if(debug) + fprint(2, "pass %s %s %I!%d -> %I!%d (new)\n", + verb == Vout ? "out" : "in", + protonum2name(p.proto), + p.src, p.sport, p.dst, p.dport); + if(write(to->dfd, buf, n) != n) + fprint(2, "fw: write %s wire: %r\n", to->side); + } +} + +enum +{ + Qctl, + Qrules, + Qflows, + Qstats, +}; + +static char *rulepath; /* the file we were started with */ + +static char *ctltext = + "prepend insert a rule at the top, where it wins\n" + "append add a rule at the bottom\n" + "delete remove rule n\n" + "flush remove every rule; then nothing is permitted\n" + "reload [file] re-read the rule file; no argument means the\n" + " one it was started with\n" + "save [file] write the current rules back out\n" + "flushflows forget tracked connections\n" + "\n" + "a rule is ndb, eg: deny=out proto=tcp port=80\n" + "changes take effect at once, and live connections that the\n" + "new rules forbid are dropped rather than left running.\n"; + +enum { Rulebuf = 64*1024 }; + +/* + * ctl edits are done by writing the rule set back out as ndb, editing + * the text, and parsing the whole thing again. It is not the quickest + * way, but rule changes are rare, and it means a rule typed at ctl and + * a rule in the file go through exactly one parser - they cannot come + * to disagree about what a rule means. + */ +static Rule* +rulesfromtext(char *text, char **err) +{ + char tmp[64]; + Rule *r; + long n; + int fd; + + snprint(tmp, sizeof tmp, "/tmp/fw.%d", getpid()); + if((fd = create(tmp, OWRITE, 0600)) < 0){ + *err = "cannot create a temporary file"; + return nil; + } + n = strlen(text); + if(write(fd, text, n) != n){ + close(fd); + remove(tmp); + *err = "cannot write a temporary file"; + return nil; + } + close(fd); + r = parserules(tmp, err); + remove(tmp); + return r; +} + +static char* +replacerules(char *text) +{ + Rule *new; + char *err; + + new = rulesfromtext(text, &err); + if(err != nil) + return err; + installrules(new); + return nil; +} + +static char* +editrules(char *add, int atfront, int delete) +{ + char *cur, *all, *err, *p, *nl; + int i; + + if((cur = mallocz(Rulebuf, 1)) == nil) + return "out of memory"; + cur[fmtrules(cur, Rulebuf-1)] = '\0'; + + if(delete > 0){ + /* fmtrules writes one line per rule, so line n is rule n */ + p = cur; + for(i = 1; i < delete && p != nil; i++) + if((p = strchr(p, '\n')) != nil) + p++; + if(p == nil || *p == '\0'){ + free(cur); + return "no such rule"; + } + if((nl = strchr(p, '\n')) != nil) + memmove(p, nl+1, strlen(nl+1)+1); + else + *p = '\0'; + all = strdup(cur); + }else if(atfront) + all = smprint("%s\n%s", add, cur); + else + all = smprint("%s%s\n", cur, add); + free(cur); + if(all == nil) + return "out of memory"; + + err = replacerules(all); + free(all); + return err; +} + +static char* +reloadrules(char *file) +{ + Rule *new; + char *err; + + if(file == nil) + return "no rule file to reload"; + if((new = parserules(file, &err)) == nil && err != nil) + return err; + installrules(new); + return nil; +} + +/* the other direction: keep what was typed at ctl across a restart */ +static char* +saverules(char *file) +{ + char *buf; + long n; + int fd; + + if(file == nil) + return "no rule file to save to"; + if((buf = mallocz(Rulebuf, 1)) == nil) + return "out of memory"; + n = fmtrules(buf, Rulebuf-1); + if((fd = create(file, OWRITE, 0644)) < 0){ + free(buf); + return "cannot create the rule file"; + } + if(write(fd, buf, n) != n){ + close(fd); + free(buf); + return "cannot write the rule file"; + } + close(fd); + free(buf); + return nil; +} + +static char* +flushflows(void) +{ + Flow *f, *next; + int i; + + lock(&flowlock); + for(i = 0; i < Nflow; i++){ + for(f = flowtab[i]; f != nil; f = next){ + next = f->next; + free(f); + } + flowtab[i] = nil; + } + unlock(&flowlock); + return nil; +} + +static char* +flowtext(void) +{ + char *buf, *p, *e; + Flow *f; + long now; + int i; + + if((buf = mallocz(Rulebuf, 1)) == nil) + return nil; + p = buf; + e = buf + Rulebuf; + now = time(0); + lock(&flowlock); + for(i = 0; i < Nflow; i++) + for(f = flowtab[i]; f != nil; f = f->next) + p = seprint(p, e, "%s %s %I!%d -> %I!%d idle %ld\n", + f->verb == Vout ? "out" : "in", + protonum2name(f->proto), + f->src, f->sport, f->dst, f->dport, + now - f->last); + unlock(&flowlock); + USED(p); + return buf; +} + +/* + * A rule set written to "rules" may not arrive in one 9P message, and + * treating each write as a complete set would install fragments. So + * writes accumulate on the fid and the set is replaced when the fid is + * clunked, which is the transaction boundary the protocol already has. + * + * A clunk cannot fail, so a set that will not parse is reported to the + * log and the running rules are kept. Use ctl, whose writes do report + * errors, when you want to be told. + */ +typedef struct Wbuf Wbuf; +struct Wbuf +{ + char *b; + long n; + long max; +}; + +static void +wbufput(Wbuf *w, char *p, long n, vlong off) +{ + long need; + + need = off + n; + if(need + 1 > w->max){ + w->max = need + 8192; + if((w->b = realloc(w->b, w->max)) == nil) + sysfatal("out of memory"); + } + if(off > w->n) + memset(w->b + w->n, 0, off - w->n); + memmove(w->b + off, p, n); + if(need > w->n) + w->n = need; +} + +static void +fsdestroyfid(Fid *fid) +{ + Wbuf *w; + char *err; + + if((w = fid->aux) == nil) + return; + fid->aux = nil; + if(w->n > 0){ + w->b[w->n] = '\0'; + if((err = replacerules(w->b)) != nil) + syslog(0, "fw", "rules rejected, keeping the old ones: %s", err); + else + syslog(0, "fw", "rule set replaced"); + } + free(w->b); + free(w); +} + +static void +fsread(Req *r) +{ + char buf[256], *s; + long n; + + switch((int)(uintptr)r->fid->file->aux){ + case Qctl: + readstr(r, ctltext); + break; + case Qrules: + if((s = mallocz(Rulebuf, 1)) == nil){ + respond(r, "out of memory"); + return; + } + n = fmtrules(s, Rulebuf-1); + s[n] = '\0'; + readstr(r, s); + free(s); + break; + case Qflows: + if((s = flowtext()) == nil){ + respond(r, "out of memory"); + return; + } + readstr(r, s); + free(s); + break; + case Qstats: + if((s = mallocz(Rulebuf, 1)) == nil){ + respond(r, "out of memory"); + return; + } + n = snprint(s, Rulebuf-1, "passed %d\ndropped %d\n\n", nallow, ndeny); + fmthits(s+n, Rulebuf-1-n); + readstr(r, s); + free(s); + break; + default: + respond(r, "not a readable file"); + return; + } + respond(r, nil); +} + +static void +fswrite(Req *r) +{ + char *buf, *err, *arg, *p; + int type; + long n; + + type = (int)(uintptr)r->fid->file->aux; + if(type != Qctl && type != Qrules){ + respond(r, "not a writable file"); + return; + } + if((buf = mallocz(r->ifcall.count+1, 1)) == nil){ + respond(r, "out of memory"); + return; + } + memmove(buf, r->ifcall.data, r->ifcall.count); + buf[r->ifcall.count] = '\0'; + + if(type == Qrules){ + Wbuf *w; + + if((w = r->fid->aux) == nil){ + w = emalloc(sizeof *w); + r->fid->aux = w; + } + wbufput(w, r->ifcall.data, r->ifcall.count, r->ifcall.offset); + err = nil; + goto Done; + } + + /* + * A write may or may not carry a trailing newline, and the + * argument of prepend is a whole ndb rule with spaces and tabs + * in it, so split off the first word by hand rather than + * tokenizing the lot. + */ + for(n = strlen(buf); n > 0; n--){ + if(buf[n-1] != '\n' && buf[n-1] != '\r' + && buf[n-1] != ' ' && buf[n-1] != '\t') + break; + buf[n-1] = '\0'; + } + if(*buf == '\0'){ + err = "no command"; + goto Done; + } + arg = nil; + for(p = buf; *p != '\0' && *p != ' ' && *p != '\t'; p++) + ; + if(*p != '\0'){ + *p++ = '\0'; + while(*p == ' ' || *p == '\t') + p++; + if(*p != '\0') + arg = p; + } + + if(strcmp(buf, "flush") == 0) + err = replacerules(""); + else if(strcmp(buf, "flushflows") == 0) + err = flushflows(); + else if(strcmp(buf, "reload") == 0) + err = reloadrules(arg != nil ? arg : rulepath); + else if(strcmp(buf, "save") == 0) + err = saverules(arg != nil ? arg : rulepath); + else if(arg == nil) + err = "usage: prepend, append or delete , " + "flush, reload, save or flushflows"; + else if(strcmp(buf, "prepend") == 0) + err = editrules(arg, 1, 0); + else if(strcmp(buf, "append") == 0) + err = editrules(arg, 0, 0); + else if(strcmp(buf, "delete") == 0) + err = editrules(nil, 0, atoi(arg)); + else + err = "unknown command; read ctl for the list"; + +Done: + free(buf); + if(err != nil){ + respond(r, err); + return; + } + r->ofcall.count = r->ifcall.count; + respond(r, nil); +} + +static Srv fs = +{ +.read= fsread, +.write= fswrite, +.destroyfid= fsdestroyfid, +}; + +/* a rule change must not leave traffic running that the rules now forbid */ +static void +rulesdidchange(void) +{ + int n; + + if((n = revalidate()) > 0) + syslog(0, "fw", "rules changed; dropped %d flow%s they forbid", + n, n == 1 ? "" : "s"); +} + +static void +servectl(char *mtpt, char *srvname) +{ + File *root; + + fs.tree = alloctree("fw", "fw", DMDIR|0555, nil); + root = fs.tree->root; + closefile(createfile(root, "ctl", "fw", 0666, (void*)Qctl)); + closefile(createfile(root, "rules", "fw", 0666, (void*)Qrules)); + closefile(createfile(root, "flows", "fw", 0444, (void*)Qflows)); + closefile(createfile(root, "stats", "fw", 0444, (void*)Qstats)); + threadpostmountsrv(&fs, srvname, mtpt, MREPL); +} + +static void +relayproc(void *a) +{ + Wire **w; + + w = a; + relay(w[0], w[1], (int)(uintptr)w[2]); +} + +/* + * Decide on one packet. Shared by every relay: the wire it came from + * only changes which way "in" and "out" mean. + */ +static int +permitted(uchar *buf, int n, int verb, Pkt *p) +{ + Rule *rule; + uchar *peer; + char *e; + int port, lport; + + parsepkt(buf, n, p); + if(!p->ok){ + ndeny++; + if(debug) + fprint(2, "drop %s: unparseable, %d bytes\n", + verb == Vout ? "out" : "in", n); + return 0; + } + p->verb = verb; + if(verb == Vout){ + peer = p->dst; + port = p->dport; + lport = p->sport; + }else{ + peer = p->src; + port = p->sport; + lport = p->dport; + } + if(flowseen(p)){ + nallow++; + return 1; + } + e = matchrule(verb, protonum2name(p->proto), peer, 0, port, lport, &rule); + if(e != nil){ + ndeny++; + if(rule != nil && rule->log) + syslog(0, "fw", "drop %s %s %I!%d -> %I!%d: %s", + verb == Vout ? "out" : "in", protonum2name(p->proto), + p->src, p->sport, p->dst, p->dport, e); + if(debug) + fprint(2, "drop %s %s %I!%d -> %I!%d: %s\n", + verb == Vout ? "out" : "in", protonum2name(p->proto), + p->src, p->sport, p->dst, p->dport, e); + return 0; + } + nallow++; + if(rule != nil && rule->log) + syslog(0, "fw", "pass %s %s %I!%d -> %I!%d", + verb == Vout ? "out" : "in", protonum2name(p->proto), + p->src, p->sport, p->dst, p->dport); + if(debug) + fprint(2, "pass %s %s %I!%d -> %I!%d\n", + verb == Vout ? "out" : "in", protonum2name(p->proto), + p->src, p->sport, p->dst, p->dport); + flowadd(p); + return 1; +} + +/* the wire -> the protected stack */ +static void +etherin(void *a) +{ + uchar *buf; + Wire *w; + Pkt p; + int n; + + w = a; + buf = emalloc(Maxpkt); + for(;;){ + if((n = read(efd, buf, Maxpkt)) <= 0){ + fprint(2, "fw: %s: read the card: %r\n", w->side); + syslog(0, "fw", "stopped reading the card: %r"); + return; + } + if(debug > 1) + fprint(2, "wire: %d bytes type %.4ux\n", n, (buf[12]<<8)|buf[13]); + if(etherisarp(buf, n)) + continue; + if(!etherisip(buf, n)) + continue; + if(permitted(buf+Ehdrlen, n-Ehdrlen, Vin, &p)) + write(w->dfd, buf+Ehdrlen, n-Ehdrlen); + } +} + +/* the protected stack -> the wire */ +static void +etherout(void *a) +{ + uchar *buf; + Wire *w; + Pkt p; + int n; + + w = a; + buf = emalloc(Maxpkt); + for(;;){ + if((n = read(w->dfd, buf, Maxpkt)) <= 0){ + fprint(2, "fw: %s: read the stack: %r\n", w->side); + syslog(0, "fw", "stopped reading the stack: %r"); + return; + } + if(permitted(buf, n, Vout, &p)) + etherwriteip(buf, n, ethermask); + } +} + +/* + * The address, mask and gateway the stack is already using. Asking the + * administrator to repeat what ipconfig(8) was told is a way of getting + * the two out of step, so read them instead: the interface status names + * the address and mask, and the routing table names the gateway. + */ +static int +learnaddr(char *net, char *dev, char *addr, int naddr, char *gw, int ngw) +{ + char path[128], buf[1024], *lines[8], *f[8], *p; + int i, fd, n, nl, nf, found; + + found = 0; + for(i = 0; i < 16 && !found; i++){ + snprint(path, sizeof path, "%s/ipifc/%d/status", net, i); + if((fd = open(path, OREAD)) < 0) + continue; + n = read(fd, buf, sizeof buf - 1); + close(fd); + if(n <= 0) + continue; + buf[n] = '\0'; + if((p = strstr(buf, dev)) == nil) + continue; + USED(p); + nl = getfields(buf, lines, nelem(lines), 0, "\n"); + if(nl < 2) + continue; + /* the address line: address, then the mask as a prefix */ + nf = tokenize(lines[1], f, nelem(f)); + if(nf < 2) + continue; + snprint(addr, naddr, "%s%s", f[0], f[1]); + found = 1; + } + if(!found) + return -1; + + *gw = '\0'; + snprint(path, sizeof path, "%s/iproute", net); + if((fd = open(path, OREAD)) >= 0){ + n = read(fd, buf, sizeof buf - 1); + close(fd); + if(n > 0){ + buf[n] = '\0'; + nl = getfields(buf, lines, nelem(lines), 0, "\n"); + for(i = 0; i < nl; i++){ + nf = tokenize(lines[i], f, nelem(f)); + if(nf >= 3 && strcmp(f[0], "0.0.0.0") == 0 + && strcmp(f[1], "/96") == 0){ + snprint(gw, ngw, "%s", f[2]); + break; + } + } + } + } + return 0; +} + +/* + * Undo a previous fw that died. + * + * An IP stack outlives the program that configured it, so a fw that is + * killed leaves its pkt interface behind, holding the address, with + * nothing on the other end of it. The machine has no network until + * someone unpicks that by hand, and the next fw to start makes a second + * interface with the same address and routes that could go to either. + * + * So before taking anything, throw away any pkt interface already + * carrying the address we are about to use. Nothing else can have made + * it: a live fw would still be holding the card we are about to take. + */ +/* + * Put the card back. + * + * Taking a card is destructive: the stack loses it, and the pkt + * interface that replaced it is unbindonclose, so when fw stops the + * address goes with it and the machine is left with a card bound to + * nothing. Restore it on the way out, so that everything short of an + * uncatchable kill leaves the machine as we found it. + */ +static char *backdev, *backaddr, *backmask, *backnet, *backgw; + +static void +putback(void) +{ + char path[128], buf[64]; + int cfd, n, ifc; + + if(backdev == nil) + return; + snprint(path, sizeof path, "%s/ipifc/clone", backnet); + if((cfd = open(path, ORDWR)) < 0) + return; + if((n = read(cfd, buf, sizeof buf - 1)) <= 0){ + close(cfd); + return; + } + buf[n] = '\0'; + ifc = atoi(buf); + if(fprint(cfd, "bind ether %s", backdev) > 0 + && fprint(cfd, "add %s %s", backaddr, backmask) > 0){ + if(backgw != nil){ + snprint(path, sizeof path, "%s/iproute", backnet); + if((n = open(path, OWRITE)) >= 0){ + fprint(n, "add 0.0.0.0 0.0.0.0 %s", backgw); + close(n); + } + } + fprint(2, "fw: put %s back on %s/ipifc/%d\n", backdev, backnet, ifc); + } + /* the ctl fd must stay open for the binding to last */ + backdev = nil; +} + +/* clean up, then let the note do what it was going to do */ +static int +notehandler(void*, char*) +{ + putback(); + return 0; +} + +static void +reclaim(char *net, char *addr) +{ + char path[128], buf[1024], *lines[8], *f[8]; + int i, fd, n, nl, nf; + + for(i = 0; i < 16; i++){ + snprint(path, sizeof path, "%s/ipifc/%d/status", net, i); + if((fd = open(path, OREAD)) < 0) + continue; + n = read(fd, buf, sizeof buf - 1); + close(fd); + if(n <= 0) + continue; + buf[n] = '\0'; + nl = getfields(buf, lines, nelem(lines), 0, "\n"); + if(nl < 2) + continue; + nf = tokenize(lines[0], f, nelem(f)); + if(nf < 2 || strncmp(f[1], "pkt", 3) != 0) + continue; + nf = tokenize(lines[1], f, nelem(f)); + if(nf < 1 || strcmp(f[0], addr) != 0) + continue; + + snprint(path, sizeof path, "%s/ipifc/%d/ctl", net, i); + if((fd = open(path, OWRITE)) < 0) + continue; + if(fprint(fd, "unbind") > 0){ + fprint(2, "fw: cleared %s/ipifc/%d, left behind by an earlier fw\n", + net, i); + syslog(0, "fw", "cleared a stale interface holding %s", addr); + } + close(fd); + } +} + +/* + * The card cannot stay attached to the stack we are protecting, or + * packets reach it whatever the rules say. Find whichever interface + * has it and take it away. + */ +static void +takecard(char *net, char *dev) +{ + char path[128], buf[512], *p; + int i, fd, n, found; + + found = 0; + for(i = 0; i < 16; i++){ + snprint(path, sizeof path, "%s/ipifc/%d/status", net, i); + if((fd = open(path, OREAD)) < 0) + continue; + n = read(fd, buf, sizeof buf - 1); + close(fd); + if(n <= 0) + continue; + buf[n] = '\0'; + if((p = strchr(buf, ' ')) == nil) + continue; + *p = '\0'; + if(strcmp(buf, "device") != 0 && strstr(buf, dev) == nil) + continue; + if(strstr(p+1, dev) == nil && strstr(buf, dev) == nil) + continue; + snprint(path, sizeof path, "%s/ipifc/%d/ctl", net, i); + if((fd = open(path, OWRITE)) < 0) + continue; + if(fprint(fd, "unbind") > 0){ + fprint(2, "fw: took %s away from %s/ipifc/%d\n", dev, net, i); + found = 1; + } + close(fd); + } + if(!found) + fprint(2, "fw: warning: %s was not bound to %s; " + "is something else still using it?\n", dev, net); +} + +void +threadmain(int argc, char **argv) +{ + static Wire *outargs[3], *inargs[3]; + Wire *out, *in, *prot; + char *mtpt, *srvname, *netmtpt, *etherdev, *etheraddr, *ethergw; + uchar ip[IPaddrlen], gw[IPaddrlen]; + int wireonly; + + mtpt = "/mnt/fw"; + netmtpt = "/net"; + srvname = nil; + etherdev = nil; + etheraddr = nil; + ethergw = nil; + wireonly = 0; + ARGBEGIN{ + case 'd': + debug++; + break; + case 'S': + stateless++; + break; + case 'm': + mtpt = EARGF(usage()); + break; + case 's': + srvname = EARGF(usage()); + break; + case 'n': + netmtpt = EARGF(usage()); + break; + case 'W': + wireonly++; + break; + case 'e': + /* + * One card per fw. Taking two would mean two pkt + * interfaces, two ARP caches and one rule set that could + * not say which card it meant; run one fw per card until + * the rules can name one. + */ + if(etherdev != nil) + sysfatal("one -e at a time; run one fw per card"); + etherdev = EARGF(usage()); + break; + case 'a': + etheraddr = EARGF(usage()); + break; + case 'g': + ethergw = EARGF(usage()); + break; + default: + usage(); + }ARGEND + + if(argc != 1 && argc != 3) + usage(); + + fmtinstall('I', eipfmt); + fmtinstall('V', eipfmt); + fmtinstall('E', eipfmt); + + rulepath = argv[0]; + readrules(rulepath); + checklogging(); + if(debug) + dumprules(); + + /* + * Naming two sides means there are wires to sit between, so we + * filter packets. Naming none means there is one namespace to + * protect, so we filter the requests it makes. Same rules + * either way; the mode is what you pointed it at, and it is + * never chosen silently. + */ + if(etherdev != nil){ + char abuf[64], rpath[128], *m; + uchar mac[6]; + int haveg, k; + + if(etheraddr == nil){ + static char abuf2[64], gbuf[64]; + + if(learnaddr(netmtpt, etherdev, abuf2, sizeof abuf2, + gbuf, sizeof gbuf) < 0) + sysfatal("%s has no address on %s; give -a addr/mask", + etherdev, netmtpt); + etheraddr = abuf2; + if(ethergw == nil && *gbuf != '\0') + ethergw = gbuf; + fprint(2, "fw: %s has %s%s%s\n", etherdev, etheraddr, + ethergw != nil ? ", gateway " : "", + ethergw != nil ? ethergw : ""); + } + m = nil; + if((m = strchr(etheraddr, '/')) != nil){ + k = m - etheraddr; + if(k >= sizeof abuf) + sysfatal("%s: address too long", etheraddr); + memmove(abuf, etheraddr, k); + abuf[k] = '\0'; + }else + sysfatal("-a wants addr/mask, eg 10.0.2.15/24"); + if(parseipandmask(ip, ethermask, abuf, m) == -1) + sysfatal("%s: unparseable address or mask", etheraddr); + haveg = 0; + if(ethergw != nil){ + if(parseip(gw, ethergw) == -1) + sysfatal("%s: unparseable gateway", ethergw); + haveg = 1; + } + + /* + * Check we can mount before touching the card. Taking it + * and then failing leaves the machine with an interface + * that has no card behind it and no network at all, which + * is a bad way to discover a typo in -m. + */ + if(access(mtpt, AEXIST) < 0) + sysfatal("%s: %r; not touching %s until it exists", + mtpt, etherdev); + + fprint(2, "fw: filtering packets on %s\n", etherdev); + syslog(0, "fw", "started, filtering %s for %s", etherdev, etheraddr); + + etherdebug = debug; + efd = etheropen(etherdev, mac); + ethersetaddr(ip, gw, haveg); + reclaim(netmtpt, abuf); + + backdev = etherdev; + backaddr = abuf; + backmask = m; + backnet = netmtpt; + backgw = ethergw; + atexit(putback); + threadnotify(notehandler, 1); + + takecard(netmtpt, etherdev); + + prot = emalloc(sizeof *prot); + prot->net = netmtpt; + prot->addr = abuf; + prot->mask = m; + prot->side = "protected"; + wireup(prot); + + /* + * Taking the card away took the routes with it. The stack + * still needs to know how to leave its own network, and we + * are the only thing that knows where the gateway is. + */ + if(haveg){ + snprint(rpath, sizeof rpath, "%s/iproute", netmtpt); + if((k = open(rpath, OWRITE)) < 0) + fprint(2, "fw: cannot open %s: %r\n", rpath); + else{ + if(fprint(k, "add 0.0.0.0 0.0.0.0 %s", ethergw) < 0) + fprint(2, "fw: cannot add default route: %r\n"); + else + fprint(2, "fw: default route via %s\n", ethergw); + close(k); + } + } + + rulechanged = rulesdidchange; + proccreate(etherin, prot, 32*1024); + proccreate(etherout, prot, 32*1024); + servectl(mtpt, srvname); + threadexits(nil); + } + + if(argc == 1){ + if(wireonly) + sysfatal("-W given, but no interfaces to filter between"); + servenet(netmtpt, srvname, netmtpt); + threadexits(nil); + } + + fprint(2, "fw: filtering packets\n"); + syslog(0, "fw", "started, filtering packets, rules %s", rulepath); + out = parseside(argv[1], "outside"); + in = parseside(argv[2], "inside"); + wireup(out); + wireup(in); + + rulechanged = rulesdidchange; + + /* inside -> outside is "out"; outside -> inside is "in" */ + outargs[0] = in; + outargs[1] = out; + outargs[2] = (Wire*)Vout; + proccreate(relayproc, outargs, 32*1024); + + inargs[0] = out; + inargs[1] = in; + inargs[2] = (Wire*)Vin; + proccreate(relayproc, inargs, 32*1024); + + servectl(mtpt, srvname); + threadexits(nil); +} diff --git a/fw/src/mkfile b/fw/src/mkfile new file mode 100644 index 0000000..25ba677 --- /dev/null +++ b/fw/src/mkfile @@ -0,0 +1,9 @@ + +#include +#include +#include +#include <9p.h> +#include +#include +#include +#include "rules.h" + +/* + * One per fid. fd is the host file behind it, and is closed only when + * the fid is clunked: for a ctl file that close is what tears down the + * connection, so the two lifetimes have to be the same one. + */ +typedef struct Fnode Fnode; +struct Fnode +{ + char *path; /* relative to the root, "" is the root */ + int fd; + char *dbuf; /* directory, encoded at open */ + long dlen; +}; + +static char *orig = "/net"; + +/* + * The real /net is a union of devip and the cs and dns mounts, whose + * qids are allocated by different servers and can collide. Hash the + * path instead: stable across walks, unique across servers. + */ +static uvlong +hashpath(char *s) +{ + uvlong h; + + h = 14695981039346656037ULL; + while(*s != '\0'){ + h ^= (uchar)*s++; + h *= 1099511628211ULL; + } + return h; +} + +static void +mkqid(Qid *q, char *path, Qid *real) +{ + q->path = hashpath(path); + q->vers = real->vers; + q->type = real->type; +} + +static char* +realpath(char *path) +{ + if(*path == '\0') + return estrdup(orig); + return smprint("%s/%s", orig, path); +} + +static char* +childpath(char *dir, char *name) +{ + char *p, *q; + + if(strcmp(name, "..") == 0){ + p = estrdup(dir); + if((q = strrchr(p, '/')) != nil) + *q = '\0'; + else + *p = '\0'; + return p; + } + if(*dir == '\0') + return estrdup(name); + return smprint("%s/%s", dir, name); +} + +/* does path name this top-level entry, or something under it? */ +static int +under(char *path, char *name) +{ + int n; + + n = strlen(name); + return strncmp(path, name, n) == 0 && (path[n] == '\0' || path[n] == '/'); +} + +/* + * Raw packet access. Kept out of the served tree entirely rather than + * made unopenable, so that a program probing for a way out does not + * even see one. + */ +static int +hidden(char *path) +{ + if(strncmp(path, "ether", 5) == 0 && path[5] >= '0' && path[5] <= '9') + return 1; + return under(path, "ipmux"); +} + +static char* +protect(char *path, int mode) +{ + if(hidden(path)) + return "fw: does not exist"; + if(strcmp(path, "ipifc/clone") == 0) + return "fw: interface creation denied"; + if(under(path, "ipifc") || under(path, "iproute") || under(path, "arp")) + if((mode & 3) != OREAD) + return "fw: read-only under fw"; + return nil; +} + +/* + * A directory in the root is a protocol directory if it has a clone + * file. Asking the filesystem beats hardcoding a list that goes stale. + */ +static int +isproto(char *name) +{ + char *p; + int ok; + + if(strchr(name, '/') != nil || *name == '\0') + return 0; + p = smprint("%s/%s/clone", orig, name); + ok = access(p, AEXIST) == 0; + free(p); + return ok; +} + +/* + * If path is a protocol ctl file - "tcp/clone" or "tcp/1/ctl" - return + * the protocol name. Opening clone yields an fd that is itself the new + * connection's ctl file, so both spellings take a connect write. + */ +static char* +ctlproto(char *path) +{ + char buf[64], *p, *q; + int n; + + if((p = strchr(path, '/')) == nil) + return nil; + n = p - path; + if(n <= 0 || n >= sizeof buf) + return nil; + memmove(buf, path, n); + buf[n] = '\0'; + p++; + if(strcmp(p, "clone") != 0){ + if((q = strchr(p, '/')) == nil || strcmp(q+1, "ctl") != 0) + return nil; + } + if(!isproto(buf)) + return nil; + return estrdup(buf); +} + +/* + * connect takes addr!port with optional trailing fields; announce takes + * a bare port, or addr!port with addr often "*". + */ +static char* +checkctl(char *proto, char *msg, long n) +{ + char buf[512], *f[8], *a[4], *addr, *e; + static char err[128]; + uchar ip[IPaddrlen], mask[IPaddrlen]; + Rule *rule; + int nf, na, verb, anyip, port, lport; + + if(n <= 0) + return nil; + if(n >= sizeof buf) + n = sizeof buf - 1; + memmove(buf, msg, n); + buf[n] = '\0'; + + if((nf = tokenize(buf, f, nelem(f))) < 1) + return nil; + if(strcmp(f[0], "connect") == 0) + verb = Vout; + else if(strcmp(f[0], "announce") == 0) + verb = Vin; + else + return nil; /* hangup, ttl, keepalive: not policy */ + if(nf < 2) + return nil; /* malformed; let the kernel say so */ + + na = getfields(f[1], a, nelem(a), 0, "!"); + if(na < 1) + return nil; + if(na == 1){ + addr = "*"; /* announce 17019 */ + port = atoi(a[0]); + }else{ + addr = a[0]; + port = strcmp(a[1], "*") == 0 ? -1 : atoi(a[1]); + } + + /* + * Which end the port and the address belong to, so that a rule + * means the same here as it does against a packet. + * + * connect names the far end: its port is the peer's, and the + * local port is whatever the kernel picks, so unknown. + * + * announce names this end: its port is ours, its address is a + * local address to listen on, and the peer is nobody yet - we + * find out who connected only at listen time. A rule naming a + * peer therefore cannot apply to an announce, which is right: + * at this point there is no peer to name. + */ + if(verb == Vin){ + lport = port; + port = -1; + anyip = 1; + }else{ + lport = -1; + anyip = strcmp(addr, "*") == 0; + if(!anyip && parseipandmask(ip, mask, addr, nil) == -1){ + syslog(0, "fw", "deny %s %s %s: unparseable address", + proto, f[0], f[1]); + return "fw: unparseable address"; + } + } + if((e = matchrule(verb, proto, ip, anyip, port, lport, &rule)) != nil){ + if(rule != nil && rule->log) + syslog(0, "fw", "deny %s %s %s: %s", proto, f[0], f[1], e); + snprint(err, sizeof err, "fw: %s", e); + return err; + } + if(rule != nil && rule->log) + syslog(0, "fw", "allow %s %s %s", proto, f[0], f[1]); + return nil; +} + +/* + * A directory is read once at open, filtered, and re-encoded; reads + * then slice that buffer at entry boundaries. This gets the offset + * rules right without a gen function, and an open directory is a + * snapshot on Plan 9 anyway. + */ +static char* +slurpdir(Fnode *f, char *rp) +{ + char *buf, *cp; + Dir *d; + Qid q; + long sz; + int fd, i, n, m; + + if((fd = open(rp, OREAD)) < 0) + return "fw: cannot open directory"; + n = dirreadall(fd, &d); + close(fd); + if(n < 0) + return "fw: cannot read directory"; + + buf = nil; + sz = 0; + for(i = 0; i < n; i++){ + cp = childpath(f->path, d[i].name); + if(hidden(cp)){ + free(cp); + continue; + } + q = d[i].qid; + mkqid(&d[i].qid, cp, &q); + free(cp); + m = sizeD2M(&d[i]); + if((buf = realloc(buf, sz + m)) == nil) + sysfatal("out of memory"); + convD2M(&d[i], (uchar*)buf + sz, m); + sz += m; + } + free(d); + f->dbuf = buf; + f->dlen = sz; + return nil; +} + +static void +dirslice(Req *r, Fnode *f) +{ + long o, e, m; + + for(o = 0; o < f->dlen && o != r->ifcall.offset; o += m) + m = GBIT16((uchar*)f->dbuf + o) + BIT16SZ; + if(o != r->ifcall.offset || o >= f->dlen){ + r->ofcall.count = 0; + return; + } + for(e = o; e < f->dlen; e += m){ + m = GBIT16((uchar*)f->dbuf + e) + BIT16SZ; + if(e + m - o > r->ifcall.count) + break; + } + memmove(r->ofcall.data, f->dbuf + o, e - o); + r->ofcall.count = e - o; +} + +static Fnode* +newfnode(char *path) +{ + Fnode *f; + + f = emalloc(sizeof *f); + f->path = estrdup(path); + f->fd = -1; + return f; +} + +static void +fsattach(Req *r) +{ + Fnode *f; + Dir *d; + + if((d = dirstat(orig)) == nil){ + responderror(r); + return; + } + f = newfnode(""); + mkqid(&r->fid->qid, "", &d->qid); + free(d); + r->fid->aux = f; + r->ofcall.qid = r->fid->qid; + respond(r, nil); +} + +static char* +fsclone(Fid *old, Fid *new) +{ + Fnode *f; + + f = old->aux; + new->aux = newfnode(f->path); + return nil; +} + +static char* +fswalk1(Fid *fid, char *name, Qid *q) +{ + Fnode *f; + Dir *d; + char *np, *rp; + + f = fid->aux; + np = childpath(f->path, name); + if(hidden(np)){ + free(np); + return "fw: does not exist"; + } + rp = realpath(np); + d = dirstat(rp); + free(rp); + if(d == nil){ + free(np); + return "fw: does not exist"; + } + mkqid(q, np, &d->qid); + free(d); + free(f->path); + f->path = np; + fid->qid = *q; + return nil; +} + +static void +fsopen(Req *r) +{ + Fnode *f; + char *rp, *e; + int mode; + + f = r->fid->aux; + mode = r->ifcall.mode; + if((e = protect(f->path, mode)) != nil){ + respond(r, e); + return; + } + rp = realpath(f->path); + if(r->fid->qid.type & QTDIR){ + e = slurpdir(f, rp); + free(rp); + respond(r, e); + return; + } + /* opening listen blocks until someone connects */ + srvrelease(r->srv); + f->fd = open(rp, mode & ~ORCLOSE); + srvacquire(r->srv); + free(rp); + if(f->fd < 0){ + responderror(r); + return; + } + respond(r, nil); +} + +static void +fsread(Req *r) +{ + Fnode *f; + long n; + + f = r->fid->aux; + if(r->fid->qid.type & QTDIR){ + dirslice(r, f); + respond(r, nil); + return; + } + if(f->fd < 0){ + respond(r, "fw: not open"); + return; + } + srvrelease(r->srv); + n = pread(f->fd, r->ofcall.data, r->ifcall.count, r->ifcall.offset); + srvacquire(r->srv); + if(n < 0){ + responderror(r); + return; + } + r->ofcall.count = n; + respond(r, nil); +} + +static void +fswrite(Req *r) +{ + Fnode *f; + char *proto, *e; + long n; + + f = r->fid->aux; + if(f->fd < 0){ + respond(r, "fw: not open"); + return; + } + if((proto = ctlproto(f->path)) != nil){ + e = checkctl(proto, r->ifcall.data, r->ifcall.count); + free(proto); + if(e != nil){ + respond(r, e); + return; + } + } + srvrelease(r->srv); + n = pwrite(f->fd, r->ifcall.data, r->ifcall.count, r->ifcall.offset); + srvacquire(r->srv); + if(n < 0){ + responderror(r); + return; + } + r->ofcall.count = n; + respond(r, nil); +} + +static void +fsstat(Req *r) +{ + Fnode *f; + Dir *d; + char *rp, *name; + Qid q; + + f = r->fid->aux; + rp = realpath(f->path); + d = dirstat(rp); + free(rp); + if(d == nil){ + responderror(r); + return; + } + q = d->qid; + mkqid(&d->qid, f->path, &q); + if((name = strrchr(f->path, '/')) != nil) + name++; + else if(*f->path != '\0') + name = f->path; + else + name = "/"; + r->d = *d; + r->d.name = estrdup(name); + r->d.uid = estrdup(d->uid); + r->d.gid = estrdup(d->gid); + r->d.muid = estrdup(d->muid); + free(d); + respond(r, nil); +} + +static void +fsdestroyfid(Fid *fid) +{ + Fnode *f; + + if((f = fid->aux) == nil) + return; + fid->aux = nil; + if(f->fd >= 0) + close(f->fd); + free(f->dbuf); + free(f->path); + free(f); +} + +static Srv fs = +{ + .attach = fsattach, + .clone = fsclone, + .walk1 = fswalk1, + .open = fsopen, + .read = fsread, + .write = fswrite, + .stat = fsstat, + .destroyfid = fsdestroyfid, +}; + +/* + * Serve a filtered view of "orig" at "mtpt". Rules have already been + * read; this is the request-filtering half of fw, kept in its own file + * only because it is a different mechanism, not a different program. + */ +void +servenet(char *mtpt, char *srvname, char *net) +{ + orig = net; + if(access(orig, AEXIST) < 0) + sysfatal("%s: %r", orig); + fprint(2, "fw: filtering requests on %s\n", mtpt); + syslog(0, "fw", "started, filtering requests on %s", mtpt); + threadpostmountsrv(&fs, srvname, mtpt, MREPL); +} diff --git a/fw/src/rules.c b/fw/src/rules.c new file mode 100644 index 0000000..c0df82d --- /dev/null +++ b/fw/src/rules.c @@ -0,0 +1,440 @@ +/* + * The shared rule engine. + * + * Rules are an ndb file: one entry is one rule, matched top to bottom, + * first match wins, no match denies. ndbparse hands entries back in + * file order, which is what keeps this a list rather than a lookup. + * Firewall matching is a solved interface and being different about it + * would be cost for its own sake. + * + * allow=out proto=tcp port=443 + * deny=in ip=1.1.1.1 + * allow=out ip=10.0.2.0/24 + * + * connect and announce are accepted as spellings of out and in, since + * that is what they mean at the ctl layer. + * + * ip and port always mean the *peer* - the far end of the traffic, + * whichever direction it is going - so one rule reads the same whether + * it is enforced against a connect string or against a packet header. + * lport is the local side, and is only meaningful at the packet layer. + */ +#include +#include +#include +#include +#include +#include "rules.h" + +Rule *rules; +void (*rulechanged)(void); + +static Lock rulelock; +static Rule *lastrule; +static char *rulefile; +static char *parseerr; +static jmp_buf parsejmp; +static int parsing; + +/* + * A bad rule typed at a ctl file must fail the write, not the firewall, + * so parsing longjmps out instead of calling sysfatal when it is being + * driven from there. + */ +static void +rulefail(char *fmt, ...) +{ + static char buf[256]; + va_list arg; + + va_start(arg, fmt); + vsnprint(buf, sizeof buf, fmt, arg); + va_end(arg); + if(parsing){ + parseerr = buf; + longjmp(parsejmp, 1); + } + sysfatal("%s", buf); +} + +static struct { + char *name; + int num; +} protos[] = { + { "icmp", 1 }, + { "igmp", 2 }, + { "tcp", 6 }, + { "udp", 17 }, + { "gre", 47 }, + { "icmpv6", 58 }, + { "il", 40 }, + { nil, 0 }, +}; + +int +protoname2num(char *name) +{ + int i; + + for(i = 0; protos[i].name != nil; i++) + if(strcmp(protos[i].name, name) == 0) + return protos[i].num; + return -1; +} + +char* +protonum2name(int num) +{ + static char buf[16]; + int i; + + for(i = 0; protos[i].name != nil; i++) + if(protos[i].num == num) + return protos[i].name; + snprint(buf, sizeof buf, "%d", num); + return buf; +} + +void* +emalloc(ulong n) +{ + void *p; + + if((p = mallocz(n, 1)) == nil) + sysfatal("out of memory"); + return p; +} + +char* +estrdup(char *s) +{ + char *p; + + if((p = strdup(s)) == nil) + sysfatal("out of memory"); + return p; +} + +static int +verbof(char *s) +{ + if(*s == '\0' || strcmp(s, "*") == 0) + return Vany; + if(strcmp(s, "out") == 0 || strcmp(s, "connect") == 0) + return Vout; + if(strcmp(s, "in") == 0 || strcmp(s, "announce") == 0) + return Vin; + return -1; +} + +/* + * An attribute we do not recognise is fatal rather than ignored. In a + * lookup database ignoring it would be the friendly thing; here, + * quietly dropping "prot=tcp" would leave a rule matching every + * protocol instead of one, and a typo that fails open is not something + * a firewall gets to do. + */ +static void +addrule(Ndbtuple *t, int nr) +{ + char *ip, *mask, *p, abuf[64]; + Rule *r; + int n, act; + + act = -1; + ip = mask = nil; + r = emalloc(sizeof *r); + r->nr = nr; + r->verb = Vany; + r->port = -1; + r->lport = -1; + r->anyip = 1; + + for(; t != nil; t = t->entry){ + if(strcmp(t->attr, "allow") == 0 || strcmp(t->attr, "deny") == 0){ + if(act >= 0) + rulefail("%s: rule %d: two actions in one rule", rulefile, nr); + act = strcmp(t->attr, "allow") == 0; + if((r->verb = verbof(t->val)) < 0) + rulefail("%s: rule %d: %s: want in, out or *", + rulefile, nr, t->val); + }else if(strcmp(t->attr, "proto") == 0){ + if(strcmp(t->val, "*") != 0) + r->proto = estrdup(t->val); + }else if(strcmp(t->attr, "port") == 0){ + if(strcmp(t->val, "*") != 0) + r->port = atoi(t->val); + }else if(strcmp(t->attr, "lport") == 0){ + if(strcmp(t->val, "*") != 0) + r->lport = atoi(t->val); + }else if(strcmp(t->attr, "log") == 0){ + if(strcmp(t->val, "no") == 0 || strcmp(t->val, "0") == 0) + r->log = 0; + else + r->log = 1; + }else if(strcmp(t->attr, "ip") == 0) + ip = t->val; + else if(strcmp(t->attr, "ipmask") == 0) + mask = t->val; + else + rulefail("%s: rule %d: %s: unknown attribute", rulefile, nr, t->attr); + } + if(act < 0) + rulefail("%s: rule %d: needs allow= or deny=", rulefile, nr); + r->allow = act; + + if(ip != nil && strcmp(ip, "*") != 0){ + /* + * ip=10.0.2.0/24 is taken as well as ip=10.0.2.0 ipmask=/24. + * parseipmask tells a prefix length from a dotted mask by the + * leading slash, so the slash has to survive the split. + */ + if((p = strchr(ip, '/')) != nil){ + if(mask != nil) + rulefail("%s: rule %d: mask given twice", rulefile, nr); + n = p - ip; + if(n >= sizeof abuf) + rulefail("%s: rule %d: address too long", rulefile, nr); + memmove(abuf, ip, n); + abuf[n] = '\0'; + mask = p; + ip = abuf; + } + if(parseipandmask(r->ip, r->mask, ip, mask) == -1) + rulefail("%s: rule %d: %s: unparseable address or mask", + rulefile, nr, ip); + r->anyip = 0; + } + + if(lastrule == nil) + rules = r; + else + lastrule->next = r; + lastrule = r; +} + +/* + * Parse without installing. Returns the new list, or nil with *err set. + * An empty file is a valid rule set: it denies everything. + */ +Rule* +parserules(char *file, char **err) +{ + Rule *new, *save, *savelast; + Ndbtuple *t; + Ndb *db; + int nr; + + save = rules; + savelast = lastrule; + rules = lastrule = nil; + rulefile = file; + parseerr = nil; + + if((db = ndbopen(file)) == nil){ + static char eb[128]; + + snprint(eb, sizeof eb, "%s: %r", file); + *err = eb; + rules = save; + lastrule = savelast; + return nil; + } + parsing = 1; + if(setjmp(parsejmp) == 0){ + for(nr = 1; (t = ndbparse(db)) != nil; nr++){ + addrule(t, nr); + ndbfree(t); + } + } + parsing = 0; + ndbclose(db); + + new = rules; + rules = save; + lastrule = savelast; + if(parseerr != nil){ + freerules(new); + *err = parseerr; + return nil; + } + *err = nil; + return new; +} + +void +freerules(Rule *r) +{ + Rule *next; + + for(; r != nil; r = next){ + next = r->next; + free(r->proto); + free(r); + } +} + +void +installrules(Rule *new) +{ + Rule *old; + + lock(&rulelock); + old = rules; + rules = new; + unlock(&rulelock); + freerules(old); + if(rulechanged != nil) + (*rulechanged)(); +} + +void +readrules(char *file) +{ + Rule *new; + char *err; + + if((new = parserules(file, &err)) == nil && err != nil) + sysfatal("%s", err); + installrules(new); +} + +/* + * The current set, written back out as ndb. What comes out here must + * parse back in unchanged; it is what gets persisted. + */ +long +fmtrules(char *buf, long nbuf) +{ + char *p, *e; + Rule *r; + + p = buf; + e = buf + nbuf; + lock(&rulelock); + for(r = rules; r != nil; r = r->next){ + p = seprint(p, e, "%s=%s", r->allow ? "allow" : "deny", + r->verb == Vin ? "in" : r->verb == Vout ? "out" : "*"); + if(r->proto != nil) + p = seprint(p, e, "\tproto=%s", r->proto); + if(!r->anyip) + p = seprint(p, e, "\tip=%I\tipmask=%M", r->ip, r->mask); + if(r->port >= 0) + p = seprint(p, e, "\tport=%d", r->port); + if(r->lport >= 0) + p = seprint(p, e, "\tlport=%d", r->lport); + if(r->log) + p = seprint(p, e, "\tlog=yes"); + p = seprint(p, e, "\n"); + } + unlock(&rulelock); + return p - buf; +} + +/* + * A firewall that was told to log and silently cannot is worse than one + * that never logged: you would believe you had an audit trail. syslog + * does not create its file, so say so plainly at startup rather than + * dropping the lines on the floor. + */ +void +checklogging(void) +{ + Rule *r; + int fd; + + for(r = rules; r != nil; r = r->next) + if(r->log) + break; + if(r == nil) + return; + if((fd = open("/sys/log/fw", OWRITE)) < 0){ + fprint(2, "fw: rules ask for logging, but /sys/log/fw cannot be " + "written: %r\n"); + fprint(2, "fw: make it once with: touch /sys/log/fw; chmod +a /sys/log/fw\n"); + fprint(2, "fw: filtering anyway, but nothing will be logged\n"); + return; + } + close(fd); +} + +void +dumprules(void) +{ + Rule *r; + + for(r = rules; r != nil; r = r->next) + fprint(2, "rule %d: %s %s proto %s port %d lport %d anyip %d ip %I mask %I\n", + r->nr, r->allow ? "allow" : "deny", + r->verb == Vin ? "in" : r->verb == Vout ? "out" : "*", + r->proto != nil ? r->proto : "*", r->port, r->lport, + r->anyip, r->ip, r->mask); +} + +/* + * The rules with a count of how often each has decided something. A + * rule that has never fired is either dead or protecting you from + * something that has not happened yet, and it is worth being able to + * tell which. Kept out of fmtrules so that what "rules" prints stays + * a rule set that can be written straight back. + */ +long +fmthits(char *buf, long nbuf) +{ + char *p, *e; + Rule *r; + + p = buf; + e = buf + nbuf; + lock(&rulelock); + for(r = rules; r != nil; r = r->next) + p = seprint(p, e, "%-8ld %s=%s%s%s\n", r->hits, + r->allow ? "allow" : "deny", + r->verb == Vin ? "in" : r->verb == Vout ? "out" : "*", + r->proto != nil ? "\tproto=" : "", + r->proto != nil ? r->proto : ""); + unlock(&rulelock); + return p - buf; +} + +char* +matchrule(int verb, char *proto, uchar *ip, int anyip, int port, int lport, Rule **rp) +{ + uchar net[IPaddrlen], rnet[IPaddrlen]; + static char err[128]; + Rule *r; + + if(rp != nil) + *rp = nil; + lock(&rulelock); + for(r = rules; r != nil; r = r->next){ + if(r->verb != Vany && r->verb != verb) + continue; + if(r->proto != nil && (proto == nil || strcmp(r->proto, proto) != 0)) + continue; + if(r->port >= 0 && r->port != port) + continue; + if(r->lport >= 0 && r->lport != lport) + continue; + if(!r->anyip){ + if(anyip) /* a wildcard request cannot match a specific rule */ + continue; + maskip(ip, r->mask, net); + maskip(r->ip, r->mask, rnet); + if(ipcmp(net, rnet) != 0) + continue; + } + if(rp != nil) + *rp = r; + r->hits++; + if(r->allow){ + unlock(&rulelock); + return nil; + } + snprint(err, sizeof err, "denied by rule %d", r->nr); + unlock(&rulelock); + return err; + } + unlock(&rulelock); + return "denied, no rule matched"; +} diff --git a/fw/src/rules.h b/fw/src/rules.h new file mode 100644 index 0000000..6aad0ae --- /dev/null +++ b/fw/src/rules.h @@ -0,0 +1,57 @@ +/* + * The rule engine, shared by both halves of fw: request filtering (the + * ctl layer) and packet filtering (the wire). One rule language, two enforcement points: a rule says what + * may happen, and the caller decides where it is enforced. + */ +typedef struct Rule Rule; + +/* + * Direction, not layer. A "connect" written to a ctl file and an + * egress packet are the same intent seen from two places, so they are + * the same verb and one rule file serves both enforcement points. + */ +enum +{ + Vin, /* inbound: announce, or a packet arriving */ + Vout, /* outbound: connect, or a packet leaving */ + Vany, +}; + +struct Rule +{ + int allow; + int verb; + char *proto; /* nil: any */ + int anyip; + uchar ip[IPaddrlen]; /* the peer, whichever end that is */ + uchar mask[IPaddrlen]; + int port; /* peer port, -1: any */ + int lport; /* local port, -1: any */ + int log; /* note matches in /sys/log/fw */ + long hits; /* how often it has decided something */ + int nr; + Rule *next; +}; + +/* + * Rules are swapped wholesale, never mutated in place, so no packet is + * ever judged against a half-applied rule set. rulechanged, if set, is + * called after a swap - the wire half uses it to re-check live flows. + */ +extern Rule *rules; +extern void (*rulechanged)(void); + +void readrules(char*); /* parse and install, fatal on error */ +Rule* parserules(char*, char**); /* parse only; nil + reason on error */ +void installrules(Rule*); /* swap in, then call rulechanged */ +void freerules(Rule*); +long fmtrules(char*, long); /* current set, back in ndb form */ +long fmthits(char*, long); /* the same, with hit counts */ +void dumprules(void); +void checklogging(void); +char* matchrule(int verb, char *proto, uchar *ip, int anyip, int port, int lport, Rule**); +int protoname2num(char*); +char* protonum2name(int); + +void* emalloc(ulong); +char* estrdup(char*); -- cgit v1.2.3