fix(relay): secure reconnect and room ownership
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+4147
-141
File diff suppressed because it is too large
Load Diff
+182
-78
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user