- 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>
372 lines
12 KiB
Python
372 lines
12 KiB
Python
"""Tests for US-017: P2P gossip, relay node, and TLS infrastructure."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# identity tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_load_or_create_identity_generates_peer_id(tmp_path):
|
|
from meshnet_p2p.identity import load_or_create_identity
|
|
|
|
identity = load_or_create_identity(tmp_path / "identity.json")
|
|
assert len(identity["peer_id"]) == 16
|
|
assert "public_key_pem" in identity
|
|
assert "private_key_pem" in identity
|
|
|
|
|
|
def test_identity_is_stable_across_loads(tmp_path):
|
|
from meshnet_p2p.identity import load_or_create_identity
|
|
|
|
path = tmp_path / "identity.json"
|
|
first = load_or_create_identity(path)
|
|
second = load_or_create_identity(path)
|
|
assert first["peer_id"] == second["peer_id"]
|
|
assert first["public_key_pem"] == second["public_key_pem"]
|
|
|
|
|
|
def test_identity_different_for_different_paths(tmp_path):
|
|
from meshnet_p2p.identity import load_or_create_identity
|
|
|
|
a = load_or_create_identity(tmp_path / "a.json")
|
|
b = load_or_create_identity(tmp_path / "b.json")
|
|
# Extremely unlikely to collide
|
|
assert a["peer_id"] != b["peer_id"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TLS / certificate tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_generate_self_signed_cert_creates_files(tmp_path):
|
|
from meshnet_p2p.tls import generate_self_signed_cert
|
|
|
|
cert_p, key_p = generate_self_signed_cert(
|
|
cert_path=tmp_path / "cert.pem",
|
|
key_path=tmp_path / "key.pem",
|
|
common_name="localhost",
|
|
)
|
|
assert cert_p.exists()
|
|
assert key_p.exists()
|
|
assert cert_p.stat().st_size > 100
|
|
assert key_p.stat().st_size > 100
|
|
|
|
|
|
def test_generate_self_signed_cert_is_idempotent(tmp_path):
|
|
from meshnet_p2p.tls import generate_self_signed_cert
|
|
|
|
args = dict(cert_path=tmp_path / "cert.pem", key_path=tmp_path / "key.pem", common_name="test")
|
|
generate_self_signed_cert(**args)
|
|
mtime1 = (tmp_path / "cert.pem").stat().st_mtime
|
|
|
|
generate_self_signed_cert(**args)
|
|
mtime2 = (tmp_path / "cert.pem").stat().st_mtime
|
|
assert mtime1 == mtime2 # file not regenerated
|
|
|
|
|
|
def test_cert_fingerprint_returns_sha256_prefix(tmp_path):
|
|
from meshnet_p2p.tls import generate_self_signed_cert, cert_fingerprint
|
|
|
|
cert_p, key_p = generate_self_signed_cert(
|
|
cert_path=tmp_path / "cert.pem",
|
|
key_path=tmp_path / "key.pem",
|
|
common_name="test",
|
|
)
|
|
fp = cert_fingerprint(cert_p)
|
|
assert fp.startswith("sha256:")
|
|
assert len(fp) == len("sha256:") + 64 # 32 bytes hex = 64 chars
|
|
|
|
|
|
def test_make_server_ssl_context_loads_cert(tmp_path):
|
|
import ssl
|
|
from meshnet_p2p.tls import generate_self_signed_cert, make_server_ssl_context
|
|
|
|
cert_p, key_p = generate_self_signed_cert(
|
|
cert_path=tmp_path / "cert.pem",
|
|
key_path=tmp_path / "key.pem",
|
|
common_name="test",
|
|
)
|
|
ctx = make_server_ssl_context(cert_p, key_p)
|
|
assert isinstance(ctx, ssl.SSLContext)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PeerRegistry tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_peer_registry_register_and_list():
|
|
from meshnet_relay.peer_registry import PeerRegistry
|
|
|
|
reg = PeerRegistry()
|
|
ws_mock = MagicMock()
|
|
reg.register("peer1", "http://1.2.3.4:8001", ws_mock)
|
|
|
|
assert len(reg) == 1
|
|
peers = reg.list_peers()
|
|
assert len(peers) == 1
|
|
assert peers[0]["peer_id"] == "peer1"
|
|
|
|
|
|
def test_peer_registry_all_except_excludes_sender():
|
|
from meshnet_relay.peer_registry import PeerRegistry
|
|
|
|
reg = PeerRegistry()
|
|
reg.register("a", "http://a:8001", MagicMock())
|
|
reg.register("b", "http://b:8001", MagicMock())
|
|
reg.register("c", "http://c:8001", MagicMock())
|
|
|
|
others = reg.all_except("a")
|
|
assert len(others) == 2
|
|
assert all(e.peer_id != "a" for e in others)
|
|
|
|
|
|
def test_peer_registry_unregister_removes_peer():
|
|
from meshnet_relay.peer_registry import PeerRegistry
|
|
|
|
reg = PeerRegistry()
|
|
reg.register("x", "http://x:8001", MagicMock())
|
|
reg.unregister("x")
|
|
assert len(reg) == 0
|
|
assert reg.get("x") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GossipClient + RelayServer integration test
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _start_relay(host="127.0.0.1", port=0):
|
|
from meshnet_relay.server import RelayServer
|
|
server = RelayServer(host=host, port=port)
|
|
actual_port = server.start()
|
|
return server, actual_port
|
|
|
|
|
|
def test_gossip_fanout_through_relay():
|
|
"""Node B publishes node-join; node A receives it within 2 seconds."""
|
|
from meshnet_p2p.gossip import GossipClient, TOPIC_NODE_JOIN
|
|
|
|
relay, port = _start_relay()
|
|
relay_url = f"ws://127.0.0.1:{port}/ws"
|
|
|
|
client_a = GossipClient(relay_url=relay_url, peer_id="peer_a")
|
|
client_b = GossipClient(relay_url=relay_url, peer_id="peer_b")
|
|
|
|
received = []
|
|
client_a.subscribe(TOPIC_NODE_JOIN, lambda env: received.append(env))
|
|
|
|
client_a.start()
|
|
client_b.start()
|
|
|
|
assert client_a.wait_connected(timeout=5), "client_a failed to connect to relay"
|
|
assert client_b.wait_connected(timeout=5), "client_b failed to connect to relay"
|
|
|
|
# Give both peers time to register with relay
|
|
time.sleep(0.2)
|
|
|
|
client_b.publish(TOPIC_NODE_JOIN, {"addr": "http://192.168.1.10:8001", "peer_id": "peer_b"})
|
|
|
|
deadline = time.monotonic() + 2.0
|
|
while time.monotonic() < deadline and not received:
|
|
time.sleep(0.05)
|
|
|
|
client_a.stop()
|
|
client_b.stop()
|
|
relay.stop()
|
|
|
|
assert received, "client_a did not receive node-join message from client_b"
|
|
assert received[0]["topic"] == TOPIC_NODE_JOIN
|
|
assert received[0]["from_peer"] == "peer_b"
|
|
|
|
|
|
def test_gossip_dedup_prevents_processing_duplicate_message_ids():
|
|
"""A message with a duplicate msg_id is only processed once."""
|
|
from meshnet_p2p.gossip import GossipClient, TOPIC_NODE_JOIN
|
|
|
|
relay, port = _start_relay()
|
|
relay_url = f"ws://127.0.0.1:{port}/ws"
|
|
|
|
client_a = GossipClient(relay_url=relay_url, peer_id="peer_a2")
|
|
client_b = GossipClient(relay_url=relay_url, peer_id="peer_b2")
|
|
|
|
received = []
|
|
client_a.subscribe(TOPIC_NODE_JOIN, lambda env: received.append(env))
|
|
|
|
client_a.start()
|
|
client_b.start()
|
|
client_a.wait_connected(5)
|
|
client_b.wait_connected(5)
|
|
time.sleep(0.2)
|
|
|
|
# Publish once
|
|
client_b.publish(TOPIC_NODE_JOIN, {"test": "dedup"})
|
|
time.sleep(0.5)
|
|
|
|
count = len(received)
|
|
|
|
client_a.stop()
|
|
client_b.stop()
|
|
relay.stop()
|
|
|
|
# Should have received exactly one message (not duplicated)
|
|
assert count <= 1
|
|
|
|
|
|
def test_relay_server_peer_list_grows_on_connect():
|
|
"""Relay registry grows when clients connect."""
|
|
from meshnet_p2p.gossip import GossipClient
|
|
|
|
relay, port = _start_relay()
|
|
relay_url = f"ws://127.0.0.1:{port}/ws"
|
|
|
|
client = GossipClient(relay_url=relay_url, peer_id="solo_peer")
|
|
client.start()
|
|
client.wait_connected(5)
|
|
time.sleep(0.3) # let peer-register message process
|
|
|
|
peer_count = len(relay.registry)
|
|
client.stop()
|
|
relay.stop()
|
|
|
|
assert peer_count >= 1
|
|
|
|
|
|
def test_relay_circuit_relay_proxies_message():
|
|
"""A node behind NAT (client_a) receives a message via circuit relay from client_b."""
|
|
import websockets.sync.client # type: ignore[import]
|
|
from meshnet_relay.server import RelayServer
|
|
|
|
relay = RelayServer(host="127.0.0.1", port=0)
|
|
port = relay.start()
|
|
|
|
# client_a connects to gossip hub and registers
|
|
received_via_relay = []
|
|
ready = threading.Event()
|
|
|
|
def run_nat_client():
|
|
import websockets.sync.client as wsc
|
|
with wsc.connect(f"ws://127.0.0.1:{port}/ws") as ws:
|
|
ws.send(json.dumps({
|
|
"topic": "peer-register",
|
|
"version": 1,
|
|
"from_peer": "nat_peer",
|
|
"msg_id": "reg-001",
|
|
"timestamp": "2026-06-29T00:00:00Z",
|
|
"ttl": 3,
|
|
"payload": {"peer_id": "nat_peer", "addr": ""},
|
|
}))
|
|
# consume peer-list response
|
|
ws.recv()
|
|
ready.set()
|
|
# wait for a relayed message
|
|
try:
|
|
msg = ws.recv(timeout=3)
|
|
received_via_relay.append(json.loads(msg))
|
|
except Exception:
|
|
pass
|
|
|
|
nat_thread = threading.Thread(target=run_nat_client, daemon=True)
|
|
nat_thread.start()
|
|
ready.wait(timeout=5)
|
|
time.sleep(0.1) # ensure peer is in registry
|
|
|
|
# client_b connects via circuit relay path
|
|
def send_via_relay():
|
|
try:
|
|
import websockets.sync.client as wsc
|
|
with wsc.connect(f"ws://127.0.0.1:{port}/relay/nat_peer") as ws:
|
|
ws.send(json.dumps({"topic": "direct-relay-test", "payload": {"hi": "there"}}))
|
|
time.sleep(0.3)
|
|
except Exception:
|
|
pass
|
|
|
|
relay_thread = threading.Thread(target=send_via_relay, daemon=True)
|
|
relay_thread.start()
|
|
relay_thread.join(timeout=3)
|
|
nat_thread.join(timeout=3)
|
|
relay.stop()
|
|
|
|
assert received_via_relay, "NAT'd peer did not receive message via circuit relay"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tracker gossip fields tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _start_tracker_and_register(extra_fields: dict) -> dict:
|
|
"""Helper: start tracker, register node with extra gossip fields, return response."""
|
|
import http.server
|
|
import json as _json
|
|
import urllib.request
|
|
|
|
from meshnet_tracker.server import TrackerServer
|
|
|
|
tracker = TrackerServer(host="127.0.0.1", port=0)
|
|
port = tracker.start()
|
|
url = f"http://127.0.0.1:{port}"
|
|
|
|
payload = {
|
|
"endpoint": f"http://127.0.0.1:8001",
|
|
"shard_start": 0,
|
|
"shard_end": 7,
|
|
"model": "stub-model",
|
|
"hardware_profile": {},
|
|
"score": 1.0,
|
|
**extra_fields,
|
|
}
|
|
data = _json.dumps(payload).encode()
|
|
req = urllib.request.Request(
|
|
f"{url}/v1/nodes/register",
|
|
data=data,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=5) as r:
|
|
resp = _json.loads(r.read())
|
|
|
|
tracker.stop()
|
|
return resp
|
|
|
|
|
|
def test_tracker_accepts_relay_addr_in_registration():
|
|
resp = _start_tracker_and_register({
|
|
"relay_addr": "ws://relay.meshnet.ai:8765/relay/abc123",
|
|
"cert_fingerprint": "sha256:deadbeef",
|
|
"peer_id": "abc123def456ef01",
|
|
})
|
|
assert "node_id" in resp
|
|
|
|
|
|
def test_tracker_accepts_registration_without_gossip_fields():
|
|
"""Existing registrations without P2P fields still work."""
|
|
resp = _start_tracker_and_register({})
|
|
assert "node_id" in resp
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# mDNS (no-op without zeroconf installed)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_mdns_discovery_is_available_flag():
|
|
from meshnet_p2p.mdns import MdnsDiscovery
|
|
|
|
disc = MdnsDiscovery(peer_id="test", port=8001)
|
|
# is_available() should be bool regardless of zeroconf install status
|
|
assert isinstance(disc.is_available(), bool)
|
|
|
|
|
|
def test_mdns_start_and_stop_without_zeroconf(monkeypatch):
|
|
from meshnet_p2p import mdns as mdns_mod
|
|
monkeypatch.setattr(mdns_mod, "_HAS_ZEROCONF", False)
|
|
from meshnet_p2p.mdns import MdnsDiscovery
|
|
|
|
disc = MdnsDiscovery(peer_id="x", port=8001)
|
|
disc.start() # should not raise
|
|
disc.stop() # should not raise
|