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:
- 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.
- 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.
- 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.
Result
Four reproducible experiments, each a one-command just recipe, prove the
properties rather than assert them:
| Experiment | What it proves |
|---|---|
just ping | End-to-end: ICMP crosses two networks that share no route |
just capture | tcpdump 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 telnet | Layer-3 means any IP traffic: a full interactive TCP session through the tunnel |
just break | Kill 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.