initial: svc-proxy — UDP valve for Simple Voice Chat
Standalone Go service that routes SVC client traffic to per-server
backend voice endpoints, configured via pg LISTEN/NOTIFY (same channel
mc-router subscribes to). Each pg `servers` row with both
`voice_address` and `voice_proxy_port` set spawns a Valve: a public
UDP listener that maintains per-client ephemeral bridges to the
backend's SVC port.
Pieces:
cmd/svc-proxy/main.go entry; wires config, log fan-out,
bridge.Manager, pgsync, httpsrv
internal/config/ DATABASE_URL + BIND_HOST +
BRIDGE_IDLE_TTL (default 1m) +
HTTP_ADDR (default :8081)
internal/pgsync/ LISTEN automc_routes_changed; diff
desired/actual routes; emit Apply()
internal/bridge/ Valve per public port; per-client
bridge with atomic up/down byte counters;
idle eviction every 15s against TTL
internal/httpsrv/ operator UI — embedded single-page HTML
with active-connections table polled
every 1s + SSE log stream
(last 500 lines backlog on connect)
Reverse-proxied behind server-manager at /infra/svc-proxy/* — bind
internal-only addresses for production; auth is the dashboard's
Basic gate.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
// Package httpsrv exposes the svc-proxy operator UI + JSON API. Designed to
|
||||
// be reverse-proxied behind server-manager (no auth/TLS at this layer; the
|
||||
// listener should bind to the container network only).
|
||||
package httpsrv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.timemachine.center/timemachine/svc-proxy/internal/bridge"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
type Server struct {
|
||||
addr string
|
||||
mgr *bridge.Manager
|
||||
bus *LogBus
|
||||
srv *http.Server
|
||||
}
|
||||
|
||||
func New(addr string, mgr *bridge.Manager, bus *LogBus) *Server {
|
||||
mux := http.NewServeMux()
|
||||
s := &Server{
|
||||
addr: addr,
|
||||
mgr: mgr,
|
||||
bus: bus,
|
||||
}
|
||||
|
||||
sub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
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/logs", sseLogs(bus))
|
||||
|
||||
s.srv = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Run blocks until ctx is cancelled or the server errors.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = s.srv.Shutdown(shutCtx)
|
||||
}()
|
||||
slog.Info("http server listening", "addr", s.addr)
|
||||
if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleConnections(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(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user