Build a mesh VPN · lesson 11

Subnet routes and ACLs

Two tables decide where a packet goes: the kernel's routing table and meshd's peer table. This lesson teaches both of them about addresses that are not overlay addresses, then uses the second one to say no.

in progress
18 min interactive

A printer that is not on the mesh

B's office has a printer at 192.168.1.20. It does not run meshd. It will never run meshd. From A, ping 100.64.0.2 works, but ping 192.168.1.20 gets nothing, and meshd status on A shows no drop counter moving. meshd never saw the packet.

Run ip route get 192.168.1.20 on A. It says via 10.0.1.1 dev eth0: the default route. A's kernel has one route into tun0, 100.64.0.0/10, and 192.168.1.20 is not in it. So the packet left through the real interface toward natA, which has no idea what 192.168.1.0/24 is either. From the routing lesson: the kernel picked the longest prefix that matched, and the only match was 0.0.0.0/0. Suppose you fix that with ip route add 192.168.1.0/24 dev tun0. Now on_tun_readable reads the packet, calls lookup_by_overlay_ip with 192.168.1.20, finds no peer whose allowed[] contains it, and drops.no_peer goes up by one. Same problem, one layer down.

Two tables, two longest-prefix matches, and both of them need a new row. The design decision is who adds the rows. Not the student at a shell on every node: the node that owns the subnet announces it, the coordination server passes the announcement on, and every other node updates both tables on its own.

You stepped through the kernel's table in the routing lesson. Step through it again with this in mind: the peer table in peers.c runs the same algorithm over the same kind of rows, except that a row resolves to a public key instead of an interface.

routing table — longest-prefix match, twice
Click next to start the walkthrough.

--advertise and the ROUTES line

meshd up --advertise 192.168.1.0/24 stores the string in n->advertise. That is the only thing the flag does at startup. control_connect, right after it sends ENDPOINTS, sends it to coordd:

c
// src/control.c void control_send_routes(struct node *n) { if (n->advertise) send_line(n, "ROUTES %s", n->advertise); } // src/coordd.c — inside handle_line(), after the client is authed } else if (strcmp(cmd, "ROUTES") == 0 && arg1) { snprintf(c->routes, sizeof c->routes, "%s", arg1); send_line(c, "OK"); broadcast_lists(); } /* PEER <pk> <ip> <endpoints|-> <ip/32[,routes]> */ static void send_peer(struct client *to, const struct client *about) { char ip[32]; format_ip(about->ip, ip, sizeof ip); send_line(to, "PEER %s %s %s %s/32%s%s", about->pk, ip, about->endpoints[0] ? about->endpoints : "-", ip, about->routes[0] ? "," : "", about->routes); }

coordd does not parse the CIDRs. It keeps the string in c->routes and appends it to the last field of this node's PEER line, after the /32 that is always first. The line A receives about B changes from PEER Zr8p… 100.64.0.2 10.0.2.2:51820,10.0.0.3:51820 100.64.0.2/32 to the same thing ending in 100.64.0.2/32,192.168.1.0/24. Then broadcast_lists re-sends the full list to everyone, as it does for any change. Simple beats clever: the node side needs no "route added" message, because it already knows how to apply a complete PEER line.

allowed[] becomes a prefix table

In lesson 03, allowed[] held exactly one entry per peer, the overlay /32, and lookup_by_overlay_ip was a longest-prefix match over a table where every prefix was 32. That was the design waiting for this lesson. Here is what apply_peer does with the last field now:

