Build a mesh VPN · lesson 12

Shipping it

It works in the lab. Now it is late, one laptop is on hotel wifi, and ping prints nothing. This lesson is about what you look at, how the daemon stays up, and every hop a ping takes from one program to another. Then the gaps, named.

in progress
18 min interactive

Ping prints nothing. Now what?

Every failure you have seen so far happened in a terminal where you could read the daemon's stderr. A node you actually use does not run in a terminal. It starts at boot, survives you closing the lid, and when ping 100.64.0.2 hangs you need to know, in one command, which of eight things went wrong: no overlay address, coordd unreachable, no peer entry, no candidates, punching, relayed, session expired, or a packet dropped by the source check. All eight are fields in struct node and struct peer. They are in the daemon's memory and nowhere else. So the daemon has to hand them out.

meshd status: a unix socket that dumps the tables

status.c is forty lines. In cmd_up, after coordd has answered, status_listen binds a SOCK_STREAM unix socket at /run/meshd.sock, or wherever $MESHD_SOCK points, and the fd goes into the poll set as the fourth entry. A unix socket is an fd like any other, which is why the loop needed no new mechanism. When it becomes readable, one connection is accepted, the tables are printed into it with fprintf, and it is closed:

c
// src/status.c const char *status_sock_path(void) { const char *env = getenv("MESHD_SOCK"); return env && *env ? env : "/run/meshd.sock"; } static const char *path_name(const struct peer *p) { if (p->via_relay) return "via_relay"; if (p->endpoint_ok) return "direct"; return "none"; } void status_on_readable(struct node *n, int64_t now) { int fd = accept(n->status_fd, NULL, NULL); if (fd < 0) return; FILE *out = fdopen(fd, "w"); char ip[32], ep[32]; fprintf(out, "node %s pub %s udp %u", format_ip(n->overlay_ip, ip, sizeof ip), n->pk_b64, n->port); if (n->have_public) fprintf(out, " public %s", format_endpoint(&n->public_ep, ep, sizeof ep)); if (n->have_relay) fprintf(out, " relay %s", format_endpoint(&n->relay, ep, sizeof ep)); fprintf(out, " control %s\n", n->ctrl.fd >= 0 ? "connected" : "disconnected"); fprintf(out, "peers %d\n", n->peers.n); for (int i = 0; i < n->peers.n; i++) dump_peer(out, &n->peers.peers[i], now); fprintf(out, "drops no_peer %llu bad_src %llu bad_auth %llu no_session %llu\n", (unsigned long long)n->drops.no_peer, (unsigned long long)n->drops.bad_src, (unsigned long long)n->drops.bad_auth, (unsigned long long)n->drops.no_session); fclose(out); /* also closes fd */ } int status_query(const char *path) { // socket(AF_UNIX) + connect(path) elided — see the repo char buf[4096]; ssize_t r; while ((r = read(fd, buf, sizeof buf)) > 0) fwrite(buf, 1, (size_t)r, stdout); close(fd); return 0; }

meshd status is the client half: connect, copy to stdout until read returns 0, exit. No protocol, no parsing; the daemon's fclose is the EOF the client waits for. tailscale status and wg show have the same shape, plus a JSON mode for scripts.

Read the output in order and it answers the eight questions. control disconnected: coordd is unreachable and control_connect retries every 5 s. peers 0: nobody was introduced, or an ACL hid them. path none with candidates listed: punching is in progress or failed with no relay. session 175s: a rekey due at 120 s did not happen. last_rx 80s: the path dies in ten more seconds. The drops line names which of the four rejection points in meshd.c is counting.

💡
Expose state, not opinions. status.c prints fields; it does not say "unhealthy". Every number maps to one line in the source, so a reader of the output and a reader of the code are looking at the same thing. Diagnoses go in your head or in a script, where they can be wrong without lying to the next person.

Logging

util.c has one function, vlog, behind log_info, log_warn, log_err and die. It writes to stderr: a wall-clock timestamp with milliseconds, a level, the message, a newline. Nothing else. Under systemd, stderr is the journal, which adds the unit name and a stable cursor, so the daemon does not need a log file, rotation or a config option for either.

