Skip to content

relay: per-IP token-bucket rate-limit type with eviction #50

@ilmoniemi

Description

@ilmoniemi

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

  • A new exported limiter type lives in 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 for ip and 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.
  • 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 that is below capacity.
  • 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. go test -race passes for parallel Allow calls against the same and different IPs, and against concurrent eviction.
  • No changes to cmd/pyrycode-relay/main.go — this ticket ships the type 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. 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.

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