c
// src/control.c /* Subnet routes: the kernel must send 192.168.1.0/24 into tun0 for our * cryptokey routing to see it at all. */ static void set_route(struct node *n, uint32_t net, uint8_t prefix, bool add) { char ip[32], cmd[128]; snprintf(cmd, sizeof cmd, "ip route %s %s/%u dev %s", add ? "replace" : "del", format_ip(net, ip, sizeof ip), prefix, n->tun_name); if (system(cmd) != 0) log_warn("control: '%s' failed", cmd); } // inside apply_peer(), after candidates are parsed: peer_clear_allowed(p); /* keeps allowed[0], the /32 */ for (char *tok = strtok(cidrs, ","); tok; tok = strtok(NULL, ",")) { uint32_t net; uint8_t prefix; if (parse_cidr(tok, &net, &prefix) && peer_add_allowed(p, net, prefix) && prefix < 32) set_route(n, net, prefix, true); } // src/peers.c struct peer *lookup_by_overlay_ip(struct peer_table *t, uint32_t ip) { struct peer *best = NULL; int best_prefix = -1; for (int i = 0; i < t->n; i++) { struct peer *p = &t->peers[i]; for (int j = 0; j < p->n_allowed; j++) { const struct allowed_ip *a = &p->allowed[j]; if (a->prefix > best_prefix && cidr_contains(a->net, a->prefix, ip)) { best = p; best_prefix = a->prefix; } } } return best; }

Each CIDR in the field goes into allowed[], up to MAX_ALLOWED, eight. Every entry with a prefix shorter than 32 also becomes a kernel route on tun0. The /32 does not need one; it is already covered by the 100.64.0.0/10 route tun_configure installed. The code says ip route replace, not add: a PEER line is re-applied on every broadcast, and add would fail with EEXIST the second time. It is still system() running the ip binary, as in lesson 02; a product would speak netlink.

Now both tables have the row. A packet for 192.168.1.20 matches 192.168.1.0/24 dev tun0 in the kernel, arrives in on_tun_readable, and lookup_by_overlay_ip finds B's entry with best_prefix = 24. It is encrypted for B's session like any other packet. The inner destination is a LAN address; the outer destination is B's endpoint. On the way back, B's inbound check peer_allows_src runs against A's allowed[], and on A, a reply with inner source 192.168.1.20 passes because B's allowed[] now contains the /24. The access-control story from lesson 03 did not change. The table it reads just grew.

When a peer disappears, remove_peer walks allowed[1..] and calls set_route with add = false for each, so the kernel route goes away with the key that owned it.

⚠️
Overlapping advertisements are resolved by prefix length, not by order. If one node advertises 192.168.0.0/16 and another 192.168.1.0/24, packets for 192.168.1.20 go to the second: a->prefix > best_prefix is strict, so the longest match wins and ties go to whichever peer was added first. The kernel resolves its copy of the same two routes the same way. Two nodes advertising the identical CIDR is a configuration error; this repo does not detect it, Tailscale makes you approve each route in the admin panel.

The router node

The receiving side is done. The advertising node needs two things from the kernel that a laptop does not have switched on. When B's meshd writes A's packet into tun0, the destination is 192.168.1.20, not B. A kernel receiving a packet that is not for itself discards it, unless ip_forward is on. And the printer's reply is addressed to 100.64.0.1, which the printer has no route for; it sends the reply to its default gateway, which is not B, and the packet is lost. The usual fix is to masquerade: rewrite the source of forwarded packets to B's own LAN address, so the printer answers B, and the NAT table on B maps the reply back to 100.64.0.1. You built exactly this table in the NAT lesson; now you are the router.

sh
# on the node that runs --advertise, when the subnet is behind it (not its own interface) sysctl -w net.ipv4.ip_forward=1 iptables -t nat -A POSTROUTING -s 100.64.0.0/10 -o eth0 -j MASQUERADE

In the lab the advertised address is B's own second interface, lan0 with 192.168.1.1/24, so B answers the ping itself and neither command is needed. On a real office network with a real printer, both are.

acl.conf: saying no on the control plane

You now have a node that can reach a whole office subnet. The next question from anyone who runs this for other people is: how do I stop node C from reaching it? The data plane already has an answer, and it is the one you have been using since lesson 03. A node can only send to peers in its table, and only accept from keys in its table. So the control plane does not need to teach the data plane anything new. It needs to tell each node less.

coordd --acl acl.conf reads a file of allow lines, each naming two public keys or *:

