rename: bridges/valves → tunnels (one term across types + API + UI)
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:
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user