Every log line in meshd marks a state change: an endpoint learned, a peer added or removed, a session established and how, a path dropped, a fallback to the relay. Per-packet events are counters in n->drops instead; at a thousand packets a second, a log line per packet is a denial of service you run against yourself.

A systemd unit

Install the binary and the key, then write this file. It is complete and it works.

ini
# /etc/systemd/system/meshd.service [Unit] Description=mesh VPN node After=network-online.target Wants=network-online.target [Service] Type=simple EnvironmentFile=/etc/mesh/env ExecStart=/usr/local/bin/meshd up --key /etc/mesh/private.key \ --coord ${MESH_COORD} --authkey ${MESH_AUTHKEY} --relay ${MESH_RELAY} Restart=on-failure RestartSec=3 RuntimeDirectory=mesh Environment=MESHD_SOCK=/run/mesh/meshd.sock [Install] WantedBy=multi-user.target

/etc/mesh/env holds three lines, MESH_COORD=vpn.example.com:7000, MESH_AUTHKEY=…, MESH_RELAY=vpn.example.com:7002, with mode 0600, so the secret is not in a file every user can read. Restart=on-failure covers the case where coordd is down at boot: control_connect fails, die exits 1, systemd tries again in three seconds, forever. RuntimeDirectory creates /run/mesh at start and removes it at stop, and the Environment line puts the status socket inside it. For meshd status to find it, either export MESHD_SOCK in your shell or run MESHD_SOCK=/run/mesh/meshd.sock meshd status.

The unit runs as root. tun_open needs CAP_NET_ADMIN to create the interface, and so does every ip command system() runs. You could add User=mesh and AmbientCapabilities=CAP_NET_ADMIN; the capability is inherited by the ip child, so that part works, but the /dev/net/tun device node must also be readable by that user. Tailscale runs tailscaled as root for the same reasons.

⚠️
The auth key is still in argv. systemd expands ${MESH_AUTHKEY} before exec, so ps shows it to every local user. A product reads secrets from a file or a socket, never a flag. meshd only has --authkey, so this unit is as good as the binary allows; adding --authkey-file to parse_up_args is a ten-line change you should make before letting anyone else log in to the machine.

Three machines

The lab put coordd, relayd and two nodes on one host. The real layout is one public host and two nodes anywhere. The public host needs three open ports: TCP 7000 for the control connection, UDP 7001 for STUN, UDP 7002 for the relay. meshd assumes the STUN port is the coordd TCP port plus one, so keep them adjacent. Nothing needs to be open on either node, which was the whole point of lessons 08 through 10.

sh
# on the public host, vpn.example.com (as root or with the ports forwarded) make ./coordd --listen 0.0.0.0:7000 --udp 0.0.0.0:7001 --authkey tskey-example-secret \ --acl acl.conf --db nodes.db & ./relayd --listen 0.0.0.0:7002 & ufw allow 7000/tcp; ufw allow 7001/udp; ufw allow 7002/udp # on each laptop, once make && sudo install -m 755 meshd /usr/local/bin/meshd sudo mkdir -p /etc/mesh && sudo ./meshd genkey /etc/mesh/private.key sudo ./meshd pubkey < /etc/mesh/private.key # paste into acl.conf if you use one sudo systemctl daemon-reload && sudo systemctl enable --now meshd # watch it join, then look at the tables journalctl -u meshd -f MESHD_SOCK=/run/mesh/meshd.sock sudo meshd status ping 100.64.0.2

coordd and relayd want units of their own, the same shape as above with a WorkingDirectory for nodes.db. The first laptop gets 100.64.0.1, the second 100.64.0.2, and each keeps its address across reboots because nodes.db remembers the key.

The whole journey

You have now written every function a ping touches. Step through the viz and read the function names alongside it. Sixteen hops; your code is nine of them, the kernel is the rest.

