summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-12 09:47:07 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-12 16:36:54 +0200
commit4cce50f09a73739387d5058a6f8183ebac65ae2c (patch)
tree30e9d72280295d913983910a956b0d3306696ec8 /packages/meshbay-hub/src
parent9cc2909cb4a360c81b471ceab1d9578a7655a88e (diff)
downloadmeshbay-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-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js71
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js4
3 files changed, 98 insertions, 22 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
index 003e396..7c37a1e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
@@ -171,6 +171,41 @@ async def _reject(ws: WebSocket, detail: str, code: int) -> None:
await ws.close(code=code)
+async def _authorized_groups(user_id: str) -> set[str]:
+ """The groups this account belongs to — the ceiling on what its nodes may claim.
+
+ Read fresh rather than captured once at registration: a node WebSocket lives
+ for hours, and a group joined in the meantime has to become claimable through
+ `update_groups` without reconnecting.
+ """
+ from meshbay_hub.db.engine import get_session_factory
+
+ async with get_session_factory()() as db:
+ result = await db.execute(
+ select(GroupMember.group_id).where(GroupMember.user_id == user_id))
+ return {gid for (gid,) in result.all()}
+
+
+def _claimable(claimed_groups, authorized: set[str]) -> list[str]:
+ """What a node actually gets registered for. Two rules.
+
+ A node may only *narrow* the set: `authorized` is the ceiling, or a node
+ could advertise itself as a source for any group on the hub (finding C2).
+
+ And an empty claim means **no groups**, never "all of them". This used to
+ read `set(claimed_groups or authorized)`, so a node hosting nothing — which
+ sends no `group_ids` at all — was registered as a host for every group its
+ owner belonged to, other people's included. Such a node cannot serve any of
+ them: it holds no GEK, and its own handshake refuses them with "Group not
+ hosted on this node". But `/v1/groups/{id}/nodes` returns nodes in
+ registration order, so once one of them won the reconnection race after a
+ hub restart it became `nodes[0]` and captured the group's entire client
+ traffic. Any member could take a group down for everyone, by accident,
+ merely by leaving an unconfigured node running.
+ """
+ return sorted(authorized & set(claimed_groups or ()))
+
+
async def _authorize_node_ws(token: str, claimed_id: str, claimed_groups) -> tuple:
"""
Resolve a node WS registration against the database.
@@ -212,8 +247,7 @@ async def _authorize_node_ws(token: str, claimed_id: str, claimed_groups) -> tup
select(GroupMember.group_id).where(GroupMember.user_id == user_id))
authorized = {gid for (gid,) in result.all()}
- claimed = set(claimed_groups or authorized)
- return claimed_id, sorted(authorized & claimed)
+ return claimed_id, _claimable(claimed_groups, authorized)
@router.websocket("/v1/nodes/ws")
@@ -286,7 +320,12 @@ async def node_websocket(ws: WebSocket):
from meshbay_hub.api.signaling import handle_webrtc_answer
handle_webrtc_answer(msg)
elif msg.get("type") == "update_groups":
- new_gids = msg.get("group_ids", [])
+ # Through the same gate as the registration above. This used to
+ # assign the message's list verbatim, so the ceiling that makes
+ # C2 hold at authentication could be stepped over one message
+ # later: a node had only to reload to claim any group on the hub.
+ new_gids = _claimable(msg.get("group_ids"),
+ await _authorized_groups(user_id))
_node_groups[node_id] = new_gids
await _mark_hosted(new_gids)
log.info("Node %s updated groups: %d", node_id[:8], len(new_gids))
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index c59d097..18d3a8f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -311,7 +311,6 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const sessionKeys = null;
setStatus('connecting');
- const nodeId = nodesData.nodes[0].node_id;
// Renewed here rather than taken from the prop. This effect no longer
// re-runs when the token rotates (see the dependency list below), so
// the captured one can be older than the session's — and it is used to
@@ -319,25 +318,59 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// connection at all. Renewals are shared, so if one is already in
// flight this waits for it instead of starting a second.
const live = (await ensureFreshToken()) || token;
- // The same base the API calls use: signaling is a hub endpoint like
- // any other, and two sources for one address is how they drift.
- const transport = new window.MeshBayTransport(HUB, live);
- transportRef.current = transport;
- // Consulted only by the automatic reconnect after a WebRTC failure
- // (transport.js's _reconnectLoop) — the token captured by this
- // connect() call can be stale by then, since the whole point is that
- // some real time (screen lock, a dead NAT mapping) passed unnoticed.
- transport.onNeedToken = async () => (await ensureFreshToken()) || token;
- // Set before connect(), because connect() is where device_hello runs —
- // and again on every reconnect it makes, which is the case this exists
- // for: nothing else tells the page the answer changed.
- transport.onDeviceIdentity = (ok) => {
- if (!cancelled) setDeviceReady(ok);
- };
- const ack = await transport.connect(
- nodeId, live, groupId, null, sessionKeys, session.bundleKey, username,
- userId, session.pendingJoinCode, session.recoveryKey);
+ // Every node the hub lists, in turn — not `nodes[0]` and nothing else.
+ // The list is in hub registration order, and its head is not
+ // necessarily a node that can serve the group: one whose config does
+ // not list it refuses the handshake with "Group not hosted on this
+ // node". Stopping at the first made that refusal indistinguishable
+ // from the group being down, while a node that *could* serve it stood
+ // second in the same list — which is how `media` went dark on
+ // 2026-09-11 with its only real host online the whole time. The hub no
+ // longer registers a node for a group it does not claim; trying the
+ // rest is what keeps one bad entry from being fatal again.
+ // `transport` holds the attempt in progress, and keeps whichever one
+ // answers — so it is null after the loop exactly when none did.
+ let transport = null, ack = null, lastErr = null;
+ for (const n of nodesData.nodes) {
+ // The same base the API calls use: signaling is a hub endpoint like
+ // any other, and two sources for one address is how they drift.
+ transport = new window.MeshBayTransport(HUB, live);
+ transportRef.current = transport;
+ // Consulted only by the automatic reconnect after a WebRTC failure
+ // (transport.js's _reconnectLoop) — the token captured by this
+ // connect() call can be stale by then, since the whole point is that
+ // some real time (screen lock, a dead NAT mapping) passed unnoticed.
+ transport.onNeedToken = async () => (await ensureFreshToken()) || token;
+ // Set before connect(), because connect() is where device_hello runs —
+ // and again on every reconnect it makes, which is the case this exists
+ // for: nothing else tells the page the answer changed.
+ transport.onDeviceIdentity = (ok) => {
+ if (!cancelled) setDeviceReady(ok);
+ };
+ try {
+ ack = await transport.connect(
+ n.node_id, live, groupId, null, sessionKeys, session.bundleKey,
+ username, userId, session.pendingJoinCode, session.recoveryKey);
+ break;
+ } catch (e) {
+ lastErr = e;
+ // Closed and dropped before anything else looks at either: an
+ // attempt that failed must not be handed to `releaseWhenIdle` by
+ // the unmount cleanup, which exists to keep a *working* connection
+ // alive for a download still using it.
+ try { transport.close(); } catch { /* never opened */ }
+ transport = null;
+ transportRef.current = null;
+ if (cancelled) return;
+ // A refusal that names a state of *this browser* — a code to enter,
+ // a passphrase, a device to approve — is the same answer from every
+ // node, and the operator to act on is this one's. Trying the next
+ // node would only replace it with a less useful message.
+ if (e.reason && e.reason !== 'not_hosted') throw e;
+ }
+ }
+ if (!transport) throw (lastErr || new Error('no node served this group'));
session.pendingJoinCode = null;
if (cancelled) return;
setIsNodeAdmin(!!ack.is_node_admin);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 32005b7..a21da19 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -283,6 +283,10 @@ const HANDSHAKE_REFUSALS = {
version_too_new: 'This node is running an older MeshBay than this page needs. '
+ 'Its operator has to update it.',
version_unreadable: 'The node could not read this page\'s protocol version.',
+ // Surfaced only when it was the *last* node the hub offered for the group —
+ // group-page.js moves on to the next one on this code rather than stopping.
+ not_hosted: 'No node the hub offered for this group is hosting it. Its '
+ + 'operator has to attach it on the node that holds its files.',
};
const JOIN_REFUSALS = {