@@ -34,6 +34,10 @@ func (s *Server) adminCreateCharacterAnchor(w http.ResponseWriter, r *http.Reque
|
||||
jsonOut(w, http.StatusConflict, map[string]string{"error": "character anchor already exists"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, artifact.ErrOpenAIBudgetExceeded) {
|
||||
jsonOut(w, http.StatusTooManyRequests, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
jsonOut(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -64,6 +64,19 @@ func (s *Server) adminArtifactUsage(w http.ResponseWriter, r *http.Request) {
|
||||
_ = s.store.DB.QueryRowContext(r.Context(), `SELECT count(*),COALESCE(sum(estimated_cost_usd),0)
|
||||
FROM artifact_api_usage WHERE kind='character_anchor'`).Scan(&anchorCalls, &anchorCost)
|
||||
|
||||
var rollingCalls1H, rollingCalls24H int64
|
||||
var rollingCost24H float64
|
||||
oneHourMS := now.Add(-time.Hour).UnixMilli()
|
||||
twentyFourHoursMS := now.Add(-24 * time.Hour).UnixMilli()
|
||||
_ = s.store.DB.QueryRowContext(r.Context(), `SELECT
|
||||
COALESCE(sum(CASE WHEN created_at>=? THEN 1 ELSE 0 END),0),
|
||||
count(*),COALESCE(sum(estimated_cost_usd),0)
|
||||
FROM artifact_api_usage WHERE provider='openai' AND created_at>=?`, oneHourMS, twentyFourHoursMS).Scan(&rollingCalls1H, &rollingCalls24H, &rollingCost24H)
|
||||
cfg := s.settings.Get()
|
||||
circuitBlocked := (cfg.OpenAIMaxCalls1H > 0 && rollingCalls1H >= int64(cfg.OpenAIMaxCalls1H)) ||
|
||||
(cfg.OpenAIMaxCalls24H > 0 && rollingCalls24H >= int64(cfg.OpenAIMaxCalls24H)) ||
|
||||
(cfg.OpenAIMaxCost24HUSD > 0 && rollingCost24H+cfg.OpenAIBudgetReserveUSD > cfg.OpenAIMaxCost24HUSD)
|
||||
|
||||
recent := make([]artifactUsageRow, 0, 20)
|
||||
rows, err := s.store.DB.QueryContext(r.Context(), `SELECT created_at,task_id,kind,model,endpoint,size,quality,request_id,
|
||||
input_tokens,input_text_tokens,input_image_tokens,output_tokens,total_tokens,estimated_cost_usd,pricing_basis
|
||||
@@ -114,6 +127,16 @@ func (s *Server) adminArtifactUsage(w http.ResponseWriter, r *http.Request) {
|
||||
"output_tokens": outputTokens,
|
||||
"total_tokens": totalTokens,
|
||||
},
|
||||
"circuit_breaker": map[string]any{
|
||||
"blocked": circuitBlocked,
|
||||
"calls_1h": rollingCalls1H,
|
||||
"calls_24h": rollingCalls24H,
|
||||
"cost_24h_usd": rollingCost24H,
|
||||
"max_calls_1h": cfg.OpenAIMaxCalls1H,
|
||||
"max_calls_24h": cfg.OpenAIMaxCalls24H,
|
||||
"max_cost_24h_usd": cfg.OpenAIMaxCost24HUSD,
|
||||
"reserve_per_call_usd": cfg.OpenAIBudgetReserveUSD,
|
||||
},
|
||||
"cost_method": "estimated from provider-reported token usage using pinned OpenAI standard public token rates; not invoice reconciliation",
|
||||
"recent": recent,
|
||||
})
|
||||
|
||||
+280
-26
@@ -10,11 +10,14 @@ import (
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
gort "runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"neuralhunt/internal/artifact"
|
||||
@@ -40,23 +43,69 @@ type Server struct {
|
||||
lottery *guessLottery
|
||||
adminUser, adminPass, staticDir, artifactDir string
|
||||
upgrader websocket.Upgrader
|
||||
wsAllowedOrigins map[string]struct{}
|
||||
maxUserWS, maxLeaderboardWS int64
|
||||
userWSCount, leaderboardWSCount atomic.Int64
|
||||
adminSessions sync.Map // sid -> exp unix seconds
|
||||
}
|
||||
|
||||
func New(store *data.Store, a *auth.Manager, sm *settings.Manager, hub *wsx.Hub, runtimeState *rtx.State, artifactDir string, artifactWorker *artifact.Worker) *Server {
|
||||
return &Server{
|
||||
store: store,
|
||||
auth: a,
|
||||
settings: sm,
|
||||
hub: hub,
|
||||
runtime: runtimeState,
|
||||
artifactWorker: artifactWorker,
|
||||
lottery: newGuessLottery(),
|
||||
adminUser: env("ADMIN_USER", "admin"),
|
||||
adminPass: env("ADMIN_PASSWORD", "change-me"),
|
||||
staticDir: env("STATIC_DIR", ""),
|
||||
artifactDir: artifactDir,
|
||||
upgrader: websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }},
|
||||
s := &Server{
|
||||
store: store,
|
||||
auth: a,
|
||||
settings: sm,
|
||||
hub: hub,
|
||||
runtime: runtimeState,
|
||||
artifactWorker: artifactWorker,
|
||||
lottery: newGuessLottery(),
|
||||
adminUser: env("ADMIN_USER", "admin"),
|
||||
adminPass: env("ADMIN_PASSWORD", "change-me"),
|
||||
staticDir: env("STATIC_DIR", ""),
|
||||
artifactDir: artifactDir,
|
||||
wsAllowedOrigins: parseOriginAllowlist(os.Getenv("WS_ALLOWED_ORIGINS")),
|
||||
maxUserWS: int64(envIntServer("WS_MAX_USER_CONNECTIONS", 5000)),
|
||||
maxLeaderboardWS: int64(envIntServer("WS_MAX_LEADERBOARD_CONNECTIONS", 500)),
|
||||
}
|
||||
s.upgrader = websocket.Upgrader{CheckOrigin: s.checkWSOrigin, Subprotocols: []string{"neuralhunt.v1"}}
|
||||
return s
|
||||
}
|
||||
|
||||
func envIntServer(k string, d int) int {
|
||||
if raw := strings.TrimSpace(os.Getenv(k)); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil && n >= 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func parseOriginAllowlist(raw string) map[string]struct{} {
|
||||
out := map[string]struct{}{}
|
||||
for _, item := range strings.Split(raw, ",") {
|
||||
item = strings.TrimSpace(strings.TrimRight(item, "/"))
|
||||
if item != "" {
|
||||
out[strings.ToLower(item)] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) checkWSOrigin(r *http.Request) bool {
|
||||
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
||||
if origin == "" {
|
||||
// Native clients do not normally send Origin. Authentication and global
|
||||
// connection caps still apply; browser cross-site websockets do send it.
|
||||
return true
|
||||
}
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(u.Host, r.Host) {
|
||||
return true
|
||||
}
|
||||
_, ok := s.wsAllowedOrigins[strings.ToLower(strings.TrimRight(origin, "/"))]
|
||||
return ok
|
||||
}
|
||||
|
||||
func env(k, d string) string {
|
||||
@@ -130,9 +179,53 @@ func (s *Server) bearer(r *http.Request) (auth.Claims, error) {
|
||||
return s.auth.Parse(strings.TrimPrefix(h, "Bearer "))
|
||||
}
|
||||
|
||||
const adminSessionCookie = "neuralhunt_admin_session"
|
||||
|
||||
func adminCookieSecure(r *http.Request) bool {
|
||||
raw := strings.ToLower(strings.TrimSpace(os.Getenv("ADMIN_COOKIE_SECURE")))
|
||||
switch raw {
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
case "auto":
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]), "https")
|
||||
default:
|
||||
// Public deployments should terminate HTTPS in front of this listener.
|
||||
// Defaulting to Secure avoids a proxy-header mistake silently weakening
|
||||
// the admin session. Local plain-HTTP development can set false explicitly.
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) adminCookieClaims(r *http.Request) (auth.Claims, error) {
|
||||
cookie, err := r.Cookie(adminSessionCookie)
|
||||
if err != nil || strings.TrimSpace(cookie.Value) == "" {
|
||||
return auth.Claims{}, errors.New("missing admin session")
|
||||
}
|
||||
c, err := s.auth.Parse(cookie.Value)
|
||||
if err != nil || c.Role != "admin" || c.SessionID == "" {
|
||||
return auth.Claims{}, errors.New("invalid admin session")
|
||||
}
|
||||
expRaw, ok := s.adminSessions.Load(c.SessionID)
|
||||
exp, typed := expRaw.(int64)
|
||||
if !ok || !typed || exp < time.Now().Unix() {
|
||||
s.adminSessions.Delete(c.SessionID)
|
||||
return auth.Claims{}, errors.New("admin session revoked or expired")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *Server) require(role string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := s.bearer(r)
|
||||
var c auth.Claims
|
||||
var err error
|
||||
if role == "admin" {
|
||||
c, err = s.adminCookieClaims(r)
|
||||
} else {
|
||||
c, err = s.bearer(r)
|
||||
}
|
||||
if err != nil || (role != "" && c.Role != role) {
|
||||
jsonOut(w, 401, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
@@ -143,15 +236,52 @@ func (s *Server) require(role string, next http.Handler) http.Handler {
|
||||
|
||||
func claims(r *http.Request) auth.Claims { return r.Context().Value(claimsKey).(auth.Claims) }
|
||||
|
||||
// PublicRoutes is safe to expose to the Internet. The admin UI and every
|
||||
// /api/admin endpoint are deliberately absent from this listener.
|
||||
func (s *Server) PublicRoutes() http.Handler {
|
||||
next := s.Routes()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := strings.ToLower(r.URL.Path)
|
||||
if p == "/admin" || strings.HasPrefix(p, "/admin/") || p == "/api/admin" || strings.HasPrefix(p, "/api/admin/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// AdminRoutes is intended for the private/VPN listener. It serves only the
|
||||
// control plane, its static frontend assets and health check.
|
||||
func (s *Server) AdminRoutes() http.Handler {
|
||||
next := s.Routes()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := strings.ToLower(r.URL.Path)
|
||||
if p == "/" {
|
||||
http.Redirect(w, r, "/admin", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
allowed := p == "/admin" || strings.HasPrefix(p, "/admin/") || p == "/api/healthz" || p == "/api/admin" || strings.HasPrefix(p, "/api/admin/") || p == "/app.js" || p == "/styles.css" || p == "/index.html"
|
||||
if !allowed {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) Routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self' data:; form-action 'self'")
|
||||
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
|
||||
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
path := strings.ToLower(req.URL.Path)
|
||||
if path == "/" || path == "/admin" || path == "/leaderboard" || strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") {
|
||||
if path == "/" || path == "/admin" || path == "/leaderboard" || strings.HasPrefix(path, "/api/admin") || strings.HasPrefix(path, "/api/auth") || strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
}
|
||||
next.ServeHTTP(w, req)
|
||||
@@ -166,6 +296,7 @@ func (s *Server) Routes() http.Handler {
|
||||
r.Post("/api/auth/challenge", s.challenge)
|
||||
r.Post("/api/auth/login", s.login)
|
||||
r.Post("/api/admin/login", s.adminLogin)
|
||||
r.Post("/api/admin/logout", s.adminLogout)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(func(n http.Handler) http.Handler { return s.require("user", n) })
|
||||
r.Get("/api/tasks", s.clientTasks)
|
||||
@@ -178,6 +309,7 @@ func (s *Server) Routes() http.Handler {
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(func(n http.Handler) http.Handler { return s.require("admin", n) })
|
||||
r.Get("/api/admin/session", s.adminSession)
|
||||
r.Get("/api/admin/overview", s.adminOverview)
|
||||
r.Get("/api/admin/performance", s.adminPerformance)
|
||||
r.Get("/api/admin/profiles/cleanup-preview", s.adminProfileCleanupPreview)
|
||||
@@ -265,20 +397,25 @@ func (s *Server) challenge(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 400, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c, err := s.auth.NewChallenge(r.Context(), cid)
|
||||
proofBits := 0
|
||||
if !s.store.ClientExists(r.Context(), cid) {
|
||||
proofBits = s.settings.Get().SybilProofOfWorkBits
|
||||
}
|
||||
c, err := s.auth.NewChallengeWithProof(r.Context(), cid, proofBits)
|
||||
if err != nil {
|
||||
log.Printf("auth challenge for %s: %v", cid, err)
|
||||
jsonOut(w, 500, map[string]string{"error": "challenge failed"})
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, map[string]string{"client_id": cid, "challenge": c})
|
||||
jsonOut(w, 200, map[string]any{"client_id": cid, "challenge": c, "proof_of_work_bits": proofBits})
|
||||
}
|
||||
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
PublicJWK auth.PublicJWK `json:"public_jwk"`
|
||||
Challenge string `json:"challenge"`
|
||||
Signature string `json:"signature"`
|
||||
PublicJWK auth.PublicJWK `json:"public_jwk"`
|
||||
Challenge string `json:"challenge"`
|
||||
Signature string `json:"signature"`
|
||||
ProofOfWorkCounter string `json:"proof_of_work_counter"`
|
||||
}
|
||||
if err := decodeAuth(r, &in); err != nil {
|
||||
log.Printf("auth login decode: %v", err)
|
||||
@@ -295,7 +432,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 401, map[string]string{"error": "invalid signature"})
|
||||
return
|
||||
}
|
||||
if err = s.auth.ConsumeChallenge(r.Context(), cid, in.Challenge); err != nil {
|
||||
if err = s.auth.ConsumeChallengeWithProof(r.Context(), cid, in.Challenge, in.ProofOfWorkCounter); err != nil {
|
||||
log.Printf("auth login challenge for %s: %v", cid, err)
|
||||
jsonOut(w, 401, map[string]string{"error": err.Error()})
|
||||
return
|
||||
@@ -323,13 +460,53 @@ func (s *Server) adminLogin(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 401, map[string]string{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
tok, _, err := s.auth.Issue("admin", "admin", 8*time.Hour)
|
||||
tok, sessionClaims, err := s.auth.Issue("admin", "admin", 8*time.Hour)
|
||||
if err != nil {
|
||||
log.Printf("admin auth issue token: %v", err)
|
||||
jsonOut(w, 500, map[string]string{"error": "token creation failed"})
|
||||
jsonOut(w, 500, map[string]string{"error": "session creation failed"})
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, map[string]string{"token": tok})
|
||||
nowUnix := time.Now().Unix()
|
||||
s.adminSessions.Range(func(key, value any) bool {
|
||||
if exp, ok := value.(int64); !ok || exp < nowUnix {
|
||||
s.adminSessions.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
s.adminSessions.Store(sessionClaims.SessionID, sessionClaims.Exp)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: adminSessionCookie,
|
||||
Value: tok,
|
||||
Path: "/",
|
||||
MaxAge: int((8 * time.Hour).Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: adminCookieSecure(r),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
jsonOut(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) adminLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(adminSessionCookie); err == nil {
|
||||
if c, err := s.auth.Parse(cookie.Value); err == nil && c.SessionID != "" {
|
||||
s.adminSessions.Delete(c.SessionID)
|
||||
}
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: adminSessionCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: adminCookieSecure(r),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
jsonOut(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) adminSession(w http.ResponseWriter, r *http.Request) {
|
||||
c := claims(r)
|
||||
jsonOut(w, 200, map[string]any{"ok": true, "role": c.Role, "expires_at": time.Unix(c.Exp, 0).UTC()})
|
||||
}
|
||||
|
||||
func taskIntervals(t data.Task, sm settings.Runtime) (int, int) {
|
||||
@@ -476,6 +653,19 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 400, false)
|
||||
return
|
||||
}
|
||||
if warmup := s.settings.Get().SybilWarmupSec; warmup > 0 {
|
||||
created, err := s.store.ClientCreatedAt(r.Context(), c.ClientID)
|
||||
if err != nil {
|
||||
jsonOut(w, 401, false)
|
||||
return
|
||||
}
|
||||
readyAt := created.Add(time.Duration(warmup) * time.Second)
|
||||
if wait := time.Until(readyAt); wait > 0 {
|
||||
retry := int(math.Ceil(wait.Seconds()))
|
||||
jsonAPIError(w, http.StatusTooManyRequests, "identity_warmup", "new identity is still in anti-sybil warmup", map[string]any{"retry_after_sec": retry})
|
||||
return
|
||||
}
|
||||
}
|
||||
t, err := s.store.SecretTask(r.Context(), id)
|
||||
if err != nil || t.Status != "active" {
|
||||
jsonAPIError(w, http.StatusConflict, "task_inactive", "task is no longer active", nil)
|
||||
@@ -751,6 +941,11 @@ func (s *Server) publicArtifactPreview(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) leaderboardWS(w http.ResponseWriter, r *http.Request) {
|
||||
if !acquireWSCap(&s.leaderboardWSCount, s.maxLeaderboardWS) {
|
||||
http.Error(w, "leaderboard websocket capacity reached", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer s.leaderboardWSCount.Add(-1)
|
||||
conn, err := s.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -760,9 +955,31 @@ func (s *Server) leaderboardWS(w http.ResponseWriter, r *http.Request) {
|
||||
defer s.hub.Remove(cl)
|
||||
initial, _ := s.store.LiveLeaderboard(r.Context(), 200)
|
||||
cl.Enqueue(wsx.Event{Type: "leaderboard", Data: initial})
|
||||
|
||||
conn.SetReadLimit(1024)
|
||||
_ = conn.SetReadDeadline(time.Now().Add(90 * time.Second))
|
||||
conn.SetPongHandler(func(string) error { return conn.SetReadDeadline(time.Now().Add(90 * time.Second)) })
|
||||
ping := time.NewTicker(30 * time.Second)
|
||||
defer ping.Stop()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-ping.C:
|
||||
if err := cl.Ping(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1226,8 +1443,40 @@ func (s *Server) adminEnsure(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func websocketAuthToken(r *http.Request) string {
|
||||
if h := strings.TrimSpace(r.Header.Get("Authorization")); strings.HasPrefix(h, "Bearer ") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
|
||||
}
|
||||
for _, part := range strings.Split(r.Header.Get("Sec-WebSocket-Protocol"), ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.HasPrefix(part, "nh-auth.") {
|
||||
return strings.TrimPrefix(part, "nh-auth.")
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(os.Getenv("WS_ALLOW_QUERY_TOKEN")), "1") {
|
||||
return r.URL.Query().Get("token")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func acquireWSCap(counter *atomic.Int64, max int64) bool {
|
||||
if max <= 0 {
|
||||
counter.Add(1)
|
||||
return true
|
||||
}
|
||||
for {
|
||||
cur := counter.Load()
|
||||
if cur >= max {
|
||||
return false
|
||||
}
|
||||
if counter.CompareAndSwap(cur, cur+1) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) ws(w http.ResponseWriter, r *http.Request) {
|
||||
tok := r.URL.Query().Get("token")
|
||||
tok := websocketAuthToken(r)
|
||||
c, err := s.auth.Parse(tok)
|
||||
if err != nil || c.Role != "user" {
|
||||
http.Error(w, "unauthorized", 401)
|
||||
@@ -1247,6 +1496,11 @@ func (s *Server) ws(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.runtime.SetTaskSelection(c.ClientID, t.ID)
|
||||
if !acquireWSCap(&s.userWSCount, s.maxUserWS) {
|
||||
http.Error(w, "websocket capacity reached", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer s.userWSCount.Add(-1)
|
||||
leaseID, err := s.runtime.AcquirePresence(c.ClientID, c.SessionID)
|
||||
if err != nil {
|
||||
http.Error(w, "identity already connected", 409)
|
||||
|
||||
Reference in New Issue
Block a user