packet journey — ping 100.64.0.2, end to end
Click next to start the walkthrough.
  1. ping writes 64 bytes to an ICMP socket; the kernel prepends a 20-byte IPv4 header. 84 bytes.
  2. Kernel routing: 100.64.0.0/10 dev tun0, installed by tun_configure, beats the default route.
  3. on_tun_readable: one read on the tun fd returns the 84 bytes; ipv4_valid checks version and length.
  4. lookup_by_overlay_ip(ipv4_dst(pkt)): longest-prefix match over every peer's allowed[]. Cryptokey routing, outbound half.
  5. path_send_data → session_encrypt: 13-byte header with the receiver's index and the counter, then ciphertext and tag. 113 bytes.
  6. path_send → udp_send: the kernel wraps it in UDP and IPv4 and routes the outer destination out eth0.
  7. NAT-A rewrites the source address and port using the row A's own earlier packets created.
  8. The internet carries two public endpoints, a size, a time and a type byte. Nothing else is readable.
  9. NAT-B rewrites the destination back to B's LAN address, through the row B punched toward A.
  10. B's kernel strips the outer headers; on_udp_readable gets the same 113 bytes A produced.
  11. handle_packet: one switch on byte 0. Type 3.
  12. lookup_by_rx_index: B's own index from the header names the session, and so the peer and the rx key.
  13. session_decrypt: the counter must be new under the replay window; the tag must verify before a byte of plaintext exists.
  14. peer_allows_src(p, ipv4_src(plain)): the inner source must be one of the addresses coordd assigned to this key. Cryptokey routing, inbound half.
  15. write(n->tun_fd, plain, plen): the kernel believes a packet arrived on tun0.
  16. The kernel delivers it to the ICMP code, which builds the echo reply. Fifteen hops back, and ping prints a line.

That list is also the checklist for the question that opened this lesson: steps 2 and 15 are ip route, steps 4, 12, 13 and 14 are the drop counters, steps 7 and 9 are the path and candidates fields. No hop is unobservable from a shell on one of the two machines.

What this is not

Each lesson named its simplification as it made it. Here they are in one place, with what WireGuard and Tailscale do instead, so that when you read their code you recognize what each piece is for.

this repo the real systems
Two-message key exchange with crypto_box around an ephemeral key (lesson 06) WireGuard uses Noise-IK: a specified handshake pattern with proofs, mixing static and ephemeral DH into a running hash so every message authenticates the whole transcript.
The sender's static public key is the first 32 bytes of every INIT, in the clear Noise-IK encrypts the initiator's static key to the responder's, so a passive observer cannot learn who is connecting. Identity hiding.
Every INIT costs the responder a DH and a crypto_box_open Under load WireGuard replies with a MAC'd cookie that the initiator must echo, so an attacker cannot spend the responder's CPU from forged addresses.
Relay registration is 33 unauthenticated bytes (lesson 10) DERP clients prove possession of their key over TLS before the relay forwards for it.
One relay over UDP, probing stops once relayed A mesh of DERP servers in regions over HTTPS/443, forwarding relay-to-relay; the first packet goes via DERP and direct probing continues until the path upgrades.
Addresses are numbers you remember MagicDNS: the control plane knows every node's name, and tailscaled answers DNS for them locally.
A key is valid until removed from nodes.db (lesson 01 table, last row) Key expiry: node keys have a lifetime and must be re-authenticated; a stolen laptop stops working on its own.
A shared --authkey string SSO: a node joins by logging in through an identity provider (OIDC), and the ACL speaks in users and groups, not keys.
allow key key, symmetric, all-or-nothing (lesson 11) Directional rules with ports and protocols, compiled into a per-node packet filter applied after decryption.
system("ip addr …") and system("ip route …") Netlink sockets: the same kernel API the ip binary uses, called directly, with errors you can read as errno instead of an exit status.
IPv4 only, inner and outer; ipv4_valid drops the kernel's IPv6 packets Dual-stack both ways: an fd7a:115c:a1e0::/48 overlay and IPv6 endpoints, which often need no NAT traversal at all.
Linux, /dev/net/tun, poll wireguard-go runs the same protocol in userspace on macOS (utun), Windows (Wintun), iOS and Android; the Linux kernel module does it in-kernel.

None of these are mysteries now. Each is a function you would add next to one you already wrote: cookies next to handshake_handle_init, the packet filter next to peer_allows_src, netlink in place of set_route, key expiry as one more column in nodes.db. The architecture does not change. That is what it means to have built the real thing small.

✅
Use the real one for anything that matters. This repo is for reading and for understanding what WireGuard and Tailscale are doing when they work and when they do not. Its handshake has not been reviewed, its relay trusts anyone, and its keys never expire. Run it between your own machines to learn; run WireGuard or Tailscale between machines you are responsible for.

