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),
+22 -15
View File
@@ -1215,7 +1215,7 @@ def test_a_pending_code_is_found_by_whichever_worker_is_polled() -> None:
for offset, code in enumerate(("OLD111", "MID222", "NEW333")):
later = devices_route._Pending(secret="s", expires=time.time() + 60 + offset)
assert minted.add(code, later)
polled.make_room(3)
polled.make_room(3, remote=False)
assert minted.get("OLD111") is None and polled.count() == 2
@@ -1694,6 +1694,10 @@ def test_one_address_asks_for_a_few_codes_a_minute(client: TestClient) -> None:
assert refused.headers["retry-after"]
# Another address is not held to this one's.
assert client.post(f"{PREFIX}/pair").status_code == 200
# Through the portal every call comes off the tunnel, so the hub limits
# those by the address it sees, and here they are not one shared bucket.
for _ in range(2 * devices_route.PAIR_PER_ADDRESS):
assert client.post(f"{PREFIX}/pair", headers=_via_portal()).status_code == 200
def test_at_the_cap_the_oldest_request_makes_room(
@@ -1704,24 +1708,27 @@ def test_at_the_cap_the_oldest_request_makes_room(
monkeypatch.setattr(devices_route, "_pending", store)
monkeypatch.setattr(devices_route, "PAIR_PER_ADDRESS", 1000)
started = [
def waiting(started: dict) -> bool:
polled = client.get(
f"{PREFIX}/pair/{started['code']}", params={"secret": started["secret"]}
)
return polled.status_code == 200
here = [
client.post(f"{PREFIX}/pair").json()
for _ in range(devices_route.MAX_PENDING + 1)
]
assert store.count() == devices_route.MAX_PENDING
oldest, newest = started[0], started[-1]
assert (
client.get(
f"{PREFIX}/pair/{oldest['code']}", params={"secret": oldest["secret"]}
).status_code
== 404
)
assert (
client.get(
f"{PREFIX}/pair/{newest['code']}", params={"secret": newest["secret"]}
).status_code
== 200
)
assert not waiting(here[0]) and waiting(here[1]) and waiting(here[-1])
# A flood through the portal pushes out its own oldest, never these.
portal = [
client.post(f"{PREFIX}/pair", headers=_via_portal()).json()
for _ in range(devices_route.MAX_PENDING + 1)
]
assert store.count() == 2 * devices_route.MAX_PENDING
assert not waiting(portal[0]) and waiting(portal[-1])
assert all(waiting(started) for started in here[1:])
def test_a_report_is_read_port_by_port(
+1 -1
View File
@@ -192,7 +192,7 @@ on its own, so this is for reclaiming space sooner.
| `POST` | `/dashboards/{name}/duplicate` | copy into an unpublished `{name}_copy` |
| `GET` | `/devices/` | list devices, with `online`, `last_seen`, `report` and `reported_at` |
| `POST`, `GET`, `PUT`, `DELETE` | `/devices/{id}` | create, read, save or delete one; a save sends the `version` it read and gets 409 when someone saved first. A read adds `io` and `bindings`, its ports as the device nodes of published flows wire them |
| `POST` | `/devices/pair` | start device pairing, optionally with `{name, board, firmware}`; answers `code`, `secret` and the two-character `check` the device shows beside its code; unauthenticated, five a minute per address, the oldest of 50 waiting requests gives way |
| `POST` | `/devices/pair` | start device pairing, optionally with `{name, board, firmware}`; answers `code`, `secret` and the two-character `check` the device shows beside its code; unauthenticated, five a minute per address (the portal limits its own), the oldest of 50 waiting requests from the same side gives way |
| `GET` | `/devices/pair/{code}` | poll pairing with its `secret`; answers `device` (and `panel` for older firmware); unauthenticated |
| `GET` | `/devices/pending` | devices waiting for a code, as they describe themselves, each with its `check` |
| `GET` | `/devices/pair/{code}/device` | what is waiting on a code, before it is approved |
+3 -2
View File
@@ -467,8 +467,9 @@ since the instance started.
Each save carries the version it was read at. When someone else saved the
device first, the save is refused and the page shows their version.
One address may ask for five codes a minute. At most 50 requests wait at once,
and a new one pushes out the oldest.
On your network one address may ask for five codes a minute, and the portal
limits the requests that come through it. At most 50 requests wait from each,
and a new one pushes out the oldest that came the same way.
A device credential reads its assigned dashboards, their bound messages and the
artifacts they show. It writes only the targets of their controls and query