Files
svc-proxy/README.md
T
claude-timemachine f823c05aa3
CI / validate (push) Successful in 24s
CI / docker (push) Failing after 1m49s
initial: svc-proxy — UDP valve for Simple Voice Chat
Standalone Go service that routes SVC client traffic to per-server
backend voice endpoints, configured via pg LISTEN/NOTIFY (same channel
mc-router subscribes to). Each pg `servers` row with both
`voice_address` and `voice_proxy_port` set spawns a Valve: a public
UDP listener that maintains per-client ephemeral bridges to the
backend's SVC port.

Pieces:
  cmd/svc-proxy/main.go     entry; wires config, log fan-out,
                            bridge.Manager, pgsync, httpsrv
  internal/config/          DATABASE_URL + BIND_HOST +
                            BRIDGE_IDLE_TTL (default 1m) +
                            HTTP_ADDR (default :8081)
  internal/pgsync/          LISTEN automc_routes_changed; diff
                            desired/actual routes; emit Apply()
  internal/bridge/          Valve per public port; per-client
                            bridge with atomic up/down byte counters;
                            idle eviction every 15s against TTL
  internal/httpsrv/         operator UI — embedded single-page HTML
                            with active-connections table polled
                            every 1s + SSE log stream
                            (last 500 lines backlog on connect)

Reverse-proxied behind server-manager at /infra/svc-proxy/* — bind
internal-only addresses for production; auth is the dashboard's
Basic gate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 18:01:04 +02:00

4.3 KiB

svc-proxy

Standalone UDP "valve" for Simple Voice Chat. Per-server public UDP port → backend voice address. Routes read from Postgres via LISTEN/NOTIFY, same pattern as mc-router.

What it does

Each MC server in the automc fleet runs SVC on its own UDP port inside its container (default 24454). svc-proxy exposes a public UDP port per server and bridges client traffic to the backend. SVC's own SecretPacket is configured per backend to advertise the public proxy hostname + the assigned proxy port, so the client connects directly to the proxy — no MITM, no plugin-channel sniffing.

SVC client ──UDP──► svc-proxy.timemachine.center:24455
                        │
                        ├── (per-server valve)
                        │
                        └──UDP──► mc-gtnh:24454 (backend SVC)

The proxy is opaque to the SVC payload — it can read the cleartext outer header (magic byte + player UUID) but the AES-GCM body stays end-to-end. Source-address bridges (one ephemeral upstream socket per client SocketAddress) survive NAT rebinds within the idle TTL.

pg schema

Two new columns on servers:

Column Type Meaning
voice_address text Backend SVC address — <host>:<port> reachable from svc-proxy
voice_proxy_port int Public UDP port svc-proxy binds for this server

Rows with both NULL are ignored. Owner of allocation: server-manager (assigns the next free port from a configured pool when the server is provisioned; clears on delete).

NOTIFY channel

Reuses automc_routes_changed from mc-router. The trigger on servers already fires on UPDATE, so adding/clearing the voice columns refreshes svc-proxy's bindings without restart.

Environment

Env Default Effect
DATABASE_URL (required) pgx DSN
BIND_HOST 0.0.0.0 host for the per-server UDP listeners
BRIDGE_IDLE_TTL 5m tear down per-client upstream sockets after this much silence
LOG_LEVEL info debug / info / warn

Operator UX

# Allocate voice ports for an existing server (server-manager does this normally)
UPDATE servers
   SET voice_address = 'mc-gtnh:24454',
       voice_proxy_port = 24455
 WHERE name = 'gtnh';
NOTIFY automc_routes_changed;

svc-proxy logs valve open: :24455 → mc-gtnh:24454 (gtnh) and is ready.

To retire a server's voice routing:

UPDATE servers SET voice_address = NULL, voice_proxy_port = NULL WHERE name = 'gtnh';
NOTIFY automc_routes_changed;

svc-proxy logs valve close: :24455 (gtnh). In-flight bridges are torn down.

Backend-side configuration

The SVC plugin on the backend must advertise the public proxy address to clients (not the backend's own LAN address). Set in the backend's SVC config (config/voicechat-server.properties):

voice_host=svc-proxy.timemachine.center:24455

…or via env if mc-wrapper templates it. SVC bakes this into SecretPacket.voiceHost, the client uses it verbatim.

Why not the SVC bundled proxy

SVC ships proxy support for BungeeCord/Velocity (common-proxy module). It sniffs the MC voicechat:secret plugin message and rewrites the host on the fly, then NAT-bridges UDP. That requires the SVC proxy to live inside the MC proxy process. We run mc-router (Go) instead of a Java MC proxy on the edge, so the bundled approach doesn't apply.

svc-proxy is the equivalent for the mc-router shape: pure UDP data plane, pg-driven config, no plugin hooks.

Limitations

  • No replay protection at the proxy layer (SVC's AES-GCM is the only freshness guarantee — same as upstream).
  • No client rate-limiting (SVC's plugin-channel rate limit covers TCP setup; UDP audio relies on Opus payload caps + the wrapper's BRIDGE_IDLE_TTL to bound per-source sockets).
  • Bridge ephemeral upstream sockets aren't pooled — one syscall per concurrent client. Fine up to a few thousand concurrent voice users on a single proxy host.