ping works. tcpdump on the wire shows your ICMP payload in the clear.
In the TUN lesson of the networking track you wrote a loop: read a packet
from tun0, sendto it over UDP, recvfrom the peer's
packets, write them to tun0. Run it on two machines and
ping 100.64.0.2 gets replies. Then run tcpdump -X on the real
interface, and there it is: inside each UDP datagram, a complete IP header, the ICMP
header, and the eight bytes of pattern that ping stuffs into the payload.
Anyone between the two machines reads it. Anyone who can reach your UDP port writes
packets into your kernel.
Those two problems are what lessons 03 to 06 fix. Before fixing them, pin down exactly
what the fix wraps. The repo's meshd has no plaintext mode, on purpose: there
must never be a build of it that leaks. So this lesson's program is a 45-line
naked.c that you write, linked against the repo's src/tun.c and
src/util.c. The functions are the real ones. Only the glue is temporary.
tun.c: six functions, one of them a shell command
tun_open is the networking lesson's code with error logging.
open("/dev/net/tun"), then ioctl(TUNSETIFF) with
IFF_TUN | IFF_NO_PI: IP packets, not Ethernet frames, and no 4-byte
packet-info prefix, so byte 0 of every read is the IP version nibble.
tun_configure does what you typed by hand in the networking lesson, from C,
through system(). Tailscale uses netlink for this; the shell commands are
here because you can read them in a log. The prefix is always 10: that one
ip addr installs the 100.64.0.0/10 dev tun0 route, and from then
on the kernel delivers every overlay packet into your read.
The MTU is 1400, from TUN_MTU in wire.h. The inner
packet you read will later be wrapped in a 13-byte header plus a 16-byte tag (lesson 05),
then 8 bytes of UDP and 20 of outer IP, and sometimes a 33-byte relay header (lesson 10).
That is 90 bytes of overhead on a 1500-byte link; 1400 leaves room for all of it and some
slack. WireGuard uses 1420 because it has no relay layer.
ipv4_valid is the gatekeeper. Every byte that enters the daemon from either
direction passes through it before any field is read. It checks four things: at least 20
bytes, version nibble 4, a header length that makes sense, and a total length that fits
inside what was actually read. ipv4_src, ipv4_dst and
ipv4_proto then read offsets 12, 16 and 9: the header you parsed byte by byte
in networking lesson 01.
tun0 comes up, Linux sends IPv6 router solicitations and multicast listener
reports through it. Their version nibble is 6. Without ipv4_valid you would
read offset 16 of an IPv6 header and get half of a 128-bit address, then try to route
it. The real daemon counts and drops these silently; so does naked.c.
Endpoints in host byte order
util.h defines struct endpoint { uint32_t ip; uint16_t port; },
and both fields are in host byte order. That is a deliberate break from the
sockaddr_in habit. The daemon compares endpoints, prints them, stores them in
the peer table and parses them from text constantly; doing all of that in network order
means ntohs in every printf and a bug the first time you forget.
Instead the conversion happens in exactly two functions, endpoint_to_sockaddr
and endpoint_from_sockaddr, at the sendto and
recvfrom boundary. udp_send(fd, &ep, buf, len) wraps the
first; you will see it in every lesson from here on.
The loop
Here is naked.c, the whole file. It opens and configures the TUN, binds a UDP
socket on the port you give it, and runs the two-fd poll loop from the
networking track, with the repo's functions in the places where the networking version
had raw syscalls.
You saw this frame in the networking track. Step through it again, and this time read
the daemon's two file descriptors as tun and udp in the code
above: the 30-byte inner packet is what read(tun) returns, and
udp_send is the moment it becomes somebody else's payload.
What meshd does differently, line by line
Now put the real daemon's handlers next to it. cmd_up in
src/meshd.c polls four descriptors, not two: the TUN, the UDP socket, the TCP
connection to coordd (lesson 07) and a unix socket for
meshd status (lesson 12). The two that matter today are the same two fds you
just saw, and their handlers differ from naked.c in exactly the places marked
with arrows.
Read the shape, not the names. Outbound: read, validate, decide who this is for,
encrypt and send to wherever that peer currently is. Inbound: decide who
sent this, decrypt and check the tag, check they are allowed to send
from that address, write. Four decisions, four lessons. The read and the write at
either end are the same two syscalls as naked.c, and ipv4_valid
guards both of them. The first byte of a received datagram is now a packet type, which is
why there is a get_u32le(pkt + 1): the inner IP packet no longer starts at
byte 0 of the datagram. From lesson 05 on, nothing on the wire does.
write(tun, pkt)
in naked.c accepts a packet from any address that sends to your UDP port and
hands it to the kernel as if it arrived on tun0. A stranger can address a
TCP SYN to 100.64.0.1:22, with any source they like, and your kernel will
process it. Never run this on an interface reachable from the internet. The lab below
uses two namespaces joined by a private cable precisely so that nothing else can reach
the port.
naked.c to the final meshd to
wireguard-go, is poll on a TUN fd and a UDP fd, with a function between
read and sendto. What the course adds is that function.
Run it
Two network namespaces, n1 and n2, joined by a veth pair: a
virtual cable with 192.168.100.1 on one end and 192.168.100.2 on
the other. Those are the "real" addresses. Each namespace runs one naked with
an overlay address and the other end's real address as its peer. make first,
so src/tun.o and src/util.o exist; util.o pulls in
libsodium for its base64 helpers, hence -lsodium even though
naked.c encrypts nothing. Everything below needs root.
Byte 28 of the datagram, where UDP's payload begins, is 45: version 4, header
length 5. Sixteen bytes later: 6440 0001, which is 100.64.0.1.
Then 0800, an echo request, and the pattern you asked ping for,
readable as ASCII in the right-hand column. In the networking lesson this was a diagram.
Here it is your traffic on a cable, and anyone holding the cable has it. The repo's
test/run.sh runs this same capture against the finished meshd
and fails the build if MESHMESH appears on the wire even once. By lesson 05
it will not.
Clean up with sudo pkill naked; sudo ip netns del n1; sudo ip netns del n2.
Deleting a namespace removes its interfaces, including tun0.
The tunnel is read(tun), udp_send, recv, write(tun) around a poll; everything the next ten lessons add goes into the gap between the read and the send.