c
// src/coordd.c /* acl.conf: "allow <pk|*> <pk|*>" lines, symmetric. No file → allow all. */ static struct { char a[KEY_B64_LEN], b[KEY_B64_LEN]; } acl[MAX_ACL]; static int n_acl; static bool acl_active; static volatile sig_atomic_t reload_requested; static void acl_load(void) { n_acl = 0; acl_active = false; if (!acl_path) return; FILE *f = fopen(acl_path, "r"); if (!f) { log_warn("acl: cannot open %s, allowing everyone", acl_path); return; } acl_active = true; char verb[16]; while (n_acl < MAX_ACL && fscanf(f, "%15s %44s %44s", verb, acl[n_acl].a, acl[n_acl].b) == 3) if (strcmp(verb, "allow") == 0) n_acl++; fclose(f); log_info("acl: %d rule(s) loaded from %s", n_acl, acl_path); } static bool acl_match(const char *pattern, const char *pk) { return strcmp(pattern, "*") == 0 || strcmp(pattern, pk) == 0; } static bool acl_allows(const char *a, const char *b) { if (!acl_active) return true; for (int i = 0; i < n_acl; i++) if ((acl_match(acl[i].a, a) && acl_match(acl[i].b, b)) || (acl_match(acl[i].a, b) && acl_match(acl[i].b, a))) return true; return false; } static void on_sighup(int sig) { (void)sig; reload_requested = 1; } static void send_peer_list(struct client *to) { for (int i = 0; i < n_clients; i++) { const struct client *c = &clients[i]; if (c != to && c->authed && acl_allows(to->pk, c->pk)) send_peer(to, c); } send_line(to, "PEERS_END"); }

acl_allows is symmetric by construction: it tests the rule both ways round, so if A may see B then B may see A. There is no "deny" verb. The absence of a matching allow is the deny, and with the file active, a node with no rule is told about nobody. Without --acl at all, acl_active stays false and the server behaves as in lesson 07. The filter runs in send_peer_list, once per recipient: a PEER line is sent only if the pair is allowed. That is the entire enforcement mechanism.

Reloading is a signal, not a restart. SIGHUP sets a flag; the poll loop checks it, calls acl_load and then broadcast_lists. Every node receives a fresh full list ending in PEERS_END, and finish_batch in control.c removes any peer that was not in it, kernel routes included. A node that was just hidden from you vanishes from your table within one round trip, without your meshd knowing an ACL exists.

Why the peer table is enough

Follow a packet from A to a hidden B through the code. Outbound, on_tun_readable calls lookup_by_overlay_ip, which returns NULL because no entry has 100.64.0.2/32; drops.no_peer increments and nothing leaves the socket. There is no session to encrypt with and no endpoint to send to, so there is nothing to leak. Inbound, if a stale or malicious B sends an INIT anyway, handshake_handle_init looks up the static key in the first 33 bytes with lookup_by_pubkey and returns "not our peer". A DATA packet carrying an old index fails in lookup_by_rx_index because finish_batch zeroed the session with the entry. Both count as bad_auth. The data plane needs no filter rules, because the data plane was never willing to talk to a key it had not been introduced to.

✅
Enforce where the keys live. The ACL is compiled into each node's peer table. Tailscale's control plane does the same thing: it sends each node only the peers the policy allows, and WireGuard's cryptokey routing drops the rest. If you add a check, add it in peers.c or coordd.c, never as a rule a node could forget to apply.

Tailscale adds one more layer that this repo does not have. Its ACL grammar is not allow key key but src → dst:port with protocols, users and tags, and it is directional: A may open an SSH connection to B without B being able to open one to A. Hiding a peer cannot express "port 22 only", so the control plane also compiles a per-node packet filter, a list of allowed inner (src, dst, proto, port) tuples that the node checks after decryption, right where peer_allows_src runs here. The peer table handles who; the packet filter handles what. You have the hook for it: it would be one more test next to peer_allows_src in handle_packet.

💡
Who do you trust? coordd decides who sees whom. From the lesson 01 table it holds keys, overlay IPs and candidates, never a data packet: a compromised coordd can isolate nodes or introduce an attacker's key as a new peer, but cannot read traffic between two honest nodes or impersonate B. Tailscale closes the remaining gap with tailnet lock, where nodes sign the peer list themselves.

