From 0f922552ad8cc73c0c3c3674d484c3d78dd8c557 Mon Sep 17 00:00:00 2001 From: Calvin Morrison Date: Tue, 18 Aug 2026 17:01:49 -0400 Subject: fw: a firewall, at a card, between two networks, or in front of a namespace One program with three modes, sharing one rule engine and one ndb rule language. Which mode it is depends on what you point it at, and it says so at startup rather than choosing silently. fw -e /net/ether0 rules.ndb a card: every packet in or out fw rules.ndb two networks: everything crossing fw rules.ndb one namespace: what programs ask for The first two filter packets on a wire, using the pkt medium: the stack gives up its card and gets a synthetic one with fw on the other end, so nothing reaches it that fw did not pass. Since the stack no longer has ethernet, fw answers ARP for the address it stands in for. The third serves a filtered /net and matches connect and announce before they reach the kernel, so a refusal comes back out of dial(2) with a reason. That is only a boundary if the program also loses #I, which /dev/drivers does and cannot be undone; fw.rc does it in the right order. Rules are ndb, matched top to bottom, first match wins, no match denies. Connections are tracked, so permitting traffic one way permits the replies. A rule change drops connections the new rules forbid rather than letting them finish: a block blocks. Logging is per rule, to /sys/log/fw. Tested on the init-test VM in all three modes: a page fetched through a real card, a TCP handshake across two networks, request filtering with the escape routes closed, live rule changes killing established connections, and one rule file working unchanged at both altitudes. doc/todo.md has what is not done. Item 1 is the one that matters: a fw that dies takes the card's address with it, so the machine loses its network and fw cannot restart unaided. That also blocks svc supervision. Co-Authored-By: Claude Opus 5 --- fw/src/netfs.c | 555 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 555 insertions(+) create mode 100644 fw/src/netfs.c (limited to 'fw/src/netfs.c') 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 +#include +#include +#include +#include <9p.h> +#include +#include +#include +#include "rules.h" + +/* + * One per fid. fd is the host file behind it, and is closed only when + * the fid is clunked: for a ctl file that close is what tears down the + * connection, so the two lifetimes have to be the same one. + */ +typedef struct Fnode Fnode; +struct Fnode +{ + char *path; /* relative to the root, "" is the root */ + int fd; + char *dbuf; /* directory, encoded at open */ + long dlen; +}; + +static char *orig = "/net"; + +/* + * The real /net is a union of devip and the cs and dns mounts, whose + * qids are allocated by different servers and can collide. Hash the + * path instead: stable across walks, unique across servers. + */ +static uvlong +hashpath(char *s) +{ + uvlong h; + + h = 14695981039346656037ULL; + while(*s != '\0'){ + h ^= (uchar)*s++; + h *= 1099511628211ULL; + } + return h; +} + +static void +mkqid(Qid *q, char *path, Qid *real) +{ + q->path = hashpath(path); + q->vers = real->vers; + q->type = real->type; +} + +static char* +realpath(char *path) +{ + if(*path == '\0') + return estrdup(orig); + return smprint("%s/%s", orig, path); +} + +static char* +childpath(char *dir, char *name) +{ + char *p, *q; + + if(strcmp(name, "..") == 0){ + p = estrdup(dir); + if((q = strrchr(p, '/')) != nil) + *q = '\0'; + else + *p = '\0'; + return p; + } + if(*dir == '\0') + return estrdup(name); + return smprint("%s/%s", dir, name); +} + +/* does path name this top-level entry, or something under it? */ +static int +under(char *path, char *name) +{ + int n; + + n = strlen(name); + return strncmp(path, name, n) == 0 && (path[n] == '\0' || path[n] == '/'); +} + +/* + * Raw packet access. Kept out of the served tree entirely rather than + * made unopenable, so that a program probing for a way out does not + * even see one. + */ +static int +hidden(char *path) +{ + if(strncmp(path, "ether", 5) == 0 && path[5] >= '0' && path[5] <= '9') + return 1; + return under(path, "ipmux"); +} + +static char* +protect(char *path, int mode) +{ + if(hidden(path)) + return "fw: does not exist"; + if(strcmp(path, "ipifc/clone") == 0) + return "fw: interface creation denied"; + if(under(path, "ipifc") || under(path, "iproute") || under(path, "arp")) + if((mode & 3) != OREAD) + return "fw: read-only under fw"; + return nil; +} + +/* + * A directory in the root is a protocol directory if it has a clone + * file. Asking the filesystem beats hardcoding a list that goes stale. + */ +static int +isproto(char *name) +{ + char *p; + int ok; + + if(strchr(name, '/') != nil || *name == '\0') + return 0; + p = smprint("%s/%s/clone", orig, name); + ok = access(p, AEXIST) == 0; + free(p); + return ok; +} + +/* + * If path is a protocol ctl file - "tcp/clone" or "tcp/1/ctl" - return + * the protocol name. Opening clone yields an fd that is itself the new + * connection's ctl file, so both spellings take a connect write. + */ +static char* +ctlproto(char *path) +{ + char buf[64], *p, *q; + int n; + + if((p = strchr(path, '/')) == nil) + return nil; + n = p - path; + if(n <= 0 || n >= sizeof buf) + return nil; + memmove(buf, path, n); + buf[n] = '\0'; + p++; + if(strcmp(p, "clone") != 0){ + if((q = strchr(p, '/')) == nil || strcmp(q+1, "ctl") != 0) + return nil; + } + if(!isproto(buf)) + return nil; + return estrdup(buf); +} + +/* + * connect takes addr!port with optional trailing fields; announce takes + * a bare port, or addr!port with addr often "*". + */ +static char* +checkctl(char *proto, char *msg, long n) +{ + char buf[512], *f[8], *a[4], *addr, *e; + static char err[128]; + uchar ip[IPaddrlen], mask[IPaddrlen]; + Rule *rule; + int nf, na, verb, anyip, port, lport; + + if(n <= 0) + return nil; + if(n >= sizeof buf) + n = sizeof buf - 1; + memmove(buf, msg, n); + buf[n] = '\0'; + + if((nf = tokenize(buf, f, nelem(f))) < 1) + return nil; + if(strcmp(f[0], "connect") == 0) + verb = Vout; + else if(strcmp(f[0], "announce") == 0) + verb = Vin; + else + return nil; /* hangup, ttl, keepalive: not policy */ + if(nf < 2) + return nil; /* malformed; let the kernel say so */ + + na = getfields(f[1], a, nelem(a), 0, "!"); + if(na < 1) + return nil; + if(na == 1){ + addr = "*"; /* announce 17019 */ + port = atoi(a[0]); + }else{ + addr = a[0]; + port = strcmp(a[1], "*") == 0 ? -1 : atoi(a[1]); + } + + /* + * Which end the port and the address belong to, so that a rule + * means the same here as it does against a packet. + * + * connect names the far end: its port is the peer's, and the + * local port is whatever the kernel picks, so unknown. + * + * announce names this end: its port is ours, its address is a + * local address to listen on, and the peer is nobody yet - we + * find out who connected only at listen time. A rule naming a + * peer therefore cannot apply to an announce, which is right: + * at this point there is no peer to name. + */ + if(verb == Vin){ + lport = port; + port = -1; + anyip = 1; + }else{ + lport = -1; + anyip = strcmp(addr, "*") == 0; + if(!anyip && parseipandmask(ip, mask, addr, nil) == -1){ + syslog(0, "fw", "deny %s %s %s: unparseable address", + proto, f[0], f[1]); + return "fw: unparseable address"; + } + } + if((e = matchrule(verb, proto, ip, anyip, port, lport, &rule)) != nil){ + if(rule != nil && rule->log) + syslog(0, "fw", "deny %s %s %s: %s", proto, f[0], f[1], e); + snprint(err, sizeof err, "fw: %s", e); + return err; + } + if(rule != nil && rule->log) + syslog(0, "fw", "allow %s %s %s", proto, f[0], f[1]); + return nil; +} + +/* + * A directory is read once at open, filtered, and re-encoded; reads + * then slice that buffer at entry boundaries. This gets the offset + * rules right without a gen function, and an open directory is a + * snapshot on Plan 9 anyway. + */ +static char* +slurpdir(Fnode *f, char *rp) +{ + char *buf, *cp; + Dir *d; + Qid q; + long sz; + int fd, i, n, m; + + if((fd = open(rp, OREAD)) < 0) + return "fw: cannot open directory"; + n = dirreadall(fd, &d); + close(fd); + if(n < 0) + return "fw: cannot read directory"; + + buf = nil; + sz = 0; + for(i = 0; i < n; i++){ + cp = childpath(f->path, d[i].name); + if(hidden(cp)){ + free(cp); + continue; + } + q = d[i].qid; + mkqid(&d[i].qid, cp, &q); + free(cp); + m = sizeD2M(&d[i]); + if((buf = realloc(buf, sz + m)) == nil) + sysfatal("out of memory"); + convD2M(&d[i], (uchar*)buf + sz, m); + sz += m; + } + free(d); + f->dbuf = buf; + f->dlen = sz; + return nil; +} + +static void +dirslice(Req *r, Fnode *f) +{ + long o, e, m; + + for(o = 0; o < f->dlen && o != r->ifcall.offset; o += m) + m = GBIT16((uchar*)f->dbuf + o) + BIT16SZ; + if(o != r->ifcall.offset || o >= f->dlen){ + r->ofcall.count = 0; + return; + } + for(e = o; e < f->dlen; e += m){ + m = GBIT16((uchar*)f->dbuf + e) + BIT16SZ; + if(e + m - o > r->ifcall.count) + break; + } + memmove(r->ofcall.data, f->dbuf + o, e - o); + r->ofcall.count = e - o; +} + +static Fnode* +newfnode(char *path) +{ + Fnode *f; + + f = emalloc(sizeof *f); + f->path = estrdup(path); + f->fd = -1; + return f; +} + +static void +fsattach(Req *r) +{ + Fnode *f; + Dir *d; + + if((d = dirstat(orig)) == nil){ + responderror(r); + return; + } + f = newfnode(""); + mkqid(&r->fid->qid, "", &d->qid); + free(d); + r->fid->aux = f; + r->ofcall.qid = r->fid->qid; + respond(r, nil); +} + +static char* +fsclone(Fid *old, Fid *new) +{ + Fnode *f; + + f = old->aux; + new->aux = newfnode(f->path); + return nil; +} + +static char* +fswalk1(Fid *fid, char *name, Qid *q) +{ + Fnode *f; + Dir *d; + char *np, *rp; + + f = fid->aux; + np = childpath(f->path, name); + if(hidden(np)){ + free(np); + return "fw: does not exist"; + } + rp = realpath(np); + d = dirstat(rp); + free(rp); + if(d == nil){ + free(np); + return "fw: does not exist"; + } + mkqid(q, np, &d->qid); + free(d); + free(f->path); + f->path = np; + fid->qid = *q; + return nil; +} + +static void +fsopen(Req *r) +{ + Fnode *f; + char *rp, *e; + int mode; + + f = r->fid->aux; + mode = r->ifcall.mode; + if((e = protect(f->path, mode)) != nil){ + respond(r, e); + return; + } + rp = realpath(f->path); + if(r->fid->qid.type & QTDIR){ + e = slurpdir(f, rp); + free(rp); + respond(r, e); + return; + } + /* opening listen blocks until someone connects */ + srvrelease(r->srv); + f->fd = open(rp, mode & ~ORCLOSE); + srvacquire(r->srv); + free(rp); + if(f->fd < 0){ + responderror(r); + return; + } + respond(r, nil); +} + +static void +fsread(Req *r) +{ + Fnode *f; + long n; + + f = r->fid->aux; + if(r->fid->qid.type & QTDIR){ + dirslice(r, f); + respond(r, nil); + return; + } + if(f->fd < 0){ + respond(r, "fw: not open"); + return; + } + srvrelease(r->srv); + n = pread(f->fd, r->ofcall.data, r->ifcall.count, r->ifcall.offset); + srvacquire(r->srv); + if(n < 0){ + responderror(r); + return; + } + r->ofcall.count = n; + respond(r, nil); +} + +static void +fswrite(Req *r) +{ + Fnode *f; + char *proto, *e; + long n; + + f = r->fid->aux; + if(f->fd < 0){ + respond(r, "fw: not open"); + return; + } + if((proto = ctlproto(f->path)) != nil){ + e = checkctl(proto, r->ifcall.data, r->ifcall.count); + free(proto); + if(e != nil){ + respond(r, e); + return; + } + } + srvrelease(r->srv); + n = pwrite(f->fd, r->ifcall.data, r->ifcall.count, r->ifcall.offset); + srvacquire(r->srv); + if(n < 0){ + responderror(r); + return; + } + r->ofcall.count = n; + respond(r, nil); +} + +static void +fsstat(Req *r) +{ + Fnode *f; + Dir *d; + char *rp, *name; + Qid q; + + f = r->fid->aux; + rp = realpath(f->path); + d = dirstat(rp); + free(rp); + if(d == nil){ + responderror(r); + return; + } + q = d->qid; + mkqid(&d->qid, f->path, &q); + if((name = strrchr(f->path, '/')) != nil) + name++; + else if(*f->path != '\0') + name = f->path; + else + name = "/"; + r->d = *d; + r->d.name = estrdup(name); + r->d.uid = estrdup(d->uid); + r->d.gid = estrdup(d->gid); + r->d.muid = estrdup(d->muid); + free(d); + respond(r, nil); +} + +static void +fsdestroyfid(Fid *fid) +{ + Fnode *f; + + if((f = fid->aux) == nil) + return; + fid->aux = nil; + if(f->fd >= 0) + close(f->fd); + free(f->dbuf); + free(f->path); + free(f); +} + +static Srv fs = +{ + .attach = fsattach, + .clone = fsclone, + .walk1 = fswalk1, + .open = fsopen, + .read = fsread, + .write = fswrite, + .stat = fsstat, + .destroyfid = fsdestroyfid, +}; + +/* + * Serve a filtered view of "orig" at "mtpt". Rules have already been + * read; this is the request-filtering half of fw, kept in its own file + * only because it is a different mechanism, not a different program. + */ +void +servenet(char *mtpt, char *srvname, char *net) +{ + orig = net; + if(access(orig, AEXIST) < 0) + sysfatal("%s: %r", orig); + fprint(2, "fw: filtering requests on %s\n", mtpt); + syslog(0, "fw", "started, filtering requests on %s", mtpt); + threadpostmountsrv(&fs, srvname, mtpt, MREPL); +} -- cgit v1.2.3