from __future__ import annotations import json from datetime import datetime from typing import Dict, Any from app.core._utils.b58 import b58decode from sanic import response from urllib.parse import urlparse from app.core.logger import make_log from app.core.network.constants import CURRENT_PROTOCOL_VERSION, NODE_TYPE_PRIVATE from app.core.network.config import NODE_PRIVACY from app.core.network.handshake import build_handshake_payload, compute_node_info, sign_response from app.core.network.nodes import upsert_known_node, list_known_public_nodes from app.core.network.semver import compatibility from app.core.network.guard import check_rate_limit, check_timestamp_fresh, check_and_remember_nonce from app.core.network.config import HANDSHAKE_TS_TOLERANCE_SEC from app.core.ipfs_client import swarm_connect from app.core._config import PROJECT_HOST from app.core.events.service import record_event from app.core.network.asn import resolver as asn_resolver from app.core.network.dht import compute_node_id, dht_config, ReachabilityReceipt def _port_from_public_host(public_host: str) -> int: """Return an integer port extracted from a public_host URL or host:port string.""" if not public_host: return 80 parsed = urlparse(public_host) if parsed.scheme: if parsed.port: return parsed.port return 443 if parsed.scheme == "https" else 80 host_port = public_host.strip() if ":" in host_port: candidate = host_port.rsplit(":", 1)[-1] try: return int(candidate) except (TypeError, ValueError): pass return 80 def _extract_ipfs_meta(payload: Dict[str, Any]) -> Dict[str, Any]: ipfs = payload or {} multiaddrs = ipfs.get("multiaddrs") or [] if not isinstance(multiaddrs, list): multiaddrs = [multiaddrs] normalized_multiaddrs = [str(m) for m in multiaddrs if m] meta: Dict[str, Any] = {} if normalized_multiaddrs: meta["multiaddrs"] = normalized_multiaddrs peer_id = ipfs.get("peer_id") if peer_id: meta["peer_id"] = str(peer_id) agent = ipfs.get("agent_version") or ipfs.get("agentVersion") if agent: meta["agent_version"] = str(agent) return meta async def _connect_ipfs_multiaddrs(addrs): for addr in addrs or []: try: await swarm_connect(addr) except Exception: pass async def s_api_v1_network_info(request): async with request.app.ctx.memory.transaction("network.info"): node = await compute_node_info(request.ctx.db_session) make_log("Network", "info served") return response.json({"node": node}) async def s_api_v1_network_nodes(request): rows = await list_known_public_nodes(request.ctx.db_session) make_log("Network", f"nodes list count={len(rows)}") return response.json({ "count": len(rows), "nodes": rows, }) async def s_api_v1_network_handshake(request): # Handshake accepted regardless of our privacy; private nodes typically have no external endpoint # Rate limit per remote IP remote_ip = (request.headers.get('X-Forwarded-For') or request.remote_addr or request.ip or '').split(',')[0].strip() if not check_rate_limit(request.app.ctx.memory, remote_ip): return response.json({"error": "RATE_LIMIT"}, status=429) data = request.json or {} required = ["version", "schema_version", "public_key", "node_id", "node_type", "metrics", "timestamp", "signature"] for f in required: if f not in data: return response.json({"error": f"Missing field {f}"}, status=400) # public_host is required for public nodes only if data.get("node_type") != "private" and not data.get("public_host"): return response.json({"error": "Missing field public_host"}, status=400) # Timestamp freshness if not check_timestamp_fresh(data.get("timestamp")): return response.json({"error": "STALE_TIMESTAMP", "tolerance_sec": HANDSHAKE_TS_TOLERANCE_SEC}, status=400) # Nonce replay protection (best-effort) if not data.get("nonce") or not check_and_remember_nonce(request.app.ctx.memory, data.get("public_key"), data.get("nonce")): return response.json({"error": "NONCE_REPLAY"}, status=400) # Base schema and identity checks if data.get("schema_version") != dht_config.schema_version: return response.json({"error": "UNSUPPORTED_SCHEMA_VERSION"}, status=400) try: expected_node_id = compute_node_id(b58decode(data["public_key"])) except Exception: return response.json({"error": "BAD_PUBLIC_KEY"}, status=400) if data.get("node_id") != expected_node_id: return response.json({"error": "NODE_ID_MISMATCH"}, status=400) peer_version = str(data.get("version")) ipfs_meta = _extract_ipfs_meta(data.get("ipfs") or {}) comp = compatibility(peer_version, CURRENT_PROTOCOL_VERSION) if comp == "blocked": # We still store the node but respond with 409 try: await upsert_known_node( request.ctx.db_session, host=data.get("public_host"), port=_port_from_public_host(data.get("public_host")), public_key=str(data.get("public_key")), meta={ "version": peer_version, "compatibility": comp, "is_public": data.get("node_type", "public") != "private", "public_host": data.get("public_host"), "unsupported_last_checked_at": datetime.utcnow().isoformat(), "ipfs": ipfs_meta, } ) except Exception: pass make_log("Handshake", f"Reject incompatible peer {data.get('public_host')} peer={peer_version} current={CURRENT_PROTOCOL_VERSION}") return response.json({ "error": "INCOMPATIBLE_VERSION", "compatibility": comp, "current": CURRENT_PROTOCOL_VERSION, "peer": peer_version, }, status=409) # Verify signature (Ed25519). If libsodium not available, accept but log a warning. signed_fields = {k: v for (k, v) in data.items() if k != "signature"} blob = json.dumps(signed_fields, sort_keys=True, separators=(",", ":")).encode() ok = False try: import nacl.signing, nacl.encoding # type: ignore vk = nacl.signing.VerifyKey(b58decode(data.get("public_key", ""))) sig = b58decode(data.get("signature", "")) vk.verify(blob, sig) ok = True except Exception as e: ok = False if not ok: make_log("Handshake", f"Signature verification failed from {data.get('public_host')}", level='warning') return response.json({"error": "BAD_SIGNATURE"}, status=400) # Update membership / reachability information try: membership_mgr = getattr(request.app.ctx.memory, "membership", None) if membership_mgr: remote_ip = (request.headers.get('X-Forwarded-For') or request.remote_addr or request.ip or '').split(',')[0].strip() or None # Determine caller ASN using advertised value or resolver remote_asn = data.get("asn") if remote_asn is None: remote_asn = await asn_resolver.resolve_async(remote_ip, request.ctx.db_session) else: if remote_ip: asn_resolver.learn(remote_ip, int(remote_asn)) membership_mgr.update_member( node_id=data["node_id"], public_key=data["public_key"], ip=remote_ip, asn=int(remote_asn) if remote_asn is not None else None, metadata={ "capabilities": data.get("capabilities", {}), "metrics": data.get("metrics", {}), "public_host": data.get("public_host"), }, ) for receipt in data.get("reachability_receipts") or []: if not receipt.get("target_id") or not receipt.get("issuer_id"): continue try: # Only accept receipts issued by the caller issuer_id = str(receipt.get("issuer_id")) if issuer_id != data["node_id"]: continue # Canonical message for receipt verification # schema_version is embedded to avoid replay across versions rec_asn = receipt.get("asn") if rec_asn is None: rec_asn = remote_asn payload = { "schema_version": dht_config.schema_version, "target_id": str(receipt.get("target_id")), "issuer_id": issuer_id, "asn": int(rec_asn) if rec_asn is not None else None, "timestamp": float(receipt.get("timestamp", data.get("timestamp"))), } blob = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() try: import nacl.signing # type: ignore from app.core._utils.b58 import b58decode as _b58d vk = nacl.signing.VerifyKey(_b58d(data["public_key"])) sig_b = _b58d(str(receipt.get("signature", ""))) vk.verify(blob, sig_b) # Accept and persist membership_mgr.record_receipt( ReachabilityReceipt( target_id=payload["target_id"], issuer_id=payload["issuer_id"], asn=payload["asn"], timestamp=payload["timestamp"], signature=str(receipt.get("signature", "")), ) ) except Exception: # Ignore invalid receipts continue except Exception: continue except Exception as exc: make_log("Handshake", f"Membership ingest failed: {exc}", level='warning') # Upsert node and respond with our info + known public nodes # Do not persist private peers (ephemeral) if data.get("node_type") != "private" and data.get("public_host"): try: await upsert_known_node( request.ctx.db_session, host=data.get("public_host"), port=_port_from_public_host(data.get("public_host")), public_key=str(data.get("public_key")), meta={ "version": peer_version, "compatibility": comp, "is_public": True, "public_host": data.get("public_host"), "last_metrics": data.get("metrics", {}), "capabilities": data.get("capabilities", {}), "ipfs": ipfs_meta, } ) await _connect_ipfs_multiaddrs(ipfs_meta.get("multiaddrs")) try: await record_event( request.ctx.db_session, 'node_registered', { 'public_key': str(data.get("public_key")), 'public_host': data.get("public_host"), 'node_type': data.get("node_type"), 'version': peer_version, 'capabilities': data.get("capabilities", {}), }, origin_host=PROJECT_HOST, ) except Exception as ev_exc: make_log("Events", f"Failed to record node_registered event: {ev_exc}", level="warning") except Exception as e: make_log("Handshake", f"Upsert peer failed: {e}", level='warning') # Merge advertised peers from the caller (optional field) for n in data.get("known_public_nodes", []) or []: known_ipfs_meta = _extract_ipfs_meta(n.get("ipfs") or {}) try: await upsert_known_node( request.ctx.db_session, host=n.get("public_host") or n.get("host"), port=int(n.get("port") or 80), public_key=n.get("public_key") or "", meta={ "version": n.get("version") or "0.0.0", "compatibility": compatibility(n.get("version") or "0.0.0", CURRENT_PROTOCOL_VERSION), "is_public": True, "public_host": n.get("public_host") or n.get("host"), "capabilities": n.get("capabilities") or {}, "ipfs": known_ipfs_meta, } ) await _connect_ipfs_multiaddrs(known_ipfs_meta.get("multiaddrs")) except Exception: pass node = await compute_node_info(request.ctx.db_session) known = await list_known_public_nodes(request.ctx.db_session) membership_mgr = getattr(request.app.ctx.memory, "membership", None) n_estimate = membership_mgr.n_estimate() if membership_mgr else 0 resp = sign_response({ "compatibility": comp, "node": node, "known_public_nodes": known, "n_estimate": n_estimate, }) make_log("Handshake", f"OK with {data.get('public_host')} compat={comp}") status = 200 if comp == "warning": status = 200 resp["warning"] = "MINOR version differs; proceed with caution" return response.json(resp, status=status)