Run it

Sections 4 and 5 of test/run.sh do both halves. By hand, with the lab up and both nodes running as in lesson 09, give B a second interface and restart it with the flag:

sh
# a LAN behind B: lan0 gets 192.168.1.1, lan1 is just the other end of the veth ip netns exec B ip link add lan0 type veth peer name lan1 ip netns exec B ip addr add 192.168.1.1/24 dev lan0 ip netns exec B ip link set lan0 up; ip netns exec B ip link set lan1 up pkill -f "meshd up --key /tmp/mesh-lab/B.key" MESHD_SOCK=/tmp/mesh-lab/B.sock ip netns exec B ./meshd up --key /tmp/mesh-lab/B.key \ --coord 10.0.0.1:7000 --authkey lab-secret --relay 10.0.0.1:7002 \ --advertise 192.168.1.0/24 & ip netns exec A ip route | grep tun0 ip netns exec A ping -c 2 192.168.1.1 MESHD_SOCK=/tmp/mesh-lab/A.sock ./meshd status | grep ^peer
sh
100.64.0.0/10 dev tun0 proto kernel scope link src 100.64.0.1 192.168.1.0/24 dev tun0 scope link 64 bytes from 192.168.1.1: icmp_seq=1 ttl=64 time=1.12 ms 64 bytes from 192.168.1.1: icmp_seq=2 ttl=64 time=0.79 ms peer 100.64.0.2 Zr8pLw1TqX5vB9cD3eF7gH2jK4mN6oP8rS0tU1wV3yA= path direct endpoint 10.0.0.3:51820 session 9s tx 3 rx 3 last_rx 0s allowed 100.64.0.2/32,192.168.1.0/24 candidates 10.0.2.2:51820,192.168.1.1:51820,10.0.0.3:51820

Two rows into tun0; the second was installed by B's announcement passing through coordd, not by you. 192.168.1.1:51820 also appeared in B's candidates, because endpoints_csv lists every non-loopback interface. A probed it with the others and got no answer, which is correct.

Now the ACL. coordd in the lab was started with --acl /tmp/mesh-lab/acl.conf, a file that does not exist yet, so it is currently allowing everyone. Write a rule that allows A to see only C, a key that is not online, and signal coordd:

sh
A_PUB=$(./meshd pubkey </tmp/mesh-lab/A.key); C_PUB=$(./meshd pubkey </tmp/mesh-lab/C.key) echo "allow $A_PUB $C_PUB" >/tmp/mesh-lab/acl.conf pkill -HUP coordd tail -1 /tmp/mesh-lab/coordd.log MESHD_SOCK=/tmp/mesh-lab/A.sock ./meshd status ip netns exec A ping -c 2 -W 1 100.64.0.2; echo "exit $?" echo "allow * *" >/tmp/mesh-lab/acl.conf pkill -HUP coordd
sh
14:07:02.551 info acl: 1 rule(s) loaded from /tmp/mesh-lab/acl.conf node 100.64.0.1 pub mK3f9Qx2bV7LwN0sPq4RtY8uZi1Jh6GdE5aFcXo2nMk= udp 51820 public 10.0.0.2:51820 relay 10.0.0.1:7002 control connected peers 0 drops no_peer 0 bad_src 0 bad_auth 0 no_session 1 2 packets transmitted, 0 received, 100% packet loss, time 1013ms exit 1

peers 0, and ip route on A no longer lists 192.168.1.0/24: remove_peer took it with the entry. After the second SIGHUP the full list comes back, the handshake runs from scratch, and the ping answers within seconds. meshd-A.log shows control: peer 100.64.0.2 removed, then control: peer 100.64.0.2 added (3 candidates, 2 allowed). meshd never saw a rule; it saw a shorter list, then a longer one.

one-line takeaway

A subnet route is one more prefix in a peer's allowed-IPs plus one kernel route, and an ACL is nothing more than a shorter peer list: both reuse the longest-prefix table the data plane already trusts.