fix(relay): secure reconnect and room ownership

This commit is contained in:
edde746
2026-07-24 03:46:50 +02:00
parent e0bf66eea8
commit 43a8fe020d
82 changed files with 12341 additions and 1382 deletions
+149
View File
@@ -0,0 +1,149 @@
package main
import (
"errors"
"net"
"net/http"
"net/netip"
"strings"
)
var errInvalidClientAddress = errors.New("invalid client address")
const (
maxForwardedForBytes = 4 * 1024
maxForwardedForHops = 32
)
type clientIPResolver struct {
trustedProxies []netip.Prefix
}
func newClientIPResolver(trustedProxies []netip.Prefix) clientIPResolver {
return clientIPResolver{trustedProxies: append([]netip.Prefix(nil), trustedProxies...)}
}
func parseTrustedProxyCIDRs(value string) ([]netip.Prefix, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
parts := strings.Split(value, ",")
prefixes := make([]netip.Prefix, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
return nil, errInvalidClientAddress
}
prefix, err := netip.ParsePrefix(part)
if err != nil {
return nil, errInvalidClientAddress
}
if prefix.Addr().Is4In6() {
if prefix.Bits() < 96 {
return nil, errInvalidClientAddress
}
prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-96)
}
prefixes = append(prefixes, prefix.Masked())
}
return prefixes, nil
}
func (r clientIPResolver) resolve(req *http.Request) (string, error) {
peer, err := parseRemoteAddress(req.RemoteAddr)
if err != nil {
return "", errInvalidClientAddress
}
peer = peer.Unmap()
if !r.trusted(peer) {
return normalizeClientAddress(peer), nil
}
values := req.Header.Values("X-Forwarded-For")
totalBytes := 0
hopCount := 0
for _, value := range values {
totalBytes += len(value)
if totalBytes > maxForwardedForBytes {
return "", errInvalidClientAddress
}
hopCount += strings.Count(value, ",") + 1
if hopCount > maxForwardedForHops {
return "", errInvalidClientAddress
}
}
if len(values) == 0 {
return normalizeClientAddress(peer), nil
}
selected := peer
useForwardedHop := true
parsedHops := 0
for valueIndex := len(values) - 1; valueIndex >= 0; valueIndex-- {
value := values[valueIndex]
end := len(value)
for {
separator := strings.LastIndexByte(value[:end], ',')
element := strings.TrimSpace(value[separator+1 : end])
if element == "" {
return "", errInvalidClientAddress
}
addr, parseErr := netip.ParseAddr(element)
if parseErr != nil || addr.Zone() != "" {
return "", errInvalidClientAddress
}
parsedHops++
if useForwardedHop {
if r.trusted(selected) {
selected = addr.Unmap()
} else {
useForwardedHop = false
}
}
if separator < 0 {
break
}
end = separator
}
}
if parsedHops == 0 {
return normalizeClientAddress(peer), nil
}
return normalizeClientAddress(selected), nil
}
func (r clientIPResolver) trusted(addr netip.Addr) bool {
addr = addr.Unmap()
for _, prefix := range r.trustedProxies {
if prefix.Contains(addr) {
return true
}
}
return false
}
func parseRemoteAddress(remote string) (netip.Addr, error) {
host, _, err := net.SplitHostPort(remote)
if err == nil {
addr, parseErr := netip.ParseAddr(host)
if parseErr != nil || addr.Zone() != "" {
return netip.Addr{}, errInvalidClientAddress
}
return addr, nil
}
addr, parseErr := netip.ParseAddr(remote)
if parseErr != nil || addr.Zone() != "" {
return netip.Addr{}, errInvalidClientAddress
}
return addr, nil
}
func normalizeClientAddress(addr netip.Addr) string {
addr = addr.Unmap()
if addr.Is6() {
return netip.PrefixFrom(addr, 64).Masked().Addr().String()
}
return addr.String()
}
+1
View File
@@ -10,6 +10,7 @@ services:
- "127.0.0.1:8080:8080"
environment:
OAUTH_BASE_URL: https://ice.plezy.app
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
MAL_CLIENT_ID: ${MAL_CLIENT_ID:-}
ANILIST_CLIENT_ID: ${ANILIST_CLIENT_ID:-}
ANILIST_CLIENT_SECRET: ${ANILIST_CLIENT_SECRET:-}
+1359 -314
View File
File diff suppressed because it is too large Load Diff
+4147 -141
View File
File diff suppressed because it is too large Load Diff
+182 -78
View File
@@ -9,6 +9,7 @@ package main
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
@@ -30,7 +31,8 @@ const (
oauthMaxSessions = 5000
oauthStartBurst = 3
oauthStartRateSustained = 1
oauthSessionIDBytes = 18 // 144 bits → 24 base64url chars
oauthBrowserStateBytes = 18 // 144 bits → 24 base64url chars
oauthPollSecretBytes = 18 // Independently generated device capability.
oauthPKCEVerifierLen = 64
oauthUpstreamTimeout = 15 * time.Second
)
@@ -54,70 +56,88 @@ type oauthTokenResult struct {
Error string `json:"error,omitempty"`
}
// oauthSession is created by /auth/start and lives until it's consumed by a
// successful /auth/result (which deletes the map entry) or GC'd after
// oauthSessionTTL. The `done` channel is closed exactly once (by complete) and
// unblocks waiters.
// oauthSession is created by /auth/start and lives until its result is claimed
// or it is removed by cleanup. browserState crosses the browser/provider trust
// boundary. Only the SHA-256 digest of the device-only poll secret is retained.
// When both locks are needed, oauthProxy.mu must be acquired before s.mu.
type oauthSession struct {
id string
browserState string
pollDigest [sha256.Size]byte
service string
codeVerifier string // MAL PKCE; empty for AniList. Cleared after token exchange.
createdAt time.Time
done chan struct{}
mu sync.Mutex
result *oauthTokenResult
mu sync.Mutex
completed bool
result *oauthTokenResult
// Test seam used to deterministically seat concurrent result waiters.
waitStarted func()
}
func (s *oauthSession) complete(r oauthTokenResult) {
s.mu.Lock()
defer s.mu.Unlock()
if s.result != nil {
return
// completeLocked publishes at most one terminal result. The caller holds s.mu.
func (s *oauthSession) completeLocked(r oauthTokenResult) bool {
if s.completed {
return false
}
s.completed = true
s.result = &r
s.codeVerifier = "" // Secret, not needed after exchange.
close(s.done)
return true
}
// wait blocks until the session is completed or ctx is cancelled.
func (s *oauthSession) wait(ctx context.Context) (*oauthTokenResult, error) {
// wait blocks only until the session is ready or ctx is cancelled. Result
// ownership is transferred separately by oauthProxy.claimResult.
func (s *oauthSession) wait(ctx context.Context) error {
if s.waitStarted != nil {
s.waitStarted()
}
select {
case <-s.done:
s.mu.Lock()
defer s.mu.Unlock()
return s.result, nil
return nil
case <-ctx.Done():
return nil, ctx.Err()
return ctx.Err()
}
}
type oauthProxy struct {
baseURL string // e.g. https://ice.plezy.app
services map[string]oauthServiceConfig
client *http.Client
func (s *oauthSession) pkceVerifier() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.codeVerifier
}
mu sync.Mutex
sessions map[string]*oauthSession
type oauthProxy struct {
baseURL string // e.g. https://ice.plezy.app
services map[string]oauthServiceConfig
client *http.Client
clientIPs clientIPResolver
mu sync.Mutex
browserStates map[string]*oauthSession
pollDigests map[[sha256.Size]byte]*oauthSession
ipMu sync.Mutex
ipRate map[string]*rateLimiter
}
func newOAuthProxy(baseURL string, services map[string]oauthServiceConfig) *oauthProxy {
func newOAuthProxy(baseURL string, services map[string]oauthServiceConfig, clientIPs clientIPResolver) *oauthProxy {
return &oauthProxy{
baseURL: strings.TrimRight(baseURL, "/"),
services: services,
client: &http.Client{Timeout: oauthUpstreamTimeout},
sessions: make(map[string]*oauthSession),
ipRate: make(map[string]*rateLimiter),
baseURL: strings.TrimRight(baseURL, "/"),
services: services,
client: &http.Client{Timeout: oauthUpstreamTimeout},
clientIPs: clientIPs,
browserStates: make(map[string]*oauthSession),
pollDigests: make(map[[sha256.Size]byte]*oauthSession),
ipRate: make(map[string]*rateLimiter),
}
}
// oauthConfigFromEnv reads the public base URL and per-service creds from the
// environment. Returns (nil, false) if OAUTH_BASE_URL is unset — the caller
// wires this as "OAuth disabled, endpoints return 503".
func oauthConfigFromEnv() (*oauthProxy, bool) {
func oauthConfigFromEnv(clientIPs clientIPResolver) (*oauthProxy, bool) {
base := os.Getenv("OAUTH_BASE_URL")
if base == "" {
return nil, false
@@ -140,7 +160,7 @@ func oauthConfigFromEnv() (*oauthProxy, bool) {
TokenURL: "https://anilist.co/api/v2/oauth/token",
}
}
return newOAuthProxy(base, services), true
return newOAuthProxy(base, services, clientIPs), true
}
// registerOAuthRoutes registers all /auth/* handlers. If p is nil (no env
@@ -180,13 +200,18 @@ func (p *oauthProxy) handleAuthRoot(w http.ResponseWriter, r *http.Request) {
}
// POST /auth/start body={"service":"mal"|"anilist"}
// Response: {"session":"...","url":"https://.../auth/:service?session=...","expiresIn":600}
// Response: {"session":"device-only poll capability","url":"https://.../auth/:service?state=...","expiresIn":600}
func (p *oauthProxy) handleStart(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store, private")
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ip := clientIP(r)
ip, err := p.clientIPs.resolve(r)
if err != nil {
http.Error(w, "Invalid client address", http.StatusBadRequest)
return
}
if !p.ipAllow(ip) {
http.Error(w, "Rate limited", http.StatusTooManyRequests)
return
@@ -205,36 +230,47 @@ func (p *oauthProxy) handleStart(w http.ResponseWriter, r *http.Request) {
return
}
// Generate tokens outside the map lock — crypto/rand syscalls would
// otherwise serialize concurrent /auth/start calls.
sess := &oauthSession{
id: randToken(oauthSessionIDBytes),
service: body.Service,
createdAt: time.Now(),
done: make(chan struct{}),
}
if cfg.UsePKCE {
sess.codeVerifier = randPKCEVerifier()
}
// Generate independent trust-domain values outside the map lock —
// crypto/rand syscalls must not serialize concurrent /auth/start calls.
var pollSecret string
var sess *oauthSession
for {
pollSecret = randToken(oauthPollSecretBytes)
sess = &oauthSession{
browserState: randToken(oauthBrowserStateBytes),
pollDigest: digestPollSecret(pollSecret),
service: body.Service,
createdAt: time.Now(),
done: make(chan struct{}),
}
if cfg.UsePKCE {
sess.codeVerifier = randPKCEVerifier()
}
p.mu.Lock()
if len(p.sessions) >= oauthMaxSessions {
p.mu.Lock()
if len(p.browserStates) >= oauthMaxSessions {
p.mu.Unlock()
http.Error(w, "Server busy", http.StatusServiceUnavailable)
return
}
if p.browserStates[sess.browserState] != nil || p.pollDigests[sess.pollDigest] != nil {
p.mu.Unlock()
continue
}
p.addSessionLocked(sess)
p.mu.Unlock()
http.Error(w, "Server busy", http.StatusServiceUnavailable)
return
break
}
p.sessions[sess.id] = sess
p.mu.Unlock()
resp := map[string]any{
"session": sess.id,
"url": fmt.Sprintf("%s/auth/%s?session=%s", p.baseURL, url.PathEscape(body.Service), url.QueryEscape(sess.id)),
"session": pollSecret,
"url": fmt.Sprintf("%s/auth/%s?state=%s", p.baseURL, url.PathEscape(body.Service), url.QueryEscape(sess.browserState)),
"expiresIn": int(oauthSessionTTL.Seconds()),
}
writeJSON(w, http.StatusOK, resp)
}
// GET /auth/:service?session=X → 302 upstream authorize URL
// GET /auth/:service?state=X → 302 upstream authorize URL
func (p *oauthProxy) handleAuthorize(w http.ResponseWriter, r *http.Request, service string) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -245,9 +281,9 @@ func (p *oauthProxy) handleAuthorize(w http.ResponseWriter, r *http.Request, ser
http.NotFound(w, r)
return
}
sessionID := r.URL.Query().Get("session")
browserState := r.URL.Query().Get("state")
p.mu.Lock()
sess := p.sessions[sessionID]
sess := p.browserStates[browserState]
p.mu.Unlock()
if sess == nil || sess.service != service {
renderErrorPage(w, http.StatusNotFound, "This sign-in link is no longer valid. Start again from Plezy.")
@@ -258,13 +294,13 @@ func (p *oauthProxy) handleAuthorize(w http.ResponseWriter, r *http.Request, ser
"response_type": {"code"},
"client_id": {cfg.ClientID},
"redirect_uri": {p.redirectURI(service)},
"state": {sess.id},
"state": {sess.browserState},
}
if cfg.Scopes != "" {
q.Set("scope", cfg.Scopes)
}
if cfg.UsePKCE {
q.Set("code_challenge", sess.codeVerifier) // plain method ⇒ challenge == verifier
q.Set("code_challenge", sess.pkceVerifier()) // plain method ⇒ challenge == verifier
q.Set("code_challenge_method", cfg.PKCEMethod)
}
http.Redirect(w, r, cfg.AuthorizeURL+"?"+q.Encode(), http.StatusFound)
@@ -284,7 +320,7 @@ func (p *oauthProxy) handleCallback(w http.ResponseWriter, r *http.Request, serv
q := r.URL.Query()
state := q.Get("state")
p.mu.Lock()
sess := p.sessions[state]
sess := p.browserStates[state]
p.mu.Unlock()
if sess == nil || sess.service != service {
renderErrorPage(w, http.StatusNotFound, "This sign-in link is no longer valid. Start again from Plezy.")
@@ -292,13 +328,25 @@ func (p *oauthProxy) handleCallback(w http.ResponseWriter, r *http.Request, serv
}
if upstreamErr := q.Get("error"); upstreamErr != "" {
sess.complete(oauthTokenResult{Error: upstreamErr})
renderErrorPage(w, http.StatusOK, "Sign-in was cancelled.")
publicError := "authorization_failed"
message := "Sign-in failed. Please try again."
if upstreamErr == "access_denied" {
publicError = "access_denied"
message = "Sign-in was cancelled."
}
if !p.completeSession(sess, oauthTokenResult{Error: publicError}) {
renderErrorPage(w, http.StatusNotFound, "This sign-in link is no longer valid. Start again from Plezy.")
return
}
renderErrorPage(w, http.StatusOK, message)
return
}
code := q.Get("code")
if code == "" {
sess.complete(oauthTokenResult{Error: "missing_code"})
if !p.completeSession(sess, oauthTokenResult{Error: "missing_code"}) {
renderErrorPage(w, http.StatusNotFound, "This sign-in link is no longer valid. Start again from Plezy.")
return
}
renderErrorPage(w, http.StatusBadRequest, "Sign-in response was incomplete. Please try again.")
return
}
@@ -306,23 +354,30 @@ func (p *oauthProxy) handleCallback(w http.ResponseWriter, r *http.Request, serv
tok, err := p.exchangeCode(r.Context(), cfg, service, sess, code)
if err != nil {
log.Printf("oauth: %s token exchange failed: %v", service, err)
sess.complete(oauthTokenResult{Error: "exchange_failed"})
if !p.completeSession(sess, oauthTokenResult{Error: "exchange_failed"}) {
renderErrorPage(w, http.StatusNotFound, "This sign-in link is no longer valid. Start again from Plezy.")
return
}
renderErrorPage(w, http.StatusBadGateway, "Couldn't complete sign-in. Please try again.")
return
}
sess.complete(tok)
if !p.completeSession(sess, tok) {
renderErrorPage(w, http.StatusNotFound, "This sign-in link is no longer valid. Start again from Plezy.")
return
}
renderSuccessPage(w)
}
// GET /auth/result?session=X → long-poll, returns tokens on success
// GET /auth/result?session=X → long-poll, returns one terminal result.
func (p *oauthProxy) handleResult(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store, private")
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
sessionID := r.URL.Query().Get("session")
pollDigest := digestPollSecret(r.URL.Query().Get("session"))
p.mu.Lock()
sess := p.sessions[sessionID]
sess := p.pollDigests[pollDigest]
p.mu.Unlock()
if sess == nil {
http.Error(w, "Session not found", http.StatusGone)
@@ -331,17 +386,16 @@ func (p *oauthProxy) handleResult(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), oauthResultWait)
defer cancel()
result, err := sess.wait(ctx)
if err != nil {
if err := sess.wait(ctx); err != nil {
// Client should retry — session may still receive its callback.
w.WriteHeader(http.StatusNoContent)
return
}
// Session consumed — delete so a retry sees 410 instead of racing another wait.
p.mu.Lock()
delete(p.sessions, sess.id)
p.mu.Unlock()
result, ok := p.claimResult(pollDigest, sess)
if !ok {
http.Error(w, "Session not found", http.StatusGone)
return
}
if result.Error != "" {
writeJSON(w, http.StatusOK, map[string]any{"error": result.Error})
return
@@ -369,7 +423,7 @@ func (p *oauthProxy) exchangeCode(ctx context.Context, cfg oauthServiceConfig, s
form.Set("client_secret", cfg.ClientSecret)
}
if cfg.UsePKCE {
form.Set("code_verifier", sess.codeVerifier)
form.Set("code_verifier", sess.pkceVerifier())
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.TokenURL, strings.NewReader(form.Encode()))
@@ -410,13 +464,59 @@ func (p *oauthProxy) redirectURI(service string) string {
return fmt.Sprintf("%s/auth/%s/callback", p.baseURL, service)
}
// addSessionLocked installs both independently generated keys as one logical
// session. The caller has already verified that neither key is live.
func (p *oauthProxy) addSessionLocked(sess *oauthSession) {
p.browserStates[sess.browserState] = sess
p.pollDigests[sess.pollDigest] = sess
}
// removeSessionLocked removes only entries still owned by sess, so a stale
// callback or waiter cannot remove a replacement.
func (p *oauthProxy) removeSessionLocked(sess *oauthSession) {
if p.browserStates[sess.browserState] == sess {
delete(p.browserStates, sess.browserState)
}
if p.pollDigests[sess.pollDigest] == sess {
delete(p.pollDigests, sess.pollDigest)
}
}
func (p *oauthProxy) completeSession(sess *oauthSession, result oauthTokenResult) bool {
p.mu.Lock()
defer p.mu.Unlock()
if p.browserStates[sess.browserState] != sess || p.pollDigests[sess.pollDigest] != sess {
return false
}
sess.mu.Lock()
defer sess.mu.Unlock()
return sess.completeLocked(result)
}
func (p *oauthProxy) claimResult(digest [sha256.Size]byte, sess *oauthSession) (oauthTokenResult, bool) {
p.mu.Lock()
defer p.mu.Unlock()
if p.pollDigests[digest] != sess || p.browserStates[sess.browserState] != sess {
return oauthTokenResult{}, false
}
sess.mu.Lock()
defer sess.mu.Unlock()
if sess.result == nil {
return oauthTokenResult{}, false
}
result := *sess.result
sess.result = nil
p.removeSessionLocked(sess)
return result, true
}
// cleanup drops sessions past oauthSessionTTL. Called by the main cleanup loop.
func (p *oauthProxy) cleanup() {
now := time.Now()
p.mu.Lock()
for id, sess := range p.sessions {
for _, sess := range p.browserStates {
if now.Sub(sess.createdAt) > oauthSessionTTL {
delete(p.sessions, id)
p.removeSessionLocked(sess)
}
}
p.mu.Unlock()
@@ -463,6 +563,10 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
_ = json.NewEncoder(w).Encode(v)
}
func digestPollSecret(secret string) [sha256.Size]byte {
return sha256.Sum256([]byte(secret))
}
func randToken(numBytes int) string {
b := make([]byte, numBytes)
if _, err := rand.Read(b); err != nil {
+402 -152
View File
@@ -104,13 +104,18 @@ func (m *mockUpstream) form() url.Values {
// newOAuthHarness boots a relay-less httptest server that mounts /auth/* only,
// with `mal` and `anilist` services pointed at a shared mock upstream.
type oauthHarness struct {
proxy *oauthProxy
srv *httptest.Server
base string
proxy *oauthProxy
srv *httptest.Server
base string
upstream *mockUpstream
}
func newOAuthHarness(t *testing.T) *oauthHarness {
t.Helper()
return newOAuthHarnessWithResolver(t, mustClientIPResolver(t, "127.0.0.0/8"))
}
func newOAuthHarnessWithResolver(t *testing.T, clientIPs clientIPResolver) *oauthHarness {
t.Helper()
up := newMockUpstream(t)
proxy := newOAuthProxy("http://placeholder", map[string]oauthServiceConfig{
@@ -127,7 +132,7 @@ func newOAuthHarness(t *testing.T) *oauthHarness {
AuthorizeURL: up.srv.URL + "/oauth/authorize",
TokenURL: up.srv.URL + "/oauth/token",
},
})
}, clientIPs)
mux := http.NewServeMux()
registerOAuthRoutes(mux, proxy)
srv := httptest.NewServer(mux)
@@ -137,7 +142,7 @@ func newOAuthHarness(t *testing.T) *oauthHarness {
return &oauthHarness{proxy: proxy, srv: srv, base: srv.URL, upstream: up}
}
func (h *oauthHarness) startSession(t *testing.T, service, ip string) (sessionID, qrURL string) {
func (h *oauthHarness) startSession(t *testing.T, service, ip string) (pollSecret, browserState, authorizeURL string) {
t.Helper()
body, _ := json.Marshal(map[string]string{"service": service})
req, _ := http.NewRequest(http.MethodPost, h.base+"/auth/start", bytes.NewReader(body))
@@ -153,6 +158,9 @@ func (h *oauthHarness) startSession(t *testing.T, service, ip string) (sessionID
if resp.StatusCode != http.StatusOK {
t.Fatalf("start status=%d", resp.StatusCode)
}
if got := resp.Header.Get("Cache-Control"); got != "no-store, private" {
t.Fatalf("start Cache-Control=%q", got)
}
var out struct {
Session string `json:"session"`
URL string `json:"url"`
@@ -161,22 +169,58 @@ func (h *oauthHarness) startSession(t *testing.T, service, ip string) (sessionID
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode: %v", err)
}
if out.Session == "" || out.URL == "" {
t.Fatalf("empty session/url: %+v", out)
parsed, err := url.Parse(out.URL)
if err != nil {
t.Fatalf("parse authorize URL: %v", err)
}
return out.Session, out.URL
state := parsed.Query().Get("state")
if out.Session == "" || out.URL == "" || state == "" {
t.Fatalf("empty poll secret/url/browser state: %+v", out)
}
return out.Session, state, out.URL
}
func postOAuthStart(t *testing.T, h *oauthHarness, service, xff string) *http.Response {
t.Helper()
body, err := json.Marshal(map[string]string{"service": service})
if err != nil {
t.Fatalf("marshal: %v", err)
}
req, err := http.NewRequest(http.MethodPost, h.base+"/auth/start", bytes.NewReader(body))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
if xff != "" {
req.Header.Set("X-Forwarded-For", xff)
}
return httpDo(t, req)
}
// ====== /auth/start ======
func TestOAuthStartReturnsSessionAndURL(t *testing.T) {
func TestOAuthStartSeparatesDeviceCapabilityFromBrowserState(t *testing.T) {
h := newOAuthHarness(t)
session, qr := h.startSession(t, "mal", "1.2.3.4")
if !strings.HasPrefix(qr, h.base+"/auth/mal?session=") {
t.Fatalf("url=%q doesn't look like the authorize start URL", qr)
pollSecret, browserState, authorizeURL := h.startSession(t, "mal", "1.2.3.4")
if pollSecret == browserState {
t.Fatal("device poll capability must differ from browser state")
}
if !strings.Contains(qr, url.QueryEscape(session)) {
t.Fatalf("url=%q missing session token", qr)
if !strings.HasPrefix(authorizeURL, h.base+"/auth/mal?state=") {
t.Fatalf("url=%q doesn't look like the authorize start URL", authorizeURL)
}
if strings.Contains(authorizeURL, pollSecret) {
t.Fatalf("authorize URL disclosed device poll capability")
}
if got := h.proxy.pollDigests[digestPollSecret(pollSecret)]; got == nil {
t.Fatal("poll capability digest was not indexed")
}
if got := h.proxy.browserStates[browserState]; got == nil {
t.Fatal("browser state was not indexed")
}
pollAsBrowser := httpGet(t, h.base+"/auth/mal?state="+url.QueryEscape(pollSecret))
pollAsBrowser.Body.Close()
if pollAsBrowser.StatusCode != http.StatusNotFound {
t.Fatalf("poll capability authorized browser path: status=%d", pollAsBrowser.StatusCode)
}
}
@@ -202,8 +246,8 @@ func TestOAuthStartRejectsInvalidJSON(t *testing.T) {
func TestOAuthStartRateLimitedPerIP(t *testing.T) {
h := newOAuthHarness(t)
ip := "5.5.5.5"
for i := 0; i < oauthStartBurst; i++ {
h.startSession(t, "mal", ip) // should all succeed
for range oauthStartBurst {
_, _, _ = h.startSession(t, "mal", ip) // should all succeed
}
body, _ := json.Marshal(map[string]string{"service": "mal"})
req, err := http.NewRequest(http.MethodPost, h.base+"/auth/start", bytes.NewReader(body))
@@ -219,6 +263,98 @@ func TestOAuthStartRateLimitedPerIP(t *testing.T) {
}
}
func TestOAuthSessionLimitCountsLogicalSessions(t *testing.T) {
h := newOAuthHarness(t)
h.proxy.mu.Lock()
for i := range oauthMaxSessions - 1 {
sess := &oauthSession{
browserState: fmt.Sprintf("state-%d", i),
pollDigest: digestPollSecret(fmt.Sprintf("poll-%d", i)),
}
h.proxy.addSessionLocked(sess)
}
h.proxy.mu.Unlock()
body, _ := json.Marshal(map[string]string{"service": "mal"})
req := httptest.NewRequest(http.MethodPost, "/auth/start", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.proxy.handleStart(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("start with %d logical sessions: status=%d want 200", oauthMaxSessions-1, rec.Code)
}
h.proxy.mu.Lock()
browserCount := len(h.proxy.browserStates)
pollCount := len(h.proxy.pollDigests)
h.proxy.mu.Unlock()
if browserCount != oauthMaxSessions || pollCount != oauthMaxSessions {
t.Fatalf("index counts browser=%d poll=%d want %d each", browserCount, pollCount, oauthMaxSessions)
}
}
func TestOAuthStartUsesTrustedCanonicalClientIdentity(t *testing.T) {
t.Run("untrusted spoof rotation shares direct peer bucket", func(t *testing.T) {
h := newOAuthHarnessWithResolver(t, newClientIPResolver(nil))
for i := range oauthStartBurst {
resp := postOAuthStart(t, h, "mal", fmt.Sprintf("203.0.113.%d", i+1))
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("start %d status=%d", i, resp.StatusCode)
}
}
denied := postOAuthStart(t, h, "mal", "198.51.100.10")
denied.Body.Close()
if denied.StatusCode != http.StatusTooManyRequests {
t.Fatalf("rotated spoof status=%d, want 429", denied.StatusCode)
}
})
t.Run("validated clients have independent buckets", func(t *testing.T) {
h := newOAuthHarness(t)
for _, ip := range []string{"203.0.113.1", "203.0.113.2"} {
for range oauthStartBurst {
resp := postOAuthStart(t, h, "mal", ip)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("client %s status=%d", ip, resp.StatusCode)
}
}
}
})
t.Run("malformed trusted chain mutates no state", func(t *testing.T) {
h := newOAuthHarness(t)
resp := postOAuthStart(t, h, "mal", "203.0.113.1,")
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status=%d, want 400", resp.StatusCode)
}
h.proxy.ipMu.Lock()
rateCount := len(h.proxy.ipRate)
h.proxy.ipMu.Unlock()
h.proxy.mu.Lock()
browserCount := len(h.proxy.browserStates)
pollCount := len(h.proxy.pollDigests)
h.proxy.mu.Unlock()
if rateCount != 0 || browserCount != 0 || pollCount != 0 {
t.Fatalf(
"malformed chain mutated OAuth state: rates=%d browser=%d poll=%d",
rateCount,
browserCount,
pollCount,
)
}
})
t.Run("untrusted malformed header is ignored", func(t *testing.T) {
h := newOAuthHarnessWithResolver(t, newClientIPResolver(nil))
resp := postOAuthStart(t, h, "mal", "bad,")
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d, want 200", resp.StatusCode)
}
})
}
func TestOAuthStartMethodNotAllowed(t *testing.T) {
h := newOAuthHarness(t)
resp := httpGet(t, h.base+"/auth/start")
@@ -232,10 +368,10 @@ func TestOAuthStartMethodNotAllowed(t *testing.T) {
func TestOAuthAuthorizeMALRedirectIncludesPKCE(t *testing.T) {
h := newOAuthHarness(t)
sess, _ := h.startSession(t, "mal", "1.1.1.1")
pollSecret, browserState, _ := h.startSession(t, "mal", "1.1.1.1")
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := client.Get(h.base + "/auth/mal?session=" + url.QueryEscape(sess))
resp, err := client.Get(h.base + "/auth/mal?state=" + url.QueryEscape(browserState))
if err != nil {
t.Fatalf("get: %v", err)
}
@@ -254,8 +390,11 @@ func TestOAuthAuthorizeMALRedirectIncludesPKCE(t *testing.T) {
if q.Get("response_type") != "code" {
t.Errorf("response_type=%q", q.Get("response_type"))
}
if q.Get("state") != sess {
t.Errorf("state=%q, want session %q", q.Get("state"), sess)
if q.Get("state") != browserState {
t.Errorf("state=%q, want browser state %q", q.Get("state"), browserState)
}
if q.Get("state") == pollSecret {
t.Error("provider state disclosed device poll capability")
}
if q.Get("code_challenge_method") != "plain" {
t.Errorf("code_challenge_method=%q, want plain", q.Get("code_challenge_method"))
@@ -270,9 +409,9 @@ func TestOAuthAuthorizeMALRedirectIncludesPKCE(t *testing.T) {
func TestOAuthAuthorizeAnilistRedirectOmitsPKCE(t *testing.T) {
h := newOAuthHarness(t)
sess, _ := h.startSession(t, "anilist", "1.1.1.2")
_, browserState, _ := h.startSession(t, "anilist", "1.1.1.2")
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := client.Get(h.base + "/auth/anilist?session=" + url.QueryEscape(sess))
resp, err := client.Get(h.base + "/auth/anilist?state=" + url.QueryEscape(browserState))
if err != nil {
t.Fatalf("get: %v", err)
}
@@ -286,7 +425,7 @@ func TestOAuthAuthorizeAnilistRedirectOmitsPKCE(t *testing.T) {
func TestOAuthAuthorizeUnknownSessionRendersError(t *testing.T) {
h := newOAuthHarness(t)
resp := httpGet(t, h.base+"/auth/mal?session=bogus")
resp := httpGet(t, h.base+"/auth/mal?state=bogus")
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status=%d want 404", resp.StatusCode)
@@ -299,9 +438,9 @@ func TestOAuthAuthorizeUnknownSessionRendersError(t *testing.T) {
func TestOAuthAuthorizeWrongServiceRejected(t *testing.T) {
h := newOAuthHarness(t)
sess, _ := h.startSession(t, "mal", "1.1.1.3")
// Try to use the MAL session against the AniList authorize endpoint.
resp := httpGet(t, h.base+"/auth/anilist?session="+url.QueryEscape(sess))
_, browserState, _ := h.startSession(t, "mal", "1.1.1.3")
// Try to use the MAL browser state against the AniList authorize endpoint.
resp := httpGet(t, h.base+"/auth/anilist?state="+url.QueryEscape(browserState))
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status=%d want 404", resp.StatusCode)
@@ -310,117 +449,238 @@ func TestOAuthAuthorizeWrongServiceRejected(t *testing.T) {
// ====== /auth/:service/callback + /auth/result ======
func TestOAuthCallbackExchangesCodeAndResultReturnsTokens(t *testing.T) {
h := newOAuthHarness(t)
sess, _ := h.startSession(t, "mal", "2.2.2.1")
type oauthResultResponse struct {
status int
cacheControl string
body map[string]any
err error
}
resultCh := make(chan map[string]any, 1)
go func() {
resp, err := http.Get(h.base + "/auth/result?session=" + url.QueryEscape(sess))
if err != nil {
resultCh <- map[string]any{"_err": err.Error()}
return
}
defer resp.Body.Close()
var m map[string]any
_ = json.NewDecoder(resp.Body).Decode(&m)
resultCh <- m
}()
// Hit the callback as the upstream browser would.
cbURL := fmt.Sprintf("%s/auth/mal/callback?code=CODE123&state=%s", h.base, url.QueryEscape(sess))
resp, err := http.Get(cbURL)
func requestOAuthResult(rawURL string) oauthResultResponse {
resp, err := http.Get(rawURL)
if err != nil {
t.Fatalf("callback: %v", err)
return oauthResultResponse{err: err}
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("callback status=%d", resp.StatusCode)
defer resp.Body.Close()
out := oauthResultResponse{
status: resp.StatusCode,
cacheControl: resp.Header.Get("Cache-Control"),
}
if resp.StatusCode == http.StatusOK {
out.body = make(map[string]any)
out.err = json.NewDecoder(resp.Body).Decode(&out.body)
}
return out
}
// Upstream should have been called with PKCE + code.
form := h.upstream.form()
if form.Get("code") != "CODE123" {
t.Errorf("upstream code=%q", form.Get("code"))
}
if form.Get("code_verifier") == "" {
t.Error("upstream missing code_verifier (PKCE)")
}
if form.Get("grant_type") != "authorization_code" {
t.Errorf("grant_type=%q", form.Get("grant_type"))
}
func TestOAuthBrowserStateCannotClaimResult(t *testing.T) {
for _, service := range []string{"mal", "anilist"} {
t.Run(service, func(t *testing.T) {
h := newOAuthHarness(t)
pollSecret, browserState, _ := h.startSession(t, service, "2.2.2.1")
select {
case got := <-resultCh:
if got["accessToken"] != "tok-abc" {
t.Errorf("accessToken=%v want tok-abc", got["accessToken"])
}
if got["refreshToken"] != "ref-xyz" {
t.Errorf("refreshToken=%v want ref-xyz", got["refreshToken"])
}
case <-time.After(3 * time.Second):
t.Fatal("result never returned")
browserClaim := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(browserState))
if browserClaim.err != nil {
t.Fatalf("browser-state result request: %v", browserClaim.err)
}
if browserClaim.status != http.StatusGone {
t.Fatalf("browser-state result status=%d want 410", browserClaim.status)
}
callback := fmt.Sprintf("%s/auth/%s/callback?code=CODE123&state=%s", h.base, service, url.QueryEscape(browserState))
resp := httpGet(t, callback)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("callback status=%d", resp.StatusCode)
}
result := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(pollSecret))
if result.err != nil {
t.Fatalf("device result request: %v", result.err)
}
if result.status != http.StatusOK {
t.Fatalf("device result status=%d want 200", result.status)
}
if result.cacheControl != "no-store, private" {
t.Fatalf("result Cache-Control=%q", result.cacheControl)
}
if result.body["accessToken"] != "tok-abc" || result.body["refreshToken"] != "ref-xyz" {
t.Fatalf("unexpected result: %v", result.body)
}
second := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(pollSecret))
if second.err != nil {
t.Fatalf("second result request: %v", second.err)
}
if second.status != http.StatusGone {
t.Fatalf("second result status=%d want 410", second.status)
}
form := h.upstream.form()
if form.Get("code") != "CODE123" {
t.Errorf("upstream code=%q", form.Get("code"))
}
if service == "mal" && form.Get("code_verifier") == "" {
t.Error("upstream missing MAL code verifier")
}
if service == "anilist" && form.Get("code_verifier") != "" {
t.Error("AniList exchange unexpectedly included a code verifier")
}
})
}
}
func TestOAuthCallbackUpstreamError(t *testing.T) {
func TestOAuthConcurrentResultClaimIsOneShot(t *testing.T) {
for _, tc := range []struct {
name string
result oauthTokenResult
}{
{name: "token", result: oauthTokenResult{AccessToken: "tok"}},
{name: "provider error", result: oauthTokenResult{Error: "authorization_failed"}},
} {
t.Run(tc.name, func(t *testing.T) {
h := newOAuthHarness(t)
pollSecret, browserState, _ := h.startSession(t, "mal", "2.2.2.2")
digest := digestPollSecret(pollSecret)
h.proxy.mu.Lock()
sess := h.proxy.pollDigests[digest]
h.proxy.mu.Unlock()
if sess == nil {
t.Fatal("session missing from poll index")
}
seated := make(chan struct{}, 2)
sess.waitStarted = func() { seated <- struct{}{} }
results := make(chan oauthResultResponse, 2)
resultURL := h.base + "/auth/result?session=" + url.QueryEscape(pollSecret)
go func() { results <- requestOAuthResult(resultURL) }()
go func() { results <- requestOAuthResult(resultURL) }()
for range 2 {
select {
case <-seated:
case <-time.After(3 * time.Second):
t.Fatal("result waiter did not reach readiness boundary")
}
}
if !h.proxy.completeSession(sess, tc.result) {
t.Fatal("could not complete current session")
}
statuses := map[int]int{}
var winningBody map[string]any
for range 2 {
select {
case got := <-results:
if got.err != nil {
t.Fatalf("result request: %v", got.err)
}
if got.cacheControl != "no-store, private" {
t.Errorf("result Cache-Control=%q", got.cacheControl)
}
statuses[got.status]++
if got.status == http.StatusOK {
winningBody = got.body
}
case <-time.After(3 * time.Second):
t.Fatal("result request did not return")
}
}
if statuses[http.StatusOK] != 1 || statuses[http.StatusGone] != 1 {
t.Fatalf("statuses=%v want one 200 and one 410", statuses)
}
if tc.result.Error != "" && winningBody["error"] != tc.result.Error {
t.Fatalf("winning error result=%v", winningBody)
}
if tc.result.AccessToken != "" && winningBody["accessToken"] != tc.result.AccessToken {
t.Fatalf("winning token result=%v", winningBody)
}
h.proxy.mu.Lock()
_, hasBrowserState := h.proxy.browserStates[browserState]
_, hasPollDigest := h.proxy.pollDigests[digest]
h.proxy.mu.Unlock()
sess.mu.Lock()
storedResult := sess.result
sess.mu.Unlock()
if hasBrowserState || hasPollDigest || storedResult != nil {
t.Fatalf("claim left state: browser=%v poll=%v result=%v", hasBrowserState, hasPollDigest, storedResult)
}
})
}
}
func TestOAuthCallbackErrorsAreGenericAndOneShot(t *testing.T) {
for _, tc := range []struct {
name string
callback func(base, state string) string
wantError string
wantCBState int
}{
{
name: "provider detail",
callback: func(base, state string) string {
return fmt.Sprintf("%s/auth/mal/callback?error=provider_detail_canary&state=%s", base, url.QueryEscape(state))
},
wantError: "authorization_failed",
wantCBState: http.StatusOK,
},
{
name: "user cancelled",
callback: func(base, state string) string {
return fmt.Sprintf("%s/auth/mal/callback?error=access_denied&state=%s", base, url.QueryEscape(state))
},
wantError: "access_denied",
wantCBState: http.StatusOK,
},
} {
t.Run(tc.name, func(t *testing.T) {
h := newOAuthHarness(t)
pollSecret, browserState, _ := h.startSession(t, "mal", "2.2.2.3")
resp := httpGet(t, tc.callback(h.base, browserState))
resp.Body.Close()
if resp.StatusCode != tc.wantCBState {
t.Fatalf("callback status=%d want %d", resp.StatusCode, tc.wantCBState)
}
result := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(pollSecret))
if result.err != nil {
t.Fatalf("result request: %v", result.err)
}
if result.status != http.StatusOK || result.body["error"] != tc.wantError {
t.Fatalf("result status=%d body=%v", result.status, result.body)
}
if result.cacheControl != "no-store, private" {
t.Fatalf("result Cache-Control=%q", result.cacheControl)
}
second := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(pollSecret))
if second.status != http.StatusGone {
t.Fatalf("second result status=%d want 410", second.status)
}
})
}
}
func TestOAuthCallbackExchangeFailureIsOneShot(t *testing.T) {
h := newOAuthHarness(t)
h.upstream.setReply(http.StatusBadRequest, `{"error":"invalid_grant"}`)
sess, _ := h.startSession(t, "mal", "2.2.2.2")
pollSecret, browserState, _ := h.startSession(t, "mal", "2.2.2.4")
resultCh := make(chan map[string]any, 1)
go func() {
resp, err := http.Get(h.base + "/auth/result?session=" + url.QueryEscape(sess))
if err != nil {
resultCh <- map[string]any{"_err": err.Error()}
return
}
defer resp.Body.Close()
var m map[string]any
_ = json.NewDecoder(resp.Body).Decode(&m)
resultCh <- m
}()
resp := httpGet(t, fmt.Sprintf("%s/auth/mal/callback?code=CODE&state=%s", h.base, url.QueryEscape(sess)))
resp := httpGet(t, fmt.Sprintf("%s/auth/mal/callback?code=CODE&state=%s", h.base, url.QueryEscape(browserState)))
resp.Body.Close()
select {
case got := <-resultCh:
if got["error"] != "exchange_failed" {
t.Errorf("expected error=exchange_failed, got %v", got)
}
case <-time.After(3 * time.Second):
t.Fatal("result never returned")
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("callback status=%d want 502", resp.StatusCode)
}
}
func TestOAuthCallbackUserCancelled(t *testing.T) {
h := newOAuthHarness(t)
sess, _ := h.startSession(t, "mal", "2.2.2.3")
resultCh := make(chan map[string]any, 1)
go func() {
resp, err := http.Get(h.base + "/auth/result?session=" + url.QueryEscape(sess))
if err != nil {
resultCh <- map[string]any{"_err": err.Error()}
return
}
defer resp.Body.Close()
var m map[string]any
_ = json.NewDecoder(resp.Body).Decode(&m)
resultCh <- m
}()
resp := httpGet(t, fmt.Sprintf("%s/auth/mal/callback?error=access_denied&state=%s", h.base, url.QueryEscape(sess)))
resp.Body.Close()
select {
case got := <-resultCh:
if got["error"] != "access_denied" {
t.Errorf("expected error=access_denied, got %v", got)
}
case <-time.After(3 * time.Second):
t.Fatal("result never returned")
result := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(pollSecret))
if result.status != http.StatusOK || result.body["error"] != "exchange_failed" {
t.Fatalf("result status=%d body=%v", result.status, result.body)
}
if result.cacheControl != "no-store, private" {
t.Fatalf("result Cache-Control=%q", result.cacheControl)
}
second := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(pollSecret))
if second.status != http.StatusGone {
t.Fatalf("second result status=%d want 410", second.status)
}
}
@@ -433,45 +693,35 @@ func TestOAuthCallbackUnknownSessionIgnored(t *testing.T) {
}
}
func TestOAuthResultConsumedSecondCallIsGone(t *testing.T) {
h := newOAuthHarness(t)
sess, _ := h.startSession(t, "mal", "3.3.3.1")
// Pre-seat the result so the first /auth/result returns immediately.
h.proxy.mu.Lock()
h.proxy.sessions[sess].complete(oauthTokenResult{AccessToken: "tok"})
h.proxy.mu.Unlock()
r1 := httpGet(t, h.base+"/auth/result?session="+url.QueryEscape(sess))
r1.Body.Close()
if r1.StatusCode != http.StatusOK {
t.Fatalf("first result status=%d", r1.StatusCode)
}
// After consumption the session is deleted; second call sees unknown session.
r2 := httpGet(t, h.base+"/auth/result?session="+url.QueryEscape(sess))
r2.Body.Close()
if r2.StatusCode != http.StatusGone {
t.Fatalf("second result status=%d want 410", r2.StatusCode)
}
}
// ====== Cleanup ======
func TestOAuthCleanupExpiresOldSessions(t *testing.T) {
func TestOAuthCleanupRemovesBothIndexesAndSuppressesStaleCompletion(t *testing.T) {
h := newOAuthHarness(t)
sess, _ := h.startSession(t, "mal", "4.4.4.1")
pollSecret, browserState, _ := h.startSession(t, "mal", "4.4.4.1")
digest := digestPollSecret(pollSecret)
h.proxy.mu.Lock()
h.proxy.sessions[sess].createdAt = time.Now().Add(-2 * oauthSessionTTL)
sess := h.proxy.browserStates[browserState]
sess.createdAt = time.Now().Add(-2 * oauthSessionTTL)
h.proxy.mu.Unlock()
h.proxy.cleanup()
h.proxy.mu.Lock()
_, exists := h.proxy.sessions[sess]
_, hasBrowserState := h.proxy.browserStates[browserState]
_, hasPollDigest := h.proxy.pollDigests[digest]
h.proxy.mu.Unlock()
if exists {
t.Fatal("expired session should have been cleaned up")
if hasBrowserState || hasPollDigest {
t.Fatalf("expired session indexes remain: browser=%v poll=%v", hasBrowserState, hasPollDigest)
}
if h.proxy.completeSession(sess, oauthTokenResult{AccessToken: "stale-token"}) {
t.Fatal("stale callback completed a removed session")
}
sess.mu.Lock()
storedResult := sess.result
sess.mu.Unlock()
if storedResult != nil {
t.Fatal("stale callback retained an orphaned token result")
}
}
@@ -525,7 +775,7 @@ func TestOAuthResultBlocksUntilCancel(t *testing.T) {
// that /auth/result blocks until the session completes or the client
// cancels. The 204-after-server-timeout path takes 50s so isn't asserted.
h := newOAuthHarness(t)
sess, _ := h.startSession(t, "mal", "5.5.5.1")
sess, _, _ := h.startSession(t, "mal", "5.5.5.1")
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
+113 -10
View File
@@ -16,27 +16,27 @@ type rateLimiter struct {
}
func newRateLimiter(burst, sustained int) *rateLimiter {
return newRateLimiterAt(burst, sustained, time.Now())
}
func newRateLimiterAt(burst, sustained int, now time.Time) *rateLimiter {
return &rateLimiter{
tokens: float64(burst),
maxTokens: float64(burst),
refillRate: float64(sustained),
lastTime: time.Now(),
lastTime: now,
}
}
func (rl *rateLimiter) allow() bool {
return rl.allowAt(time.Now())
}
func (rl *rateLimiter) allowAt(now time.Time) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
elapsed := now.Sub(rl.lastTime).Seconds()
rl.lastTime = now
rl.tokens += elapsed * rl.refillRate
if rl.tokens > rl.maxTokens {
rl.tokens = rl.maxTokens
}
rl.refillAtLocked(now)
if rl.tokens < 1 {
return false
}
@@ -44,6 +44,27 @@ func (rl *rateLimiter) allow() bool {
return true
}
func (rl *rateLimiter) refund() {
rl.mu.Lock()
defer rl.mu.Unlock()
rl.tokens++
if rl.tokens > rl.maxTokens {
rl.tokens = rl.maxTokens
}
}
func (rl *rateLimiter) refillAtLocked(now time.Time) {
if now.Before(rl.lastTime) {
return
}
elapsed := now.Sub(rl.lastTime).Seconds()
rl.lastTime = now
rl.tokens += elapsed * rl.refillRate
if rl.tokens > rl.maxTokens {
rl.tokens = rl.maxTokens
}
}
// reclaimable reports whether discarding this limiter would preserve its
// behavior: enough idle time has passed for the bucket to be full again.
func (rl *rateLimiter) reclaimable(now time.Time) bool {
@@ -69,6 +90,68 @@ func cleanupRateWindows(windows map[string]time.Time, now time.Time, duration ti
}
}
// --- Poster upload admission (global, per-IP, and concurrency) ---
type posterUploadLimiter struct {
mu sync.Mutex
global *rateLimiter
perIP map[string]*rateLimiter
active int
maxConcurrent int
perIPBurst int
perIPSustained int
}
func newPosterUploadLimiter(
perIPBurst, perIPSustained, globalBurst, globalSustained, maxConcurrent int,
now time.Time,
) *posterUploadLimiter {
return &posterUploadLimiter{
global: newRateLimiterAt(globalBurst, globalSustained, now),
perIP: make(map[string]*rateLimiter),
maxConcurrent: maxConcurrent,
perIPBurst: perIPBurst,
perIPSustained: perIPSustained,
}
}
func (pl *posterUploadLimiter) tryStart(ip string, now time.Time) bool {
pl.mu.Lock()
defer pl.mu.Unlock()
if pl.active >= pl.maxConcurrent {
return false
}
if !pl.global.allowAt(now) {
return false
}
limiter := pl.perIP[ip]
if limiter == nil {
limiter = newRateLimiterAt(pl.perIPBurst, pl.perIPSustained, now)
pl.perIP[ip] = limiter
}
if !limiter.allowAt(now) {
pl.global.refund()
return false
}
pl.active++
return true
}
func (pl *posterUploadLimiter) finish() {
pl.mu.Lock()
defer pl.mu.Unlock()
if pl.active > 0 {
pl.active--
}
}
func (pl *posterUploadLimiter) cleanup(now time.Time) {
pl.mu.Lock()
defer pl.mu.Unlock()
cleanupRateLimiters(pl.perIP, now, nil)
}
// --- Connection tracker (per-IP limits) ---
type connTracker struct {
@@ -127,6 +210,9 @@ func (ct *connTracker) disconnect(ip string) {
}
}
// tryCreateRoom reserves capacity for a retained room created in this process.
// The reservation survives creator disconnect and is released only when the
// authoritative room is removed from Server.rooms.
func (ct *connTracker) tryCreateRoom(ip string) bool {
ct.mu.Lock()
defer ct.mu.Unlock()
@@ -137,6 +223,23 @@ func (ct *connTracker) tryCreateRoom(ip string) bool {
return true
}
// tryCreateRoomReplacing reserves a room while accounting for the reservation
// that removeRoomLocked will immediately release from an empty same-ID room.
// Server.mu serializes this paired reservation/removal transaction.
func (ct *connTracker) tryCreateRoomReplacing(ip, replacedOwnerKey string) bool {
ct.mu.Lock()
defer ct.mu.Unlock()
projected := ct.roomsPerIP[ip]
if replacedOwnerKey == ip {
projected--
}
if projected >= maxRoomsPerIP {
return false
}
ct.roomsPerIP[ip]++
return true
}
func (ct *connTracker) releaseRoom(ip string) {
ct.mu.Lock()
defer ct.mu.Unlock()
+28 -19
View File
@@ -3,25 +3,34 @@
package main
const (
relayTypeCreate = "create"
relayTypeJoin = "join"
relayTypeBroadcast = "broadcast"
relayTypeSendTo = "sendTo"
relayTypePing = "ping"
relayTypeCreated = "created"
relayTypeJoined = "joined"
relayTypePeerJoined = "peerJoined"
relayTypePeerLeft = "peerLeft"
relayTypeMessage = "message"
relayTypeError = "error"
relayTypePong = "pong"
relayErrorRateLimited = "rate_limited"
relayErrorInvalidMessage = "invalid_message"
relayErrorRoomExists = "room_exists"
relayErrorRoomNotFound = "room_not_found"
relayErrorRoomFull = "room_full"
relayErrorNotInRoom = "not_in_room"
relayErrorAlreadyInRoom = "already_in_room"
relayProtocolVersion = 2
legacyRelayProtocolVersion = 0
relayTypeCreate = "create"
relayTypeJoin = "join"
relayTypeBroadcast = "broadcast"
relayTypeSendTo = "sendTo"
relayTypePing = "ping"
relayTypeLeave = "leave"
relayTypeEndSession = "endSession"
relayTypeCreated = "created"
relayTypeJoined = "joined"
relayTypePeerJoined = "peerJoined"
relayTypePeerLeft = "peerLeft"
relayTypeMessage = "message"
relayTypeError = "error"
relayTypePong = "pong"
relayTypeLeft = "left"
relayTypeEnded = "ended"
relayErrorRateLimited = "rate_limited"
relayErrorInvalidMessage = "invalid_message"
relayErrorRoomExists = "room_exists"
relayErrorRoomNotFound = "room_not_found"
relayErrorRoomFull = "room_full"
relayErrorNotInRoom = "not_in_room"
relayErrorAlreadyInRoom = "already_in_room"
relayErrorPeerIdUnavailable = "peer_id_unavailable"
relayErrorProtocolMismatch = "protocol_mismatch"
maxRoomSize = 8
maxMessageSize = 65536