-
Notifications
You must be signed in to change notification settings - Fork 0
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:
-
docs/agent/architecture.md— subsystem deep-dives and "The numbers", the single source of every timeout, cap and buffer size. -
CLAUDE.md— the invariants that survive any rewrite, and the Reality-check table behind Not Yet Implemented. -
docs/agent/reasoning.md— the debugging procedure and symptom→suspect table.
Deliberately, this wiki does not restate any tuned number. Numbers copied into a wiki go stale silently; a citation can't.
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 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.
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.
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.
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.
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 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.
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.
proxyforward · Issues · GPL-3.0 — Windows only, TCP only. Check Not Yet Implemented before filing a bug.