import time from collections import defaultdict from typing import Mapping def resolve_client_ip(headers: Mapping[str, str], direct_host: str | None) -> str: """Resolves the real visitor IP for per-IP rate limiting. The App CT sits behind a Cloudflare Tunnel that runs on a separate machine (see README Architecture) — every internet-facing connection's raw TCP peer is that tunnel machine, not the visitor, which would collapse per-IP limiting to a single shared bucket for all remote traffic. Cloudflare's edge sets `CF-Connecting-IP` itself, stripping any client-supplied value first, so it's safe to trust here. Direct LAN/local access (no Cloudflare in front, e.g. local dev) has no such header and falls back to the raw socket peer. """ forwarded = headers.get("cf-connecting-ip") if forwarded: return forwarded return direct_host or "unknown" class RateLimiter: """Fixed-window limiter keyed by an arbitrary string (user id or client IP). Expired keys are swept periodically rather than left to accumulate. Without that, every distinct key ever seen stays in the dict forever — and since the open door provisions a real account per visitor, "every distinct key" now means every visitor, across all eleven limiter instances. That is a slow but genuine leak in a process designed to run for months. """ # Sweep every N admitted calls rather than on a timer: no background # task to own, and the cost lands on whoever is generating the load. At # 512 the amortised cost is negligible, and a limiter can hold at most a # window's worth of traffic plus 512 stale keys. SWEEP_EVERY = 512 def __init__(self, max_requests: int, window_seconds: float): self.max_requests = max_requests self.window_seconds = window_seconds self._hits: dict[str, list[float]] = defaultdict(list) self._calls_since_sweep = 0 def _sweep(self, window_start: float) -> None: """Drop keys whose most recent hit has fallen out of the window.""" stale = [ key for key, hits in self._hits.items() if not hits or hits[-1] < window_start ] for key in stale: del self._hits[key] def allow(self, key: str) -> bool: now = time.monotonic() window_start = now - self.window_seconds self._calls_since_sweep += 1 if self._calls_since_sweep >= self.SWEEP_EVERY: self._calls_since_sweep = 0 self._sweep(window_start) hits = self._hits[key] while hits and hits[0] < window_start: hits.pop(0) if len(hits) >= self.max_requests: return False hits.append(now) return True @property def tracked_keys(self) -> int: """Live key count — exposed so the leak is testable.""" return len(self._hits)