Build a mesh VPN · lesson 07

The coordination server

Six lessons in, your mesh is a config file. Every node has to be told every other node's key, address and port by hand, and a laptop that changes wifi silently breaks everyone's entry for it. One small server fixes that, and it never sees a single packet of yours.

in progress
22 min interactive

Who tells a node who its peers are?

Add a third machine to the mesh you have now. You generate a key, then edit the config on both existing nodes to add its public key, its overlay address and the ip:port where it listens. Then you copy both of their entries into the new node's config. Three machines, six entries. A fourth machine makes it twelve. Now one of them is a laptop. It leaves the office, joins a café network, and its ip:port changes. Every other node's entry for it is wrong until a human fixes it.

The handshake from lesson 06 can prove who a packet came from. It cannot tell you where to send the first one. Somebody has to hold the current answer to "which keys are in this mesh, what addresses do they own, and where might they be reachable right now?" and hand it to every node. That somebody is the coordination server, coordd. Tailscale calls it the control plane. Its job is to introduce. It is not on the path of a single data packet, and the whole design depends on keeping it that way.

💡
The server is a phone book, not a switchboard. It holds one table keyed by public key: overlay address, endpoint candidates, allowed CIDRs, last seen. Nodes read entries out of it and then talk to each other directly. If coordd dies, existing sessions keep running; only new introductions stop.

A protocol you can type

The control channel is TCP. You wrote the framing for it in the networking track: a TCP connection is a byte stream, so each message ends in \n and the receiver cuts on newlines. Fields are separated by spaces. That is the whole wire format. You can drive the server from nc and read its replies without a decoder.

node → server reply meaning
HELLO <pubkey_b64> <authkey>OK <overlay_ip> or ERR <why>join; the lowest free 100.64.0.N is assigned and remembered
ENDPOINTS a:p[,a:p…]OKwhere this node might be reachable (lesson 08 builds the list)
ROUTES cidr[,cidr…]OKsubnet router advertisement; lesson 11, ignore for now
PINGPONGevery 30 s; 90 s of silence and the node is dropped
server → node any time after OK
PEER <pubkey_b64> <ip> <endpoints|-> <ip/32[,cidr…]>one line per peer this node may see
PEERS_ENDend of a full list
BYE <pubkey_b64>forget this peer

Look at what identifies a node in every line: the public key. The overlay IP is something the server assigns to a key; the endpoints are something the key reports. Neither is the name. Lesson 04 made that argument in the abstract. Here it is in the protocol.

The server: one table, one rule

coordd is a single file. Its state is an array of connected clients, each with a TCP fd, a line buffer, and, once authenticated, a key, an overlay address, and the last ENDPOINTS and ROUTES text that node sent. The server poll()s over the listening socket, one UDP socket (lesson 08) and every client fd, exactly the loop from the event-loop lesson. Each complete line goes to handle_line.

c
// src/coordd.c struct client { int fd; char buf[LINE_MAX]; size_t len; // bytes received, not yet a full line bool authed; char pk[KEY_B64_LEN]; // the node's name uint32_t ip; char endpoints[1024]; // csv as the node sent it, or "" char routes[512]; // lesson 11 int64_t last_seen_ms; }; // 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); } 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)) // acl: lesson 11 send_peer(to, c); } send_line(to, "PEERS_END"); } static void broadcast_lists(void) { for (int i = 0; i < n_clients; i++) if (clients[i].authed) send_peer_list(&clients[i]); } static void handle_line(struct client *c, char *line) { char *cmd = strtok(line, " "); char *arg1 = strtok(NULL, " "); char *arg2 = strtok(NULL, " "); if (!cmd) return; if (strcmp(cmd, "HELLO") == 0) { handle_hello(c, arg1, arg2); // checks authkey, assigns ip, OK/ERR, broadcast } else if (!c->authed) { send_line(c, "ERR hello-first"); } else if (strcmp(cmd, "ENDPOINTS") == 0 && arg1) { snprintf(c->endpoints, sizeof c->endpoints, "%s", strcmp(arg1, "-") ? arg1 : ""); send_line(c, "OK"); broadcast_lists(); } else if (strcmp(cmd, "ROUTES") == 0 && arg1) { snprintf(c->routes, sizeof c->routes, "%s", arg1); send_line(c, "OK"); broadcast_lists(); } else if (strcmp(cmd, "PING") == 0) { send_line(c, "PONG"); } else { send_line(c, "ERR unknown-command"); } }

The full file is src/coordd.c in the project repo; handle_hello and drop_client are described below rather than shown.

