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.
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:25565and the port space is never exhausted. - Many routes from one client. A single
bftunnel localprocess 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.
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 |
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.comLater 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:25566Adding --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.
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.
MIT, as with upstream bore.