summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub')
-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
-rw-r--r--packages/meshbay-hub/tests/test_node_ws_auth.py120
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py54
5 files changed, 272 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 = {
diff --git a/packages/meshbay-hub/tests/test_node_ws_auth.py b/packages/meshbay-hub/tests/test_node_ws_auth.py
index 1391722..4ead0d7 100644
--- a/packages/meshbay-hub/tests/test_node_ws_auth.py
+++ b/packages/meshbay-hub/tests/test_node_ws_auth.py
@@ -344,3 +344,123 @@ async def test_announce_with_valid_proof_succeeds_and_is_idempotent(client):
assert second.status_code == 201, second.text
assert second.json()["node_id"] == first.json()["node_id"], (
"re-announcing the same key must not create a second node record (M8)")
+
+
+# ── An empty claim is not a claim on everything ──────────────────────────────
+#
+# 2026-09-11, found on a live deployment. `_claimable` used to read
+# `set(claimed_groups or authorized)`, and the node omits `group_ids` entirely
+# when it hosts nothing — so "I host no groups" was read as "I host all of
+# yours". The node cannot serve any of them (no GEK, and its own handshake
+# refuses them), but `/v1/groups/{id}/nodes` lists nodes in registration order,
+# so whenever such a node won the reconnection race after a hub restart it
+# became `nodes[0]` and the group stopped opening for every member.
+
+
+@pytest.mark.asyncio
+async def test_ws_absent_claim_registers_no_groups(client):
+ """A node that declares nothing hosts nothing — it must not inherit the set."""
+ from meshbay_hub.api.revocation import _authorize_node_ws
+
+ user = await _make_user(client, "empty1")
+ node_id = await _announce_node(client, user)
+ for name in ("has-one", "has-two"):
+ r = await client.post(
+ "/v1/groups",
+ json={"name": name, "visibility": "private", "join_policy": "invite"},
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert r.status_code == 201, r.text
+
+ resolved, groups = await _authorize_node_ws(_node_token(user), node_id, None)
+ assert resolved == node_id
+ assert groups == [], (
+ "a node hosting nothing was registered as a host for its owner's groups")
+
+
+@pytest.mark.asyncio
+async def test_ws_explicit_empty_claim_registers_no_groups(client):
+ """And the same when the node says so out loud, which it now does."""
+ from meshbay_hub.api.revocation import _authorize_node_ws
+
+ user = await _make_user(client, "empty2")
+ node_id = await _announce_node(client, user)
+ r = await client.post(
+ "/v1/groups",
+ json={"name": "lonely", "visibility": "private", "join_policy": "invite"},
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert r.status_code == 201, r.text
+
+ resolved, groups = await _authorize_node_ws(_node_token(user), node_id, [])
+ assert resolved == node_id
+ assert groups == []
+
+
+@pytest.mark.asyncio
+async def test_empty_node_cannot_shadow_another_members_group(client):
+ """
+ The outage itself: two members, one group, and only one of them hosts it.
+ The other's node — running, configured with nothing — must not appear as a
+ source for that group, because it is the one clients would reach first.
+ """
+ from meshbay_hub.api.revocation import (
+ _authorize_node_ws, _node_groups, get_online_nodes_for_group)
+
+ host = await _make_user(client, "hoster")
+ guest = await _make_user(client, "guest")
+ host_node = await _announce_node(client, host)
+ guest_node = await _announce_node(client, guest)
+
+ r = await client.post(
+ "/v1/groups",
+ json={"name": "shared", "visibility": "private", "join_policy": "invite"},
+ headers={"Authorization": f"Bearer {host['token']}"},
+ )
+ assert r.status_code == 201, r.text
+ group_id = r.json()["group_id"]
+
+ r = await client.post(
+ f"/v1/groups/{group_id}/members/{'guest'}",
+ headers={"Authorization": f"Bearer {host['token']}"},
+ )
+ assert r.status_code == 201, r.text
+
+ # The guest's node registers first — the order that made this fatal.
+ _, guest_groups = await _authorize_node_ws(_node_token(guest), guest_node, None)
+ _, host_groups = await _authorize_node_ws(
+ _node_token(host), host_node, [group_id])
+ _node_groups[guest_node] = guest_groups
+ _node_groups[host_node] = host_groups
+ try:
+ assert get_online_nodes_for_group(group_id) == [host_node], (
+ "a member's empty node shadowed the node actually hosting the group")
+ finally:
+ _node_groups.pop(guest_node, None)
+ _node_groups.pop(host_node, None)
+
+
+@pytest.mark.asyncio
+async def test_update_groups_is_held_to_the_same_ceiling(client):
+ """
+ `update_groups` assigned the message's list verbatim, so the C2 ceiling held
+ at authentication could be stepped over one message later: a node had only
+ to reload to claim any group on the hub. It now goes through the same gate,
+ which is what this asserts — the socket loop itself needs a WebSocket the
+ ASGI harness has not got (see this module's docstring).
+ """
+ from meshbay_hub.api.revocation import _authorized_groups, _claimable
+
+ user = await _make_user(client, "reloader")
+ r = await client.post(
+ "/v1/groups",
+ json={"name": "owned", "visibility": "private", "join_policy": "invite"},
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert r.status_code == 201, r.text
+ own = r.json()["group_id"]
+
+ authorized = await _authorized_groups(user["user_id"])
+ assert _claimable([own, "someone-elses-group"], authorized) == [own]
+ assert _claimable([], authorized) == []
+ assert _claimable(None, authorized) == []
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index f2f4372..c506ff1 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -452,3 +452,57 @@ def test_the_index_is_never_reported_from_a_failed_decrypt(transport):
assert "catch" not in body, (
"_applyIndexMessage swallows its own failure instead of letting "
"_queueIndexMessage end the session")
+
+
+# ── One node refusing must not take a group down (2026-09-11) ────────────────
+#
+# `/v1/groups/{id}/nodes` returns every node registered for the group, in hub
+# registration order. GroupPage took `nodesData.nodes[0]` and stopped there, so
+# a node that could not serve the group — refusing the handshake with "Group
+# not hosted on this node" — made the group unopenable while the node that
+# *did* host it sat second in the same list.
+#
+# These strip comments first. The lesson this repeats otherwise is the CSP read
+# out of the comment above the meta tag, and the packaging unit whose test
+# matched the comment explaining why `User=` was absent: a source-level check
+# that can match prose is not a check.
+
+def _code_only(src: str) -> str:
+ """Source with // and /* */ comments removed. Crude, and enough here: no
+ string literal in these files carries a comment marker."""
+ src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
+ return re.sub(r"^\s*//.*$", "", src, flags=re.M)
+
+
+def test_the_group_page_tries_every_node_the_hub_offers(group_page):
+ code = _code_only(group_page)
+ assert "for (const n of nodesData.nodes)" in code, (
+ "the connect effect must walk the list, not index into it")
+ assert "nodesData.nodes[0]" not in code, (
+ "taking the head and stopping is the defect — one wrongly registered "
+ "node captured the whole group's traffic")
+
+
+def test_a_not_hosted_refusal_moves_on_to_the_next_node(group_page):
+ code = _code_only(group_page)
+ body = code[code.index("for (const n of nodesData.nodes)"):]
+ body = body[:body.index("if (!transport)")]
+ assert "not_hosted" in body, (
+ "without the code, a refusal this browser cannot act on is "
+ "indistinguishable from one it must stop for")
+ assert "throw e" in body, (
+ "a refusal naming a state of this browser — a pairing code, a "
+ "passphrase, a device — is the same from every node and must stop here")
+
+
+def test_the_last_refusal_is_what_the_reader_is_told(group_page):
+ code = _code_only(group_page)
+ assert "if (!transport) throw (lastErr" in code, (
+ "exhausting the list must report why, not fall through silently")
+
+
+def test_the_refusal_the_loop_keys_on_has_a_message(transport):
+ """A code the page routes on, with nothing to show, is a blank error."""
+ refusals = transport[transport.index("const HANDSHAKE_REFUSALS"):]
+ refusals = refusals[:refusals.index("};")]
+ assert "not_hosted:" in refusals