Skip to content

relay: per-IP token-bucket rate-limit primitive (limiter type + eviction) #48

@ilmoniemi

Description

@ilmoniemi

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

  • A new exported type lives in internal/relay/ exposing a constructor (refill rate, burst capacity, eviction interval), an Allow(ip string) bool method, and a Close() (or equivalent) method that 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. A background goroutine evicts buckets that have been idle (full + no recent 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.
  • Race-tested: go test -race passes for parallel Allow calls against the same and different IPs, and against concurrent eviction.
  • Tests cover at minimum: burst exhaustion, refill behaviour after wall-clock advance (or an injectable clock — architect's call), and eviction reclaiming an idle bucket.
  • No changes to cmd/pyrycode-relay/main.go — this ticket ships the primitive only.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    security-sensitiveTouches auth, crypto, or internet-exposed input pathssize:sSmall ticket: <100 lines production code

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions