Add relay-backed public node registration

This commit is contained in:
Dobromir Popov
2026-06-30 15:01:49 +02:00
parent a4373a6202
commit 9ca198ee1e
9 changed files with 516 additions and 2 deletions

View File

@@ -18,7 +18,7 @@ python3 -m venv .venv
.venv/bin/python -m pip install --upgrade pip setuptools wheel .venv/bin/python -m pip install --upgrade pip setuptools wheel
# Install Python packages (editable — picks up code changes immediately) # Install Python packages (editable — picks up code changes immediately)
.venv/bin/pip install -e packages/tracker -e packages/node -e packages/p2p -e packages/gateway .venv/bin/pip install -e packages/tracker -e packages/node -e packages/p2p -e packages/gateway -e packages/relay
# CPU-only PyTorch (skip if you have CUDA/ROCm already) # CPU-only PyTorch (skip if you have CUDA/ROCm already)
.venv/bin/pip install torch --index-url https://download.pytorch.org/whl/cpu .venv/bin/pip install torch --index-url https://download.pytorch.org/whl/cpu
@@ -39,7 +39,7 @@ wsl
cd /mnt/d/DEV/workspace/REPOS/git.d-popov.com/neuron-tai cd /mnt/d/DEV/workspace/REPOS/git.d-popov.com/neuron-tai
python3 -m venv .venv python3 -m venv .venv
.venv/bin/python -m pip install --upgrade pip setuptools wheel .venv/bin/python -m pip install --upgrade pip setuptools wheel
.venv/bin/pip install -e packages/tracker -e packages/node -e packages/p2p -e packages/gateway .venv/bin/pip install -e packages/tracker -e packages/node -e packages/p2p -e packages/gateway -e packages/relay
.venv/bin/pip install torch --index-url https://download.pytorch.org/whl/cpu .venv/bin/pip install torch --index-url https://download.pytorch.org/whl/cpu
.venv/bin/pip install transformers accelerate .venv/bin/pip install transformers accelerate
.venv/bin/meshnet-node --help .venv/bin/meshnet-node --help
@@ -48,6 +48,34 @@ python3 -m venv .venv
If `.venv/bin/meshnet-node` is missing, the editable install step did not finish If `.venv/bin/meshnet-node` is missing, the editable install step did not finish
successfully. Re-run the `.venv/bin/pip install -e ...` command above inside WSL. successfully. Re-run the `.venv/bin/pip install -e ...` command above inside WSL.
### Public tracker + WSS relay
For internet nodes, expose one public HTTPS host and proxy these paths:
```text
/v1/* -> meshnet-tracker, for registration, heartbeats, routing, and OpenAI requests
/ws -> meshnet-relay, for outbound node gossip/bridge connections
/rpc/* -> meshnet-relay, for tracker-to-node relay requests
```
Start the tracker with the public relay URL it should advertise:
```bash
.venv/bin/meshnet-relay --host 0.0.0.0 --port 8765
.venv/bin/meshnet-tracker start \
--host 0.0.0.0 \
--port 8081 \
--relay-url wss://ai.neuron.d-popov.com/ws
```
Then a node only needs the public tracker address:
```bash
.venv/bin/meshnet-node start \
--tracker https://ai.neuron.d-popov.com \
--model-id Qwen/Qwen2.5-0.5B-Instruct
```
--- ---
## Step 1 — Start the tracker (Terminal 1) ## Step 1 — Start the tracker (Terminal 1)

View File

