Skip to content

birdflop/tunnel

 
 

Repository files navigation

Birdflop Tunnel

A self-hosted Minecraft tunnel that gives every user one stable subdomain under *.tunnel.birdflop.com and routes incoming traffic by reading the hostname out of the Minecraft handshake. Because routing is by hostname, a single public IP and a single port serve unlimited users — the port space is never exhausted.

This is a heavily modified fork of bore by Eric Zhang. The original assigns each tunnel a random public port; this version instead assigns persistent per-user subdomains and multiplexes everyone onto shared ports by Minecraft hostname.

How it works

Player connects to:   a3k9zq.tunnel.birdflop.com 
                     (or a3k9zq.tunnel.birdflop.com:25566 for a second server)
        │
        │   DNS:  *.tunnel.birdflop.com  →  ONE relay IP   (single-label wildcard)
        ▼
   relay :25565   ← ONE shared port for ALL users   (a second server uses :25566, etc.)
        │   reads the Minecraft handshake → hostname = "a3k9zq.tunnel.birdflop.com"
        │   routes by (hostname, port) → the client that registered it
        ▼
   forwards the (replayed) stream to that client → its local Minecraft server
  • One domain per user. The relay issues a random 6-char subdomain (e.g. a3k9zq) plus a long secret token. The subdomain is the public address; the token proves ownership and never crosses the wire after issuance (HMAC challenge/response).
  • Many servers per user, by port. A user exposes each server on its own port under their subdomain — a3k9zq.tunnel.birdflop.com (default port 25565), a3k9zq.tunnel.birdflop.com:25566, and so on. Every port is host-muxed, so two users can both use :25565 and the port space is never exhausted.
  • Many routes from one client. A single bftunnel local process can register several routes at once (via --config), each forwarding to a different local backend. They are all multiplexed over one control connection.
  • Optional sub-labels. A server can instead be exposed at survival.a3k9zq…, but this relies on multi-label wildcard DNS (see the DNS note below) and may not resolve on every provider, so port addressing is the default and the robust choice.
  • Stable URLs. Identities are persisted, so a user's address never changes.

The only traffic that cannot be host-muxed is genuinely non-Minecraft raw TCP (no handshake to read); that would need a dedicated global port. This relay is optimized for Java Minecraft.

Usage

Relay (server)

bftunnel server \
  --base-domain tunnel.birdflop.com \
  --store /var/lib/bftunnel/identities.db