Reading list

  • Jason A. Donenfeld, WireGuard: Next Generation Kernel Network Tunnel (NDSS 2017). Twenty pages. You will recognize every section: cryptokey routing, the handshake, the counter nonce, the sliding window, cookies, roaming.
  • David Anderson, How NAT traversal works (Tailscale, 2020). Lessons 08 through 10 of this course in long form, including the cases this repo does not handle: hairpinning, NAT64, and why birthday attacks on symmetric NATs sometimes work.
  • Trevor Perrin, The Noise Protocol Framework. Read section 7 for the handshake patterns and find IK; then reread handshake.c and list what it lacks.
  • RFC 1918 (private address space) and RFC 6598 (100.64.0.0/10, shared address space). Why the overlay lives where it does, and why it never collides with your office LAN.
  • RFC 4787, NAT behavioral requirements for UDP. The vocabulary (endpoint-independent mapping, address-and-port-dependent filtering) behind the four NAT kinds you met in the networking track.
  • The project repo. Read src/meshd.c top to bottom one more time. It should take ten minutes now.

Run it

The last run is the one on real machines, which is the sequence in the three-machines section above. The checks are the lab's checks, done by hand on two laptops behind two different NATs:

sh
# laptop 1 sudo systemctl enable --now meshd journalctl -u meshd -n 6 --no-pager MESHD_SOCK=/run/mesh/meshd.sock sudo meshd status ping -c 3 100.64.0.2
sh
Oct 19 23:41:07 laptop1 meshd[4121]: 23:41:07.102 info meshd starting, identity mK3f9Qx2bV7LwN0sPq4RtY8uZi1Jh6GdE5aFcXo2nMk= Oct 19 23:41:07 laptop1 meshd[4121]: 23:41:07.139 info stun: public endpoint 198.51.100.4:40001 Oct 19 23:41:07 laptop1 meshd[4121]: 23:41:07.188 info control: assigned overlay address 100.64.0.1 Oct 19 23:41:07 laptop1 meshd[4121]: 23:41:07.190 info control: peer 100.64.0.2 added (2 candidates, 1 allowed) Oct 19 23:41:08 laptop1 meshd[4121]: 23:41:08.412 info peer 100.64.0.2 now at 203.0.113.50:41200 Oct 19 23:41:08 laptop1 meshd[4121]: 23:41:08.413 info session with 100.64.0.2 established (initiator, direct) node 100.64.0.1 pub mK3f9Qx2bV7LwN0sPq4RtY8uZi1Jh6GdE5aFcXo2nMk= udp 51820 public 198.51.100.4:40001 relay 192.0.2.99:7002 control connected peers 1 peer 100.64.0.2 Zr8pLw1TqX5vB9cD3eF7gH2jK4mN6oP8rS0tU1wV3yA= path direct endpoint 203.0.113.50:41200 session 21s tx 4 rx 4 last_rx 3s allowed 100.64.0.2/32 candidates 192.168.1.7:51820,203.0.113.50:41200 drops no_peer 0 bad_src 0 bad_auth 0 no_session 1 64 bytes from 100.64.0.2: icmp_seq=1 ttl=64 time=38.2 ms 64 bytes from 100.64.0.2: icmp_seq=2 ttl=64 time=37.6 ms 64 bytes from 100.64.0.2: icmp_seq=3 ttl=64 time=37.9 ms

Six log lines, one per state change, 1.3 seconds from start to an established direct session through two NATs neither of you controls. The 38 ms is the real distance between the two laptops; the relay at 192.0.2.99 was configured and never used. tcpdump -i any udp port 51820 on either side shows type-3 packets and nothing readable after byte 13.

That is the course. You started with a program that read a packet from a file descriptor and sent it in the clear, and you finished with three programs that find each other through NATs, prove who they are with keys, encrypt everything, route by prefix, say no by policy, and tell you what they are doing when asked. Every one of those properties is a function you can point to. The next time something says "secure" or "mesh" or "zero-config", you know which functions to go looking for.

one-line takeaway

A ping from one laptop to another is sixteen hops through code you can now read, nine of them yours; the real systems differ from this one in the gaps you can name, not in the shape.