@@ -0,0 +1,148 @@
"""Outbound relay bridge for NAT-safe node HTTP requests."""
from __future__ import annotations
import json
import logging
import threading
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class RelayBridgeInfo:
peer_id: str
relay_addr: str
def _make_envelope(topic: str, payload: dict, peer_id: str) -> dict:
return {
"topic": topic,
"version": 1,
"from_peer": peer_id,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"msg_id": f"{peer_id}-{time.time_ns():x}",
"ttl": 1,
"payload": payload,
}
class RelayHttpBridge:
"""Connect outbound to a relay and proxy relay HTTP requests to localhost."""
def __init__(
self,
relay_url: str,
peer_id: str,
local_base_url: str,
advertised_addr: str,
reconnect_interval: float = 3.0,
) -> None:
self.relay_url = relay_url.rstrip("/")
self.peer_id = peer_id
self.local_base_url = local_base_url.rstrip("/")
self.advertised_addr = advertised_addr
self.reconnect_interval = reconnect_interval
self._running = False
self._thread: threading.Thread | None = None
self._connected = threading.Event()
@property
def relay_addr(self) -> str:
base = self.relay_url
if base.endswith("/ws"):
base = base[:-3]
return f"{base}/rpc/{self.peer_id}"
def start(self) -> RelayBridgeInfo:
self._running = True
self._thread = threading.Thread(target=self._run, daemon=True, name="relay-http-bridge")
self._thread.start()
return RelayBridgeInfo(peer_id=self.peer_id, relay_addr=self.relay_addr)
def wait_connected(self, timeout: float = 5.0) -> bool:
return self._connected.wait(timeout)
def stop(self) -> None:
self._running = False
if self._thread:
self._thread.join(timeout=3.0)
def _run(self) -> None:
import websockets.sync.client as wsc # type: ignore[import]
while self._running:
try:
with wsc.connect(self.relay_url, open_timeout=5) as ws:
self._connected.set()
ws.send(json.dumps(_make_envelope(
"peer-register",
{"peer_id": self.peer_id, "addr": self.advertised_addr},
self.peer_id,
)))
while self._running:
try:
raw = ws.recv(timeout=1)
except TimeoutError:
continue
try:
envelope = json.loads(raw)
except (TypeError, json.JSONDecodeError):
continue
if envelope.get("topic") != "relay-http-request":
continue
payload = envelope.get("payload", {})
if payload.get("target_peer") not in {None, self.peer_id}:
continue
response = self._handle_request(payload)
ws.send(json.dumps(_make_envelope(
"relay-http-response",
response,
self.peer_id,
)))
except Exception as exc:
self._connected.clear()
if self._running:
log.debug("relay bridge disconnected: %s", exc)
time.sleep(self.reconnect_interval)
def _handle_request(self, payload: dict) -> dict:
request_id = str(payload.get("request_id") or "")
method = str(payload.get("method") or "POST").upper()
path = str(payload.get("path") or "/")
body_text = payload.get("body") or ""
headers = payload.get("headers") if isinstance(payload.get("headers"), dict) else {}
url = f"{self.local_base_url}{path}"
data = body_text.encode() if isinstance(body_text, str) else bytes(body_text)
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=300.0) as resp:
return {
"request_id": request_id,
"status": resp.status,
"headers": {"Content-Type": resp.headers.get("Content-Type", "application/json")},
"body": resp.read().decode(errors="replace"),
}
except urllib.error.HTTPError as exc:
return {
"request_id": request_id,
"status": exc.code,
"headers": {"Content-Type": exc.headers.get("Content-Type", "application/json")},
"body": exc.read().decode(errors="replace"),
}
except Exception as exc:
return {
"request_id": request_id,
"status": 503,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"error": f"relay bridge local request failed: {exc}"}),
}
def peer_id_from_wallet(wallet_address: str) -> str:
return wallet_address[:16] if len(wallet_address) >= 16 else wallet_address

View File

