summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/hub_client.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/hub_client.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/hub_client.py95
1 files changed, 79 insertions, 16 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py
index 432af0a..d8417e3 100644
--- a/packages/meshbay-node/src/meshbay_node/hub_client.py
+++ b/packages/meshbay-node/src/meshbay_node/hub_client.py
@@ -13,6 +13,7 @@ No auth_key or password is ever stored on or transmitted from the node.
The hub issues a node-scoped JWT that cannot manage group membership.
"""
+import asyncio
import base64
import json
import logging
@@ -127,8 +128,8 @@ class HubClient:
access_token = data["access_token"]
decoded = jwt.decode(access_token, hub_pk_pem, algorithms=["EdDSA"])
- assert decoded["pk_user"] == self._keys.pk_ed25519_b64, \
- "Hub returned token for wrong public key"
+ # No pk_user claim to check any more: tokens carry no key. What binds this
+ # token to this node is the Ed25519 challenge it was issued against.
assert "jti" in decoded, "Hub token missing jti — hub is outdated"
assert decoded.get("scope") == "node", \
"Expected node-scoped token"
@@ -163,9 +164,19 @@ class HubClient:
raise RuntimeError("Not logged in")
await self.ensure_fresh_token()
+ # Proof of possession of the node key (M8) — same domain-separated shape
+ # as node_auth, so a signature for one can never satisfy the other.
+ timestamp = int(time.time())
+ message = (f"meshbay:node_announce:{self._session.user_id}:"
+ f"{self._keys.pk_ed25519_b64}:{timestamp}").encode()
+ signature = base64.b64encode(
+ self._keys.sk_ed25519.sign(message)).decode()
+
r = await self._http.post("/v1/nodes/announce", json={
"pk_node": self._keys.pk_ed25519_b64,
"endpoint_hint": endpoint_hint,
+ "timestamp": timestamp,
+ "signature": signature,
}, headers=self._session.auth_headers)
r.raise_for_status()
node_id = r.json()["node_id"]
@@ -219,9 +230,19 @@ class HubClient:
hub_url = self._session.hub_url.replace("https://", "wss://").replace("http://", "ws://")
ws_url = f"{hub_url}/v1/nodes/ws"
+ # Offers are handled off the read loop (see below), so keep a handle on
+ # the tasks to avoid them being garbage-collected mid-negotiation.
+ pending: set[asyncio.Task] = set()
+
while True:
try:
- async with websockets.connect(ws_url) as ws:
+ # Explicit keepalive: this connection is how a node stays visible
+ # to the hub, and a silently half-open socket looks exactly like a
+ # working one until someone notices the node has vanished.
+ async with websockets.connect(
+ ws_url, ping_interval=20, ping_timeout=20, close_timeout=5,
+ open_timeout=15,
+ ) as ws:
auth_msg = {
"type": "auth",
"token": self._session.access_token,
@@ -230,10 +251,20 @@ class HubClient:
if group_ids:
auth_msg["group_ids"] = group_ids
await ws.send(json.dumps(auth_msg))
- auth_resp = json.loads(await ws.recv())
+ # Bounded: a hub that accepts the socket and then says nothing
+ # — which is what it does for a few seconds while restarting —
+ # would otherwise park this task here forever, with the node
+ # running, silent, and invisible to everyone.
+ auth_resp = json.loads(
+ await asyncio.wait_for(ws.recv(), timeout=15))
if auth_resp.get("type") != "auth_ok":
- log.error("WS auth failed: %s", auth_resp)
- return
+ # Not fatal: the token may simply have expired while we
+ # were disconnected. Refresh on the next pass rather than
+ # ending the task, which used to strand the node for good.
+ log.warning("WS auth refused: %s — retrying in 5s", auth_resp)
+ await asyncio.sleep(5)
+ await self.ensure_fresh_token()
+ continue
self._ws = ws
log.info("Hub WS connected")
@@ -250,28 +281,60 @@ class HubClient:
on_revocation(msg.get("token", ""))
elif mtype == "webrtc_offer" and on_webrtc_offer:
- answer = await on_webrtc_offer(
- msg["sdp"], msg["peer_id"],
- msg.get("ice_candidates", []))
- if answer:
- await ws.send(json.dumps({
- "type": "webrtc_answer",
- "peer_id": msg["peer_id"],
- "sdp": answer[0],
- "ice_candidates": answer[1],
- }))
+ # Answered off the read loop on purpose. Awaiting the
+ # handler here meant one slow negotiation stopped the
+ # node reading this socket at all: no pings answered,
+ # no close frame noticed, no further offers served. A
+ # client that gave up mid-ICE left the node in
+ # CLOSE-WAIT, still running but invisible to the hub
+ # and unreachable by everyone, until it was restarted.
+ task = asyncio.create_task(
+ self._answer_offer(ws, on_webrtc_offer, msg))
+ pending.add(task)
+ task.add_done_callback(pending.discard)
elif mtype == "pong":
pass
except asyncio.CancelledError:
+ for task in pending:
+ task.cancel()
raise
except Exception as e:
log.warning("Hub WS disconnected: %s — reconnecting in 5s", e)
await asyncio.sleep(5)
+ else:
+ # A clean close ends the `async for` without raising. Say so, so a
+ # node that quietly stopped being reachable leaves a trace.
+ log.warning("Hub WS closed by the hub — reconnecting in 5s")
+ await asyncio.sleep(5)
finally:
self._ws = None
+ async def _answer_offer(self, ws, on_webrtc_offer, msg: dict) -> None:
+ """Negotiate one WebRTC offer and return the answer, off the read loop."""
+ try:
+ answer = await on_webrtc_offer(
+ msg["sdp"], msg["peer_id"], msg.get("ice_candidates", []))
+ except Exception as e:
+ log.warning("WebRTC offer from %s failed: %s",
+ str(msg.get("peer_id"))[:8], e)
+ return
+ if not answer:
+ return
+ try:
+ await ws.send(json.dumps({
+ "type": "webrtc_answer",
+ "peer_id": msg["peer_id"],
+ "sdp": answer[0],
+ "ice_candidates": answer[1],
+ }))
+ except Exception as e:
+ # The socket may have gone while we were negotiating; the client will
+ # retry, and the read loop is reconnecting.
+ log.warning("Could not deliver WebRTC answer to %s: %s",
+ str(msg.get("peer_id"))[:8], e)
+
# ── Swarm registration ─────────────────────────────────────────────────
async def register_swarm(self, content_hashes: list[str], endpoint: str) -> int: