- share.go: cross-machine vital sharing (share_subscribe/unsubscribe/share_*), faithful port of the peer-state snapshot + plugin fan-out + /vital-sharing/peers. The last ingest handler — the Go tracker now handles every plugin event type. - shadow consumer: drop the outbound keepalive ping (the firehose is never idle) and tighten the read-deadline watchdog to 12s for faster reconnect after the upstream's periodic eviction (full-firehose browser clients get evicted ~every 90s; the watchdog recovers it, ~90% duty cycle). Production-bound /ws/position is unaffected (plugins connect to us; no eviction). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
156 lines
3.8 KiB
Go
156 lines
3.8 KiB
Go
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()
|
|
}
|
|
}
|
|
|
|
// fanoutShare forwards a share_* message to other opted-in plugin clients
|
|
// (every connected name that is subscribed and isn't the origin). Send failures
|
|
// are logged-and-ignored, not evicted (main.py:2829).
|
|
func (p *pluginRegistry) fanoutShare(data map[string]any, origin string, subs map[string]bool) {
|
|
p.mu.RLock()
|
|
type target struct {
|
|
name string
|
|
c *websocket.Conn
|
|
}
|
|
var targets []target
|
|
for n, c := range p.conns {
|
|
if n != origin && subs[n] {
|
|
targets = append(targets, target{n, c})
|
|
}
|
|
}
|
|
p.mu.RUnlock()
|
|
if len(targets) == 0 {
|
|
return
|
|
}
|
|
b, _ := json.Marshal(data)
|
|
for _, t := range targets {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
_ = t.c.Write(ctx, websocket.MessageText, b)
|
|
cancel()
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|