Add relay-backed public node registration
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
148
packages/node/meshnet_node/relay_bridge.py
Normal file
148
packages/node/meshnet_node/relay_bridge.py
Normal 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
|
||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user