From 41a7e397546b49abaab58e62e59eae75af88ecb4 Mon Sep 17 00:00:00 2001 From: claude-timemachine Date: Wed, 10 Jun 2026 18:48:17 +0200 Subject: [PATCH] =?UTF-8?q?rename:=20bridges/valves=20=E2=86=92=20tunnels?= =?UTF-8?q?=20(one=20term=20across=20types=20+=20API=20+=20UI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API shape: GET /api/connections → GET /api/tunnels body: {"connections": […]} → {"tunnels": […]} Type rename (package stays "bridge" — internal): Valve → Listener clientBridge → tunnel ConnSnapshot → TunnelSnapshot Log messages mirror the new vocab ("listener open/close", "tunnel open/idle evict/forward failed"). UI header is now "Active tunnels" and the empty state reads "no active tunnels". server-manager's dashboard polls /infra/svc-proxy/api/tunnels and shows "N tunnels" on the svc-proxy infra card. Co-Authored-By: Claude Opus 4.7 --- README.md | 16 +-- internal/bridge/manager.go | 210 +++++++++++++++-------------- internal/bridge/stats.go | 75 ++++++----- internal/httpsrv/server.go | 8 +- internal/httpsrv/static/index.html | 14 +- 5 files changed, 163 insertions(+), 160 deletions(-) diff --git a/README.md b/README.md index 5f68e5a..c5317f0 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,20 @@ # svc-proxy -Standalone UDP "valve" for [Simple Voice Chat](https://github.com/henkelmax/simple-voice-chat). Per-server public UDP port → backend voice address. Routes read from Postgres via `LISTEN`/`NOTIFY`, same pattern as `mc-router`. +Standalone UDP tunnel for [Simple Voice Chat](https://github.com/henkelmax/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. +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 tunnels 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) + ├── (per-server tunnel) │ └──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. +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. Per-client tunnels (one ephemeral upstream socket per client `SocketAddress`) survive NAT rebinds within the idle TTL. ## pg schema @@ -51,7 +51,7 @@ UPDATE servers NOTIFY automc_routes_changed; ``` -svc-proxy logs `valve open: :24455 → mc-gtnh:24454 (gtnh)` and is ready. +svc-proxy logs `listener open: :24455 → mc-gtnh:24454 (gtnh)` and is ready. To retire a server's voice routing: @@ -60,7 +60,7 @@ UPDATE servers SET voice_address = NULL, voice_proxy_port = NULL WHERE name = 'g NOTIFY automc_routes_changed; ``` -svc-proxy logs `valve close: :24455 (gtnh)`. In-flight bridges are torn down. +svc-proxy logs `listener close: :24455 (gtnh)`. In-flight tunnels are torn down. ## Backend-side configuration @@ -81,8 +81,8 @@ svc-proxy is the equivalent for the mc-router shape: pure UDP data plane, pg-dri ## 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. +- No client rate-limiting (SVC's plugin-channel rate limit covers TCP setup; UDP audio relies on Opus payload caps + the `BRIDGE_IDLE_TTL` env to bound per-tunnel sockets). +- Per-tunnel ephemeral upstream sockets aren't pooled — one syscall per concurrent client. Fine up to a few thousand concurrent voice tunnels on a single proxy host. ## Related diff --git a/internal/bridge/manager.go b/internal/bridge/manager.go index a22d5ae..5c53254 100644 --- a/internal/bridge/manager.go +++ b/internal/bridge/manager.go @@ -1,6 +1,6 @@ -// Package bridge owns the UDP data plane: one Valve per backend, each Valve -// owns a public listener socket and a pool of per-client bridges that copy -// datagrams to the backend's voice address and back. +// Package bridge owns the UDP data plane: one Listener per backend route, +// each Listener owns the public listener socket plus a pool of per-client +// tunnels that copy datagrams to the backend's voice address and back. package bridge import ( @@ -17,22 +17,22 @@ import ( // Manager is the top-level coordinator. Implements pgsync.Applier so the // pgsync goroutine can hand it desired/undesired routes; Manager turns those -// into open/close calls on a Valve registry keyed by public port. +// into open/close calls on a Listener registry keyed by public port. type Manager struct { - ctx context.Context - bindHost string - bridgeIdleTTL time.Duration + ctx context.Context + bindHost string + tunnelIdleTTL time.Duration - mu sync.Mutex - valves map[int]*Valve // key: public UDP port + mu sync.Mutex + listeners map[int]*Listener // key: public UDP port } func NewManager(ctx context.Context, bindHost string, idleTTL time.Duration) *Manager { return &Manager{ ctx: ctx, bindHost: bindHost, - bridgeIdleTTL: idleTTL, - valves: map[int]*Valve{}, + tunnelIdleTTL: idleTTL, + listeners: map[int]*Listener{}, } } @@ -44,47 +44,47 @@ func (m *Manager) Apply(add []pgsync.Route, del []pgsync.Route) { defer m.mu.Unlock() for _, r := range add { - if existing, ok := m.valves[r.Port]; ok { + if existing, ok := m.listeners[r.Port]; ok { // Same port, different backend — close, then re-open. existing.Close() - delete(m.valves, r.Port) + delete(m.listeners, r.Port) } - v, err := openValve(m.ctx, m.bindHost, r, m.bridgeIdleTTL) + l, err := openListener(m.ctx, m.bindHost, r, m.tunnelIdleTTL) if err != nil { - slog.Error("valve open failed", "port", r.Port, "addr", r.Address, "name", r.Name, "err", err) + slog.Error("listener open failed", "port", r.Port, "addr", r.Address, "name", r.Name, "err", err) continue } - m.valves[r.Port] = v - slog.Info("valve open", "port", r.Port, "addr", r.Address, "name", r.Name) + m.listeners[r.Port] = l + slog.Info("listener open", "port", r.Port, "addr", r.Address, "name", r.Name) } for _, r := range del { - v, ok := m.valves[r.Port] + l, ok := m.listeners[r.Port] if !ok { continue } - v.Close() - delete(m.valves, r.Port) - slog.Info("valve close", "port", r.Port, "name", r.Name) + l.Close() + delete(m.listeners, r.Port) + slog.Info("listener close", "port", r.Port, "name", r.Name) } } -// Shutdown closes every active valve. Safe to call once; idempotent for -// per-valve Close. +// Shutdown closes every active listener. Safe to call once; idempotent for +// per-listener Close. func (m *Manager) Shutdown() { m.mu.Lock() defer m.mu.Unlock() - for port, v := range m.valves { - v.Close() - delete(m.valves, port) + for port, l := range m.listeners { + l.Close() + delete(m.listeners, port) } } -// Valve owns one public UDP listener and the per-client bridges hanging off -// it. Each bridge is a goroutine that copies datagrams from one ephemeral -// upstream socket back to the original client. The public socket itself is -// the egress for backend → client. -type Valve struct { +// Listener owns one public UDP socket and the per-client tunnels hanging +// off it. Each tunnel is a goroutine that copies datagrams from one +// ephemeral upstream socket back to the original client. The public socket +// itself is the egress for backend → client. +type Listener struct { route pgsync.Route backend *net.UDPAddr pub *net.UDPConn // 0.0.0.0: @@ -95,10 +95,10 @@ type Valve struct { cancel context.CancelFunc mu sync.Mutex - bridges map[string]*clientBridge // key: client.RemoteAddr().String() + tunnels map[string]*tunnel // key: client.RemoteAddr().String() } -func openValve(parent context.Context, bindHost string, r pgsync.Route, idleTTL time.Duration) (*Valve, error) { +func openListener(parent context.Context, bindHost string, r pgsync.Route, idleTTL time.Duration) (*Listener, error) { backend, err := net.ResolveUDPAddr("udp", r.Address) if err != nil { return nil, fmt.Errorf("resolve backend %q: %w", r.Address, err) @@ -112,112 +112,114 @@ func openValve(parent context.Context, bindHost string, r pgsync.Route, idleTTL return nil, fmt.Errorf("bind %s: %w", pubAddr, err) } ctx, cancel := context.WithCancel(parent) - v := &Valve{ + l := &Listener{ route: r, backend: backend, pub: pub, idleTTL: idleTTL, ctx: ctx, cancel: cancel, - bridges: map[string]*clientBridge{}, + tunnels: map[string]*tunnel{}, } - go v.readLoop() - go v.evictIdle() - return v, nil + go l.readLoop() + go l.evictIdle() + return l, nil } // readLoop runs forever copying packets from the public socket to per-client -// upstream sockets. The reverse direction (backend → client) is per-bridge -// goroutines on the upstream sockets writing back to v.pub. -func (v *Valve) readLoop() { +// upstream sockets. The reverse direction (backend → client) is per-tunnel +// goroutines on the upstream sockets writing back to l.pub. +func (l *Listener) readLoop() { buf := make([]byte, 2048) // SVC max datagram body for { - n, src, err := v.pub.ReadFromUDP(buf) + n, src, err := l.pub.ReadFromUDP(buf) if err != nil { - if v.ctx.Err() != nil || errors.Is(err, net.ErrClosed) { + if l.ctx.Err() != nil || errors.Is(err, net.ErrClosed) { return } - slog.Warn("valve read error", "port", v.route.Port, "err", err) + slog.Warn("listener read error", "port", l.route.Port, "err", err) continue } - v.mu.Lock() - b, ok := v.bridges[src.String()] + l.mu.Lock() + t, ok := l.tunnels[src.String()] if !ok { - b, err = v.openBridge(src) + t, err = l.openTunnel(src) if err != nil { - v.mu.Unlock() - slog.Warn("bridge open failed", "port", v.route.Port, "src", src, "err", err) + l.mu.Unlock() + slog.Warn("tunnel open failed", "port", l.route.Port, "src", src, "err", err) continue } - v.bridges[src.String()] = b - slog.Debug("bridge open", "port", v.route.Port, "client", src.String()) + l.tunnels[src.String()] = t + slog.Debug("tunnel open", "port", l.route.Port, "client", src.String()) } - v.mu.Unlock() - b.touch() - if _, err := b.upstream.Write(buf[:n]); err != nil { - if v.ctx.Err() == nil { - slog.Warn("bridge forward failed", "port", v.route.Port, "err", err) + l.mu.Unlock() + t.touch() + if _, err := t.upstream.Write(buf[:n]); err != nil { + if l.ctx.Err() == nil { + slog.Warn("tunnel forward failed", "port", l.route.Port, "err", err) } continue } - b.counters.bytesUp.Add(uint64(n)) + t.counters.bytesUp.Add(uint64(n)) } } -func (v *Valve) openBridge(src *net.UDPAddr) (*clientBridge, error) { - up, err := net.DialUDP("udp", nil, v.backend) +func (l *Listener) openTunnel(src *net.UDPAddr) (*tunnel, error) { + up, err := net.DialUDP("udp", nil, l.backend) if err != nil { return nil, fmt.Errorf("dial backend: %w", err) } now := time.Now() - b := &clientBridge{ + t := &tunnel{ client: src, upstream: up, - valve: v, + listener: l, openedAt: now, } - b.lastSeen = now - go b.readBackend() - return b, nil + t.lastSeen = now + go t.readBackend() + return t, nil } -func (v *Valve) evictIdle() { - t := time.NewTicker(15 * time.Second) - defer t.Stop() +func (l *Listener) evictIdle() { + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() for { select { - case <-v.ctx.Done(): + case <-l.ctx.Done(): return - case <-t.C: - cutoff := time.Now().Add(-v.idleTTL) - v.mu.Lock() - for k, b := range v.bridges { - if b.lastUseBefore(cutoff) { - slog.Debug("bridge idle evict", "port", v.route.Port, "client", k) - b.close() - delete(v.bridges, k) + case <-ticker.C: + cutoff := time.Now().Add(-l.idleTTL) + l.mu.Lock() + for k, t := range l.tunnels { + if t.lastUseBefore(cutoff) { + slog.Debug("tunnel idle evict", "port", l.route.Port, "client", k) + t.close() + delete(l.tunnels, k) } } - v.mu.Unlock() + l.mu.Unlock() } } } -func (v *Valve) Close() { - v.cancel() - v.pub.Close() - v.mu.Lock() - for k, b := range v.bridges { - b.close() - delete(v.bridges, k) +func (l *Listener) Close() { + l.cancel() + l.pub.Close() + l.mu.Lock() + for k, t := range l.tunnels { + t.close() + delete(l.tunnels, k) } - v.mu.Unlock() + l.mu.Unlock() } -type clientBridge struct { +// tunnel is one client's UDP relay: a dedicated upstream socket to the +// backend + a touch-tracked last-seen timestamp for idle eviction. +type tunnel struct { client *net.UDPAddr upstream *net.UDPConn - valve *Valve + listener *Listener counters counters // atomic — hot path @@ -226,38 +228,38 @@ type clientBridge struct { openedAt time.Time } -func (b *clientBridge) touch() { - b.mu.Lock() - b.lastSeen = time.Now() - b.mu.Unlock() +func (t *tunnel) touch() { + t.mu.Lock() + t.lastSeen = time.Now() + t.mu.Unlock() } -func (b *clientBridge) lastUseBefore(t time.Time) bool { - b.mu.Lock() - defer b.mu.Unlock() - return b.lastSeen.Before(t) +func (t *tunnel) lastUseBefore(cutoff time.Time) bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.lastSeen.Before(cutoff) } -func (b *clientBridge) close() { - _ = b.upstream.Close() +func (t *tunnel) close() { + _ = t.upstream.Close() } // readBackend pumps datagrams from the backend back to the client via the // public socket. Exits when the upstream socket is closed. -func (b *clientBridge) readBackend() { +func (t *tunnel) readBackend() { buf := make([]byte, 2048) for { - n, err := b.upstream.Read(buf) + n, err := t.upstream.Read(buf) if err != nil { return } - b.touch() - if _, err := b.valve.pub.WriteToUDP(buf[:n], b.client); err != nil { - if b.valve.ctx.Err() == nil { - slog.Warn("bridge reverse failed", "port", b.valve.route.Port, "err", err) + t.touch() + if _, err := t.listener.pub.WriteToUDP(buf[:n], t.client); err != nil { + if t.listener.ctx.Err() == nil { + slog.Warn("tunnel reverse failed", "port", t.listener.route.Port, "err", err) } return } - b.counters.bytesDown.Add(uint64(n)) + t.counters.bytesDown.Add(uint64(n)) } } diff --git a/internal/bridge/stats.go b/internal/bridge/stats.go index ef6a888..b003cd3 100644 --- a/internal/bridge/stats.go +++ b/internal/bridge/stats.go @@ -5,61 +5,62 @@ import ( "time" ) -// counters is the per-bridge byte tally. Updated from the two hot paths +// counters is the per-tunnel byte tally. Updated from the two hot paths // (readLoop client→backend, readBackend backend→client) — atomic to avoid -// locking the bridge for every datagram. +// locking the tunnel for every datagram. type counters struct { bytesUp atomic.Uint64 // client → backend bytesDown atomic.Uint64 // backend → client } -// ConnSnapshot is one row of the active-connections table the UI renders. -// All times are wall-clock; sizes are total bytes since the bridge opened. -type ConnSnapshot struct { - Server string `json:"server"` // pg row name (e.g. "gtnh") - Port int `json:"port"` // public UDP port (the valve) - Backend string `json:"backend"` // backend addr - Client string `json:"client"` // source IP:port - BytesUp uint64 `json:"bytes_up"` // client → backend - BytesDown uint64 `json:"bytes_down"` // backend → client - OpenedAt time.Time `json:"opened_at"` // bridge creation - LastSeen time.Time `json:"last_seen"` // most-recent datagram either direction - IdleSeconds float64 `json:"idle_seconds"` // derived; UI sorts by this +// TunnelSnapshot is one row of the tunnels table the UI renders. All times +// are wall-clock; sizes are total bytes since the tunnel opened. +type TunnelSnapshot struct { + Server string `json:"server"` // pg row name (e.g. "gtnh") + Port int `json:"port"` // public UDP port + Backend string `json:"backend"` // backend addr + Client string `json:"client"` // source IP:port + BytesUp uint64 `json:"bytes_up"` // client → backend + BytesDown uint64 `json:"bytes_down"` // backend → client + OpenedAt time.Time `json:"opened_at"` // tunnel creation + LastSeen time.Time `json:"last_seen"` // most-recent datagram either direction + IdleSeconds float64 `json:"idle_seconds"` // derived; UI sorts by this } -// Snapshot returns one row per active client bridge across all valves. -// Cheap-ish: takes the Manager lock + each Valve lock briefly, no per-bridge -// lock (counters are atomic; LastSeen is read under the bridge lock). -func (m *Manager) Snapshot() []ConnSnapshot { +// Snapshot returns one row per active per-client tunnel across all +// listeners. Cheap-ish: takes the Manager lock + each Listener lock briefly, +// no per-tunnel lock (counters are atomic; LastSeen is read under the +// tunnel lock). +func (m *Manager) Snapshot() []TunnelSnapshot { m.mu.Lock() - valves := make([]*Valve, 0, len(m.valves)) - for _, v := range m.valves { - valves = append(valves, v) + listeners := make([]*Listener, 0, len(m.listeners)) + for _, l := range m.listeners { + listeners = append(listeners, l) } m.mu.Unlock() now := time.Now() - var out []ConnSnapshot - for _, v := range valves { - v.mu.Lock() - for _, b := range v.bridges { - b.mu.Lock() - lastSeen := b.lastSeen - opened := b.openedAt - b.mu.Unlock() - out = append(out, ConnSnapshot{ - Server: v.route.Name, - Port: v.route.Port, - Backend: v.route.Address, - Client: b.client.String(), - BytesUp: b.counters.bytesUp.Load(), - BytesDown: b.counters.bytesDown.Load(), + var out []TunnelSnapshot + for _, l := range listeners { + l.mu.Lock() + for _, t := range l.tunnels { + t.mu.Lock() + lastSeen := t.lastSeen + opened := t.openedAt + t.mu.Unlock() + out = append(out, TunnelSnapshot{ + Server: l.route.Name, + Port: l.route.Port, + Backend: l.route.Address, + Client: t.client.String(), + BytesUp: t.counters.bytesUp.Load(), + BytesDown: t.counters.bytesDown.Load(), OpenedAt: opened, LastSeen: lastSeen, IdleSeconds: now.Sub(lastSeen).Seconds(), }) } - v.mu.Unlock() + l.mu.Unlock() } return out } diff --git a/internal/httpsrv/server.go b/internal/httpsrv/server.go index 3df9690..7ec8d21 100644 --- a/internal/httpsrv/server.go +++ b/internal/httpsrv/server.go @@ -40,7 +40,7 @@ func New(addr string, mgr *bridge.Manager, bus *LogBus) *Server { panic(err) // embed.FS misconfigured at build time } mux.Handle("GET /", http.FileServer(http.FS(sub))) - mux.HandleFunc("GET /api/connections", s.handleConnections) + mux.HandleFunc("GET /api/tunnels", s.handleTunnels) mux.HandleFunc("GET /api/logs", sseLogs(bus)) s.srv = &http.Server{ @@ -66,13 +66,13 @@ func (s *Server) Run(ctx context.Context) error { return nil } -func (s *Server) handleConnections(w http.ResponseWriter, _ *http.Request) { +func (s *Server) handleTunnels(w http.ResponseWriter, _ *http.Request) { snap := s.mgr.Snapshot() // Sort by most-recently-active first so the UI can render top-down. sort.Slice(snap, func(i, j int) bool { return snap[i].LastSeen.After(snap[j].LastSeen) }) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ - "connections": snap, - "at": time.Now(), + "tunnels": snap, + "at": time.Now(), }) } diff --git a/internal/httpsrv/static/index.html b/internal/httpsrv/static/index.html index a078ea2..549d0c6 100644 --- a/internal/httpsrv/static/index.html +++ b/internal/httpsrv/static/index.html @@ -55,7 +55,7 @@
-

