User Story
As a relay operator, I want a reusable per-IP token-bucket rate-limit primitive so that connection-flood and fork-bomb-retry threats can be bounded at the WS-upgrade seam with a small, testable component.
Context
docs/threat-model.md § DoS resistance flags connection floods + fork-bomb retry as in-scope concerns. Today the relay accepts unbounded WS upgrade attempts from any single IP. A misbehaving phone (or an attacker) can pin per-connection memory + goroutines without amortised cost.
This ticket introduces the token-bucket primitive only — a pure type with full test coverage. The companion IP-extraction helper lives in a sibling ticket; wiring both into the upgrade handlers lives in a follow-up ticket. The frame-level cost is already bounded by SetReadLimit (#29) and will be further bounded by max-phones (#30); this primitive is exclusively for pre-upgrade attempt throttling.
Acceptance Criteria
Technical Notes
- The "passive in-memory store guarded by one RWMutex" pattern is already established in
Registry (internal/relay/registry.go); the same shape applies here.
- Tests in this codebase live in
package relay (not relay_test) so they can reach unexported helpers.
- An injectable clock (
func() time.Time or clock.Clock) is a reasonable way to make refill behaviour deterministic without sleeping in tests — the architect picks the exact shape.
- Eviction-goroutine lifecycle: keep it simple — a
time.NewTicker + a done channel closed by Close.
- Read
docs/threat-model.md § "DoS resistance" before choosing default policy values; defaults are the wiring ticket's call, but the primitive must not preclude them.
Size Estimate
S — one mutable type + goroutine + tests. Under 100 lines of production code; tests scale linearly.
Split from #46 (which was split from #34). The IP-extraction helper that originally lived alongside this primitive is now a sibling ticket; both block the wiring ticket #47.
User Story
As a relay operator, I want a reusable per-IP token-bucket rate-limit primitive so that connection-flood and fork-bomb-retry threats can be bounded at the WS-upgrade seam with a small, testable component.
Context
docs/threat-model.md§ DoS resistance flags connection floods + fork-bomb retry as in-scope concerns. Today the relay accepts unbounded WS upgrade attempts from any single IP. A misbehaving phone (or an attacker) can pin per-connection memory + goroutines without amortised cost.This ticket introduces the token-bucket primitive only — a pure type with full test coverage. The companion IP-extraction helper lives in a sibling ticket; wiring both into the upgrade handlers lives in a follow-up ticket. The frame-level cost is already bounded by
SetReadLimit(#29) and will be further bounded by max-phones (#30); this primitive is exclusively for pre-upgrade attempt throttling.Acceptance Criteria
internal/relay/exposing a constructor (refill rate, burst capacity, eviction interval), anAllow(ip string) boolmethod, and aClose()(or equivalent) method that stops the eviction goroutine cleanly so the type is test-friendly and process-shutdown-safe.Allow) so the map does not grow unboundedly under address-space scanning. Eviction interval is configurable via the constructor; eviction never drops a bucket below capacity.go test -racepasses for parallelAllowcalls against the same and different IPs, and against concurrent eviction.cmd/pyrycode-relay/main.go— this ticket ships the primitive only.Technical Notes
Registry(internal/relay/registry.go); the same shape applies here.package relay(notrelay_test) so they can reach unexported helpers.func() time.Timeorclock.Clock) is a reasonable way to make refill behaviour deterministic without sleeping in tests — the architect picks the exact shape.time.NewTicker+ adonechannel closed byClose.docs/threat-model.md§ "DoS resistance" before choosing default policy values; defaults are the wiring ticket's call, but the primitive must not preclude them.Size Estimate
S — one mutable type + goroutine + tests. Under 100 lines of production code; tests scale linearly.
Split from #46 (which was split from #34). The IP-extraction helper that originally lived alongside this primitive is now a sibling ticket; both block the wiring ticket #47.