Learn Zig Series (#94) - NAT Traversal and Hole Punching

Words
4167
Reading
19 min
Listen
Play
3M

Learn Zig Series (#94) - NAT Traversal and Hole Punching

zig.png

What will I learn?

  • What a NAT actually is once you stop fearing it -- a box on your router that rewrites the source address and port of every packet on the way out, and remembers the swap so replies find their way home;
  • Why two machines both behind NAT cannot simply dial each other, even though either one can reach a public server fine;
  • How STUN lets a machine ask "what does the outside world see as my address and port?" -- and how to build and parse a STUN binding request from the raw bytes;
  • How the XOR-MAPPED-ADDRESS attribute hides your endpoint behind the magic cookie, and how to un-XOR it correctly;
  • The four flavours of NAT behaviour, and which single one (symmetric) quietly defeats hole punching;
  • How UDP hole punching works: both peers fire packets at each other at the same time so each side's outbound packet opens the door for the other's inbound packet;
  • Why a rendezvous server is unavoidable, and how little it actually does;
  • How a hand-rolled Zig punch compares to the heavy ICE machinery you'd reach for in C, Rust, or Go.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Zig 0.14+ distribution (download from ziglang.org);
  • The ambition to learn Zig programming.

Difficulty

  • Intermediate

Curriculum (of the Learn Zig Series):

Learn Zig Series (#94) - NAT Traversal and Hole Punching

Solutions to Episode 93 Exercises

Last episode we built a SOCKS5 proxy from its three honest phases -- a method greeting, a single CONNECT request, and a dumb bidirectional relay. The exercises pushed it toward something you'd actually deploy: username/password auth, a connection budget, and the client half of the protocol. Each solution leans on the same readExact discipline we've used since the DNS episodes -- every read bounds-checked, because a network-facing parser talks to hostile peers by definition.

Exercise 1: Add username/password authentication

After the no-auth greeting, RFC 1929 defines its own little subnegotiation: [version=0x01][ulen][username][plen][password], and the server answers [0x01][status] where status 0 means success. The whole thing is four readExact calls and a constant-time-ish comparison:

const std = @import("std");

fn readExact(stream: anytype, buf: []u8) !void {
    var n: usize = 0;
    while (n < buf.len) {
        const got = try stream.read(buf[n..]);
        if (got == 0) return error.UnexpectedEof; // short read from a hostile client
        n += got;
    }
}

const AUTH_VERSION: u8 = 0x01;

// RFC 1929 subnegotiation. Returns true only if the credential matches.
fn authenticate(stream: anytype, want_user: []const u8, want_pass: []const u8) !bool {
    var head: [2]u8 = undefined;
    try readExact(stream, &head); // [version=0x01][ulen]
    if (head[0] != AUTH_VERSION) return error.BadVersion;

    var ubuf: [255]u8 = undefined; // ulen is one byte, so 255 is the hard ceiling
    try readExact(stream, ubuf[0..head[1]]);

    var plen: [1]u8 = undefined;
    try readExact(stream, &plen);
    var pbuf: [255]u8 = undefined;
    try readExact(stream, pbuf[0..plen[0]]);

    const ok = std.mem.eql(u8, ubuf[0..head[1]], want_user) and
        std.mem.eql(u8, pbuf[0..plen[0]], want_pass);
    try stream.writeAll(&[_]u8{ AUTH_VERSION, if (ok) 0x00 else 0x01 });
    return ok;
}

// A read+write stream backed by memory, so we can drive the whole exchange offline.
const MemStream = struct {
    fbs: std.io.FixedBufferStream([]const u8),
    out: std.ArrayListUnmanaged(u8) = .{},
    alloc: std.mem.Allocator,
    fn read(self: *MemStream, buf: []u8) !usize { return self.fbs.reader().read(buf); }
    fn writeAll(self: *MemStream, b: []const u8) !void { try self.out.appendSlice(self.alloc, b); }
};

test "auth accepts the right password and rejects the wrong one" {
    const alloc = std.testing.allocator;
    const wire = [_]u8{ 0x01, 6 } ++ "scipio".* ++ [_]u8{3} ++ "zig".*;

    var good = MemStream{ .fbs = std.io.fixedBufferStream(@as([]const u8, &wire)), .alloc = alloc };
    defer good.out.deinit(alloc);
    try std.testing.expect(try authenticate(&good, "scipio", "zig"));
    try std.testing.expectEqualSlices(u8, &[_]u8{ 0x01, 0x00 }, good.out.items);

    var bad = MemStream{ .fbs = std.io.fixedBufferStream(@as([]const u8, &wire)), .alloc = alloc };
    defer bad.out.deinit(alloc);
    try std.testing.expect(!try authenticate(&bad, "scipio", "WRONG"));
    try std.testing.expectEqualSlices(u8, &[_]u8{ 0x01, 0x01 }, bad.out.items);
}

The key insight is the MemStream shim: because authenticate takes anytype, the same code drives a real std.net.Stream in production and an in-memory buffer in the test, so the entire greeting-plus-auth dance gets exercised without a socket (episode 12's discipline, again).

Exercise 2: Enforce a connection budget

An unlimited proxy is a denial-of-service waiting to happen. A single atomic counter (episode 30) gives every connection a cheap "may I come in?" check with no lock:

const std = @import("std");

const ConnLimiter = struct {
    active: std.atomic.Value(u32) = std.atomic.Value(u32).init(0),
    max: u32,

    // Optimistically claim a slot; back out atomically if we overshot the ceiling.
    fn acquire(self: *ConnLimiter) bool {
        const prev = self.active.fetchAdd(1, .acq_rel);
        if (prev >= self.max) {
            _ = self.active.fetchSub(1, .acq_rel); // we never really got in
            return false;
        }
        return true;
    }
    fn release(self: *ConnLimiter) void {
        _ = self.active.fetchSub(1, .acq_rel);
    }
};

test "limiter admits up to max, refuses the next, recovers on release" {
    var lim = ConnLimiter{ .max = 2 };
    try std.testing.expect(lim.acquire());  // 1 of 2
    try std.testing.expect(lim.acquire());  // 2 of 2
    try std.testing.expect(!lim.acquire()); // full -> refused
    lim.release();
    try std.testing.expect(lim.acquire());  // a slot freed up
}

In handleClient you'd call lim.acquire() first thing; if it returns false you send reply 0x01 (general failure) and close before relaying, and you defer lim.release() so the slot comes back no matter which exit path the handler takes. The optimistic add-then-subtract avoids a compare-and-swap loop -- under contention a couple of connections briefly see the counter above max and bounce, which is exactly the behaviour you want.

Exercise 3: Build a SOCKS5 client

Writing the other half is the most satisfying test of your own server. connectThrough performs the greeting, sends a domain-name CONNECT, validates the reply code into a typed ProxyError, and drains the bound-address tail so the returned stream starts exactly at the tunnel:

const std = @import("std");

const ProxyError = error{
    GeneralFailure, ConnectionNotAllowed, NetworkUnreachable,
    HostUnreachable, ConnectionRefused, CommandNotSupported, AddressTypeNotSupported,
};

fn replyToError(code: u8) ?ProxyError {
    return switch (code) {
        0x00 => null,
        0x02 => error.ConnectionNotAllowed,
        0x03 => error.NetworkUnreachable,
        0x04 => error.HostUnreachable,
        0x05 => error.ConnectionRefused,
        0x07 => error.CommandNotSupported,
        0x08 => error.AddressTypeNotSupported,
        else => error.GeneralFailure,
    };
}

fn connectThrough(proxy: std.net.Address, host: []const u8, port: u16) !std.net.Stream {
    std.debug.assert(host.len <= 255); // domain length is a single byte on the wire
    const stream = try std.net.tcpConnectToAddress(proxy);
    errdefer stream.close();

    try stream.writeAll(&[_]u8{ 0x05, 0x01, 0x00 }); // greeting: 1 method, no-auth
    var greet: [2]u8 = undefined;
    try readExact(stream, &greet);
    if (greet[0] != 0x05 or greet[1] != 0x00) return error.NoAcceptableMethod;

    var req: std.BoundedArray(u8, 262) = .{};
    try req.appendSlice(&[_]u8{ 0x05, 0x01, 0x00, 0x03, @intCast(host.len) }); // CONNECT, domain
    try req.appendSlice(host);
    var port_be: [2]u8 = undefined;
    std.mem.writeInt(u16, &port_be, port, .big);
    try req.appendSlice(&port_be);
    try stream.writeAll(req.slice());

    var head: [4]u8 = undefined; // [ver][code][rsv][atyp]
    try readExact(stream, &head);
    if (replyToError(head[1])) |e| return e;

    const tail: usize = switch (head[3]) { // skip the bound address we don't need
        0x01 => 4 + 2,
        0x04 => 16 + 2,
        0x03 => blk: {
            var l: [1]u8 = undefined;
            try readExact(stream, &l);
            break :blk l[0] + @as(usize, 2);
        },
        else => return error.AddressTypeNotSupported,
    };
    var scratch: [256]u8 = undefined;
    try readExact(stream, scratch[0..tail]);
    return stream; // live tunnel, ready to use
}

The honest part is replyToError: a non-zero reply does not become a vague "connection failed", it becomes the specific ProxyError member, so the caller can branch on error.ConnectionRefused versus error.HostUnreachable and mean it. Point this client at your own proxy from episode 93 over localhost and the two validating each other is, as promised, the nicest test in the whole exercise set.

Learn Zig Series (#94) - NAT Traversal and Hole Punching

Here we go ;-) At the very end of episode 93 I left you with a deliberately uncomfortable question. A proxy solves "the client can't reach the server" by inserting a machine that can reach both. But that only works because the proxy itself has a clean, publicly reachable address -- somebody, somewhere, has to be dialable from the open internet. So what happens when neither peer does? Two laptops, each behind a home router, each with a private address its router rewrites on the way out. No friendly middleman either of them can reach. That, jonguh, is the genuinely hard version of "two machines find each other", and the box doing the rewriting is the NAT.

This episode is about defeating that box -- not with a proxy, but by tricking the two NATs into letting the peers talk directly. The technique is called hole punching, and the first time you watch it work it feels like cheating. Two machines that provably cannot reach each other suddenly can, and no traffic flows through any server in the middle. Let's understand exactly why, byte by byte.

The box that rewrites your address

Your laptop almost certainly has a private address -- something like 192.168.1.42 -- that is meaningless on the public internet (millions of laptops have that same address right now). When you open a TCP or UDP connection out, your router performs Network Address Translation: it rewrites the source address to its own public IP, picks a public source port, and writes down the mapping in a table:

(192.168.1.42 : 51000)  <->  (203.0.113.7 : 49152)

Replies coming back to 203.0.113.7:49152 get the swap reversed and delivered to your laptop. This is the same source-rewriting trick a proxy does explicitly, except your router does it transparently to every packet, and -- crucially -- it only creates an inbound mapping after you send an outbound packet. The table entry is born from your traffic leaving. Nobody on the outside can create one for you.

That last sentence is the entire problem, and the entire solution. A NAT will happily forward a reply to a destination you already started talking to. It will silently drop a packet from a stranger you never contacted. So both peers are unreachable strangers to each other -- until they aren't.

Why two NATs can't shake hands

Picture peer A behind NAT-A and peer B behind NAT-B. A wants to send B a packet. But A only knows B's private address (useless), or at best B's public mapping -- and even if A fires a packet at B-public:port, NAT-B has no table entry for A yet, so it drops the packet as unsolicited junk. The symmetric thing happens to B. Each side is waiting for an inbound packet that the other side's NAT refuses to forward. Deadlock.

The escape is timing. If A sends to B and B sends to A at roughly the same moment, then A's outbound packet creates a mapping in NAT-A that says "I'm expecting replies from B-public", and B's outbound packet does the mirror in NAT-B. Now when A's next packet arrives at NAT-B, the table entry exists (B's earlier outbound packet created it), so it gets forwarded. The hole is punched from both sides at once. Each peer's own outbound traffic is what opens the door for the other's inbound traffic.

But this raises two questions we have to answer before any of it works. First, how does A even learn B's public mapping, given that B doesn't know its own (the NAT picked the public port, not B)? Second, how do the two peers agree to fire at the same moment when they can't yet talk to each other? The answers are STUN and a rendezvous server, in that order.

STUN: asking "what does the world see?"

A peer cannot know its own public mapping by looking inward -- the kernel only knows the private 192.168.x.x address. The mapping lives in the router. So the peer asks a public server: "when my packet reached you, what source address and port did you see?" That tiny question-and-answer is STUN (Session Traversal Utilities for NAT, RFC 5389), and a binding request is gloriously small -- a 20-byte header and zero attributes:

const std = @import("std");

const MAGIC_COOKIE: u32 = 0x2112A442; // fixed constant that marks every STUN message

// A STUN Binding Request is just a 20-byte header: type, length, cookie, txid.
fn buildBindingRequest(txid: [12]u8) [20]u8 {
    var msg: [20]u8 = undefined;
    std.mem.writeInt(u16, msg[0..2], 0x0001, .big); // message type: Binding Request
    std.mem.writeInt(u16, msg[2..4], 0x0000, .big); // attribute length: none
    std.mem.writeInt(u32, msg[4..8], MAGIC_COOKIE, .big);
    @memcpy(msg[8..20], &txid); // 12-byte transaction id, echoed back in the reply
    return msg;
}

You send those 20 bytes over UDP to a public STUN server (Google runs one at stun.l.google.com:19302, and there are dozens of others), and it replies with a Binding Success Response containing your public mapping. The big-endian writeInt discipline is the same one we've named explicitly in every protocol of this arc (protobuf, DNS, HTTP/2, SOCKS5) -- on the wire, byte order is part of the contract, so we never leave it to chance and cross-compilation (episode 35) never reinterprets it behind our back.

The transaction id matters more than it looks. UDP gives you no connection, so the only way to match a reply to the request that caused it is that 12-byte id echoed verbatim. It's the same idea as the DNS query id from episode 82 -- correlation without a connection.

Reading the XOR-MAPPED-ADDRESS

The response carries your mapping in an attribute called XOR-MAPPED-ADDRESS (type 0x0020). Why XOR? Because some broken old NATs would inspect packets and "helpfully" rewrite any bytes that looked like an IP address, corrupting the very answer STUN was trying to deliver. So STUN obfuscates the address by XOR-ing it with the magic cookie -- the NAT no longer recognises it as an address, but the client can trivially un-scramble it:

const Endpoint = struct { addr: [4]u8, port: u16 };

fn parseBindingResponse(msg: []const u8) !Endpoint {
    if (msg.len < 20) return error.ShortMessage;
    if (std.mem.readInt(u16, msg[0..2], .big) != 0x0101) return error.NotBindingSuccess;
    if (std.mem.readInt(u32, msg[4..8], .big) != MAGIC_COOKIE) return error.BadCookie;

    const attr_len = std.mem.readInt(u16, msg[2..4], .big);
    const end = @min(msg.len, 20 + @as(usize, attr_len));
    var off: usize = 20;
    while (off + 4 <= end) {
        const atyp = std.mem.readInt(u16, msg[off..][0..2], .big);
        const alen = std.mem.readInt(u16, msg[off + 2 ..][0..2], .big);
        if (off + 4 + alen > end) return error.TruncatedAttr;
        if (atyp == 0x0020) return parseXorMapped(msg[off + 4 ..][0..alen]);
        off += 4 + std.mem.alignForward(usize, alen, 4); // attributes are padded to 4 bytes
    }
    return error.NoMappedAddress;
}

fn parseXorMapped(val: []const u8) !Endpoint {
    if (val.len < 8) return error.ShortAttr;
    if (val[1] != 0x01) return error.NotIpv4; // family byte: 0x01 = IPv4
    // Port is XORed with the TOP 16 bits of the cookie; address with the whole cookie.
    const port = std.mem.readInt(u16, val[2..4], .big) ^ @as(u16, @truncate(MAGIC_COOKIE >> 16));
    const addr = std.mem.readInt(u32, val[4..8], .big) ^ MAGIC_COOKIE;
    var out: [4]u8 = undefined;
    std.mem.writeInt(u32, &out, addr, .big);
    return .{ .addr = out, .port = port };
}

The attribute walk is the kind of loop a fuzzer loves to break, so notice the two guards: off + 4 + alen > end catches a length that claims more bytes than the message holds, and the @min clamp keeps a lying attr_len field from sending us reading past the buffer. This is episode 4's philosophy made physical -- a hostile STUN server (or a corrupted packet) gets a named error, never a wild read.

Because this is pure byte parsing with no socket involved, it tests beautifully against a canned response. Let me encode a known mapping -- 203.0.113.5:50000 -- pre-XORed, and assert we recover it:

test "parse a STUN binding success response" {
    // type=0x0101, attr-len=12, cookie, 12-byte txid, then one XOR-MAPPED-ADDRESS attr.
    // The 8 attr-value bytes encode 203.0.113.5:50000 already XORed with the cookie.
    const wire = [_]u8{ 0x01, 0x01, 0x00, 0x0C } ++ // header: success response, 12 attr bytes
        [_]u8{ 0x21, 0x12, 0xA4, 0x42 } ++ // magic cookie
        [_]u8{0xAA} ** 12 ++ // transaction id (arbitrary here)
        [_]u8{ 0x00, 0x20, 0x00, 0x08 } ++ // attr type 0x0020, length 8
        [_]u8{ 0x00, 0x01, 0xE2, 0x42, 0xEA, 0x12, 0xD5, 0x47 }; // family, x-port, x-addr

    const ep = try parseBindingResponse(&wire);
    try std.testing.expectEqual([4]u8{ 203, 0, 113, 5 }, ep.addr);
    try std.testing.expectEqual(@as(u16, 50000), ep.port);
}

If you want to convince yourself those scrambled bytes are right: 50000 is 0xC350, and 0xC350 ^ 0x2112 (the top half of the cookie) is 0xE242. The address XOR works the same way over all four bytes. Un-scrambling is its own inverse, which is the whole charm of XOR.

NAT types, and the one that ruins everything

Here's where honesty matters, because hole punching is not guaranteed to work. NAT behaviour falls into a few buckets, and they decide your fate:

const NatType = enum {
    open,       // no NAT, or full-cone: the mapping accepts packets from anyone
    restricted, // address/port-restricted cone: peer reachable only after we send first
    symmetric,  // a NEW public port per destination -> the punch usually fails
};

The first two are friendly. A restricted-cone NAT (by far the most common home router) keeps one public port for your socket and lets anyone reply to it once you've sent to them first -- which is exactly the precondition hole punching arranges. The killer is symmetric NAT: it assigns a different public port for every distinct destination. So the mapping STUN discovered (when you talked to the STUN server) is useless for talking to your peer, because the NAT will mint a fresh, unpredictable port the moment you aim at a new address. When either side is symmetric, the port you tell your peer to aim at is already wrong.

Detecting your type means sending binding requests to two different STUN servers and comparing the mappings: same public port for both means cone (good), different ports means symmetric (bad). When the punch can't work, production systems fall back to a relay -- which is exactly the proxy from episode 93, wearing the name TURN. Having said that, real-world numbers are encouraging: somewhere around 80-90% of peer pairs can punch successfully, and only the unlucky symmetric-to-symmetric combinations are forced onto a relay. The proxy never dies; it just becomes plan B.

The rendezvous: introducing two strangers

The peers still can't talk to each other to coordinate the punch -- that's the whole problem. So we need one publicly reachable machine they can both reach: a rendezvous (or signaling) server. Its job is almost insultingly small. Each peer runs STUN, learns its own public endpoint, and uploads those six bytes to the rendezvous. The server pairs them up and hands each peer the other's endpoint. Then it gets out of the way -- no application data ever flows through it:

// The endpoints get swapped through the rendezvous as six flat bytes: 4 addr + 2 port.
fn encodeEndpoint(ep: Endpoint) [6]u8 {
    var out: [6]u8 = undefined;
    @memcpy(out[0..4], &ep.addr);
    std.mem.writeInt(u16, out[4..6], ep.port, .big);
    return out;
}

fn decodeEndpoint(b: [6]u8) std.net.Address {
    return std.net.Address.initIp4(b[0..4].*, std.mem.readInt(u16, b[4..6], .big));
}

test "endpoint round-trips through the wire form" {
    const ep = Endpoint{ .addr = .{ 203, 0, 113, 5 }, .port = 50000 };
    const back = decodeEndpoint(encodeEndpoint(ep));
    try std.testing.expectEqual(@as(u16, 50000), back.getPort());
}

Notice this is the same six-byte endpoint encoding STUN handed us, and the same one we'll feed straight into the punch. One representation, three uses (discover, exchange, punch) -- the parse-once-pass-around discipline we've kept since the file-sync project. The rendezvous itself is just a tiny TCP server (the accept loop from episode 51) holding a HashMap of waiting peers (episode 22); it touches none of the bytes the peers will eventually exchange.

The punch

Now the moment of truth. Both peers, having swapped public endpoints, fire UDP packets at each other from the same socket they used for STUN. That detail is non-negotiable: the mapping STUN discovered belongs to that specific local port, so the punch must reuse it (set SO_REUSEADDR and bind the same local port for both). Each side sends and listens in a tight loop -- the outbound packets open the local NAT's door, and within a few round trips an inbound packet slips through:

// Both peers call this with the other's public endpoint. The socket is non-blocking
// and bound to the SAME local port used for the STUN request earlier.
fn punch(sock: std.posix.socket_t, peer: std.net.Address) !void {
    const hello = "PUNCH"; // any small payload; we only care that a packet arrives
    var attempt: u32 = 0;
    while (attempt < 50) : (attempt += 1) {
        // Always send: each outbound packet (re)opens our own NAT mapping toward the peer.
        _ = std.posix.sendto(sock, hello, 0, &peer.any, peer.getOsSockLen()) catch {};

        var buf: [64]u8 = undefined;
        var from: std.posix.sockaddr = undefined;
        var len: std.posix.socklen_t = @sizeOf(std.posix.sockaddr);
        const n = std.posix.recvfrom(sock, &buf, 0, &from, &len) catch |e| switch (e) {
            error.WouldBlock => { // nothing through yet -- wait a tick and fire again
                std.time.sleep(50 * std.time.ns_per_ms);
                continue;
            },
            else => return e,
        };
        if (n > 0) return; // a packet from the peer means BOTH holes are open
    }
    return error.PunchFailed; // give up; the caller falls back to a relay
}

The shape is symmetric on purpose -- there is no client and no server here, just two equal peers doing the identical thing. The first few packets each side sends will be dropped by the other NAT (no mapping yet), but each dropped packet still created a mapping on the sender's side. After a round or two, both mappings exist, and the next packets sail through in both directions. The WouldBlock handling is why the socket must be non-blocking: a blocking recvfrom would stall, so we couldn't keep sending while we wait, and the simultaneity that makes the whole trick work would be lost.

Once a packet arrives, you've got a direct peer-to-peer UDP path -- and from there it's exactly the territory of episode 81 (datagrams) plus whatever reliability layer you care to build on top. The hole, by the way, is not permanent: NATs expire idle mappings (often after 30 seconds for UDP), so a long-lived peer connection sends a tiny keepalive packet every 15-20 seconds to keep the door from quietly closing. Forget that and your connection mysteriously dies after half a minute of silence -- a bug that has eaten quite some developer evenings.

Performance and reliability considerations

The good news: once the hole is open, there is zero per-packet overhead. No proxy, no relay, no protocol framing -- the data plane is just sendto/recvfrom between two real endpoints, as fast as the network allows. All the cost is in setup: two STUN round trips, a rendezvous exchange, and the punch loop, all of which complete in well under a second on a healthy network.

The reliability story is more nuanced and worth being honest about. Hole punching has a success rate, not a guarantee. Symmetric NATs, carrier-grade NAT (where your ISP NATs you a second time and you never see a usable mapping at all), and corporate firewalls that block UDP outright will all defeat it. A production system therefore always pairs the punch with a relay fallback -- and accidentically shipping without that fallback is the classic mistake, because it works perfectly on your home network and then fails for the 15% of users behind hostile NATs. Measure your real population, then tune how aggressively you retry before giving up (episode 34's profiling rule, applied to a network instead of a CPU).

How this compares to C, Rust, and Go

In C, the STUN parsing and the punch loop look almost identical to ours -- it's all sendto/recvfrom and byte fiddling, which is C's home turf. What C lacks is the compiler holding you to bounds: that attribute-walk loop, where we guarded against a lying alen, is exactly where a real CVE has lived in more than one C STUN library. The footgun is right there.

In Go, you'd lean on pion/stun and most likely pion/ice, which implements the full ICE protocol (RFC 8445) -- the grown-up version of everything in this episode, where each side gathers many candidate addresses, pairs them all up, and runs connectivity checks to find any working path. Goroutines make "send and listen at the same time" trivial. You'd write far less code, at the cost of a dependency that hides the byte-level reality we just made visible.

In Rust, the same story with webrtc-rs or str0m: production-grade ICE, async all the way down, memory-safe, and a fairly heavy dependency tree. Rust's enums would model NatType and the STUN attributes as cleanly as Zig's do.

Zig lands where it keeps landing in this arc: we wrote the binding request, the XOR-MAPPED parse, the endpoint exchange, and the punch loop in a couple hundred readable lines, every byte and every syscall ours, with the compiler refusing to let a malformed attribute become a wild read. You would not ship this as a replacement for full ICE -- real deployments want candidate gathering, TURN fallback, and IPv6 -- but you now understand precisely what those libraries are doing under their polished surface, and that is the point of building it from scratch.

Where this is heading

Step back at what we've assembled across this whole networking arc. We can speak raw TCP and UDP (21, 81), resolve names (82), frame our own protocols (90, 91), proxy connections (93), and now -- the last missing piece -- get two machines behind NAT to talk directly without anything in the middle. That's the complete toolkit for real peer-to-peer software: discovery, transport, framing, and traversal.

Which means it's finally time to stop building isolated pieces and build something whole with them. We've got the protocol-design instincts, the server patterns from the HTTP and key-value episodes, the concurrency from the threads and atomics episodes, and now the networking foundation underneath all of it. The next stretch puts them together into a real, multi-user program you could actually run with your friends -- starting, as every good system does, not with code but with a careful look at the messages the participants will exchange.

The thread running through all of it hasn't moved since episode 21: a protocol is a contract written in bytes. Parse every length like the sender means you harm, name your endianness so no target surprises you, and let the type system carry the shapes so the compiler catches the case you'd otherwise forget. NAT traversal sounds like black magic from the outside. From the inside it's a 20-byte question, an XOR, and two machines firing packets at each other at exactly the right moment -- and now you've written all three.

Exercises

  1. Detect your NAT type. Send binding requests to two different public STUN servers from the same local socket, parse both XOR-MAPPED-ADDRESS replies, and compare the public ports. Equal ports mean a cone NAT (hole punching will work); different ports mean symmetric (it probably won't). Print a verdict, and write a test that feeds two canned responses into your comparison logic and asserts the classification.

  2. Add a keepalive timer. Wrap a punched UDP path in a small struct that sends a 1-byte keepalive every 15 seconds (use the std.time.Timer from episode 70) whenever no real traffic has gone out, so the NAT mapping never expires. Prove with a test that the "should I send a keepalive now?" predicate fires only after the idle interval has elapsed.

  3. Write the rendezvous server. Build the tiny TCP server that makes the punch possible: accept two peers (the accept loop from episode 51), read each one's six-byte endpoint, store them keyed by a shared room name in a HashMap (episode 22), and once both have arrived, send each peer the other's endpoint. Test the pairing logic in isolation by registering two endpoints under one room and asserting each receives its counterpart -- no real sockets needed.

Thanks for reading -- de groeten!

scipio@scipio

Learn Zig Series (#94) - NAT Traversal and Hole Punching | Ecency