feat(relay): wire per-IP rate-limit middleware on /v1/server + /v1/client (#47)#89
Conversation
…ient (#47) Composes the #50 IPRateLimiter and #51 ClientIP primitives as middleware around the two WS upgrade endpoints. Excess attempts from a single source IP return 429 before websocket.Accept runs; /healthz is intentionally unwrapped. A new --trust-x-forwarded-for boolean flag (default false) controls whether XFF is honoured; the flag's Usage string carries the spoofing-without-a-proxy warning. Empty-IP requests deny before the limiter is consulted, per the loud-failure rule. Each deny emits one slog Warn line with the strconv.Quote-wrapped remote only. Closes #47. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Code Review: #47Decision: PASS FindingsNone. SummaryImplementation matches the spec exactly. The empty-IP guard correctly precedes Verified locally:
Test coverage maps 1:1 onto the AC bullets: burst-then-deny + sentinel-counter for "handler never invoked on deny"; per-IP isolation; XFF trust on/off semantics; empty-IP deny without touching the limiter; log-line shape (level=WARN, msg=rate_limited, single Threat-model doc update correctly converts the future-hardening line to v1 mitigation and trims residual risk + future hardening of items now delivered. Knowledge doc |
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
What
Wires the #50
IPRateLimiterand #51ClientIPprimitives into the upgrade pipeline as middleware. Excess attempts from a single source IP are rejected with HTTP429 Too Many Requestsbeforewebsocket.Acceptruns.internal/relay/ratelimit_middleware.go— new exportedNewRateLimitMiddleware(limiter, logger, trustForwardedFor). Empty-IP requests deny beforelimiter.Allowis called (loud-failure rule fromdocs/PROJECT-MEMORY.md). Both deny paths emit oneslog.Warn("rate_limited", "remote", strconv.Quote(ip))line —"remote"is already inlog_allowlist.go, no edit needed.cmd/pyrycode-relay/main.go— new--trust-x-forwarded-forbool flag (defaultfalse, Usage string carries the spoofing-without-a-proxy warning). Policy constantsrateLimitRefillEvery=6s,rateLimitBurst=20,rateLimitEvictionInterval=5min(~10 attempts/IP/minute steady-state, burst 20). One limiter shared across/v1/serverand/v1/client;/healthzis intentionally NOT wrapped.defer limiter.Close()is placed before the listeners block (best-effort — pre-existingos.Exit-on-error path skips defers; graceful shutdown is out of scope per AC).docs/threat-model.md§ DoS resistance — v1 mitigation updated to reference the wiring; the "token-bucket rate limit on /v1/server and /v1/client" line removed from Future hardening. Residual-risk paragraph now names the concurrent-connection-count gap and the single-instance limitation.docs/knowledge/codebase/47.md— per-ticket implementation summary + design choices + trust model + out-of-scope items.Issue
Closes #47. Depends on #50 (limiter primitive, landed) and #51 (IP helper, landed).
Testing
internal/relay/ratelimit_middleware_test.go— 9 tests:BurstThenDeny— burst exhausts, sentinel handler counter pins "handler not invoked on deny".PerIPIsolation— denial of IP A does not affect IP B.TrustXFF_BucketsByForgedIP— withtrustForwardedFor=true, two requests with sameRemoteAddrbut identical XFF share a bucket; different XFF lands in a fresh bucket.TrustXFFOff_IgnoresHeader— default mode keys byRemoteAddreven when XFF is set.EmptyIPDeniesWithoutTouchingLimiter—RemoteAddr=""returns 429 andlen(limiter.buckets)==0afterwards (assertsAllowwas not called).DenyEmitsOneLogLine— exactly onelevel=WARN msg=rate_limited remote="\"1.2.3.4\""line per deny; forbidden fields (server_id,binary_version,conn_id,device_name,user_agent) explicitly absent.EmptyIPDenyLogsQuotedEmptyString— empty-IP path rendersremote="\"\""so operators can disambiguate.AllowEmitsNoLogLine— allow path is silent.RegistryNotTouchedOnDeny— wrapped over a realServerHandler,reg.Counts()stays0,0after a denied attempt.TestLogKeysAreAllowlistedcontinues to pass — the middleware uses literal-string keys only.go test -race ./...cleango vet ./...cleango build ./cmd/pyrycode-relaycleanArchitecture compliance
ServerHandler/ClientHandlersingle-purpose;/healthz's exemption falls out for free. Matches the existingEnforceHost(*domain, mux)shape.EmptyIPDeniesWithoutTouchingLimiter.Warn, singleremotekey,strconv.Quote-wrapped value — conforms todocs/threat-model.md§ Log hygiene. Mobile/binary token never read by this code path.http.Error(w, "", 429)) — no internal state leaked per § Error response leakage.🤖 Generated with Claude Code