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.
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…] | OK | where this node might be reachable (lesson 08 builds the list) |
ROUTES cidr[,cidr…] | OK | subnet router advertisement; lesson 11, ignore for now |
PING | PONG | every 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_END | end 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.
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.
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.
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.
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.
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.
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.
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.