feat(go-services): tracker WS servers (/ws/position + /ws/live) + robust shadow
Completes the Go tracker as a cutover-ready drop-in: - wslive.go: browser broadcast hub with per-client subscribe filters (nil=all), request_dungeon_map replies, and command routing; auth = internal-trust or session cookie. The ingestor broadcasts every handled event to it. - wsposition.go: plugin ingest server with X-Plugin-Secret/SHARED_SECRET auth (constant-time, fails closed, legacy fallback), register -> plugin_conns, and dispatch into the shared Ingestor. plugin registry for backend->plugin commands. - main.go: statusRecorder.Unwrap() so coder/websocket can hijack through the logging middleware (WS handshakes failed without it); /ws/ bypasses HTTP auth. Shadow consumer robustness (the harness was being evicted under the full firehose): decouple socket read from processing — the read loop only copies raw frames to a queue; a worker unmarshals + dispatches. JSON parsing in the read loop was slowing it enough that Python's broadcast send errored and evicted us (Read then blocked forever). Added a 25s read-deadline watchdog to self-heal. Validated live: shadow /live online = 73 = production; telemetry sustained ~12/s, 0 drops, no eviction; and the shadow's /ws/live re-broadcast stream is IDENTICAL to production's (TOTAL 2150=2150, every event type exact). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7350b00341
commit
27757636e4
7 changed files with 418 additions and 34 deletions
129
go-services/tracker-go/wsposition.go
Normal file
129
go-services/tracker-go/wsposition.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// pluginRegistry maps character_name -> plugin connection for backend->plugin
|
||||
// command routing (main.py plugin_conns).
|
||||
type pluginRegistry struct {
|
||||
mu sync.RWMutex
|
||||
conns map[string]*websocket.Conn
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func newPluginRegistry(log *slog.Logger) *pluginRegistry {
|
||||
return &pluginRegistry{conns: map[string]*websocket.Conn{}, log: log}
|
||||
}
|
||||
|
||||
func (p *pluginRegistry) register(name string, c *websocket.Conn) {
|
||||
p.mu.Lock()
|
||||
p.conns[name] = c
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// removeConn drops every name bound to this connection (on disconnect).
|
||||
func (p *pluginRegistry) removeConn(c *websocket.Conn) {
|
||||
p.mu.Lock()
|
||||
for n, cc := range p.conns {
|
||||
if cc == c {
|
||||
delete(p.conns, n)
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *pluginRegistry) isConnected(name string) bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
_, ok := p.conns[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// send routes an opaque {player_name, command} envelope to a plugin; evicts the
|
||||
// connection on write failure (main.py command-forward semantics).
|
||||
func (p *pluginRegistry) send(name string, payload map[string]any) {
|
||||
p.mu.RLock()
|
||||
c := p.conns[name]
|
||||
p.mu.RUnlock()
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := c.Write(ctx, websocket.MessageText, b); err != nil {
|
||||
p.mu.Lock()
|
||||
if p.conns[name] == c {
|
||||
delete(p.conns, name)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// pluginAuthOK constant-time-compares the supplied secret to SHARED_SECRET (and
|
||||
// the optional rotation fallback). Fails closed when unset or left at the
|
||||
// placeholder, matching main.py.
|
||||
func (s *Server) pluginAuthOK(key string) bool {
|
||||
ok := s.sharedSecret != "" && s.sharedSecret != "your_shared_secret" &&
|
||||
hmac.Equal([]byte(key), []byte(s.sharedSecret))
|
||||
if !ok && s.sharedSecretLegacy != "" {
|
||||
ok = hmac.Equal([]byte(key), []byte(s.sharedSecretLegacy))
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *Server) handleWSPosition(w http.ResponseWriter, r *http.Request) {
|
||||
if s.ingestor == nil {
|
||||
http.Error(w, "ingest disabled on this instance", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
key := r.URL.Query().Get("secret")
|
||||
if key == "" {
|
||||
key = r.Header.Get("X-Plugin-Secret")
|
||||
}
|
||||
if !s.pluginAuthOK(key) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
defer s.plugins.removeConn(conn)
|
||||
conn.SetReadLimit(32 << 20)
|
||||
|
||||
ctx := r.Context()
|
||||
for {
|
||||
_, raw, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
continue
|
||||
}
|
||||
if toStr(m["type"]) == "register" {
|
||||
name := toStr(m["character_name"])
|
||||
if name == "" {
|
||||
name = toStr(m["player_name"])
|
||||
}
|
||||
if name != "" {
|
||||
s.plugins.register(name, conn)
|
||||
s.ingestor.clearEquipmentCantrip(name)
|
||||
s.log.Info("plugin registered", "character", name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
s.ingestor.dispatch(ctx, m)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue