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