Active connections

+

Active tunnels

@@ -66,7 +66,7 @@
-
no active bridges
+
no active tunnels

Logs

@@ -88,20 +88,20 @@ const fmtAgo = secs => { }; async function refreshConnections() { try { - const r = await fetch('./api/connections'); + const r = await fetch('./api/tunnels'); const j = await r.json(); const rows = document.getElementById('conn-rows'); const empty = document.getElementById('conn-empty'); const count = document.getElementById('conn-count'); rows.innerHTML = ''; const now = new Date(j.at).getTime(); - if (!j.connections || j.connections.length === 0) { + if (!j.tunnels || j.tunnels.length === 0) { empty.style.display = ''; count.textContent = ''; } else { empty.style.display = 'none'; - count.textContent = '(' + j.connections.length + ')'; - for (const c of j.connections) { + count.textContent = '(' + j.tunnels.length + ')'; + for (const c of j.tunnels) { const opened = new Date(c.opened_at).getTime(); const ageSecs = (now - opened) / 1000; const idleCls = c.idle_seconds > 60 ? 'dead' : c.idle_seconds > 30 ? 'stale' : ''; @@ -118,7 +118,7 @@ async function refreshConnections() { rows.appendChild(tr); } } - document.getElementById('meta').textContent = '— ' + j.connections.length + ' bridges'; + document.getElementById('meta').textContent = '— ' + j.tunnels.length + ' tunnels'; } catch (e) { document.getElementById('meta').textContent = '— api error'; }