Skip to content
Javier Hengda

Draco

tun-vpn

The dragon guarding the passage — a guarded tunnel.

Role
Solo — design, implementation, experiments
Timeline
Spring 2026 · Computer Security coursework, UB
Stack
Go · Linux TUN/TAP · TLS 1.3 · Docker Compose · tcpdump

Problem

Everyone uses VPNs; very few people can say what one is beneath the product branding. The goal was to strip the concept to its irreducible core and build it: intercept IP packets at layer 3, carry them through an encrypted stream across a hostile network, and re-inject them into a private LAN on the far side — with security properties you can demonstrate, not just claim.

Three constraints shaped the design:

  1. Self-contained — no extra hardware, no kernel modules, no changes to the host’s networking. Anyone with Docker on Linux can run the whole topology.
  2. Provable, not plausible — the demos had to show that traffic genuinely flows through the tunnel (and dies without it), not that two containers can ping each other.
  3. Readable — the reference model is the C implementation in Wenliang Du’s Computer & Internet Security (ch. 19), built around select(2). The challenge was translating that model into idiomatic Go without obscuring it.

Approach

The entire VPN reduces to two primitives, about 560 lines of Go with no dependencies outside the standard library.

CreateTun asks the kernel for a virtual layer-3 interface via the TUNSETIFF ioctl with IFF_TUN | IFF_NO_PI — a raw IP device with no packet-info prefix. The subtle part is the ifreq struct: the kernel reads it directly, so the Go struct must match the C ABI byte-for-byte, including 22 bytes of explicit padding for the union that follows the flags field. The returned file descriptor is the packet stream — reads yield IP datagrams the kernel routed to the interface, writes inject packets as if they arrived off the wire.

Relay pumps packets between the TUN device and a TLS connection. Where Du multiplexes two file descriptors with select(2), Go gets the same semantics from two goroutines with a first-error-wins channel. The wire needs framing — TLS-over-TCP is a byte stream, so each IP packet is prefixed with a 2-byte big-endian length, read back with io.ReadFull (a plain Read can return a partial frame at any byte boundary). Before injecting into the kernel, a cheap sanity check validates the IP version nibble, so a corrupted or malicious stream degrades into a logged skip instead of kernel noise. An earlier UDP phase needed no framing at all — message boundaries come free with datagrams; moving to TLS/TCP is what bought the framing requirement.

Security model. The client trusts exactly one certificate authority — the private CA generated at build time, pinned via RootCAs — and pins the expected identity with ServerName, so the handshake fails unless the server’s certificate carries the right SAN. That decouples the dial address from the cert identity (the server can move IPs without regenerating certificates) and makes an on-path attacker unable to impersonate the server. TLS 1.2 is the floor; 1.3 is what actually negotiates. The handshake is forced eagerly on accept, so a certificate problem surfaces as a clear handshake failed log instead of a cryptic mid-stream I/O error. One honest scope line, documented in the server itself: clients are not certificate authenticated — mutual TLS is the known next step, not an oversight.

Topology as code. Docker Compose builds a three-node world: a client and server sharing a simulated public internet, and a target host that lives only on the internal network — bridge isolation guarantees the tunnel is the sole path to it, exactly the situation a real remote client faces. The server enables ip_forward and routes between the tunnel subnet and the LAN. The piece that surprises everyone is on the target host: a return route sending tunnel-subnet traffic back via the VPN server — without it the outbound path works perfectly and every reply still dies at the default gateway.

internet_net · 10.0.0.0/24internal_net · 192.168.60.0/24TLS 1.3 · :55555 · length-framed IPvpn_clientvpn_serverhost_v10.0.0.20 · tun0 10.4.2.9910.0.0.10 · tun0 10.4.2.5ip_forward=1192.168.60.20tunnel is the only path
The topology, charted — the dragon guards the only passage

Result

Four reproducible experiments, each a one-command just recipe, prove the properties rather than assert them:

ExperimentWhat it proves
just pingEnd-to-end: ICMP crosses two networks that share no route
just capturetcpdump on tun0 and eth0 simultaneously — the inner ICMP packet is visible on one and completely invisible on the other, which only ever sees a TLS byte stream
just telnetLayer-3 means any IP traffic: a full interactive TCP session through the tunnel
just breakKill the server → pings die (connectivity is genuinely tunnel-dependent, not a Docker artifact) → restart → the client’s reconnect loop restores the tunnel in ~2 seconds, unattended

The dual-capture experiment is the one that makes the concept land: the same ping observed as a plaintext IP packet inside the tunnel and as opaque TLS records outside it, side by side.

Deliberately left on the map for a future expedition: mutual TLS, a DTLS-over-UDP transport (which would delete the framing layer entirely), multi-client support with a routing table, and IPv6 setup scripts — the TUN primitive already passes IPv6 packets untouched.