from __future__ import annotations import json from datetime import datetime from typing import Dict, Any from base58 import b58decode from sanic import response from sqlalchemy import select from app.core.logger import make_log from app.core.models.my_network import KnownNode 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 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", "public_key", "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) peer_version = str(data.get("version")) 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=int(str(data.get("public_host") or "").split(":")[-1]) if ":" in str(data.get("public_host") or "") else 80, 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(), } ) 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 try: # Verify signature over the entire payload except the signature itself signed_fields = {k: v for (k, v) in data.items() if k != "signature"} blob = json.dumps(signed_fields, sort_keys=True, separators=(",", ":")).encode() import nacl.signing, nacl.encoding vk = nacl.signing.VerifyKey(b58decode(data["public_key"])) sig = b58decode(data["signature"]) vk.verify(blob, sig) ok = True except Exception: 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) # 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=int(str(data.get("public_host") or "").split(":")[-1]) if ":" in str(data.get("public_host") or "") else 80, 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", {}), } ) 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 []: 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 {}, } ) except Exception: pass node = await compute_node_info(request.ctx.db_session) known = await list_known_public_nodes(request.ctx.db_session) resp = sign_response({ "compatibility": comp, "node": node, "known_public_nodes": known, }) 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)