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
+8 -8
View File
@@ -1,20 +1,20 @@
# svc-proxy # 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 ## 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 SVC client ──UDP──► svc-proxy.timemachine.center:24455
├── (per-server valve) ├── (per-server tunnel)
└──UDP──► mc-gtnh:24454 (backend SVC) └──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 ## pg schema
@@ -51,7 +51,7 @@ UPDATE servers
NOTIFY automc_routes_changed; 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: 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; 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 ## Backend-side configuration
@@ -81,8 +81,8 @@ svc-proxy is the equivalent for the mc-router shape: pure UDP data plane, pg-dri
## Limitations ## Limitations
- No replay protection at the proxy layer (SVC's AES-GCM is the only freshness guarantee — same as upstream). - 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). - 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).
- 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. - 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 ## Related
+103 -101
View File
@@ -1,6 +1,6 @@
// Package bridge owns the UDP data plane: one Valve per backend, each Valve // Package bridge owns the UDP data plane: one Listener per backend route,
// owns a public listener socket and a pool of per-client bridges that copy // each Listener owns the public listener socket plus a pool of per-client
// datagrams to the backend's voice address and back. // tunnels that copy datagrams to the backend's voice address and back.
package bridge package bridge
import ( import (
@@ -17,22 +17,22 @@ import (
// Manager is the top-level coordinator. Implements pgsync.Applier so the // Manager is the top-level coordinator. Implements pgsync.Applier so the
// pgsync goroutine can hand it desired/undesired routes; Manager turns those // 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 { type Manager struct {
ctx context.Context ctx context.Context
bindHost string bindHost string
bridgeIdleTTL time.Duration tunnelIdleTTL time.Duration
mu sync.Mutex mu sync.Mutex
valves map[int]*Valve // key: public UDP port listeners map[int]*Listener // key: public UDP port
} }
func NewManager(ctx context.Context, bindHost string, idleTTL time.Duration) *Manager { func NewManager(ctx context.Context, bindHost string, idleTTL time.Duration) *Manager {
return &Manager{ return &Manager{
ctx: ctx, ctx: ctx,
bindHost: bindHost, bindHost: bindHost,
bridgeIdleTTL: idleTTL, tunnelIdleTTL: idleTTL,
valves: map[int]*Valve{}, listeners: map[int]*Listener{},
} }
} }
@@ -44,47 +44,47 @@ func (m *Manager) Apply(add []pgsync.Route, del []pgsync.Route) {
defer m.mu.Unlock() defer m.mu.Unlock()
for _, r := range add { 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. // Same port, different backend — close, then re-open.
existing.Close() 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 { 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 continue
} }
m.valves[r.Port] = v m.listeners[r.Port] = l
slog.Info("valve open", "port", r.Port, "addr", r.Address, "name", r.Name) slog.Info("listener open", "port", r.Port, "addr", r.Address, "name", r.Name)
} }
for _, r := range del { for _, r := range del {
v, ok := m.valves[r.Port] l, ok := m.listeners[r.Port]
if !ok { if !ok {
continue continue
} }
v.Close() l.Close()
delete(m.valves, r.Port) delete(m.listeners, r.Port)
slog.Info("valve close", "port", r.Port, "name", r.Name) slog.Info("listener close", "port", r.Port, "name", r.Name)
} }
} }
// Shutdown closes every active valve. Safe to call once; idempotent for // Shutdown closes every active listener. Safe to call once; idempotent for
// per-valve Close. // per-listener Close.
func (m *Manager) Shutdown() { func (m *Manager) Shutdown() {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
for port, v := range m.valves { for port, l := range m.listeners {
v.Close() l.Close()
delete(m.valves, port) delete(m.listeners, port)
} }
} }
// Valve owns one public UDP listener and the per-client bridges hanging off // Listener owns one public UDP socket and the per-client tunnels hanging
// it. Each bridge is a goroutine that copies datagrams from one ephemeral // off it. Each tunnel is a goroutine that copies datagrams from one
// upstream socket back to the original client. The public socket itself is // ephemeral upstream socket back to the original client. The public socket
// the egress for backend → client. // itself is the egress for backend → client.
type Valve struct { type Listener struct {
route pgsync.Route route pgsync.Route
backend *net.UDPAddr backend *net.UDPAddr
pub *net.UDPConn // 0.0.0.0:<route.Port> pub *net.UDPConn // 0.0.0.0:<route.Port>
@@ -95,10 +95,10 @@ type Valve struct {
cancel context.CancelFunc cancel context.CancelFunc
mu sync.Mutex 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) backend, err := net.ResolveUDPAddr("udp", r.Address)
if err != nil { if err != nil {
return nil, fmt.Errorf("resolve backend %q: %w", r.Address, err) 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) return nil, fmt.Errorf("bind %s: %w", pubAddr, err)
} }
ctx, cancel := context.WithCancel(parent) ctx, cancel := context.WithCancel(parent)
v := &Valve{ l := &Listener{
route: r, route: r,
backend: backend, backend: backend,
pub: pub, pub: pub,
idleTTL: idleTTL, idleTTL: idleTTL,
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
bridges: map[string]*clientBridge{}, tunnels: map[string]*tunnel{},
} }
go v.readLoop() go l.readLoop()
go v.evictIdle() go l.evictIdle()
return v, nil return l, nil
} }
// readLoop runs forever copying packets from the public socket to per-client // readLoop runs forever copying packets from the public socket to per-client
// upstream sockets. The reverse direction (backend → client) is per-bridge // upstream sockets. The reverse direction (backend → client) is per-tunnel
// goroutines on the upstream sockets writing back to v.pub. // goroutines on the upstream sockets writing back to l.pub.
func (v *Valve) readLoop() { func (l *Listener) readLoop() {
buf := make([]byte, 2048) // SVC max datagram body buf := make([]byte, 2048) // SVC max datagram body
for { for {
n, src, err := v.pub.ReadFromUDP(buf) n, src, err := l.pub.ReadFromUDP(buf)
if err != nil { if err != nil {
if v.ctx.Err() != nil || errors.Is(err, net.ErrClosed) { if l.ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
return return
} }
slog.Warn("valve read error", "port", v.route.Port, "err", err) slog.Warn("listener read error", "port", l.route.Port, "err", err)
continue continue
} }
v.mu.Lock() l.mu.Lock()
b, ok := v.bridges[src.String()] t, ok := l.tunnels[src.String()]
if !ok { if !ok {
b, err = v.openBridge(src) t, err = l.openTunnel(src)
if err != nil { if err != nil {
v.mu.Unlock() l.mu.Unlock()
slog.Warn("bridge open failed", "port", v.route.Port, "src", src, "err", err) slog.Warn("tunnel open failed", "port", l.route.Port, "src", src, "err", err)
continue continue
} }
v.bridges[src.String()] = b l.tunnels[src.String()] = t
slog.Debug("bridge open", "port", v.route.Port, "client", src.String()) slog.Debug("tunnel open", "port", l.route.Port, "client", src.String())
} }
v.mu.Unlock() l.mu.Unlock()
b.touch() t.touch()
if _, err := b.upstream.Write(buf[:n]); err != nil { if _, err := t.upstream.Write(buf[:n]); err != nil {
if v.ctx.Err() == nil { if l.ctx.Err() == nil {
slog.Warn("bridge forward failed", "port", v.route.Port, "err", err) slog.Warn("tunnel forward failed", "port", l.route.Port, "err", err)
} }
continue continue
} }
b.counters.bytesUp.Add(uint64(n)) t.counters.bytesUp.Add(uint64(n))
} }
} }
func (v *Valve) openBridge(src *net.UDPAddr) (*clientBridge, error) { func (l *Listener) openTunnel(src *net.UDPAddr) (*tunnel, error) {
up, err := net.DialUDP("udp", nil, v.backend) up, err := net.DialUDP("udp", nil, l.backend)
if err != nil { if err != nil {
return nil, fmt.Errorf("dial backend: %w", err) return nil, fmt.Errorf("dial backend: %w", err)
} }
now := time.Now() now := time.Now()
b := &clientBridge{ t := &tunnel{
client: src, client: src,
upstream: up, upstream: up,
valve: v, listener: l,
openedAt: now, openedAt: now,
} }
b.lastSeen = now t.lastSeen = now
go b.readBackend() go t.readBackend()
return b, nil return t, nil
} }
func (v *Valve) evictIdle() { func (l *Listener) evictIdle() {
t := time.NewTicker(15 * time.Second) ticker := time.NewTicker(15 * time.Second)
defer t.Stop() defer ticker.Stop()
for { for {
select { select {
case <-v.ctx.Done(): case <-l.ctx.Done():
return return
case <-t.C: case <-ticker.C:
cutoff := time.Now().Add(-v.idleTTL) cutoff := time.Now().Add(-l.idleTTL)
v.mu.Lock() l.mu.Lock()
for k, b := range v.bridges { for k, t := range l.tunnels {
if b.lastUseBefore(cutoff) { if t.lastUseBefore(cutoff) {
slog.Debug("bridge idle evict", "port", v.route.Port, "client", k) slog.Debug("tunnel idle evict", "port", l.route.Port, "client", k)
b.close() t.close()
delete(v.bridges, k) delete(l.tunnels, k)
} }
} }
v.mu.Unlock() l.mu.Unlock()
} }
} }
} }
func (v *Valve) Close() { func (l *Listener) Close() {
v.cancel() l.cancel()
v.pub.Close() l.pub.Close()
v.mu.Lock() l.mu.Lock()
for k, b := range v.bridges { for k, t := range l.tunnels {
b.close() t.close()
delete(v.bridges, k) 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 client *net.UDPAddr
upstream *net.UDPConn upstream *net.UDPConn
valve *Valve listener *Listener
counters counters // atomic — hot path counters counters // atomic — hot path
@@ -226,38 +228,38 @@ type clientBridge struct {
openedAt time.Time openedAt time.Time
} }
func (b *clientBridge) touch() { func (t *tunnel) touch() {
b.mu.Lock() t.mu.Lock()
b.lastSeen = time.Now() t.lastSeen = time.Now()
b.mu.Unlock() t.mu.Unlock()
} }
func (b *clientBridge) lastUseBefore(t time.Time) bool { func (t *tunnel) lastUseBefore(cutoff time.Time) bool {
b.mu.Lock() t.mu.Lock()
defer b.mu.Unlock() defer t.mu.Unlock()
return b.lastSeen.Before(t) return t.lastSeen.Before(cutoff)
} }
func (b *clientBridge) close() { func (t *tunnel) close() {
_ = b.upstream.Close() _ = t.upstream.Close()
} }
// readBackend pumps datagrams from the backend back to the client via the // readBackend pumps datagrams from the backend back to the client via the
// public socket. Exits when the upstream socket is closed. // public socket. Exits when the upstream socket is closed.
func (b *clientBridge) readBackend() { func (t *tunnel) readBackend() {
buf := make([]byte, 2048) buf := make([]byte, 2048)
for { for {
n, err := b.upstream.Read(buf) n, err := t.upstream.Read(buf)
if err != nil { if err != nil {
return return
} }
b.touch() t.touch()
if _, err := b.valve.pub.WriteToUDP(buf[:n], b.client); err != nil { if _, err := t.listener.pub.WriteToUDP(buf[:n], t.client); err != nil {
if b.valve.ctx.Err() == nil { if t.listener.ctx.Err() == nil {
slog.Warn("bridge reverse failed", "port", b.valve.route.Port, "err", err) slog.Warn("tunnel reverse failed", "port", t.listener.route.Port, "err", err)
} }
return return
} }
b.counters.bytesDown.Add(uint64(n)) t.counters.bytesDown.Add(uint64(n))
} }
} }
+31 -30
View File
@@ -5,61 +5,62 @@ import (
"time" "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 // (readLoop client→backend, readBackend backend→client) — atomic to avoid
// locking the bridge for every datagram. // locking the tunnel for every datagram.
type counters struct { type counters struct {
bytesUp atomic.Uint64 // client → backend bytesUp atomic.Uint64 // client → backend
bytesDown atomic.Uint64 // backend → client bytesDown atomic.Uint64 // backend → client
} }
// ConnSnapshot is one row of the active-connections table the UI renders. // TunnelSnapshot is one row of the tunnels table the UI renders. All times
// All times are wall-clock; sizes are total bytes since the bridge opened. // are wall-clock; sizes are total bytes since the tunnel opened.
type ConnSnapshot struct { type TunnelSnapshot struct {
Server string `json:"server"` // pg row name (e.g. "gtnh") Server string `json:"server"` // pg row name (e.g. "gtnh")
Port int `json:"port"` // public UDP port (the valve) Port int `json:"port"` // public UDP port
Backend string `json:"backend"` // backend addr Backend string `json:"backend"` // backend addr
Client string `json:"client"` // source IP:port Client string `json:"client"` // source IP:port
BytesUp uint64 `json:"bytes_up"` // client → backend BytesUp uint64 `json:"bytes_up"` // client → backend
BytesDown uint64 `json:"bytes_down"` // backend → client BytesDown uint64 `json:"bytes_down"` // backend → client
OpenedAt time.Time `json:"opened_at"` // bridge creation OpenedAt time.Time `json:"opened_at"` // tunnel creation
LastSeen time.Time `json:"last_seen"` // most-recent datagram either direction LastSeen time.Time `json:"last_seen"` // most-recent datagram either direction
IdleSeconds float64 `json:"idle_seconds"` // derived; UI sorts by this IdleSeconds float64 `json:"idle_seconds"` // derived; UI sorts by this
} }
// Snapshot returns one row per active client bridge across all valves. // Snapshot returns one row per active per-client tunnel across all
// Cheap-ish: takes the Manager lock + each Valve lock briefly, no per-bridge // listeners. Cheap-ish: takes the Manager lock + each Listener lock briefly,
// lock (counters are atomic; LastSeen is read under the bridge lock). // no per-tunnel lock (counters are atomic; LastSeen is read under the
func (m *Manager) Snapshot() []ConnSnapshot { // tunnel lock).
func (m *Manager) Snapshot() []TunnelSnapshot {
m.mu.Lock() m.mu.Lock()
valves := make([]*Valve, 0, len(m.valves)) listeners := make([]*Listener, 0, len(m.listeners))
for _, v := range m.valves { for _, l := range m.listeners {
valves = append(valves, v) listeners = append(listeners, l)
} }
m.mu.Unlock() m.mu.Unlock()
now := time.Now() now := time.Now()
var out []ConnSnapshot var out []TunnelSnapshot
for _, v := range valves { for _, l := range listeners {
v.mu.Lock() l.mu.Lock()
for _, b := range v.bridges { for _, t := range l.tunnels {
b.mu.Lock() t.mu.Lock()
lastSeen := b.lastSeen lastSeen := t.lastSeen
opened := b.openedAt opened := t.openedAt
b.mu.Unlock() t.mu.Unlock()
out = append(out, ConnSnapshot{ out = append(out, TunnelSnapshot{
Server: v.route.Name, Server: l.route.Name,
Port: v.route.Port, Port: l.route.Port,
Backend: v.route.Address, Backend: l.route.Address,
Client: b.client.String(), Client: t.client.String(),
BytesUp: b.counters.bytesUp.Load(), BytesUp: t.counters.bytesUp.Load(),
BytesDown: b.counters.bytesDown.Load(), BytesDown: t.counters.bytesDown.Load(),
OpenedAt: opened, OpenedAt: opened,
LastSeen: lastSeen, LastSeen: lastSeen,
IdleSeconds: now.Sub(lastSeen).Seconds(), IdleSeconds: now.Sub(lastSeen).Seconds(),
}) })
} }
v.mu.Unlock() l.mu.Unlock()
} }
return out return out
} }
+3 -3
View File
@@ -40,7 +40,7 @@ func New(addr string, mgr *bridge.Manager, bus *LogBus) *Server {
panic(err) // embed.FS misconfigured at build time panic(err) // embed.FS misconfigured at build time
} }
mux.Handle("GET /", http.FileServer(http.FS(sub))) 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)) mux.HandleFunc("GET /api/logs", sseLogs(bus))
s.srv = &http.Server{ s.srv = &http.Server{
@@ -66,13 +66,13 @@ func (s *Server) Run(ctx context.Context) error {
return nil return nil
} }
func (s *Server) handleConnections(w http.ResponseWriter, _ *http.Request) { func (s *Server) handleTunnels(w http.ResponseWriter, _ *http.Request) {
snap := s.mgr.Snapshot() snap := s.mgr.Snapshot()
// Sort by most-recently-active first so the UI can render top-down. // 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) }) sort.Slice(snap, func(i, j int) bool { return snap[i].LastSeen.After(snap[j].LastSeen) })
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"connections": snap, "tunnels": snap,
"at": time.Now(), "at": time.Now(),
}) })
} }
+7 -7
View File
@@ -55,7 +55,7 @@
</header> </header>
<main> <main>
<section> <section>
<h2><span>Active connections <span class="count" id="conn-count"></span></span></h2> <h2><span>Active tunnels <span class="count" id="conn-count"></span></span></h2>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -66,7 +66,7 @@
</thead> </thead>
<tbody id="conn-rows"></tbody> <tbody id="conn-rows"></tbody>
</table> </table>
<div class="empty" id="conn-empty">no active bridges</div> <div class="empty" id="conn-empty">no active tunnels</div>
</section> </section>
<section> <section>
<h2><span>Logs</span><button onclick="document.getElementById('logbox').innerHTML=''">clear</button></h2> <h2><span>Logs</span><button onclick="document.getElementById('logbox').innerHTML=''">clear</button></h2>
@@ -88,20 +88,20 @@ const fmtAgo = secs => {
}; };
async function refreshConnections() { async function refreshConnections() {
try { try {
const r = await fetch('./api/connections'); const r = await fetch('./api/tunnels');
const j = await r.json(); const j = await r.json();
const rows = document.getElementById('conn-rows'); const rows = document.getElementById('conn-rows');
const empty = document.getElementById('conn-empty'); const empty = document.getElementById('conn-empty');
const count = document.getElementById('conn-count'); const count = document.getElementById('conn-count');
rows.innerHTML = ''; rows.innerHTML = '';
const now = new Date(j.at).getTime(); const now = new Date(j.at).getTime();
if (!j.connections || j.connections.length === 0) { if (!j.tunnels || j.tunnels.length === 0) {
empty.style.display = ''; empty.style.display = '';
count.textContent = ''; count.textContent = '';
} else { } else {
empty.style.display = 'none'; empty.style.display = 'none';
count.textContent = '(' + j.connections.length + ')'; count.textContent = '(' + j.tunnels.length + ')';
for (const c of j.connections) { for (const c of j.tunnels) {
const opened = new Date(c.opened_at).getTime(); const opened = new Date(c.opened_at).getTime();
const ageSecs = (now - opened) / 1000; const ageSecs = (now - opened) / 1000;
const idleCls = c.idle_seconds > 60 ? 'dead' : c.idle_seconds > 30 ? 'stale' : ''; const idleCls = c.idle_seconds > 60 ? 'dead' : c.idle_seconds > 30 ? 'stale' : '';
@@ -118,7 +118,7 @@ async function refreshConnections() {
rows.appendChild(tr); rows.appendChild(tr);
} }
} }
document.getElementById('meta').textContent = '— ' + j.connections.length + ' bridges'; document.getElementById('meta').textContent = '— ' + j.tunnels.length + ' tunnels';
} catch (e) { } catch (e) {
document.getElementById('meta').textContent = '— api error'; document.getElementById('meta').textContent = '— api error';
} }