@@ -15,6 +15,7 @@ from typing import Any
from .downloader import compute_shard_checksum, download_shard from .downloader import compute_shard_checksum, download_shard
from .hardware import detect_hardware from .hardware import detect_hardware
from .relay_bridge import RelayHttpBridge, peer_id_from_wallet
from .server import StubNodeServer from .server import StubNodeServer
from .torch_server import TorchNodeServer from .torch_server import TorchNodeServer
from .wallet import load_or_create_wallet from .wallet import load_or_create_wallet
@@ -34,6 +35,57 @@ def _get_json(url: str, timeout: float = 10.0) -> dict:
return json.loads(r.read()) return json.loads(r.read())
def _discover_relay_url(tracker_url: str) -> str | None:
try:
network_map = _get_json(f"{tracker_url}/v1/network/map", timeout=5.0)
except Exception:
return None
relay_url = network_map.get("relay_url")
return relay_url if isinstance(relay_url, str) and relay_url else None
def _start_relay_bridge_if_available(
tracker_url: str,
wallet_address: str,
local_base_url: str,
advertised_endpoint: str,
) -> tuple[RelayHttpBridge | None, dict]:
relay_url = _discover_relay_url(tracker_url)
if not relay_url:
return None, {}
peer_id = peer_id_from_wallet(wallet_address)
bridge = RelayHttpBridge(
relay_url=relay_url,
peer_id=peer_id,
local_base_url=local_base_url,
advertised_addr=advertised_endpoint,
)
info = bridge.start()
if bridge.wait_connected(timeout=5.0):
print(f" Relay connected — {info.relay_addr}", flush=True)
else:
print(f" Relay configured but not connected yet — {info.relay_addr}", flush=True)
return bridge, {
"relay_addr": info.relay_addr,
"peer_id": info.peer_id,
}
def _attach_relay_bridge(node: StubNodeServer | TorchNodeServer, bridge: RelayHttpBridge | None) -> None:
setattr(node, "relay_bridge", bridge)
if bridge is None:
return
original_stop = node.stop
def _stop_with_bridge() -> None:
try:
bridge.stop()
finally:
original_stop()
node.stop = _stop_with_bridge # type: ignore[method-assign]
def _start_heartbeat( def _start_heartbeat(
tracker_url: str, tracker_url: str,
node_id: str, node_id: str,
@@ -212,6 +264,14 @@ def run_startup(
shard_label = f"layers {shard_start}{shard_end}" shard_label = f"layers {shard_start}{shard_end}"
public_host = advertise_host or (socket.getfqdn() if host == "0.0.0.0" else host) public_host = advertise_host or (socket.getfqdn() if host == "0.0.0.0" else host)
endpoint = f"http://{public_host}:{actual_port}" endpoint = f"http://{public_host}:{actual_port}"
local_base_url = f"http://127.0.0.1:{actual_port}"
relay_bridge, relay_fields = _start_relay_bridge_if_available(
tracker_url,
address,
local_base_url,
endpoint,
)
_attach_relay_bridge(node, relay_bridge)
# Register with tracker so other nodes can auto-join this model. # Register with tracker so other nodes can auto-join this model.
total_layers = getattr(getattr(node, "backend", None), "total_layers", None) total_layers = getattr(getattr(node, "backend", None), "total_layers", None)
reg_payload = { reg_payload = {
@@ -226,6 +286,7 @@ def run_startup(
"quantization": quantization, "quantization": quantization,
"score": 1.0, "score": 1.0,
"tracker_mode": (shard_start == 0), "tracker_mode": (shard_start == 0),
**relay_fields,
} }
tracker_node_id: str | None = None tracker_node_id: str | None = None
try: try:
@@ -289,6 +350,14 @@ def run_startup(
actual_port = node.start() actual_port = node.start()
public_host = advertise_host or (socket.getfqdn() if host == "0.0.0.0" else host) public_host = advertise_host or (socket.getfqdn() if host == "0.0.0.0" else host)
endpoint = f"http://{public_host}:{actual_port}" endpoint = f"http://{public_host}:{actual_port}"
local_base_url = f"http://127.0.0.1:{actual_port}"
relay_bridge, relay_fields = _start_relay_bridge_if_available(
tracker_url,
address,
local_base_url,
endpoint,
)
_attach_relay_bridge(node, relay_bridge)
auto_reg_payload = { auto_reg_payload = {
"endpoint": endpoint, "endpoint": endpoint,
"model": assigned_hf_repo.split("/")[-1], "model": assigned_hf_repo.split("/")[-1],
@@ -301,6 +370,7 @@ def run_startup(
"quantization": quantization, "quantization": quantization,
"score": 1.0, "score": 1.0,
"tracker_mode": (assigned_shard_start == 0), "tracker_mode": (assigned_shard_start == 0),
**relay_fields,
} }
tracker_node_id = None tracker_node_id = None
try: try:
@@ -376,6 +446,14 @@ def run_startup(
actual_port = node.start() actual_port = node.start()
public_host = advertise_host or (socket.getfqdn() if host == "0.0.0.0" else host) public_host = advertise_host or (socket.getfqdn() if host == "0.0.0.0" else host)
endpoint = f"http://{public_host}:{actual_port}" endpoint = f"http://{public_host}:{actual_port}"
local_base_url = f"http://127.0.0.1:{actual_port}"
relay_bridge, relay_fields = _start_relay_bridge_if_available(
tracker_url,
address,
local_base_url,
endpoint,
)
_attach_relay_bridge(node, relay_bridge)
# 6. Register with tracker # 6. Register with tracker
print("Registering with tracker...", flush=True) print("Registering with tracker...", flush=True)
@@ -391,6 +469,7 @@ def run_startup(
"hardware_profile": hw, "hardware_profile": hw,
"wallet_address": address, "wallet_address": address,
"score": 1.0, "score": 1.0,
**relay_fields,
}, },
) )
node_id = str(reg_resp["node_id"]) node_id = str(reg_resp["node_id"])

View File

@@ -17,6 +17,7 @@ dependencies = [
"safetensors>=0.4", "safetensors>=0.4",
"torch>=2.1", "torch>=2.1",
"transformers>=4.39", "transformers>=4.39",
"websockets>=13",
"zstandard>=0.22", "zstandard>=0.22",
] ]

View File

@@ -16,6 +16,7 @@ import asyncio
import json import json
import logging import logging
import threading import threading
import uuid
from pathlib import Path from pathlib import Path
from .peer_registry import PeerRegistry from .peer_registry import PeerRegistry
@@ -56,6 +57,7 @@ class RelayServer:
self._ready = threading.Event() self._ready = threading.Event()
self._running = False self._running = False
self._stop_event: asyncio.Event | None = None self._stop_event: asyncio.Event | None = None
self._pending_rpc: dict[str, asyncio.Future] = {}
@property @property
def registry(self) -> PeerRegistry: def registry(self) -> PeerRegistry:
@@ -121,6 +123,9 @@ class RelayServer:
if path.startswith("/relay/"): if path.startswith("/relay/"):
peer_id = path[len("/relay/"):] peer_id = path[len("/relay/"):]
await self._handle_circuit_relay(ws, peer_id) await self._handle_circuit_relay(ws, peer_id)
elif path.startswith("/rpc/"):
peer_id = path[len("/rpc/"):]
await self._handle_rpc(ws, peer_id)
elif path == "/health": elif path == "/health":
await ws.send(json.dumps({"status": "ok", "peers": len(self._registry)})) await ws.send(json.dumps({"status": "ok", "peers": len(self._registry)}))
await ws.close() await ws.close()
@@ -164,6 +169,14 @@ class RelayServer:
})) }))
continue continue
if topic == "relay-http-response":
payload = envelope.get("payload", {})
request_id = payload.get("request_id")
fut = self._pending_rpc.pop(request_id, None)
if fut is not None and not fut.done():
fut.set_result(payload)
continue
# Fan out to all other registered peers # Fan out to all other registered peers
if peer_id: if peer_id:
self._registry.touch(peer_id) self._registry.touch(peer_id)
@@ -205,6 +218,50 @@ class RelayServer:
return_exceptions=True, return_exceptions=True,
) )
async def _handle_rpc(self, ws_requester, target_peer_id: str) -> None:
"""Send one HTTP-shaped request to a connected peer and relay its response."""
target = self._registry.get(target_peer_id)
if target is None:
await ws_requester.send(json.dumps({
"status": 503,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"error": f"peer {target_peer_id!r} not connected to relay"}),
}))
await ws_requester.close()
return
try:
raw = await asyncio.wait_for(ws_requester.recv(), timeout=30.0)
payload = json.loads(raw)
except Exception:
await ws_requester.close(1003, "invalid relay rpc request")
return
request_id = str(payload.get("request_id") or uuid.uuid4())
payload["request_id"] = request_id
payload["target_peer"] = target_peer_id
fut = self._loop.create_future() if self._loop is not None else asyncio.get_running_loop().create_future()
self._pending_rpc[request_id] = fut
try:
await target.ws.send(json.dumps({
"topic": "relay-http-request",
"version": 1,
"from_peer": "relay",
"payload": payload,
}))
response = await asyncio.wait_for(fut, timeout=310.0)
await ws_requester.send(json.dumps(response))
except asyncio.TimeoutError:
await ws_requester.send(json.dumps({
"request_id": request_id,
"status": 504,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"error": "relay rpc timed out"}),
}))
finally:
self._pending_rpc.pop(request_id, None)
await ws_requester.close()
async def _broadcast(raw: str | bytes, peers: list) -> None: async def _broadcast(raw: str | bytes, peers: list) -> None:
"""Send raw message to all peers; ignore individual send failures.""" """Send raw message to all peers; ignore individual send failures."""