Option Default Meaning
--base-domain tunnel.birdflop.com Domain subdomains live under
--min-port / --max-port 1024 / 65535 Public ports clients may claim
--store tunnel-identities.db Persistent identity store (embedded database)
--control-port 7835 Control connection port
--bind-addr 0.0.0.0 Where the control server binds
--bind-tunnels = --bind-addr Where public listeners bind
--metrics-addr — (off) Serve Prometheus metrics (+ admin API) here, e.g. 127.0.0.1:9090
--admin-token — (off) Bearer token that enables and protects /admin/*
--tls-cert / --tls-key — (off) PEM cert chain + key; terminates control connections with TLS
--max-identities 0 (unlimited) Cap on total issued identities
--max-pending 1024 Cap on un-accepted player connections (anti-flood)
--max-tunnels-per-identity 10 Cap on routes one identity may hold at once
--register-rate 5 New-identity registrations allowed per source IP per minute
--drain-timeout 30 Seconds to drain live player connections on shutdown

Identity store: identities live in an embedded redb database, so issuing or revoking one identity is a single-key write. A store written by an older release (one JSON file) is migrated automatically on first load — in place if --store points at it, or from the .json sibling of the new default path — and the original is kept with a .bak suffix.

Graceful shutdown: on SIGTERM/ctrl-c the relay stops accepting new connections, closes control connections (clients reconnect on their own — see below), waits up to --drain-timeout seconds for live player connections to finish, then exits. A systemd unit with matching TimeoutStopSec and hardening lives at deploy/bftunnel.service.

Logging: logs go to stderr and honor RUST_LOG (e.g. RUST_LOG=debug), defaulting to info.

TLS: with --tls-cert/--tls-key, every control-port connection (registration, authentication, and the per-player accept streams) is wrapped in TLS, so tokens issued at registration never cross the wire in plaintext. Player traffic on the public Minecraft ports is untouched. Clients opt in with --tls (and --tls-ca for a self-signed or private CA).

Abuse controls: issuance is rate-limited per source IP (--register-rate) and bounded by --max-identities; each identity may hold at most --max-tunnels-per-identity routes, and the relay holds at most --max-pending un-accepted player connections before shedding new ones. Player handshakes have a hard read deadline so a slow/partial sender can't pin a public connection open.

DNS: point a single wildcard record *.tunnel.birdflop.com at the relay's public IP. This single-label wildcard covers every user's subdomain (a3k9zq.tunnel.birdflop.com), which is all the default port-based addressing needs, and works on every DNS provider. Optional sub-labels (survival.a3k9zq.tunnel.birdflop.com) require the wildcard to also synthesize multi-label names (RFC 4592). Standards-compliant authoritative servers do this — provided you never create an explicit record for a base subdomain — but some managed DNS providers don't, so sub-labels may not resolve everywhere. Port addressing always does.

Firewall: allow the control port (7835, ideally restricted to clients) and the public Minecraft ports you let clients claim (25565 and anything else in range).

Metrics: pass --metrics-addr 127.0.0.1:9090 to expose a Prometheus /metrics endpoint (bind it to a private interface). It reports counters — registrations (issued, rate-limited, at-capacity), auth failures, player connections (routed, handshake-failed, unknown-host, pending-full, stale), and bytes proxied — plus live gauges for active connections, identities, pending connections, open ports, and active tunnels.

Admin API: passing --admin-token <secret> additionally enables a small JSON API on the metrics address. Every request needs Authorization: Bearer <secret>:

Endpoint Meaning
GET /admin/identities All issued identities with created_at and live route counts
GET /admin/tunnels Every registered route with live active/total connections + bytes
DELETE /admin/identities/<sub> Revoke an identity (deletes it and kicks its live connections)
POST /admin/identities/<sub>/disconnect Kick an identity's live connections without revoking it

Client (next to a Minecraft server)

First run — request a new identity (printed once, save it):

bftunnel local 25565 --to tunnel.birdflop.com
# → BFTUNNEL_IDENTITY subdomain=a3k9zq token=<secret>
# → BFTUNNEL_ADDRESS a3k9zq.tunnel.birdflop.com

Later runs — reuse the saved identity:

bftunnel local 25565 --to tunnel.birdflop.com \
  --subdomain a3k9zq --token <secret>

Expose a second server under the same domain on its own port:

bftunnel local 25566 --to tunnel.birdflop.com \
  --subdomain a3k9zq --token <secret> --port 25566
# → BFTUNNEL_ADDRESS a3k9zq.tunnel.birdflop.com:25566

Adding --label creative would instead expose it at creative.a3k9zq.tunnel.birdflop.com:25566, but that depends on multi-label wildcard DNS (see the DNS note above).

Expose several backends from one process with a JSON config (supersedes the single-route flags). Each route may set its own label, local_host, local_port, and public_port:

bftunnel local --to tunnel.birdflop.com \
  --subdomain a3k9zq --token <secret> --config routes.json
{
  "routes": [
    { "label": "survival", "local_port": 25565, "public_port": 25565 },
    { "label": "creative", "local_port": 25566, "public_port": 25566 },
    { "local_host": "10.0.0.5", "local_port": 25567, "public_port": 25567 }
  ]
}

label defaults to none (bare subdomain), local_host to localhost, and public_port to 25565. The relay prints one BFTUNNEL_ADDRESS line per route.

Option Default Meaning
<local_port> Local port to forward (omit when using --config)
--local-host localhost Local host to forward
--to Relay address
--port 25565 Public port under your subdomain (must be > 1000)
--label Optional sub-name (survival.<you>…)
--config JSON file of multiple routes (supersedes the single-route flags)
--subdomain / --token Existing identity (provide both, or neither to enroll)
--json off Emit NDJSON events on stdout instead of the legacy lines
--tls off Connect to the relay over TLS
--tls-ca Extra PEM root cert(s) to trust (self-hosted relays)

Reconnects: if the control connection drops (relay restart, network blip), the client reconnects automatically with exponential backoff (1s → 60s, jittered), re-authenticates via the HMAC challenge, and re-registers the same routes at the same addresses. It gives up only on permanent rejection (e.g. a revoked identity).

Machine-readable lines are printed to stdout on startup: BFTUNNEL_IDENTITY subdomain=… token=… (only when a new identity is issued) and BFTUNNEL_ADDRESS <public address>.

With --json, stdout instead carries one JSON event per line (logs stay on stderr):

{"event":"identity_issued","subdomain":"a3k9zq","token":""}
{"event":"bound","addresses":["a3k9zq.tunnel.birdflop.com"]}
{"event":"connected","reconnect":false,"addresses":["a3k9zq.tunnel.birdflop.com"]}
{"event":"stats","routes":[{"hostname":"a3k9zq.tunnel.birdflop.com","port":25565,"active_connections":2,"total_connections":9,"bytes":52133}]}
{"event":"reconnecting","attempt":1,"delay_ms":1043,"error":"relay closed the connection"}
{"event":"error","message":"authentication failed: unknown subdomain","fatal":true}

stats events arrive roughly every five seconds while connected (pushed by the relay alongside its heartbeat). error events with "fatal":true mean the client is exiting.

Protocol

The client opens a control connection and announces its protocol version with Hello(version); the relay replies HelloAck { version } and both sides speak the minimum. A connection whose first message is not Hello is treated as protocol 0 (pre-versioning), so old clients keep working — they just never receive newer message variants like RouteStats.

The client then either Registers (the relay issues {subdomain, token} and treats the connection as authenticated) or Authenticates by subdomain (the relay replies with a Challenge; the client returns an HMAC Answer keyed by the token). It then Listens with a list of routes (each a public port + optional label); the relay registers [label.]subdomain.<base> on each port and replies Bound { addresses } (one per route, in order). While connected, the relay pushes RouteStats (live per-route connection counts and bytes) roughly every five seconds, which doubles as the heartbeat.

When a player connects, the relay reads the handshake, finds the registered client, stores the pending connection under a UUID, and sends Connection { id, hostname, port }. The client uses (hostname, port) to pick the right local backend, opens a fresh stream, sends Accept(id), and the relay splices the two — replaying the buffered handshake bytes so the backend sees a normal connection.

License

MIT, as with upstream bore.

About

fork of bore.pub for use specifically with our test server manager

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages

  • Rust 99.8%
  • Dockerfile 0.2%