Pairing limits: no shared bucket for portal calls, a cap per side

Every call through the portal arrives off the tunnel, so the per-address limit
skips those (the hub limits them by the real address), and the cap of 50 counts
and evicts portal and local requests apart, so a portal flood never pushes out
a device waiting on this network.
This commit is contained in:
Melvin Strobl
2026-10-16 19:33:33 +02:00
parent c604cfdf3b
commit d79203a5f7
4 changed files with 46 additions and 29 deletions
+20 -11
View File
@@ -97,12 +97,16 @@ CODE_LENGTH = 6
#: The check a device shows beside its code, to compare with the pending list.
CHECK_LENGTH = 2
#: Never more waiting than this: codes are free to ask for and cost memory.
#: The oldest makes room for a new one, so a flood cannot lock a device out.
#: Never more waiting than this from each way in, this network or the portal:
#: codes are free to ask for and cost memory. The oldest of the same way in
#: makes room for a new one, so a flood cannot lock a device out, and one
#: through the portal never pushes out a device waiting here.
MAX_PENDING = 50
#: Codes one address may ask for per window. A device asks once per code,
#: every ten minutes, and again after a failure.
#: Codes one address may ask for per window, here. A device asks once per
#: code, every ten minutes, and again after a failure. Through the portal the
#: hub limits each address and the instance by the address it sees, since
#: here every such call arrives off the tunnel and would share one bucket.
PAIR_PER_ADDRESS = 5
PAIR_WINDOW_S = 60.0
@@ -197,10 +201,15 @@ class _PendingStore:
self._redis.zremrangebyscore(_PENDING_INDEX, "-inf", now)
return list(cast("list[str]", self._redis.zrange(_PENDING_INDEX, 0, -1)))
def make_room(self, room: int) -> None:
"""Drop the oldest codes until fewer than ``room`` are left."""
codes = self._codes()
for code in codes[: max(0, len(codes) - room + 1)]:
def make_room(self, room: int, remote: bool) -> None:
"""Drop the oldest codes asked for the same way, through the portal
or not, until fewer than ``room`` of them are left."""
same = [
code
for code in self._codes()
if (entry := self.get(code)) is not None and entry.remote == remote
]
for code in same[: max(0, len(same) - room + 1)]:
self.drop(code)
def count(self) -> int:
@@ -486,15 +495,15 @@ def start_pairing(request: Request, body: PairHello | None = None) -> Any:
the cap and the portal's own limits are between. At the cap the oldest
request makes room, so a flood delays a device rather than locking it out.
"""
if too_many(request, "pair", PAIR_PER_ADDRESS, PAIR_WINDOW_S):
remote = cloud_config.via_portal(request.headers)
if not remote and too_many(request, "pair", PAIR_PER_ADDRESS, PAIR_WINDOW_S):
raise HTTPException(
status_code=429,
detail="Too many pairing codes asked for from here. Try again shortly.",
headers={"Retry-After": str(int(PAIR_WINDOW_S))},
)
_pending.make_room(MAX_PENDING)
_pending.make_room(MAX_PENDING, remote)
remote = cloud_config.via_portal(request.headers)
hello = body or PairHello()
entry = _Pending(
secret=secrets.token_urlsafe(16),