rename: bridges/valves → tunnels (one term across types + API + UI)
CI / validate (push) Successful in 7s
CI / docker (push) Failing after 5s

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 18:48:17 +02:00
parent 7fbe2555fb
commit 41a7e39754
5 changed files with 163 additions and 160 deletions
+106 -104
View File
@@ -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:<route.Port>
@@ -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))
}
}