User Story
As a relay operator, I want a reusable client-IP extraction helper so that the rate-limit middleware (and any future per-IP policy) can derive a consistent source identifier from an inbound HTTP request — with explicit, configurable trust in proxy-supplied headers.
Context
docs/threat-model.md § DoS resistance and § "Spoofable headers" together set the constraint: behind a trusted reverse proxy the X-Forwarded-For header is authoritative for the original client IP; otherwise the only trustworthy source is the TCP peer (r.RemoteAddr), and trusting XFF without a proxy in front allows clients to spoof their bucket identity. The helper must make that trust decision a parameter, not a default.
This ticket introduces the extraction helper only — a pure function with full test coverage. The token-bucket primitive that consumes it lives in a sibling ticket; wiring both into the upgrade handlers lives in a follow-up. (Split from #46, which was split from #34.)
Acceptance Criteria
Technical Notes
- Tests in this codebase live in
package relay (not relay_test) so they can reach unexported helpers; use httptest.NewRequest or hand-built *http.Request literals as needed.
net.SplitHostPort handles the IPv6 bracket case correctly; if the address has no port, it returns an error and the raw value is the host. Use that as the branching cue rather than substring math.
- Header lookup must be case-insensitive —
r.Header.Get("X-Forwarded-For") is the right shape (Go canonicalises).
Size Estimate
XS — one exported function (~20-30 lines) + tests. Trivial change with no goroutines or mutable state.
Split from #46 (which was split from #34). The companion token-bucket primitive is a sibling ticket; both block the wiring ticket #47.
User Story
As a relay operator, I want a reusable client-IP extraction helper so that the rate-limit middleware (and any future per-IP policy) can derive a consistent source identifier from an inbound HTTP request — with explicit, configurable trust in proxy-supplied headers.
Context
docs/threat-model.md§ DoS resistance and § "Spoofable headers" together set the constraint: behind a trusted reverse proxy theX-Forwarded-Forheader is authoritative for the original client IP; otherwise the only trustworthy source is the TCP peer (r.RemoteAddr), and trusting XFF without a proxy in front allows clients to spoof their bucket identity. The helper must make that trust decision a parameter, not a default.This ticket introduces the extraction helper only — a pure function with full test coverage. The token-bucket primitive that consumes it lives in a sibling ticket; wiring both into the upgrade handlers lives in a follow-up. (Split from #46, which was split from #34.)
Acceptance Criteria
internal/relay/taking an*http.Requestand atrustForwardedFor booland returning the client IP as astring:trustForwardedForis false, returns the host portion ofr.RemoteAddr(port stripped).trustForwardedForis true andX-Forwarded-Foris present and non-empty, returns the left-most entry (the original client) with surrounding whitespace stripped; falls back tor.RemoteAddr(host portion) if the header is absent or empty.RemoteAddrextraction withhost:port, with bare host (no port), and with IPv6-bracketed[::1]:port; XFF extraction with a single entry, with multiple comma-separated entries (verifies left-most selected), with leading/trailing whitespace around entries; XFF disabled (header is ignored even when present); empty XFF header falls back toRemoteAddr; both sources empty returns"".cmd/pyrycode-relay/main.go— this ticket ships the helper only.Technical Notes
package relay(notrelay_test) so they can reach unexported helpers; usehttptest.NewRequestor hand-built*http.Requestliterals as needed.net.SplitHostPorthandles the IPv6 bracket case correctly; if the address has no port, it returns an error and the raw value is the host. Use that as the branching cue rather than substring math.r.Header.Get("X-Forwarded-For")is the right shape (Go canonicalises).Size Estimate
XS — one exported function (~20-30 lines) + tests. Trivial change with no goroutines or mutable state.
Split from #46 (which was split from #34). The companion token-bucket primitive is a sibling ticket; both block the wiring ticket #47.