diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-12 09:47:07 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-12 16:36:54 +0200 |
| commit | 4cce50f09a73739387d5058a6f8183ebac65ae2c (patch) | |
| tree | 30e9d72280295d913983910a956b0d3306696ec8 /packages/meshbay-node | |
| parent | 9cc2909cb4a360c81b471ceab1d9578a7655a88e (diff) | |
| download | meshbay-4cce50f09a73739387d5058a6f8183ebac65ae2c.tar.gz | |
fix: an empty group claim is a claim on nothing
A node that hosts no groups sends no `group_ids` on its hub socket, and the
hub resolved the claim with `set(claimed_groups or authorized)` — so "I host
nothing" arrived as "I host every group this account belongs to", other
members' included. Such a node can serve none of them: it holds no GEK, and
its own handshake refuses them with "Group not hosted on this node".
`/v1/groups/{id}/nodes` answers in registration order and `_node_groups` is
in-memory, so which node a client was sent to depended on who reconnected
first after a hub restart. GroupPage took `nodes[0]` with no fallback. On
2026-09-11 a hub deploy at 20:14 reshuffled the registry, a second member's
unconfigured node won the race, and a group stopped opening for everyone in
it with its only real host online throughout. Any member could take one of
their groups down, by accident, by leaving an empty node running.
Four changes, because no one of them is sufficient:
- the hub never widens an absent claim, and `update_groups` goes through
the same ceiling as registration — it assigned its list verbatim, so the
bound that makes C2 hold at authentication was one message wide
- the node states the empty set rather than omitting the field
- the refusal carries `not_hosted`, so a client can tell "try the next
node" from "you, here, must do something first"
- GroupPage walks the list instead of indexing into it
The three lines involved date from 13, 20 and 23 August and each is
defensible alone. The defect is in the seam, which is where the last two
also were: a falsy empty collection must never mean "unspecified".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
Diffstat (limited to 'packages/meshbay-node')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/hub_client.py | 10 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_hub_ws_group_claim.py | 100 |
2 files changed, 108 insertions, 2 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py index 8873958..ef12bb4 100644 --- a/packages/meshbay-node/src/meshbay_node/hub_client.py +++ b/packages/meshbay-node/src/meshbay_node/hub_client.py @@ -319,9 +319,15 @@ class HubClient: "token": self._session.access_token, "node_id": self._session.node_id, } + # `if gids:` here, and the hub read a missing key as "claims + # every group this account belongs to" — so a node hosting + # nothing advertised itself for all of them and swallowed + # their traffic. The hub no longer widens an absent claim, + # and this says the empty set out loud rather than by + # omission: hosting nothing is a fact, not a missing field. gids = group_ids() if callable(group_ids) else group_ids - if gids: - auth_msg["group_ids"] = gids + if gids is not None: + auth_msg["group_ids"] = list(gids) await ws.send(json.dumps(auth_msg)) # Bounded: a hub that accepts the socket and then says nothing # — which is what it does for a few seconds while restarting — diff --git a/packages/meshbay-node/tests/test_hub_ws_group_claim.py b/packages/meshbay-node/tests/test_hub_ws_group_claim.py new file mode 100644 index 0000000..49ac776 --- /dev/null +++ b/packages/meshbay-node/tests/test_hub_ws_group_claim.py @@ -0,0 +1,100 @@ +""" +What the node claims to host, on the wire. + +2026-09-11: the node omitted `group_ids` from its WS auth frame whenever it +hosted nothing (`if gids:`), and the hub read an absent claim as "every group +this account belongs to". An unconfigured node was therefore registered as a +source for other people's groups, could serve none of them, and — being listed +first — took them down for every member. + +The hub no longer widens an absent claim. This pins the node's half: the empty +set is stated, not left to be inferred from a missing field. +""" + +import asyncio +import json + +import pytest + +from meshbay_node.hub_client import HubClient, HubConfig, HubSession + + +class _FakeWS: + """Just enough of a websockets connection for one auth round trip.""" + + def __init__(self, sent: list, authed: asyncio.Event): + self._sent = sent + self._authed = authed + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + async def send(self, raw: str) -> None: + self._sent.append(json.loads(raw)) + + async def recv(self) -> str: + return json.dumps({"type": "auth_ok", "node_id": "node-1"}) + + def __aiter__(self): + return self + + async def __anext__(self): + # Registered; nothing more to drive. Hold here rather than closing, so + # the reconnect path does not run and muddy what was sent. + self._authed.set() + await asyncio.sleep(3600) + raise StopAsyncIteration + + +async def _auth_frame(monkeypatch, group_ids) -> dict: + import websockets + + sent: list = [] + authed = asyncio.Event() + monkeypatch.setattr( + websockets, "connect", lambda *a, **kw: _FakeWS(sent, authed)) + + client = HubClient(HubConfig(hub_url="https://hub.test", username="u"), + keys=None) + client._session = HubSession( + hub_url="https://hub.test", username="u", user_id="user-1", + access_token="tok", refresh_token="ref", hub_pk_pem=b"", node_id="node-1") + + task = asyncio.create_task(client.maintain_ws(group_ids=group_ids)) + try: + await asyncio.wait_for(authed.wait(), timeout=5) + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + await client.close() + + assert sent, "no auth frame was sent" + return sent[0] + + +@pytest.mark.asyncio +async def test_a_node_hosting_nothing_says_so(monkeypatch): + """The empty set on the wire, rather than a key the hub has to interpret.""" + frame = await _auth_frame(monkeypatch, lambda: []) + assert "group_ids" in frame, ( + "an absent claim is what the hub used to read as 'all of them'") + assert frame["group_ids"] == [] + + +@pytest.mark.asyncio +async def test_a_node_declares_the_groups_it_hosts(monkeypatch): + frame = await _auth_frame(monkeypatch, lambda: ["g-1", "g-2"]) + assert frame["group_ids"] == ["g-1", "g-2"] + + +@pytest.mark.asyncio +async def test_a_caller_with_nothing_to_declare_sends_no_key(monkeypatch): + """`None` is not the same as `[]`: it is "I am not answering that question".""" + frame = await _auth_frame(monkeypatch, None) + assert "group_ids" not in frame |