Skip to content

Architecture

xeri edited this page Jul 14, 2026 · 1 revision

Architecture

A tour of how the tunnel actually works. This page is the shape of the system; the exhaustive version — repo map, life-of-a-byte, every tuned constant — lives in the repository, and is kept honest by a test:

Deliberately, this wiki does not restate any tuned number. Numbers copied into a wiki go stale silently; a citation can't.

The shape of it

players ──TCP──▶ gateway ══one TLS 1.3 connection, yamux══▶ agent ──TCP──▶ minecraft
                                (control + 1 stream/player)

Go engine, Wails v2 shell, React 19 GUI, one Windows binary that runs in either role.

The agent dials out; the gateway never dials in. That inversion is the whole product — it's why no port forwarding is needed on the Minecraft side.

One connection carries everything. The agent opens a single outbound TCP+TLS connection and multiplexes it with yamux: one long-lived control stream for messages (hello, tunnel registration, heartbeats, stats), plus one new stream per player. A player connecting to the gateway becomes a stream; the agent accepts it and dials your Minecraft server; the two are then spliced together.

Nothing outside the transport package knows yamux exists — both sides program against a small Session/Stream interface, so the multiplexer can be swapped (QUIC, for instance) without the agent or gateway noticing.

The control plane

The wire format is a 4-byte length prefix and a JSON envelope. Two properties are load-bearing:

  • The length is checked before anything is allocated, with a much smaller cap before authentication than after — so an internet scanner can't make the gateway allocate a big buffer by claiming a big frame.
  • The protocol grows by capabilities, not version bumps. The agent offers a set of capability strings, the gateway replies with the intersection it supports, and both sides act only on that. Unknown capabilities and unknown message types are ignored, never errors. That's what lets a new agent talk to an old gateway, and vice versa, without a flag day.

Liveness: exactly one owner

A tunnel that's silently dead is worse than one that's honestly down, so liveness has a single owner: an application-level ping in both directions, with an idle read deadline. yamux's own keepalive is deliberately off, and its write timeout deliberately long, so that the heartbeat — and only the heartbeat — is what declares a peer dead. Two liveness mechanisms would race and give you contradictory answers.

That same heartbeat is what feeds the RTT, jitter and packet-loss numbers on the dashboard. It has a second job, and it does both.

Reconnection

Full-jitter exponential backoff, with the sequence resetting after the connection has been stable for a while. Full jitter matters: when a gateway restarts, a hundred agents must not all reconnect in the same instant.

Two things short-circuit the backoff rather than waiting out a dead read: network-change notifications from Windows, and a resume-from-sleep detector that watches for wall-clock jumps. DNS is re-resolved on every attempt, which is what makes a gateway on a dynamic IP work.

Failures that can never succeed by retrying — bad token, agent conflict, incompatible version — are classified as fatal and stop the agent instead of hammering the gateway forever.

The gateway is a single-goroutine actor

Every session and listener lifecycle change happens on one goroutine. Not for simplicity — for a guarantee.

When an agent reconnects and its old session is evicted, the gateway closes each listener and waits for its accept loop to exit before anything else proceeds. So when the port is rebound, it is provably free. Without this, a reconnecting agent could race its own dying listener for port 25565 and lose. There's an end-to-end regression test that restarts an agent and asserts the rebind.

Same idea, different rule, for identity: a reconnect by the same agent supersedes the old session; a different agent on the same token is rejected. Two agents flapping against each other forever is not a failure mode anyone should have to debug.

The hot path

Player bytes never touch the control path, never get logged, and never take a lock.

The relay is a purpose-built splice rather than an io.Copy: pooled buffers (sized for chunk-load bursts, not io.Copy's default), zero allocations per iteration, and atomic byte counters the GUI reads lock-free. TCP_NODELAY is set end-to-end — no Nagle delay on either leg — and the yamux window is sized so a burst fits in flight without stalling.

Half-close is preserved: EOF on one leg becomes a CloseWrite (a real FIN) on the other while the opposite direction keeps draining. That's what makes a disconnect message written just before close actually arrive, instead of being lost to a reset. And every write refreshes a progress deadline, so a player whose client stops reading can't park a goroutine forever.

This is enforced, not asserted. CI pushes a large burst through the full agent→gateway→client path on every commit and fails the build if throughput drops below a floor or a concurrent stream's round-trip time exceeds a ceiling — a standing guard against head-of-line blocking. The floor is never lowered to make a build go green.

The GUI boundary

The engine owns everything; the GUI is a viewer. Exactly one process serves the named pipe \\.\pipe\proxyforward — an app that finds the pipe already taken attaches as a thin client instead of starting a second engine (see Windows Service). A pipe conflict is fatal by design: two processes fighting over port 25565 is far worse than one clean refusal.

Go→JS traffic is coalesced into a single periodic tick; logs and analytics are polled. Player traffic never generates a UI event — a busy server must not be able to drown its own dashboard.

Where the honesty lives

The repository keeps a Reality check table of things that are advertised but not implemented, and a rule that implementing one means deleting its row in the same commit. That table is the source of Not Yet Implemented. A separate test walks the docs and fails the build if they cite a file, symbol or test that no longer exists — so the repo's own documentation can't rot quietly.

Next: Contributing.

Clone this wiki locally