summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/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
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 = {