summaryrefslogtreecommitdiff
path: root/fw/src/rules.c
diff options
context:
space:
mode:
authorCalvin Morrison <calvin@pobox.com>2026-08-18 17:01:49 -0400
committerCalvin Morrison <calvin@pobox.com>2026-08-18 17:01:49 -0400
commit0f922552ad8cc73c0c3c3674d484c3d78dd8c557 (patch)
tree4bfc4222ccc202daa2d79d6a782727fac1fb9ae0 /fw/src/rules.c
parent441c64d81594bb521350dbee6348f552401a1a2e (diff)
fw: a firewall, at a card, between two networks, or in front of a namespace
One program with three modes, sharing one rule engine and one ndb rule language. Which mode it is depends on what you point it at, and it says so at startup rather than choosing silently. fw -e /net/ether0 rules.ndb a card: every packet in or out fw rules.ndb <side> <side> two networks: everything crossing fw rules.ndb one namespace: what programs ask for The first two filter packets on a wire, using the pkt medium: the stack gives up its card and gets a synthetic one with fw on the other end, so nothing reaches it that fw did not pass. Since the stack no longer has ethernet, fw answers ARP for the address it stands in for. The third serves a filtered /net and matches connect and announce before they reach the kernel, so a refusal comes back out of dial(2) with a reason. That is only a boundary if the program also loses #I, which /dev/drivers does and cannot be undone; fw.rc does it in the right order. Rules are ndb, matched top to bottom, first match wins, no match denies. Connections are tracked, so permitting traffic one way permits the replies. A rule change drops connections the new rules forbid rather than letting them finish: a block blocks. Logging is per rule, to /sys/log/fw. Tested on the init-test VM in all three modes: a page fetched through a real card, a TCP handshake across two networks, request filtering with the escape routes closed, live rule changes killing established connections, and one rule file working unchanged at both altitudes. doc/todo.md has what is not done. Item 1 is the one that matters: a fw that dies takes the card's address with it, so the machine loses its network and fw cannot restart unaided. That also blocks svc supervision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'fw/src/rules.c')
-rw-r--r--fw/src/rules.c440
1 files changed, 440 insertions, 0 deletions
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";
+}