View File

@@ -33,6 +33,11 @@ def main() -> None:
default=None, default=None,
help="This tracker's own URL as seen by peers (auto-derived from --host/--port if omitted)", help="This tracker's own URL as seen by peers (auto-derived from --host/--port if omitted)",
) )
start_cmd.add_argument(
"--relay-url",
default=None,
help="Public ws(s):// relay URL advertised to nodes, for example wss://ai.neuron.d-popov.com/ws",
)
args = parser.parse_args() args = parser.parse_args()
@@ -44,6 +49,7 @@ def main() -> None:
heartbeat_timeout=args.heartbeat_timeout, heartbeat_timeout=args.heartbeat_timeout,
cluster_peers=cluster_peers or None, cluster_peers=cluster_peers or None,
cluster_self_url=args.self_url, cluster_self_url=args.self_url,
relay_url=args.relay_url,
) )
port = server.start() port = server.start()
print(f"meshnet-tracker listening on http://{args.host}:{port}", flush=True) print(f"meshnet-tracker listening on http://{args.host}:{port}", flush=True)

View File

@@ -28,6 +28,8 @@ import socketserver
import threading import threading
import time import time
import urllib.parse import urllib.parse
import urllib.request
import uuid
from typing import Any from typing import Any
from .gossip import NodeGossip from .gossip import NodeGossip
@@ -238,6 +240,35 @@ def _coverage_gaps(coverage: list[dict]) -> list[tuple[int, int]]:
] ]
def _relay_http_request(
relay_addr: str,
path: str,
body: bytes,
headers: dict[str, str],
timeout: float = 310.0,
) -> dict | None:
"""Send an HTTP-shaped request through a relay RPC WebSocket."""
try:
import websockets.sync.client as wsc # type: ignore[import]
request_id = str(uuid.uuid4())
with wsc.connect(relay_addr, open_timeout=10, close_timeout=5) as ws:
ws.send(json.dumps({
"request_id": request_id,
"method": "POST",
"path": path,
"headers": headers,
"body": body.decode(errors="replace"),
}))
raw = ws.recv(timeout=timeout)
response = json.loads(raw)
if response.get("request_id") not in {None, request_id}:
return None
return response
except Exception:
return None
def _nodes_and_bounds_for_model( def _nodes_and_bounds_for_model(
server: "_TrackerHTTPServer", server: "_TrackerHTTPServer",
model: str, model: str,
@@ -432,6 +463,7 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
model_presets: dict, model_presets: dict,
contracts: Any | None, contracts: Any | None,
minimum_stake: int, minimum_stake: int,
relay_url: str | None = None,
raft: "RaftNode | None" = None, raft: "RaftNode | None" = None,
gossip: "NodeGossip | None" = None, gossip: "NodeGossip | None" = None,
) -> None: ) -> None:
@@ -442,6 +474,7 @@ class _TrackerHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
self.model_presets = model_presets self.model_presets = model_presets
self.contracts = contracts self.contracts = contracts
self.minimum_stake = minimum_stake self.minimum_stake = minimum_stake
self.relay_url = relay_url.rstrip("/") if relay_url else None
self.raft = raft self.raft = raft
self.gossip = gossip self.gossip = gossip
@@ -511,6 +544,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
self._handle_assign(parsed) self._handle_assign(parsed)
elif parsed.path == "/v1/network/assign": elif parsed.path == "/v1/network/assign":
self._handle_network_assign(parsed) self._handle_network_assign(parsed)
elif parsed.path == "/v1/network/map":
self._handle_network_map()
elif parsed.path == "/v1/models": elif parsed.path == "/v1/models":
self._handle_models() self._handle_models()
elif parsed.path.startswith("/v1/coverage/"): elif parsed.path.startswith("/v1/coverage/"):
@@ -653,12 +688,38 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
{ {
"node_id": node.node_id, "node_id": node.node_id,
"endpoint": node.endpoint, "endpoint": node.endpoint,
"relay_addr": node.relay_addr,
"peer_id": node.peer_id,
"benchmark_tokens_per_sec": node.benchmark_tokens_per_sec, "benchmark_tokens_per_sec": node.benchmark_tokens_per_sec,
} }
for node in tracker_nodes for node in tracker_nodes
], ],
}) })
def _handle_network_map(self) -> None:
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
with server.lock:
self._purge_expired_nodes()
nodes = list(server.registry.values())
self._send_json(200, {
"relay_url": server.relay_url,
"nodes": [
{
"node_id": node.node_id,
"endpoint": node.endpoint,
"relay_addr": node.relay_addr,
"peer_id": node.peer_id,
"model": node.model,
"hf_repo": node.hf_repo,
"shard_start": node.shard_start,
"shard_end": node.shard_end,
"tracker_mode": node.tracker_mode,
"last_heartbeat": node.last_heartbeat,
}
for node in nodes
],
})
# ---------------------------------------------------------------- OpenAI proxy # ---------------------------------------------------------------- OpenAI proxy
def _handle_proxy_chat(self) -> None: def _handle_proxy_chat(self) -> None:
@@ -771,6 +832,25 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
pass pass
return return
except Exception as exc: except Exception as exc:
if node.relay_addr:
print(
f"[tracker] direct proxy failed {request_id}: {target_url}: {exc}; "
f"trying relay {node.relay_addr}",
flush=True,
)
relayed = _relay_http_request(
node.relay_addr,
path="/v1/chat/completions",
body=raw_body,
headers={
"Content-Type": "application/json",
"X-Meshnet-Route": downstream_urls,
**({"Authorization": auth} if auth else {}),
},
)
if relayed is not None:
self._send_relayed_response(relayed)
return
print(f"[tracker] proxy failed {request_id}: {target_url}: {exc}", flush=True) print(f"[tracker] proxy failed {request_id}: {target_url}: {exc}", flush=True)
self._send_json(503, {"error": { self._send_json(503, {"error": {
"message": f"upstream node unreachable: {exc}", "message": f"upstream node unreachable: {exc}",
@@ -812,6 +892,20 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
except BrokenPipeError: except BrokenPipeError:
pass pass
def _send_relayed_response(self, response: dict) -> None:
status = int(response.get("status", 503))
headers = response.get("headers") if isinstance(response.get("headers"), dict) else {}
body_text = response.get("body") or ""
body = body_text.encode() if isinstance(body_text, str) else bytes(body_text)
self.send_response(status)
self.send_header("Content-Type", headers.get("Content-Type", "application/json"))
self.send_header("Content-Length", str(len(body)))
self.end_headers()
try:
self.wfile.write(body)
except BrokenPipeError:
pass
def _handle_register(self): def _handle_register(self):
server: _TrackerHTTPServer = self.server # type: ignore[assignment] server: _TrackerHTTPServer = self.server # type: ignore[assignment]
body = self._read_json_body() body = self._read_json_body()
@@ -1340,6 +1434,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
{ {
"node_id": e.node_id, "node_id": e.node_id,
"endpoint": e.endpoint, "endpoint": e.endpoint,
"relay_addr": e.relay_addr,
"peer_id": e.peer_id,
"wallet_address": e.wallet_address, "wallet_address": e.wallet_address,
"shard_start": e.shard_start, "shard_start": e.shard_start,
"shard_end": e.shard_end, "shard_end": e.shard_end,
@@ -1400,6 +1496,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
{ {
"node_id": e.node_id, "node_id": e.node_id,
"endpoint": e.endpoint, "endpoint": e.endpoint,
"relay_addr": e.relay_addr,
"peer_id": e.peer_id,
"wallet_address": e.wallet_address, "wallet_address": e.wallet_address,
"shard_start": e.shard_start, "shard_start": e.shard_start,
"shard_end": e.shard_end, "shard_end": e.shard_end,
@@ -1433,6 +1531,7 @@ class TrackerServer:
minimum_stake: int = 0, minimum_stake: int = 0,
cluster_peers: list[str] | None = None, cluster_peers: list[str] | None = None,
cluster_self_url: str | None = None, cluster_self_url: str | None = None,
relay_url: str | None = None,
) -> None: ) -> None:
self._host = host self._host = host
self._requested_port = port self._requested_port = port
@@ -1445,6 +1544,7 @@ class TrackerServer:
self._minimum_stake = minimum_stake self._minimum_stake = minimum_stake
self._cluster_peers: list[str] = list(cluster_peers) if cluster_peers else [] self._cluster_peers: list[str] = list(cluster_peers) if cluster_peers else []
self._cluster_self_url: str | None = cluster_self_url self._cluster_self_url: str | None = cluster_self_url
self._relay_url = relay_url
self._registry: dict[str, _NodeEntry] = {} self._registry: dict[str, _NodeEntry] = {}
self._lock = threading.Lock() self._lock = threading.Lock()
self._server: _TrackerHTTPServer | None = None self._server: _TrackerHTTPServer | None = None
@@ -1469,6 +1569,7 @@ class TrackerServer:
self._model_presets, self._model_presets,
self._contracts, self._contracts,
self._minimum_stake, self._minimum_stake,
self._relay_url,
) )
self.port = self._server.server_address[1] self.port = self._server.server_address[1]

View File

@@ -8,6 +8,10 @@ version = "0.1.0"
description = "Distributed Inference Network node registry and route selection" description = "Distributed Inference Network node registry and route selection"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [
"websockets>=13",
]
[project.scripts] [project.scripts]
meshnet-tracker = "meshnet_tracker.cli:main" meshnet-tracker = "meshnet_tracker.cli:main"

View File

@@ -295,6 +295,61 @@ def test_relay_circuit_relay_proxies_message():
assert received_via_relay, "NAT'd peer did not receive message via circuit relay" assert received_via_relay, "NAT'd peer did not receive message via circuit relay"
def test_relay_rpc_round_trips_http_request_to_peer():
"""Relay /rpc/<peer> sends one HTTP-shaped request to a connected peer."""
import websockets.sync.client as wsc # type: ignore[import]
from meshnet_relay.server import RelayServer
relay = RelayServer(host="127.0.0.1", port=0)
port = relay.start()
ready = threading.Event()
def run_peer():
with wsc.connect(f"ws://127.0.0.1:{port}/ws") as ws:
ws.send(json.dumps({
"topic": "peer-register",
"version": 1,
"from_peer": "rpc_peer",
"msg_id": "rpc-reg-001",
"payload": {"peer_id": "rpc_peer", "addr": ""},
}))
ws.recv()
ready.set()
envelope = json.loads(ws.recv(timeout=3))
payload = envelope["payload"]
ws.send(json.dumps({
"topic": "relay-http-response",
"version": 1,
"from_peer": "rpc_peer",
"payload": {
"request_id": payload["request_id"],
"status": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"ok": True, "path": payload["path"]}),
},
}))
peer_thread = threading.Thread(target=run_peer, daemon=True)
peer_thread.start()
assert ready.wait(timeout=5)
with wsc.connect(f"ws://127.0.0.1:{port}/rpc/rpc_peer") as ws:
ws.send(json.dumps({
"request_id": "req-1",
"method": "POST",
"path": "/v1/chat/completions",
"headers": {"Content-Type": "application/json"},
"body": "{}",
}))
response = json.loads(ws.recv(timeout=5))
relay.stop()
peer_thread.join(timeout=3)
assert response["status"] == 200
assert json.loads(response["body"]) == {"ok": True, "path": "/v1/chat/completions"}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tracker gossip fields tests # Tracker gossip fields tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -349,6 +404,41 @@ def test_tracker_accepts_registration_without_gossip_fields():
assert "node_id" in resp assert "node_id" in resp
def test_tracker_network_map_exposes_relay_and_registered_peer():
import json as _json
import urllib.request
from meshnet_tracker.server import TrackerServer
tracker = TrackerServer(host="127.0.0.1", port=0, relay_url="wss://ai.neuron.d-popov.com/ws")
port = tracker.start()
url = f"http://127.0.0.1:{port}"
try:
req = urllib.request.Request(
f"{url}/v1/nodes/register",
data=_json.dumps({
"endpoint": "http://192.0.2.10:7000",
"model": "stub-model",
"shard_start": 0,
"shard_end": 31,
"relay_addr": "wss://ai.neuron.d-popov.com/rpc/peer123",
"peer_id": "peer123",
}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=5):
pass
with urllib.request.urlopen(f"{url}/v1/network/map", timeout=5) as resp:
body = _json.loads(resp.read())
finally:
tracker.stop()
assert body["relay_url"] == "wss://ai.neuron.d-popov.com/ws"
assert body["nodes"][0]["relay_addr"] == "wss://ai.neuron.d-popov.com/rpc/peer123"
assert body["nodes"][0]["peer_id"] == "peer123"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# mDNS (no-op without zeroconf installed) # mDNS (no-op without zeroconf installed)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------