User Story
As a relay operator, I want a reusable per-IP token-bucket rate-limit type 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 limiter type only — the IP-extraction helper that callers will use to feed it lives in a sibling ticket so each primitive can land + be reviewed in isolation. The wiring of both into the upgrade handlers is a separate ticket again.
The frame-level cost is already bounded by SetReadLimit (#29) and will be further bounded by max-phones (#30); this ticket is exclusively about the pre-upgrade attempt-throttling primitive.
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. The "per-conn goroutines exit cleanly via LIFO defers" pattern in docs/PROJECT-MEMORY.md is a related but distinct shape.
- 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 — a small mutable type + tests. Under 100 lines of production code; tests scale linearly.
Split from #46.
User Story
As a relay operator, I want a reusable per-IP token-bucket rate-limit type 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 limiter type only — the IP-extraction helper that callers will use to feed it lives in a sibling ticket so each primitive can land + be reviewed in isolation. The wiring of both into the upgrade handlers is a separate ticket again.
The frame-level cost is already bounded by
SetReadLimit(#29) and will be further bounded by max-phones (#30); this ticket is exclusively about the pre-upgrade attempt-throttling primitive.Acceptance Criteria
internal/relay/. The constructor takes a refill rate, burst capacity, and a stale-bucket eviction interval, returning a handle.Allow(ip string) bool(or equivalent) decrements the bucket foripand reports whether the attempt is permitted.Close()(or equivalent) stops the eviction goroutine cleanly so the type is test-friendly and process-shutdown-safe. Bucket bookkeeping is in-memory: a map keyed by IP, guarded by a mutex; per-IP buckets hold tokens + last-refill timestamp.Allow) so the map does not grow unboundedly under address-space scanning. Eviction interval is configurable via the constructor; eviction never drops a bucket that is 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 type 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. The "per-conn goroutines exit cleanly via LIFO defers" pattern indocs/PROJECT-MEMORY.mdis a related but distinct shape.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 — a small mutable type + tests. Under 100 lines of production code; tests scale linearly.
Split from #46.