"""Outbound relay bridge for NAT-safe node HTTP requests.""" from __future__ import annotations import base64 import http.client import json import logging import os import re import threading import time import urllib.parse from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass log = logging.getLogger(__name__) DEFAULT_MAX_CONCURRENCY = 8 # Activation tensors ride the relay as one WebSocket frame per hop, so the # websockets default of 1 MiB rejects any real prefill (close code 1009). DEFAULT_WS_MAX_BYTES = 256 * 1024 * 1024 def ws_max_size() -> int | None: """Max inbound WebSocket frame size; MESHNET_WS_MAX_BYTES<=0 means unlimited.""" raw = os.environ.get("MESHNET_WS_MAX_BYTES", "").strip() if not raw: return DEFAULT_WS_MAX_BYTES try: value = int(raw) except ValueError: return DEFAULT_WS_MAX_BYTES return None if value <= 0 else value # Binary relay frame: JSON header + raw body in one WebSocket binary message, # so activation bodies travel as bytes instead of base64 inside JSON. Same wire # format as meshnet_relay.server — duplicated because node and relay ship as # independent distributions. BINARY_FRAME_MAGIC = b"MRF1" def encode_binary_frame(header: dict, body: bytes) -> bytes: """Build one request-owned binary frame without base64 expansion. ``join`` makes one owned output frame rather than creating intermediate concatenation frames. The layout is intentionally unchanged because the relay ships an independent copy of this codec. """ header_bytes = json.dumps(header, separators=(",", ":")).encode() return b"".join((BINARY_FRAME_MAGIC, len(header_bytes).to_bytes(4, "big"), header_bytes, body)) def decode_binary_frame(frame: bytes) -> tuple[dict, bytes]: if len(frame) < 8 or frame[:4] != BINARY_FRAME_MAGIC: raise ValueError("not a meshnet binary relay frame") header_len = int.from_bytes(frame[4:8], "big") header = json.loads(frame[8:8 + header_len].decode()) # The slice is a request-owned body. It cannot retain the enclosing relay # frame after callers finish processing it. return header, frame[8 + header_len:] @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, } def _max_concurrency_from_env() -> int: try: value = int(os.environ.get("MESHNET_RELAY_CONCURRENCY", DEFAULT_MAX_CONCURRENCY)) except (TypeError, ValueError): return DEFAULT_MAX_CONCURRENCY return max(1, value) class _LoopbackHttpClientPool: """Bounded worker-local HTTP/1.1 clients for relay loopback forwarding.""" def __init__(self, base_url: str, timeout: float = 300.0) -> None: parsed = urllib.parse.urlsplit(base_url.rstrip("/")) if parsed.scheme not in {"http", "https"} or not parsed.hostname: raise ValueError(f"invalid local bridge URL: {base_url!r}") self._scheme = parsed.scheme self._host = parsed.hostname self._port = parsed.port self._base_path = parsed.path.rstrip("/") self._timeout = timeout self._local = threading.local() self._lock = threading.Lock() self._clients: set[http.client.HTTPConnection] = set() def _connection(self) -> http.client.HTTPConnection: connection = getattr(self._local, "connection", None) if connection is None: kind = http.client.HTTPSConnection if self._scheme == "https" else http.client.HTTPConnection connection = kind(self._host, self._port, timeout=self._timeout) self._local.connection = connection with self._lock: self._clients.add(connection) return connection def request(self, method: str, path: str, body: bytes, headers: dict): request_path = f"{self._base_path}{path if path.startswith('/') else '/' + path}" connection = self._connection() try: connection.request(method, request_path, body=body, headers=headers) return connection.getresponse() except Exception: self.discard() raise def discard(self) -> None: connection = getattr(self._local, "connection", None) if connection is None: return try: connection.close() finally: self._local.connection = None with self._lock: self._clients.discard(connection) def close(self) -> None: with self._lock: clients = tuple(self._clients) self._clients.clear() for connection in clients: connection.close() self._local.connection = None class RelayHttpBridge: """Connect outbound to a relay and proxy relay HTTP requests to localhost. Requests are dispatched on a bounded worker pool (US-037) so a node that is the head of one route can still serve per-token ``/forward`` hops of another. Streaming responses (``text/event-stream``) are forwarded as multiple chunk frames sharing one ``request_id`` (US-036); everything else stays a single frame for backward compatibility. """ def __init__( self, relay_url: str, peer_id: str, local_base_url: str, advertised_addr: str, reconnect_interval: float = 3.0, max_concurrency: int | None = None, ) -> 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.max_concurrency = max(1, max_concurrency) if max_concurrency else _max_concurrency_from_env() self._running = False self._thread: threading.Thread | None = None self._connected = threading.Event() self._executor: ThreadPoolExecutor | None = None self._send_lock = threading.Lock() self._decode_log_lock = threading.Lock() self._decode_steps: dict[str, int] = {} self._ws = None self._loopback_clients = _LoopbackHttpClientPool(self.local_base_url) @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._executor = ThreadPoolExecutor( max_workers=self.max_concurrency, thread_name_prefix="relay-http-worker" ) 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) if self._executor is not None: self._executor.shutdown(wait=False) self._loopback_clients.close() 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, max_size=ws_max_size(), compression=None, ) as ws: self._ws = 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 if isinstance(raw, (bytes, bytearray)): try: payload, body = decode_binary_frame(bytes(raw)) except (ValueError, json.JSONDecodeError): continue if payload.get("target_peer") not in {None, self.peer_id}: continue if self._executor is None: break self._executor.submit(self._process_request, payload, body) 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 if self._executor is None: break self._executor.submit(self._process_request, payload) except Exception as exc: self._connected.clear() self._ws = None if self._running: log.debug("relay bridge disconnected: %s", exc) time.sleep(self.reconnect_interval) self._ws = None def _send_response_frame(self, payload: dict) -> bool: """Send one relay-http-response frame; False if the socket is gone. The lock is held per frame so concurrent workers interleave whole frames on the shared WebSocket, never torn ones. """ ws = self._ws if ws is None: return False message = json.dumps(_make_envelope("relay-http-response", payload, self.peer_id)) try: with self._send_lock: ws.send(message) return True except Exception as exc: log.debug("relay bridge send failed (request orphaned): %s", exc) return False def _send_binary_response_frame(self, header: dict, body: bytes) -> bool: """Send one binary response frame; False if the socket is gone.""" ws = self._ws if ws is None: return False frame = encode_binary_frame(header, body) try: with self._send_lock: ws.send(frame) return True except Exception as exc: log.debug("relay bridge binary send failed (request orphaned): %s", exc) return False def _process_request(self, payload: dict, binary_body: bytes | None = None) -> None: request_id = str(payload.get("request_id") or "") method = str(payload.get("method") or "POST").upper() path = str(payload.get("path") or "/") headers = payload.get("headers") if isinstance(payload.get("headers"), dict) else {} binary_mode = binary_body is not None session = str(headers.get("X-Meshnet-Session") or "") cache_mode = headers.get("X-Meshnet-Cache") req_suffix = f" request_id={request_id}" if request_id else "" if path == "/forward" and cache_mode == "decode" and session: with self._decode_log_lock: steps = self._decode_steps.get(session, 0) + 1 self._decode_steps[session] = steps if steps == 1 or steps % 32 == 0: print( f" [node] relay {method} {path} session={session[:8]} steps={steps}{req_suffix}", flush=True, ) else: session_suffix = f" session={session[:8]}" if session else "" print(f" [node] relay {method} {path}{session_suffix}{req_suffix}", flush=True) if binary_mode: data = binary_body else: # Legacy JSON request: body_base64 carries binary data, text "body" # covers non-binary requests. body_b64 = payload.get("body_base64") if body_b64: data = base64.b64decode(body_b64) else: body_text = payload.get("body") or "" data = body_text.encode() if isinstance(body_text, str) else bytes(body_text) try: resp = self._loopback_clients.request(method, path, data, headers) try: resp_headers = dict(resp.headers) content_type = resp.headers.get("Content-Type", "") if "text/event-stream" in content_type: if not self._stream_response(request_id, resp, resp_headers): self._loopback_clients.discard() return resp_bytes = resp.read() # Forward all X-Meshnet-* headers so the caller can reconstruct the activation. if binary_mode: self._send_binary_response_frame({ "request_id": request_id, "status": resp.status, "headers": resp_headers, }, resp_bytes) return is_binary = "octet-stream" in content_type result: dict = { "request_id": request_id, "status": resp.status, "headers": resp_headers, } if is_binary: result["body_base64"] = base64.b64encode(resp_bytes).decode() else: result["body"] = resp_bytes.decode(errors="replace") self._send_response_frame(result) finally: resp.close() except http.client.HTTPException as exc: self._loopback_clients.discard() self._send_response_frame({ "request_id": request_id, "status": 503, "headers": {"Content-Type": "application/json"}, "body": json.dumps({"error": f"relay bridge local request failed: {exc}"}), }) except Exception as exc: self._loopback_clients.discard() self._send_response_frame({ "request_id": request_id, "status": 503, "headers": {"Content-Type": "application/json"}, "body": json.dumps({"error": f"relay bridge local request failed: {exc}"}), }) def _stream_response(self, request_id: str, resp, resp_headers: dict) -> bool: """Forward an SSE response as chunk frames, one per complete SSE event. Frame order: header frame (status + headers), chunk frames, done frame. A receiver that sees no ``stream`` key treats the frame as a complete legacy response, so non-streaming peers are unaffected. """ sent = self._send_response_frame({ "request_id": request_id, "status": resp.status, "headers": resp_headers, "stream": True, "done": False, }) if not sent: return False event_lines: list[str] = [] for raw_line in resp: line = raw_line.decode(errors="replace") event_lines.append(line) if line.strip(): continue # Blank line terminates one SSE event — flush it as a frame. if not self._send_response_frame({ "request_id": request_id, "stream": True, "chunk": "".join(event_lines), "done": False, }): return False event_lines = [] if event_lines: if not self._send_response_frame({ "request_id": request_id, "stream": True, "chunk": "".join(event_lines), "done": False, }): return False return self._send_response_frame({ "request_id": request_id, "stream": True, "done": True, }) def _peer_id_suffix(value: str) -> str: """Return a relay-safe suffix for a human node name or numeric instance id.""" suffix = re.sub(r"[^A-Za-z0-9_.-]+", "-", value.strip()).strip("-._") return suffix[:32] def peer_id_from_wallet( wallet_address: str, *, node_name: str | None = None, advertised_addr: str | None = None, ) -> str: """Build a per-node relay peer id from the wallet plus node identity. Multiple nodes can legitimately share one wallet for payouts, but the relay registry is keyed by peer_id. Using only the wallet prefix makes those nodes overwrite each other at the relay. Prefer the operator-provided node name; if absent, use the advertised endpoint port as the stable integer instance suffix (7001, 7002, ... for local multi-node runs). """ wallet_prefix = wallet_address[:16] if len(wallet_address) >= 16 else wallet_address suffix = _peer_id_suffix(node_name or "") if node_name else "" if not suffix and advertised_addr: parsed = urllib.parse.urlparse(advertised_addr) if parsed.port is not None: suffix = str(parsed.port) return f"{wallet_prefix}-{suffix}" if suffix else wallet_prefix