The one rule: any change re-sends the full list to everyone. A node joins, a node reports new endpoints, a node leaves: broadcast_lists(). There is no diff protocol, no version number, no "PEER_UPDATE" message. For a mesh of ten nodes that is a hundred short lines per change, which is nothing. What it buys is a node side that cannot get out of sync: whatever the node holds before PEERS_END is replaced by what arrived since the previous one. Tailscale's control server sends incremental map updates and has to get the deltas right. At this scale, simple beats clever. Step through what the table looks like as three nodes come and go.

coordd — one table, keyed by public key
Click next to start the walkthrough.

Addresses that survive restarts

handle_hello checks the pre-auth key with strcmp, then asks db_assign for an address. If the key has been seen before, it gets the same address back. Otherwise the lowest free host number in 100.64.0.0/10 is taken and the pair is appended to nodes.db, one pubkey ip line per node ever seen. Nothing more than fprintf to a file opened with "a". Restart coordd and db_load reads it back with fscanf. A node's address is a property of its key, which is what lets the other nodes keep 100.64.0.2 in their allowed[] entry across its reboots.

A reconnecting node is the other half of this. If a key is already connected on a stale socket (the laptop lid closed and the old TCP connection never got a FIN), the new HELLO drops the old client first. Otherwise the same key would be in the table twice.

Liveness: TCP will not tell you

A TCP connection that nobody writes to looks alive forever. If a node's power cable is pulled, the server's socket for it sits in ESTABLISHED with an empty receive buffer and no error. So the node sends PING every 30 s and the server stamps last_seen_ms on every line it receives. On each pass of the loop, a client that has been silent for NODE_TIMEOUT_MS (90 s) is dropped, and drop_client sends BYE <pubkey> to everyone else. The server's poll() times out every second for this reason, even when no fd is ready.

The node side: lines in, peers out

control.c is the client. It owns a struct control: the fd, a 4 KiB buffer, how many bytes of it are filled, and two timestamps. When poll() says the control fd is readable, control_on_readable drains the socket into the buffer and then cuts out every complete line. You have written this exact pair of functions before.

c
// src/control.c // TCP is a byte stream (net/06): cut one '\n'-terminated line out of the // buffer, or return false if none is complete yet. static bool take_line(struct control *c, char *line) { char *nl = memchr(c->buf, '\n', c->len); if (!nl) return false; size_t n = (size_t)(nl - c->buf); memcpy(line, c->buf, n); line[n] = '\0'; memmove(c->buf, nl + 1, c->len - n - 1); // slide the rest down c->len -= n + 1; return true; } // PEER <pubkey> <overlay_ip> <endpoints_csv|-> <allowed_cidrs_csv> static void apply_peer(struct node *n, char *pk_b64, char *ip_text, char *eps, char *cidrs) { uint8_t pk[KEY_LEN]; uint32_t ip; if (!crypto_key_from_b64(pk_b64, pk) || !parse_ip(ip_text, &ip)) return; if (memcmp(pk, n->pk, KEY_LEN) == 0) return; // that's us struct peer *p = peer_add(&n->peers, pk); // existing entry or a new one if (!p) return; peer_set_overlay_ip(p, ip); p->seen_in_batch = true; p->n_candidates = 0; for (char *tok = strtok(eps, ","); tok && p->n_candidates < MAX_CANDIDATES; tok = strtok(NULL, ",")) if (parse_endpoint(tok, &p->candidates[p->n_candidates])) p->n_candidates++; peer_clear_allowed(p); 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); // subnet routes: lesson 11 } } // The server re-sends the full list on every change, so anything we did // not see in this batch is gone (ACL change, node left). static void finish_batch(struct node *n) { for (int i = n->peers.n - 1; i >= 0; i--) { struct peer *p = &n->peers.peers[i]; if (!p->seen_in_batch) remove_peer(n, p); else p->seen_in_batch = false; } }

apply_peer writes straight into the peer table from lesson 03. The PEER line's fields map one to one: the key becomes pubkey, the address becomes overlay_ip, the CSV of endpoints fills candidates[], the CSV of CIDRs fills allowed[]. The static config you had been typing is now a line the server types for you. Note what is not touched: cur, prev, endpoint. A re-sent PEER line for a peer you already have a session with updates its candidates and leaves the session alone.

finish_batch is the node side of "full re-send". Each PEER line marks its entry seen_in_batch; PEERS_END removes every entry that was not marked and clears the marks for next time. A peer that disappeared from the list, for whatever reason, disappears from the table. Walking backwards matters because peer_remove shifts the array.

