Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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_TTLto 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.
Related
- mc-router (Timemachine fork) — same NOTIFY channel, same pg-driven route source.
- Simple Voice Chat — upstream mod whose wire protocol we pass through.