- packages/p2p: identity (peer_id from sha256 of RSA pubkey), TLS cert generation with SHA-256 fingerprint, GossipClient (WSS PubSub with per-topic handlers, dedup by msg_id, auto-reconnect), MdnsDiscovery (zeroconf optional dependency, graceful no-op fallback) - packages/relay: new meshnet-relay package — RelayServer (asyncio + websockets) with gossip fanout hub, circuit relay proxy for NAT traversal, peer registry; meshnet-relay CLI - packages/p2p/relay_bootstrap.json: team relay bootstrap list - Tracker: _NodeEntry gains relay_addr, cert_fingerprint, peer_id; both register and heartbeat handlers read and store these optional fields - docs/adr/0010 already written (previous commit) - conftest.py: packages/relay added to sys.path - 18 new tests; 115 passed total, 1 skipped (no regressions) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
163 lines
5.8 KiB
Python
163 lines
5.8 KiB
Python
"""WebSocket gossip client — connects to relay, publish/subscribe to topics."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from collections import defaultdict
|
|
from typing import Callable
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# Message envelope topics
|
|
TOPIC_NODE_JOIN = "node-join"
|
|
TOPIC_NODE_LEAVE = "node-leave"
|
|
TOPIC_COVERAGE_UPDATE = "coverage-update"
|
|
TOPIC_HEARTBEAT = "heartbeat"
|
|
TOPIC_PEER_LIST = "peer-list"
|
|
TOPIC_RELAY_ANNOUNCE = "relay-announce"
|
|
|
|
_MSG_TTL = 3 # max re-broadcast hops
|
|
|
|
|
|
def _make_envelope(topic: str, payload: dict, from_peer: str, ttl: int = _MSG_TTL) -> dict:
|
|
return {
|
|
"topic": topic,
|
|
"version": 1,
|
|
"from_peer": from_peer,
|
|
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
"msg_id": str(uuid.uuid4()),
|
|
"ttl": ttl,
|
|
"payload": payload,
|
|
}
|
|
|
|
|
|
class GossipClient:
|
|
"""Thread-safe WebSocket gossip client.
|
|
|
|
Usage::
|
|
|
|
client = GossipClient(relay_url="ws://relay:8765", peer_id="abc123")
|
|
client.subscribe("node-join", lambda env: print(env["payload"]))
|
|
client.start()
|
|
client.publish("node-join", {"addr": "http://192.168.1.42:8001", ...})
|
|
...
|
|
client.stop()
|
|
"""
|
|
|
|
def __init__(self, relay_url: str, peer_id: str, reconnect_interval: float = 3.0):
|
|
self.relay_url = relay_url
|
|
self.peer_id = peer_id
|
|
self.reconnect_interval = reconnect_interval
|
|
|
|
self._handlers: dict[str, list[Callable]] = defaultdict(list)
|
|
self._seen: set[str] = set()
|
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
self._thread: threading.Thread | None = None
|
|
self._ws = None
|
|
self._running = False
|
|
self._connected = threading.Event()
|
|
self._stop_event: asyncio.Event | None = None
|
|
|
|
def subscribe(self, topic: str, handler: Callable) -> None:
|
|
"""Register a sync callback for messages on topic."""
|
|
self._handlers[topic].append(handler)
|
|
|
|
def publish(self, topic: str, payload: dict) -> None:
|
|
"""Send a gossip message to all peers via the relay. Thread-safe."""
|
|
envelope = _make_envelope(topic, payload, self.peer_id)
|
|
if self._loop and self._running:
|
|
asyncio.run_coroutine_threadsafe(self._send(envelope), self._loop)
|
|
|
|
def start(self) -> None:
|
|
"""Start the gossip client in a background thread."""
|
|
self._running = True
|
|
self._loop = asyncio.new_event_loop()
|
|
self._thread = threading.Thread(target=self._run_loop, daemon=True, name="gossip")
|
|
self._thread.start()
|
|
|
|
def wait_connected(self, timeout: float = 5.0) -> bool:
|
|
"""Block until connected to relay or timeout. Returns True if connected."""
|
|
return self._connected.wait(timeout)
|
|
|
|
def stop(self) -> None:
|
|
"""Stop the gossip client and clean up."""
|
|
self._running = False
|
|
if self._loop and self._stop_event is not None:
|
|
self._loop.call_soon_threadsafe(self._stop_event.set)
|
|
if self._thread:
|
|
self._thread.join(timeout=3.0)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Internal asyncio methods (run inside the background event loop)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _run_loop(self) -> None:
|
|
asyncio.set_event_loop(self._loop)
|
|
self._stop_event = asyncio.Event()
|
|
try:
|
|
self._loop.run_until_complete(self._connect_loop())
|
|
except Exception:
|
|
log.debug("Gossip loop exited", exc_info=True)
|
|
|
|
async def _connect_loop(self) -> None:
|
|
import websockets # type: ignore[import]
|
|
|
|
while self._running and not (self._stop_event and self._stop_event.is_set()):
|
|
try:
|
|
async with websockets.connect(
|
|
self.relay_url,
|
|
ping_interval=20,
|
|
ping_timeout=10,
|
|
open_timeout=5,
|
|
) as ws:
|
|
self._ws = ws
|
|
self._connected.set()
|
|
log.debug("Gossip connected to %s", self.relay_url)
|
|
# Send peer registration
|
|
await ws.send(json.dumps(
|
|
_make_envelope(
|
|
"peer-register",
|
|
{"peer_id": self.peer_id},
|
|
self.peer_id,
|
|
)
|
|
))
|
|
await self._receive_loop(ws)
|
|
except Exception as exc:
|
|
self._connected.clear()
|
|
if self._running:
|
|
log.debug("Gossip disconnected (%s); reconnecting in %ss", exc, self.reconnect_interval)
|
|
await asyncio.sleep(self.reconnect_interval)
|
|
|
|
async def _receive_loop(self, ws) -> None:
|
|
async for raw in ws:
|
|
try:
|
|
envelope = json.loads(raw)
|
|
except (json.JSONDecodeError, TypeError):
|
|
continue
|
|
msg_id = envelope.get("msg_id", "")
|
|
if msg_id in self._seen:
|
|
continue
|
|
self._seen.add(msg_id)
|
|
if len(self._seen) > 10_000:
|
|
# Trim seen set to avoid unbounded growth
|
|
self._seen = set(list(self._seen)[-5_000:])
|
|
|
|
topic = envelope.get("topic", "")
|
|
for handler in self._handlers.get(topic, []):
|
|
try:
|
|
handler(envelope)
|
|
except Exception:
|
|
log.debug("Gossip handler error for topic %s", topic, exc_info=True)
|
|
|
|
async def _send(self, envelope: dict) -> None:
|
|
if self._ws is not None:
|
|
try:
|
|
await self._ws.send(json.dumps(envelope))
|
|
except Exception as exc:
|
|
log.debug("Gossip send failed: %s", exc)
|