⚠️
Common mistake: parsing before the newline arrives. One read() can return half a PEER line, or three lines and a half. control_on_readable loops on fill_buffer until EAGAIN and only then calls take_line in a loop. If a line is longer than the buffer with no \n, it is not a line, and the connection is dropped rather than guessed at.

Joining

control_connect is the only synchronous part of meshd. It connects, sends HELLO, and blocks up to 5 s for the reply, because the node cannot do anything useful without an overlay address. On OK 100.64.0.1 it configures the interface it opened in lesson 02: ip addr replace 100.64.0.1/10 dev tun0 and ip link set tun0 mtu 1400 up, both through system(). The /10 installs the route that sends every overlay address into tun0; the MTU leaves room for the 13 + 16 bytes of lesson 05 plus the outer IP and UDP headers. A product would use netlink here and never fork a shell. Then the fd is switched to O_NONBLOCK, ENDPOINTS and ROUTES go out, and the fd joins the poll() set. If the connection later drops, tick() reconnects every 5 s; the peer table survives the gap.

What Tailscale's control server does that this one does not

The shape is the same: a server keyed by node key that assigns 100.x addresses, distributes a "netmap" of peers with their endpoints, and never forwards traffic. The differences are all about running it on the real internet for strangers. Tailscale's control connection is itself encrypted and authenticated with the node key (Noise over HTTPS); ours is plaintext TCP, and the pre-auth key travels in the clear. It authenticates people through an identity provider and ties node keys to user accounts; ours has one shared secret. Node keys expire and are rotated; ours live until removed from nodes.db. Map updates are incremental; ours are full re-sends. It runs DNS for the mesh (MagicDNS) and hands out a map of relay regions; ours does neither. Lesson 12 collects this list with pointers. The ACL filtering you saw in send_peer_list is the Tailscale model, and lesson 11 turns it on.

🚫
Security hole: the control channel is plaintext. Anyone on the path between a node and coordd reads the pre-auth key out of HELLO and can join the mesh. They cannot decrypt a data packet, because those keys never touch the server, but they can add a node. Run coordd only where you would run an unencrypted admin interface, and read lesson 12 before pointing it at the internet.

Run it

Build, start the server, then watch the protocol by hand before letting meshd speak it. The first terminal is a machine every node can reach; in the lab it is the cloud namespace, on a LAN it is any host.

sh
make ./coordd --listen 0.0.0.0:7000 --udp 0.0.0.0:7001 --authkey lab-secret --db nodes.db # second terminal: speak the protocol yourself mkdir -p ~/.mesh && ./meshd genkey ~/.mesh/private.key PK=$(./meshd pubkey < ~/.mesh/private.key) printf 'HELLO %s lab-secret\nPING\n' "$PK" | nc -w 2 127.0.0.1 7000 cat nodes.db # then the real client, on each node (needs root for tun0 and ip ...) sudo ./meshd up --key ~/.mesh/private.key --coord 192.0.2.10:7000 --authkey lab-secret sudo ./meshd status
sh
# coordd's terminal 12:04:01.118 info coordd listening tcp :7000 udp :7001, 0 node(s) in nodes.db 12:04:09.402 info node mK3f9Qx2…Yk= joined as 100.64.0.1 # what nc printed: the server's side of the conversation OK 100.64.0.1 PEERS_END PONG # nodes.db — the key owns the address now mK3f9Qx2…Yk= 100.64.0.1 # meshd status on the second node, after both have joined node 100.64.0.2 pub Zr8pLw1T…Qc= udp 51820 public 192.0.2.44:51820 control connected peers 1 peer 100.64.0.1 mK3f9Qx2…Yk= path none session none allowed 100.64.0.1/32 candidates 192.168.1.7:51820,192.0.2.10:51820 drops no_peer 0 bad_src 0 bad_auth 0 no_session 0

The nc session joined as a node, so coordd also logged it leaving two seconds later with connection closed and sent BYE to nobody. The meshd log will show a line starting with stun: before the control connection; that is lesson 08, and so is the public field in status. What matters tonight is the peer line: a key, an address, and candidates, none of which you typed. path none is honest: the two nodes know about each other and have not yet exchanged a packet. If they are on the same LAN or both publicly reachable, ping between them works now. Behind two NATs, it does not yet, and lessons 08 and 09 are about why.

one-line takeaway

The coordination server is a table keyed by public key that it re-sends in full on every change; nodes rebuild their peer table from PEER lines, and not one data packet ever passes through it.