MosswartOverlord/go-services/tracker-go/auth.go
Erik 27757636e4 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>
2026-06-24 11:15:05 +02:00

168 lines
4.3 KiB
Go

package main
import (
"bytes"
"compress/zlib"
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"io"
"net"
"net/http"
"strings"
"time"
)
// Session-cookie verification compatible with the Python service's
// itsdangerous URLSafeTimedSerializer(SECRET_KEY) (itsdangerous 2.2):
// - HMAC-SHA1 signature
// - django-concat key derivation: sha1(salt + b"signer" + secret_key)
// - salt "itsdangerous", separator ".", Unix-epoch timestamp
// - payload = urlsafe-base64(no pad) of compact JSON {"u":username,"a":is_admin},
// optionally zlib-compressed with a leading "." marker
// Reusing the same SECRET_KEY means a login on the Python service authenticates
// on the Go service during the parallel run.
const sessionMaxAge = 30 * 24 * 3600 // SESSION_MAX_AGE seconds (30 days)
type sessionUser struct {
Username string
IsAdmin bool
}
func deriveSignerKey(secretKey string) []byte {
h := sha1.New()
h.Write([]byte("itsdangerous")) // salt
h.Write([]byte("signer"))
h.Write([]byte(secretKey))
return h.Sum(nil)
}
// verifySessionCookie returns the user encoded in a valid, unexpired token, or
// nil. Constant-time signature comparison; never partially trusts a bad token.
func verifySessionCookie(secretKey, token string) *sessionUser {
if secretKey == "" || token == "" {
return nil
}
// signature is everything after the final separator.
i := strings.LastIndexByte(token, '.')
if i <= 0 {
return nil
}
signed := token[:i] // payload + "." + timestamp
sig, err := base64.RawURLEncoding.DecodeString(token[i+1:])
if err != nil {
return nil
}
mac := hmac.New(sha1.New, deriveSignerKey(secretKey))
mac.Write([]byte(signed))
if !hmac.Equal(sig, mac.Sum(nil)) {
return nil
}
// timestamp is after the second-to-last separator; payload precedes it
// (the payload may itself start with "." when zlib-compressed).
j := strings.LastIndexByte(signed, '.')
if j < 0 {
return nil
}
tsBytes, err := base64.RawURLEncoding.DecodeString(signed[j+1:])
if err != nil {
return nil
}
var ts int64
for _, b := range tsBytes {
ts = ts<<8 | int64(b)
}
if time.Now().Unix()-ts > sessionMaxAge {
return nil // expired
}
payload, err := decodeItsdangerousPayload(signed[:j])
if err != nil {
return nil
}
var data struct {
U string `json:"u"`
A bool `json:"a"`
}
if json.Unmarshal(payload, &data) != nil {
return nil
}
return &sessionUser{Username: data.U, IsAdmin: data.A}
}
func decodeItsdangerousPayload(p string) ([]byte, error) {
compressed := strings.HasPrefix(p, ".")
if compressed {
p = p[1:]
}
raw, err := base64.RawURLEncoding.DecodeString(p)
if err != nil {
return nil, err
}
if !compressed {
return raw, nil
}
zr, err := zlib.NewReader(bytes.NewReader(raw))
if err != nil {
return nil, err
}
defer zr.Close()
return io.ReadAll(zr)
}
// authMiddleware replicates main.py's AuthMiddleware: public paths pass through;
// private-source + no X-Forwarded-For is internal-trust (skip auth); otherwise a
// valid session cookie is required.
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isPublicPath(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
// Internal trust: only when the peer is private AND nginx did not add
// X-Forwarded-For (nginx sets XFF on all proxied internet traffic).
if r.Header.Get("X-Forwarded-For") == "" && isPrivateAddr(clientIP(r)) {
next.ServeHTTP(w, r)
return
}
if c, err := r.Cookie("session"); err == nil {
if u := verifySessionCookie(s.secretKey, c.Value); u != nil {
next.ServeHTTP(w, r)
return
}
}
if strings.Contains(r.Header.Get("Accept"), "text/html") {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "Not authenticated"})
})
}
func isPublicPath(p string) bool {
switch p {
case "/login", "/logout", "/login.html", "/login-style.css", "/health":
return true
}
// WS endpoints authenticate inside their own handlers.
return strings.HasPrefix(p, "/icons/") || strings.HasPrefix(p, "/ws/")
}
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func isPrivateAddr(ip string) bool {
a := net.ParseIP(ip)
if a == nil {
return false
}
return a.IsLoopback() || a.IsPrivate()
}