Completes the Go backend so it can fully replace Python in production: tracker-go website layer (serves the unchanged frontend): - static file serving + SPA fallback + /icons (website.go) - login/logout with itsdangerous cookie ISSUING (bcrypt, Python-interop) and the /me handler (auth.go issueSessionCookie + website.go) - admin user CRUD (website_admin.go) and the issue-board write side (website_issues.go) - request-scoped user context + requireAdmin (auth.go) cutover ingest (gated off during the parallel run, required for a clean cutover): - inventory forwarding: full_inventory -> /process-inventory, inventory_delta -> item POST/DELETE, per-character serialized, fire-and-forget (inventory_forward.go) - death/idle Discord alerts via DISCORD_ACLOG_WEBHOOK (aclog.go) - SKIP_SCHEMA_INIT so write mode against the prod DBs runs no DDL (tracker-go + inventory-go) two bugs found live and fixed: - coerceNum: the plugin sends kills_per_hour/deaths/total_deaths/prismatic_taper_count as STRINGS; pydantic coerced them, Go's number helpers wrote null/0 (reads.go/ingest.go) - telemetry is broadcast TYPELESS so the browser ignores it and uses the /live poll; broadcasting it typed flapped the per-player counters 0<->value (ingest.go stripType) docker-compose.cutover.yml: reversible override flipping the Go services to write mode against the production DBs and repointing the Discord bot at the Go /ws/live. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
236 lines
6.7 KiB
Go
236 lines
6.7 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/zlib"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha1"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type userCtxKey struct{}
|
|
|
|
func withUser(ctx context.Context, u *sessionUser) context.Context {
|
|
return context.WithValue(ctx, userCtxKey{}, u)
|
|
}
|
|
|
|
// currentUser returns the authenticated user for the request, or nil (e.g.
|
|
// internal-trust loopback requests carry no user identity).
|
|
func currentUser(r *http.Request) *sessionUser {
|
|
u, _ := r.Context().Value(userCtxKey{}).(*sessionUser)
|
|
return u
|
|
}
|
|
|
|
// requireAdmin writes 403 and returns false unless the request is an admin
|
|
// (main.py _require_admin).
|
|
func requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
|
if u := currentUser(r); u != nil && u.IsAdmin {
|
|
return true
|
|
}
|
|
writeJSON(w, http.StatusForbidden, map[string]any{"detail": "Admin access required"})
|
|
return false
|
|
}
|
|
|
|
// 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}
|
|
}
|
|
|
|
// issueSessionCookie produces an itsdangerous URLSafeTimedSerializer token
|
|
// compatible with the Python service (so Go-issued cookies verify on Python and
|
|
// vice-versa). Inverse of verifySessionCookie.
|
|
func issueSessionCookie(secretKey string, u sessionUser) string {
|
|
payload, _ := json.Marshal(struct {
|
|
U string `json:"u"`
|
|
A bool `json:"a"`
|
|
}{u.Username, u.IsAdmin})
|
|
payloadPart := encodeItsdangerousPayload(payload)
|
|
tsPart := base64.RawURLEncoding.EncodeToString(int64ToBytes(time.Now().Unix()))
|
|
signed := payloadPart + "." + tsPart
|
|
mac := hmac.New(sha1.New, deriveSignerKey(secretKey))
|
|
mac.Write([]byte(signed))
|
|
return signed + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
// encodeItsdangerousPayload mirrors URLSafeSerializerBase.dump_payload: zlib-
|
|
// compress only when it actually saves more than one byte (it won't for our tiny
|
|
// payload), then urlsafe-base64 (no pad), with a "." marker if compressed.
|
|
func encodeItsdangerousPayload(jsonBytes []byte) string {
|
|
var buf bytes.Buffer
|
|
zw := zlib.NewWriter(&buf)
|
|
_, _ = zw.Write(jsonBytes)
|
|
_ = zw.Close()
|
|
if compressed := buf.Bytes(); len(compressed) < len(jsonBytes)-1 {
|
|
return "." + base64.RawURLEncoding.EncodeToString(compressed)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(jsonBytes)
|
|
}
|
|
|
|
// int64ToBytes encodes a non-negative int as minimal big-endian bytes, matching
|
|
// itsdangerous int_to_bytes (verifySessionCookie reads it back the same way).
|
|
func int64ToBytes(n int64) []byte {
|
|
if n == 0 {
|
|
return []byte{0}
|
|
}
|
|
var b []byte
|
|
for n > 0 {
|
|
b = append([]byte{byte(n & 0xff)}, b...)
|
|
n >>= 8
|
|
}
|
|
return b
|
|
}
|
|
|
|
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.WithContext(withUser(r.Context(), u)))
|
|
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()
|
|
}
|