diff --git a/backend/.env.example b/backend/.env.example index 0d9d8d5..75d5c61 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -227,7 +227,16 @@ AIS_API_KEY= # https://aisstream.io/ — free tier WebSocket key # MESH_GATE_SESSION_STREAM_MAX_GATES=16 # MESH_BOOTSTRAP_DISABLED=false # MESH_BOOTSTRAP_MANIFEST_PATH=data/bootstrap_peers.json +# Swarm discovery (signed peer manifest). Participants need only the public key; +# the seed operator sets MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY (never commit it). +# Generate a fleet keypair: uv run python backend/scripts/bootstrap_manifest_helper.py generate-keypair # MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY= +# MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY= # seed only +# MESH_BOOTSTRAP_SIGNER_ID=shadowbroker-seed +# MESH_PEER_REGISTRY_ENABLED=true # seed only (auto-enabled when private key is set) +# MESH_SWARM_MANIFEST_TTL_S=14400 +# MESH_SWARM_MANIFEST_PULL_INTERVAL_S=300 +# MESH_PEER_REGISTRY_STALE_S=604800 # Infonet/Wormhole fails closed to onion/RNS by default. Only enable clearnet # sync for local relay development or an explicitly public testnet. # MESH_INFONET_ALLOW_CLEARNET_SYNC=false diff --git a/backend/auth.py b/backend/auth.py index fc8572a..6c4610c 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -1404,6 +1404,27 @@ def _peer_hmac_url_from_request(request: Request) -> str: return "" +def _verify_peer_transport_hmac(request: Request, body_bytes: bytes) -> bool: + """Verify HMAC-SHA256 peer authentication without an allowlist check.""" + provided = str(request.headers.get("x-peer-hmac", "") or "").strip() + if not provided: + return False + + peer_url = _peer_hmac_url_from_request(request) + if not peer_url: + return False + peer_key = resolve_peer_key_for_url(peer_url) + if not peer_key: + return False + + expected = _hmac_mod.new( + peer_key, + body_bytes, + _hashlib_mod.sha256, + ).hexdigest() + return _hmac_mod.compare_digest(provided.lower(), expected.lower()) + + def _verify_peer_push_hmac(request: Request, body_bytes: bytes) -> bool: """Verify HMAC-SHA256 peer authentication on push requests. diff --git a/backend/main.py b/backend/main.py index 1783739..0c1ad58 100644 --- a/backend/main.py +++ b/backend/main.py @@ -244,6 +244,7 @@ from services.mesh.mesh_protocol import ( PROTOCOL_VERSION, normalize_payload, ) +from services.mesh.mesh_hashchain import GENESIS_HASH from services.mesh.mesh_signed_events import ( MeshWriteExemption, SignedWriteKind, @@ -324,6 +325,7 @@ from auth import ( _validate_insecure_admin_startup, _validate_peer_push_secret, _verify_peer_push_hmac, + _verify_peer_transport_hmac, ) from node_state import ( _NODE_BOOTSTRAP_STATE, @@ -1275,6 +1277,7 @@ def _ensure_infonet_private_transport_ready(reason: str = "") -> bool: get_settings.cache_clear() if _check_arti_ready(): logger.info("Infonet private transport ready%s", label) + threading.Thread(target=_swarm_bootstrap_after_transport_ready, daemon=True).start() return True logger.warning("Infonet private transport warmup incomplete%s: %s", label, tor_result) return False @@ -1416,6 +1419,16 @@ def _refresh_node_peer_store(*, now: float | None = None) -> dict[str, Any]: if private_transport_required and skipped_clearnet_peers and not bootstrap_error: bootstrap_error = _infonet_private_transport_error() + swarm_pull: dict[str, Any] = {} + try: + from services.mesh.mesh_swarm_runtime import refresh_swarm_manifest_from_seeds + + swarm_pull = refresh_swarm_manifest_from_seeds(now=timestamp) + if swarm_pull.get("ok") and not swarm_pull.get("skipped"): + store.load() + except Exception as exc: + swarm_pull = {"ok": False, "detail": str(exc or type(exc).__name__)} + store.save() bootstrap_records = store.records_for_bucket("bootstrap") sync_records = store.records_for_bucket("sync") @@ -1424,6 +1437,8 @@ def _refresh_node_peer_store(*, now: float | None = None) -> dict[str, Any]: bootstrap_records = [record for record in bootstrap_records if _is_private_infonet_transport(record.transport)] sync_records = [record for record in sync_records if _is_private_infonet_transport(record.transport)] push_records = [record for record in push_records if _is_private_infonet_transport(record.transport)] + swarm_sync_peer_count = len([record for record in sync_records if str(record.source or "") == "swarm"]) + swarm_push_peer_count = len([record for record in push_records if str(record.source or "") == "swarm"]) snapshot = { "node_mode": mode, "private_transport_required": private_transport_required, @@ -1435,16 +1450,30 @@ def _refresh_node_peer_store(*, now: float | None = None) -> dict[str, Any]: "bootstrap_peer_count": len(bootstrap_records), "sync_peer_count": len(sync_records), "push_peer_count": len(push_records), + "swarm_sync_peer_count": swarm_sync_peer_count, + "swarm_push_peer_count": swarm_push_peer_count, "operator_peer_count": len(operator_peers), "bootstrap_seed_peer_count": len(bootstrap_seed_peers), "default_sync_peer_count": len(bootstrap_seed_peers), "last_bootstrap_error": bootstrap_error, + "swarm_manifest_pull": swarm_pull, } with _NODE_RUNTIME_LOCK: _NODE_BOOTSTRAP_STATE.update(snapshot) return snapshot +def _swarm_bootstrap_after_transport_ready() -> None: + try: + from services.mesh.mesh_swarm_runtime import announce_local_peer_to_seeds, refresh_swarm_manifest_from_seeds + + announce_local_peer_to_seeds(force=True) + refresh_swarm_manifest_from_seeds(force=True) + _refresh_node_peer_store() + except Exception: + logger.warning("swarm bootstrap after transport ready failed", exc_info=True) + + def _materialize_local_infonet_state() -> None: from services.mesh.mesh_hashchain import infonet @@ -1669,7 +1698,29 @@ def _sync_from_peer( _hydrate_dm_relay_from_chain(events) rejected = list(result.get("rejected", []) or []) if rejected: - return False, f"sync ingest rejected {len(rejected)} event(s)", False, 0 + reasons = [ + str((item or {}).get("reason", "") or "").strip() + for item in rejected + if isinstance(item, dict) + ] + reason_summary = ", ".join(reason for reason in reasons if reason) + detail = f"sync ingest rejected {len(rejected)} event(s)" + if reason_summary: + detail = f"{detail}: {reason_summary}" + local_empty = len(infonet.events) == 0 + stale_genesis = ( + local_empty + and bool(events) + and str((events[0] or {}).get("prev_hash", "") or "") == GENESIS_HASH + and any("timestamp outside freshness window" in reason.lower() for reason in reasons) + ) + if stale_genesis: + detail = ( + f"{detail}; peer appears to be serving an expired genesis chain. " + "Refresh or reset the peer chain, or perform an explicit one-time migration " + "with MESH_INGEST_EVENT_MAX_AGE_S=0." + ) + return False, detail, False, 0 if int(result.get("accepted", 0) or 0) == 0 and int(result.get("duplicates", 0) or 0) >= len(events): return True, "", False, 0 if len(events) < page_limit: @@ -1922,9 +1973,22 @@ def _propagate_public_event_to_peers(event_dict: dict[str, Any]) -> None: ) +def _propagate_ledger_event_to_peers(event_dict: dict[str, Any]) -> None: + if not _participant_node_enabled(): + return + event_type = str(event_dict.get("event_type") or "") + if event_type in {"gate_message", "dm_message"}: + from services.mesh.mesh_swarm_runtime import push_infonet_events_to_http_peers + + push_infonet_events_to_http_peers([event_dict]) + _kick_public_sync_background("ledger_event") + return + _propagate_public_event_to_peers(event_dict) + + def _schedule_public_event_propagation(event_dict: dict[str, Any]) -> None: threading.Thread( - target=_propagate_public_event_to_peers, + target=_propagate_ledger_event_to_peers, args=(dict(event_dict),), daemon=True, ).start() @@ -1960,6 +2024,7 @@ def _start_infonet_node_runtime(reason: str = "startup") -> None: threading.Thread(target=_http_peer_push_loop, daemon=True).start() threading.Thread(target=_http_gate_push_loop, daemon=True).start() threading.Thread(target=_http_gate_pull_loop, daemon=True).start() + threading.Thread(target=_swarm_manifest_pull_loop, daemon=True).start() _NODE_RUNTIME_THREADS_STARTED = True _kick_public_sync_background(reason) if not _NODE_PUBLIC_EVENT_HOOK_REGISTERED: @@ -2067,6 +2132,22 @@ def _http_peer_push_loop() -> None: _NODE_SYNC_STOP.wait(_PEER_PUSH_INTERVAL_S) +def _swarm_manifest_pull_loop() -> None: + """Background thread: pull signed peer manifests from bootstrap seeds.""" + while not _NODE_SYNC_STOP.is_set(): + try: + if _participant_node_enabled(): + from services.mesh.mesh_swarm_runtime import refresh_swarm_manifest_from_seeds + + result = refresh_swarm_manifest_from_seeds() + if result.get("ok") and not result.get("skipped"): + _refresh_node_peer_store() + except Exception: + logger.exception("swarm manifest pull loop error") + interval_s = int(getattr(get_settings(), "MESH_SWARM_MANIFEST_PULL_INTERVAL_S", 0) or 300) + _NODE_SYNC_STOP.wait(max(30, interval_s)) + + # ─── Background Gate Message Pull Worker ───────────────────────────────── # Periodically pulls gate events from relay peers that this node is missing. # Complements the push loop: push sends OUR events to peers, pull fetches @@ -5497,6 +5578,65 @@ async def infonet_ingest(request: Request): return {"ok": True, **result} +@app.get("/api/mesh/infonet/peer-registry", dependencies=[Depends(require_local_operator)]) +@limiter.limit("30/minute") +async def infonet_peer_registry(request: Request): + """Operator view of the live swarm peer registry (seed nodes only).""" + from services.mesh.mesh_peer_registry import DEFAULT_PEER_REGISTRY_PATH, PeerRegistry + from services.mesh.mesh_swarm_runtime import peer_registry_enabled + + if not peer_registry_enabled(): + return {"ok": False, "detail": "peer registry is not enabled on this node"} + registry = PeerRegistry(DEFAULT_PEER_REGISTRY_PATH) + try: + peers = registry.load() + except Exception as exc: + return {"ok": False, "detail": str(exc or type(exc).__name__)} + return { + "ok": True, + "peer_count": len(peers), + "peers": [peer.to_dict() for peer in peers], + } + + +@app.get("/api/mesh/infonet/bootstrap-manifest") +@limiter.limit(_INFONET_SYNC_RATE_LIMIT) +async def infonet_bootstrap_manifest(request: Request): + """Return the current signed bootstrap/swarm peer manifest.""" + from services.mesh.mesh_swarm_runtime import load_live_bootstrap_manifest + + manifest = load_live_bootstrap_manifest() + if manifest is None: + return {"ok": False, "detail": "bootstrap manifest unavailable"} + return {"ok": True, "manifest": manifest.to_dict()} + + +@app.post("/api/mesh/infonet/peer-announce") +@limiter.limit("30/minute") +@mesh_write_exempt(MeshWriteExemption.PEER_GOSSIP) +async def infonet_peer_announce(request: Request): + """Register a participant onion peer in the swarm registry (HMAC-authenticated).""" + from auth import _peer_hmac_url_from_request + from services.mesh.mesh_swarm_runtime import peer_registry_enabled, record_peer_announcement + + body_bytes = await request.body() + if not _verify_peer_transport_hmac(request, body_bytes): + return Response( + content='{"ok":false,"detail":"Invalid or missing peer HMAC"}', + status_code=403, + media_type="application/json", + ) + if not peer_registry_enabled(): + return {"ok": False, "detail": "peer registry is not enabled on this node"} + body = json_mod.loads(body_bytes or b"{}") + announced_url = normalize_peer_url(str(body.get("peer_url", "") or "")) + header_url = _peer_hmac_url_from_request(request) + if not announced_url or announced_url != header_url: + return {"ok": False, "detail": "peer_url must match X-Peer-Url"} + peer = record_peer_announcement(body) + return {"ok": True, "peer_url": peer.peer_url, "role": peer.role, "transport": peer.transport} + + @app.post("/api/mesh/infonet/peer-push") @limiter.limit("30/minute") @mesh_write_exempt(MeshWriteExemption.PEER_GOSSIP) @@ -5535,6 +5675,8 @@ async def infonet_peer_push(request: Request): result = infonet.ingest_events(events) _hydrate_gate_store_from_chain(events) _hydrate_dm_relay_from_chain(events) + if any(str(event.get("event_type") or "") in {"gate_message", "dm_message"} for event in events): + _kick_public_sync_background("peer_push_ingest") return {"ok": True, **result} diff --git a/backend/services/config.py b/backend/services/config.py index a032262..e6a625d 100644 --- a/backend/services/config.py +++ b/backend/services/config.py @@ -44,6 +44,13 @@ class Settings(BaseSettings): MESH_BOOTSTRAP_DISABLED: bool = False MESH_BOOTSTRAP_MANIFEST_PATH: str = "data/bootstrap_peers.json" MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY: str = "" + MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY: str = "" + MESH_BOOTSTRAP_SIGNER_ID: str = "" + MESH_PEER_REGISTRY_ENABLED: bool = False + MESH_PEER_REGISTRY_DISABLED: bool = False + MESH_PEER_REGISTRY_STALE_S: int = 604800 + MESH_SWARM_MANIFEST_TTL_S: int = 14400 + MESH_SWARM_MANIFEST_PULL_INTERVAL_S: int = 300 MESH_NODE_MODE: str = "participant" MESH_SYNC_INTERVAL_S: int = 300 MESH_SYNC_FAILURE_BACKOFF_S: int = 60 diff --git a/backend/services/mesh/mesh_bootstrap_manifest.py b/backend/services/mesh/mesh_bootstrap_manifest.py index 2da6cb0..923837e 100644 --- a/backend/services/mesh/mesh_bootstrap_manifest.py +++ b/backend/services/mesh/mesh_bootstrap_manifest.py @@ -287,28 +287,18 @@ def write_signed_bootstrap_manifest( return manifest -def load_bootstrap_manifest( - path: str | Path, +def parse_bootstrap_manifest_dict( + raw: dict[str, Any], *, signer_public_key_b64: str, now: float | None = None, ) -> BootstrapManifest: - manifest_path = _resolve_manifest_path(str(path)) - try: - raw = json.loads(manifest_path.read_text(encoding="utf-8")) - except FileNotFoundError as exc: - raise BootstrapManifestError(f"bootstrap manifest not found: {manifest_path}") from exc - except json.JSONDecodeError as exc: - raise BootstrapManifestError("bootstrap manifest is not valid JSON") from exc - if not isinstance(raw, dict): raise BootstrapManifestError("bootstrap manifest root must be an object") - signature = str(raw.get("signature", "") or "").strip() payload = {key: value for key, value in raw.items() if key != "signature"} if not signature: raise BootstrapManifestError("bootstrap manifest signature is required") - _verify_manifest_signature( payload, signature_b64=signature, @@ -325,6 +315,29 @@ def load_bootstrap_manifest( ) +def load_bootstrap_manifest( + path: str | Path, + *, + signer_public_key_b64: str, + now: float | None = None, +) -> BootstrapManifest: + manifest_path = _resolve_manifest_path(str(path)) + try: + raw = json.loads(manifest_path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise BootstrapManifestError(f"bootstrap manifest not found: {manifest_path}") from exc + except json.JSONDecodeError as exc: + raise BootstrapManifestError("bootstrap manifest is not valid JSON") from exc + + if not isinstance(raw, dict): + raise BootstrapManifestError("bootstrap manifest root must be an object") + return parse_bootstrap_manifest_dict( + raw, + signer_public_key_b64=signer_public_key_b64, + now=now, + ) + + def load_bootstrap_manifest_from_settings(*, now: float | None = None) -> BootstrapManifest | None: settings = get_settings() if bool(getattr(settings, "MESH_BOOTSTRAP_DISABLED", False)): diff --git a/backend/services/mesh/mesh_peer_registry.py b/backend/services/mesh/mesh_peer_registry.py new file mode 100644 index 0000000..5546301 --- /dev/null +++ b/backend/services/mesh/mesh_peer_registry.py @@ -0,0 +1,152 @@ +"""Operator-signed peer registry for private Infonet swarm discovery.""" + +from __future__ import annotations + +import json +import time +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + +from services.mesh.mesh_crypto import normalize_peer_url +from services.mesh.mesh_router import peer_transport_kind + +BACKEND_DIR = Path(__file__).resolve().parents[2] +DATA_DIR = BACKEND_DIR / "data" +DEFAULT_PEER_REGISTRY_PATH = DATA_DIR / "peer_registry.json" +REGISTRY_VERSION = 1 +ALLOWED_REGISTRY_ROLES = {"participant", "relay", "seed"} + + +@dataclass +class RegistryPeer: + peer_url: str + transport: str + role: str + node_id: str = "" + label: str = "" + announced_at: int = 0 + last_seen_at: int = 0 + failure_count: int = 0 + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + def manifest_peer(self) -> dict[str, str]: + return { + "peer_url": self.peer_url, + "transport": self.transport, + "role": self.role, + "label": self.label or self.node_id[:16], + } + + +class PeerRegistry: + def __init__(self, path: str | Path = DEFAULT_PEER_REGISTRY_PATH): + self.path = Path(path) + self._peers: dict[str, RegistryPeer] = {} + + def load(self) -> list[RegistryPeer]: + if not self.path.exists(): + self._peers = {} + return [] + raw = json.loads(self.path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError("peer registry root must be an object") + version = int(raw.get("version", 0) or 0) + if version != REGISTRY_VERSION: + raise ValueError(f"unsupported peer registry version: {version}") + entries = raw.get("peers", []) + if not isinstance(entries, list): + raise ValueError("peer registry peers must be a list") + peers: dict[str, RegistryPeer] = {} + for entry in entries: + if not isinstance(entry, dict): + continue + peer = self._normalize_entry(entry) + peers[peer.peer_url] = peer + self._peers = peers + return self.records() + + def save(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "version": REGISTRY_VERSION, + "updated_at": int(time.time()), + "peers": [peer.to_dict() for peer in self.records()], + } + self.path.write_text( + json.dumps(payload, sort_keys=True, indent=2) + "\n", + encoding="utf-8", + ) + + def records(self) -> list[RegistryPeer]: + return sorted(self._peers.values(), key=lambda item: (item.role, item.peer_url)) + + def upsert_announcement( + self, + *, + peer_url: str, + transport: str, + role: str, + node_id: str = "", + label: str = "", + now: float | None = None, + ) -> RegistryPeer: + normalized = normalize_peer_url(peer_url) + if not normalized: + raise ValueError("peer_url is required") + resolved_transport = str(transport or "").strip().lower() or str(peer_transport_kind(normalized) or "") + if resolved_transport not in {"onion", "clearnet"}: + raise ValueError("unsupported peer transport") + resolved_role = str(role or "participant").strip().lower() + if resolved_role not in ALLOWED_REGISTRY_ROLES: + raise ValueError("unsupported peer role") + timestamp = int(now if now is not None else time.time()) + existing = self._peers.get(normalized) + peer = RegistryPeer( + peer_url=normalized, + transport=resolved_transport, + role=resolved_role, + node_id=str(node_id or (existing.node_id if existing else "") or "").strip(), + label=str(label or (existing.label if existing else "") or "").strip(), + announced_at=int(existing.announced_at if existing and existing.announced_at else timestamp), + last_seen_at=timestamp, + failure_count=int(existing.failure_count if existing else 0), + ) + self._peers[normalized] = peer + return peer + + def prune_stale(self, *, max_age_s: int, now: float | None = None) -> int: + timestamp = int(now if now is not None else time.time()) + removed = 0 + for peer_url, peer in list(self._peers.items()): + if peer.role == "seed": + continue + last_seen = int(peer.last_seen_at or peer.announced_at or 0) + if last_seen > 0 and timestamp - last_seen > max(60, int(max_age_s or 0)): + del self._peers[peer_url] + removed += 1 + return removed + + def manifest_peers(self) -> list[dict[str, str]]: + return [peer.manifest_peer() for peer in self.records()] + + def _normalize_entry(self, entry: dict[str, Any]) -> RegistryPeer: + peer_url = normalize_peer_url(str(entry.get("peer_url", "") or "")) + if not peer_url: + raise ValueError("registry peer_url is required") + transport = str(entry.get("transport", "") or peer_transport_kind(peer_url) or "").strip().lower() + role = str(entry.get("role", "participant") or "participant").strip().lower() + if role not in ALLOWED_REGISTRY_ROLES: + raise ValueError("registry role unsupported") + return RegistryPeer( + peer_url=peer_url, + transport=transport, + role=role, + node_id=str(entry.get("node_id", "") or "").strip(), + label=str(entry.get("label", "") or "").strip(), + announced_at=int(entry.get("announced_at", 0) or 0), + last_seen_at=int(entry.get("last_seen_at", 0) or entry.get("announced_at", 0) or 0), + failure_count=int(entry.get("failure_count", 0) or 0), + ) diff --git a/backend/services/mesh/mesh_peer_store.py b/backend/services/mesh/mesh_peer_store.py index 84b402f..c0e9ebb 100644 --- a/backend/services/mesh/mesh_peer_store.py +++ b/backend/services/mesh/mesh_peer_store.py @@ -16,7 +16,7 @@ DATA_DIR = BACKEND_DIR / "data" DEFAULT_PEER_STORE_PATH = DATA_DIR / "peer_store.json" PEER_STORE_VERSION = 1 ALLOWED_PEER_BUCKETS = {"bootstrap", "sync", "push"} -ALLOWED_PEER_SOURCES = {"bundle", "operator", "bootstrap_promoted", "runtime"} +ALLOWED_PEER_SOURCES = {"bundle", "operator", "bootstrap_promoted", "runtime", "swarm"} ALLOWED_PEER_TRANSPORTS = {"clearnet", "onion"} ALLOWED_PEER_ROLES = {"participant", "relay", "seed"} diff --git a/backend/services/mesh/mesh_swarm_runtime.py b/backend/services/mesh/mesh_swarm_runtime.py new file mode 100644 index 0000000..b6a37d3 --- /dev/null +++ b/backend/services/mesh/mesh_swarm_runtime.py @@ -0,0 +1,455 @@ +"""Private Infonet swarm discovery and immediate ledger propagation.""" + +from __future__ import annotations + +import json +import logging +import threading +import time +from typing import Any + +from services.config import get_settings +from services.mesh.mesh_bootstrap_manifest import ( + BootstrapManifest, + BootstrapManifestError, + BootstrapPeer, + build_bootstrap_manifest_payload, + load_bootstrap_manifest, + parse_bootstrap_manifest_dict, + sign_bootstrap_manifest_payload, + write_signed_bootstrap_manifest, +) +from services.mesh.mesh_crypto import normalize_peer_url, resolve_peer_key_for_url +from services.mesh.mesh_peer_registry import DEFAULT_PEER_REGISTRY_PATH, PeerRegistry, RegistryPeer +from services.mesh.mesh_peer_store import ( + DEFAULT_PEER_STORE_PATH, + PeerStore, + make_push_peer_record, + make_sync_peer_record, +) +from services.mesh.mesh_router import parse_configured_relay_peers, peer_transport_kind + +logger = logging.getLogger(__name__) + +_SWARM_LOCK = threading.Lock() +_LAST_MANIFEST_PULL_AT = 0.0 +_LAST_ANNOUNCE_AT = 0.0 + + +def peer_registry_enabled() -> bool: + settings = get_settings() + if bool(getattr(settings, "MESH_PEER_REGISTRY_DISABLED", False)): + return False + if str(getattr(settings, "MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY", "") or "").strip(): + return True + return bool(getattr(settings, "MESH_PEER_REGISTRY_ENABLED", False)) + + +def _manifest_path() -> str: + return str(getattr(get_settings(), "MESH_BOOTSTRAP_MANIFEST_PATH", "") or "data/bootstrap_peers.json") + + +def _signer_public_key_b64() -> str: + return str(getattr(get_settings(), "MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", "") or "").strip() + + +def _signer_private_key_b64() -> str: + return str(getattr(settings, "MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY", "") or "").strip() if (settings := get_settings()) else "" + + +def _signer_id() -> str: + configured = str(getattr(get_settings(), "MESH_BOOTSTRAP_SIGNER_ID", "") or "").strip() + return configured or "shadowbroker-seed" + + +def _private_transport_required() -> bool: + return not bool(getattr(get_settings(), "MESH_INFONET_ALLOW_CLEARNET_SYNC", False)) + + +def _configured_seed_peer_urls() -> list[str]: + settings = get_settings() + primary = str(getattr(settings, "MESH_BOOTSTRAP_SEED_PEERS", "") or "").strip() + legacy = str(getattr(settings, "MESH_DEFAULT_SYNC_PEERS", "") or "").strip() + return parse_configured_relay_peers(primary or legacy) + + +def _seed_manifest_peers() -> list[dict[str, str]]: + peers: list[dict[str, str]] = [] + for peer_url in _configured_seed_peer_urls(): + transport = str(peer_transport_kind(peer_url) or "") + if _private_transport_required() and transport != "onion": + continue + peers.append( + { + "peer_url": peer_url, + "transport": transport, + "role": "seed", + "label": "ShadowBroker bootstrap seed", + } + ) + return peers + + +def publish_registry_manifest(*, now: float | None = None, persist: bool = True) -> BootstrapManifest: + private_key = _signer_private_key_b64() + public_key = _signer_public_key_b64() + if not private_key or not public_key: + raise BootstrapManifestError("bootstrap signer keys are required to publish swarm manifest") + + timestamp = int(now if now is not None else time.time()) + registry = PeerRegistry(DEFAULT_PEER_REGISTRY_PATH) + try: + registry.load() + except Exception: + registry = PeerRegistry(DEFAULT_PEER_REGISTRY_PATH) + stale_s = int(getattr(get_settings(), "MESH_PEER_REGISTRY_STALE_S", 0) or 7 * 86400) + if stale_s > 0: + registry.prune_stale(max_age_s=stale_s, now=timestamp) + + peers = _seed_manifest_peers() + registry.manifest_peers() + ttl_s = int(getattr(get_settings(), "MESH_SWARM_MANIFEST_TTL_S", 0) or 4 * 3600) + payload = build_bootstrap_manifest_payload( + signer_id=_signer_id(), + peers=peers, + issued_at=timestamp, + valid_until=timestamp + max(300, ttl_s), + ) + signature = sign_bootstrap_manifest_payload(payload, signer_private_key_b64=private_key) + manifest = BootstrapManifest( + version=int(payload["version"]), + issued_at=int(payload["issued_at"]), + valid_until=int(payload["valid_until"]), + signer_id=str(payload["signer_id"]), + peers=tuple(BootstrapPeer(**dict(peer)) for peer in peers), + signature=signature, + ) + if persist: + registry.save() + write_signed_bootstrap_manifest( + _manifest_path(), + signer_id=manifest.signer_id, + signer_private_key_b64=private_key, + peers=[peer.to_dict() for peer in manifest.peers], + issued_at=manifest.issued_at, + valid_until=manifest.valid_until, + ) + return manifest + + +def load_live_bootstrap_manifest(*, now: float | None = None) -> BootstrapManifest | None: + public_key = _signer_public_key_b64() + if not public_key: + return None + if peer_registry_enabled(): + try: + return publish_registry_manifest(now=now, persist=False) + except BootstrapManifestError: + logger.warning("live registry manifest unavailable", exc_info=True) + try: + return load_bootstrap_manifest(_manifest_path(), signer_public_key_b64=public_key, now=now) + except BootstrapManifestError: + return None + + +def _upsert_swarm_peer_into_store( + *, + peer_url: str, + transport: str, + role: str, + label: str = "", + signer_id: str = "", + now: float | None = None, +) -> None: + timestamp = int(now if now is not None else time.time()) + if _private_transport_required() and transport != "onion": + return + store = PeerStore(DEFAULT_PEER_STORE_PATH) + try: + store.load() + except Exception: + store = PeerStore(DEFAULT_PEER_STORE_PATH) + store.upsert( + make_sync_peer_record( + peer_url=peer_url, + transport=transport, + role=role, + source="swarm", + label=label, + signer_id=signer_id, + now=timestamp, + ) + ) + store.upsert( + make_push_peer_record( + peer_url=peer_url, + transport=transport, + role=role if role != "seed" else "relay", + source="swarm", + label=label, + now=timestamp, + ) + ) + store.save() + + +def record_peer_announcement(body: dict[str, Any], *, now: float | None = None) -> RegistryPeer: + if not peer_registry_enabled(): + raise ValueError("peer registry is not enabled on this node") + registry = PeerRegistry(DEFAULT_PEER_REGISTRY_PATH) + try: + registry.load() + except Exception: + registry = PeerRegistry(DEFAULT_PEER_REGISTRY_PATH) + peer = registry.upsert_announcement( + peer_url=str(body.get("peer_url", "") or ""), + transport=str(body.get("transport", "") or ""), + role=str(body.get("role", "participant") or "participant"), + node_id=str(body.get("node_id", "") or ""), + label=str(body.get("label", "") or ""), + now=now, + ) + registry.save() + _upsert_swarm_peer_into_store( + peer_url=peer.peer_url, + transport=peer.transport, + role=peer.role, + label=peer.label, + signer_id=_signer_id(), + now=now, + ) + try: + publish_registry_manifest(now=now, persist=True) + except Exception: + logger.warning("failed to republish swarm manifest after announce", exc_info=True) + return peer + + +def merge_manifest_into_peer_store(manifest: BootstrapManifest, *, now: float | None = None) -> int: + timestamp = int(now if now is not None else time.time()) + merged = 0 + for peer in manifest.peers: + if _private_transport_required() and peer.transport != "onion": + continue + _upsert_swarm_peer_into_store( + peer_url=peer.peer_url, + transport=peer.transport, + role=peer.role, + label=peer.label, + signer_id=manifest.signer_id, + now=timestamp, + ) + merged += 1 + return merged + + +def fetch_remote_bootstrap_manifest(seed_peer_url: str, *, now: float | None = None) -> BootstrapManifest | None: + import requests + + public_key = _signer_public_key_b64() + if not public_key: + return None + normalized = normalize_peer_url(seed_peer_url) + if not normalized: + return None + + from main import _infonet_peer_requests_proxies + + proxies = _infonet_peer_requests_proxies(normalized) + timeout = int(getattr(get_settings(), "MESH_SYNC_TIMEOUT_S", 0) or 45) + request_kwargs: dict[str, Any] = {"timeout": timeout} + if proxies: + request_kwargs["proxies"] = proxies + try: + response = requests.get(f"{normalized}/api/mesh/infonet/bootstrap-manifest", **request_kwargs) + except Exception as exc: + logger.debug("swarm manifest fetch failed for %s: %s", normalized, exc) + return None + if response.status_code != 200: + return None + try: + raw = response.json() + except Exception: + return None + if not isinstance(raw, dict) or raw.get("ok") is False: + return None + manifest_body = dict(raw.get("manifest") or raw) + try: + return parse_bootstrap_manifest_dict( + manifest_body, + signer_public_key_b64=public_key, + now=now, + ) + except BootstrapManifestError: + return None + + +def refresh_swarm_manifest_from_seeds(*, now: float | None = None, force: bool = False) -> dict[str, Any]: + global _LAST_MANIFEST_PULL_AT + interval_s = int(getattr(get_settings(), "MESH_SWARM_MANIFEST_PULL_INTERVAL_S", 0) or 300) + timestamp = float(now if now is not None else time.time()) + with _SWARM_LOCK: + if not force and _LAST_MANIFEST_PULL_AT and timestamp - _LAST_MANIFEST_PULL_AT < max(30, interval_s): + return {"ok": True, "skipped": True, "reason": "manifest_pull_interval"} + _LAST_MANIFEST_PULL_AT = timestamp + + if not _signer_public_key_b64(): + return {"ok": False, "detail": "MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY is not configured"} + + last_error = "manifest fetch failed" + for seed_url in _configured_seed_peer_urls(): + manifest = fetch_remote_bootstrap_manifest(seed_url, now=timestamp) + if manifest is None: + continue + try: + merged = merge_manifest_into_peer_store(manifest, now=timestamp) + return { + "ok": True, + "seed_peer_url": seed_url, + "peer_count": len(manifest.peers), + "merged_peer_count": merged, + } + except Exception as exc: + last_error = str(exc or type(exc).__name__) + return {"ok": False, "detail": last_error} + + +def announce_local_peer_to_seeds(*, now: float | None = None, force: bool = False) -> dict[str, Any]: + global _LAST_ANNOUNCE_AT + import hashlib as _hashlib_mod + import hmac as _hmac_mod + import requests + + from main import _infonet_peer_requests_proxies, _local_infonet_peer_url, _participant_node_enabled + + if not _participant_node_enabled(): + return {"ok": False, "detail": "participant node disabled"} + peer_url = _local_infonet_peer_url() + if not peer_url: + return {"ok": False, "detail": "local peer URL is not ready"} + peer_key = resolve_peer_key_for_url(peer_url) + if not peer_key: + return {"ok": False, "detail": "peer HMAC secret is not configured"} + + timestamp = float(now if now is not None else time.time()) + with _SWARM_LOCK: + if not force and _LAST_ANNOUNCE_AT and timestamp - _LAST_ANNOUNCE_AT < 300: + return {"ok": True, "skipped": True, "reason": "announce_interval"} + _LAST_ANNOUNCE_AT = timestamp + + transport = str(peer_transport_kind(peer_url) or "onion") + body = { + "peer_url": peer_url, + "transport": transport, + "role": "participant", + "node_id": "", + "label": "", + "ts": int(timestamp), + } + body_bytes = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + hmac_hex = _hmac_mod.new(peer_key, body_bytes, _hashlib_mod.sha256).hexdigest() + timeout = int(getattr(get_settings(), "MESH_RELAY_PUSH_TIMEOUT_S", 0) or 45) + results: list[dict[str, Any]] = [] + for seed_url in _configured_seed_peer_urls(): + normalized = normalize_peer_url(seed_url) + if not normalized: + continue + proxies = _infonet_peer_requests_proxies(normalized) + request_kwargs: dict[str, Any] = { + "data": body_bytes, + "headers": { + "Content-Type": "application/json", + "X-Peer-Url": peer_url, + "X-Peer-HMAC": hmac_hex, + }, + "timeout": timeout, + } + if proxies: + request_kwargs["proxies"] = proxies + try: + response = requests.post( + f"{normalized}/api/mesh/infonet/peer-announce", + **request_kwargs, + ) + results.append( + { + "seed_peer_url": normalized, + "status_code": int(response.status_code), + "ok": response.status_code == 200, + } + ) + except Exception as exc: + results.append({"seed_peer_url": normalized, "ok": False, "detail": str(exc)}) + ok = any(bool(item.get("ok")) for item in results) + return {"ok": ok, "peer_url": peer_url, "results": results} + + +def push_infonet_events_to_http_peers(events: list[dict[str, Any]]) -> dict[str, Any]: + import hashlib as _hashlib_mod + import hmac as _hmac_mod + import requests + + from main import ( + _filter_infonet_peer_urls, + _infonet_peer_requests_proxies, + _local_infonet_peer_url, + _participant_node_enabled, + _record_public_push_result, + ) + from services.mesh.mesh_router import authenticated_push_peer_urls + + if not _participant_node_enabled() or not events: + return {"ok": False, "detail": "nothing to push"} + peers = _filter_infonet_peer_urls(authenticated_push_peer_urls()) + if not peers: + return {"ok": False, "detail": "no push peers configured"} + + sender_url = _local_infonet_peer_url() + peer_key = resolve_peer_key_for_url(sender_url) + if not peer_key: + return {"ok": False, "detail": "peer HMAC secret is not configured"} + + body_bytes = json.dumps( + {"events": events}, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode("utf-8") + hmac_hex = _hmac_mod.new(peer_key, body_bytes, _hashlib_mod.sha256).hexdigest() + timeout = int(getattr(get_settings(), "MESH_RELAY_PUSH_TIMEOUT_S", 0) or 45) + results: list[dict[str, Any]] = [] + for peer_url in peers: + normalized = normalize_peer_url(peer_url) + if not normalized: + continue + proxies = _infonet_peer_requests_proxies(normalized) + request_kwargs: dict[str, Any] = { + "data": body_bytes, + "headers": { + "Content-Type": "application/json", + "X-Peer-Url": sender_url, + "X-Peer-HMAC": hmac_hex, + }, + "timeout": timeout, + } + if proxies: + request_kwargs["proxies"] = proxies + try: + response = requests.post(f"{normalized}/api/mesh/infonet/peer-push", **request_kwargs) + results.append( + { + "peer_url": normalized, + "ok": response.status_code == 200, + "status_code": int(response.status_code), + } + ) + except Exception as exc: + results.append({"peer_url": normalized, "ok": False, "detail": str(exc)}) + ok = any(bool(item.get("ok")) for item in results) + event_id = str((events[-1] or {}).get("event_id", "") or "") + _record_public_push_result( + event_id, + ok=ok, + error="" if ok else "immediate peer push failed", + results=results, + ) + return {"ok": ok, "results": results} diff --git a/backend/tests/mesh/test_mesh_node_bootstrap_runtime.py b/backend/tests/mesh/test_mesh_node_bootstrap_runtime.py index da466b2..2b8ff7a 100644 --- a/backend/tests/mesh/test_mesh_node_bootstrap_runtime.py +++ b/backend/tests/mesh/test_mesh_node_bootstrap_runtime.py @@ -402,6 +402,57 @@ def test_public_sync_cycle_allows_first_node_without_peers(tmp_path, monkeypatch assert result.consecutive_failures == 0 +def test_sync_from_peer_explains_stale_genesis_chain(monkeypatch): + import main + from services.mesh import mesh_hashchain + + class FakeInfonet: + events = [] + head_hash = mesh_hashchain.GENESIS_HASH + + def get_locator(self): + return [mesh_hashchain.GENESIS_HASH] + + def ingest_events(self, events): + return { + "accepted": 0, + "duplicates": 0, + "rejected": [ + {"index": 0, "reason": "Event timestamp outside freshness window"}, + {"index": 1, "reason": "prev_hash does not match head"}, + ], + } + + stale_events = [ + { + "event_id": "old-1", + "prev_hash": mesh_hashchain.GENESIS_HASH, + "event_type": "message", + "timestamp": 1, + }, + { + "event_id": "old-2", + "prev_hash": "old-1", + "event_type": "message", + "timestamp": 2, + }, + ] + + monkeypatch.setattr(mesh_hashchain, "infonet", FakeInfonet()) + monkeypatch.setattr(main, "_peer_sync_response", lambda *_args, **_kwargs: {"events": stale_events}) + monkeypatch.setattr(main, "_hydrate_gate_store_from_chain", lambda *_args, **_kwargs: None) + monkeypatch.setattr(main, "_hydrate_dm_relay_from_chain", lambda *_args, **_kwargs: None) + + ok, error, forked, retry_after_s = main._sync_from_peer("https://node.shadowbroker.info") + + assert ok is False + assert forked is False + assert retry_after_s == 0 + assert "Event timestamp outside freshness window" in error + assert "expired genesis chain" in error + assert "MESH_INGEST_EVENT_MAX_AGE_S=0" in error + + def test_headless_mesh_node_runtime_is_explicit(monkeypatch): import main diff --git a/backend/tests/mesh/test_mesh_swarm_runtime.py b/backend/tests/mesh/test_mesh_swarm_runtime.py new file mode 100644 index 0000000..909e3a9 --- /dev/null +++ b/backend/tests/mesh/test_mesh_swarm_runtime.py @@ -0,0 +1,219 @@ +import json +import time + +import pytest +from httpx import ASGITransport, AsyncClient + +from services.mesh.mesh_bootstrap_manifest import ( + BootstrapManifestError, + generate_bootstrap_signer, + parse_bootstrap_manifest_dict, + write_signed_bootstrap_manifest, +) +from services.mesh.mesh_peer_registry import PeerRegistry +from services.mesh.mesh_peer_store import DEFAULT_PEER_STORE_PATH, PeerStore +from services.mesh.mesh_swarm_runtime import ( + merge_manifest_into_peer_store, + peer_registry_enabled, + publish_registry_manifest, + record_peer_announcement, +) + + +def test_peer_registry_upsert_and_prune(tmp_path, monkeypatch): + registry_path = tmp_path / "peer_registry.json" + monkeypatch.setattr( + "services.mesh.mesh_peer_registry.DEFAULT_PEER_REGISTRY_PATH", + registry_path, + ) + registry = PeerRegistry(registry_path) + peer = registry.upsert_announcement( + peer_url="http://abc123.onion:8000", + transport="onion", + role="participant", + node_id="!sb_test", + now=1_750_000_000, + ) + registry.save() + assert peer.peer_url == "http://abc123.onion:8000" + assert registry.prune_stale(max_age_s=3600, now=1_750_000_500) == 0 + assert registry.prune_stale(max_age_s=60, now=1_750_010_000) == 1 + + +def test_publish_registry_manifest_round_trip(tmp_path, monkeypatch): + signer = generate_bootstrap_signer() + manifest_path = tmp_path / "bootstrap_peers.json" + registry_path = tmp_path / "peer_registry.json" + monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", signer["public_key_b64"]) + monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY", signer["private_key_b64"]) + monkeypatch.setenv("MESH_PEER_REGISTRY_ENABLED", "true") + monkeypatch.setenv( + "MESH_BOOTSTRAP_SEED_PEERS", + "http://seedpeer.onion:8000", + ) + monkeypatch.setenv("MESH_BOOTSTRAP_MANIFEST_PATH", str(manifest_path)) + monkeypatch.setattr( + "services.mesh.mesh_peer_registry.DEFAULT_PEER_REGISTRY_PATH", + registry_path, + ) + from services.config import get_settings + + get_settings.cache_clear() + try: + assert peer_registry_enabled() is True + manifest = publish_registry_manifest(now=1_750_000_000, persist=True) + assert manifest_path.exists() + parsed = parse_bootstrap_manifest_dict( + json.loads(manifest_path.read_text(encoding="utf-8")), + signer_public_key_b64=signer["public_key_b64"], + now=1_750_000_000, + ) + assert parsed.signer_id == manifest.signer_id + assert any(peer.role == "seed" for peer in parsed.peers) + finally: + get_settings.cache_clear() + + +def test_record_peer_announcement_updates_store(tmp_path, monkeypatch): + signer = generate_bootstrap_signer() + registry_path = tmp_path / "peer_registry.json" + peer_store_path = tmp_path / "peer_store.json" + manifest_path = tmp_path / "bootstrap_peers.json" + monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", signer["public_key_b64"]) + monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY", signer["private_key_b64"]) + monkeypatch.setenv("MESH_PEER_REGISTRY_ENABLED", "true") + monkeypatch.setenv("MESH_BOOTSTRAP_MANIFEST_PATH", str(manifest_path)) + monkeypatch.setenv("MESH_BOOTSTRAP_SEED_PEERS", "http://seedpeer.onion:8000") + monkeypatch.setattr( + "services.mesh.mesh_peer_registry.DEFAULT_PEER_REGISTRY_PATH", + registry_path, + ) + monkeypatch.setattr("services.mesh.mesh_peer_store.DEFAULT_PEER_STORE_PATH", peer_store_path) + monkeypatch.setattr("services.mesh.mesh_swarm_runtime.DEFAULT_PEER_STORE_PATH", peer_store_path) + from services.config import get_settings + + get_settings.cache_clear() + try: + peer = record_peer_announcement( + { + "peer_url": "http://participant.onion:8000", + "transport": "onion", + "role": "participant", + }, + now=1_750_000_000, + ) + assert peer.peer_url == "http://participant.onion:8000" + store = PeerStore(peer_store_path) + store.load() + buckets = {record.bucket for record in store.records()} + assert buckets == {"push", "sync"} + assert any(record.source == "swarm" for record in store.records()) + finally: + get_settings.cache_clear() + + +def test_merge_manifest_into_peer_store(tmp_path, monkeypatch): + signer = generate_bootstrap_signer() + peer_store_path = tmp_path / "peer_store.json" + manifest_path = tmp_path / "bootstrap_peers.json" + monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", signer["public_key_b64"]) + monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY", signer["private_key_b64"]) + monkeypatch.setattr("services.mesh.mesh_peer_store.DEFAULT_PEER_STORE_PATH", peer_store_path) + monkeypatch.setattr("services.mesh.mesh_swarm_runtime.DEFAULT_PEER_STORE_PATH", peer_store_path) + manifest = write_signed_bootstrap_manifest( + manifest_path, + signer_id="test-signer", + signer_private_key_b64=signer["private_key_b64"], + peers=[ + { + "peer_url": "http://relay.onion:8000", + "transport": "onion", + "role": "relay", + "label": "relay-a", + } + ], + issued_at=1_750_000_000, + valid_until=1_750_360_000, + ) + merged = merge_manifest_into_peer_store(manifest, now=1_750_000_000) + assert merged == 1 + store = PeerStore(peer_store_path) + store.load() + assert len(store.records()) == 2 + + +def test_parse_bootstrap_manifest_dict_rejects_expired(): + signer = generate_bootstrap_signer() + manifest_path = None + payload = { + "version": 1, + "issued_at": 1, + "valid_until": 2, + "signer_id": "test", + "peers": [ + { + "peer_url": "http://seedpeer.onion:8000", + "transport": "onion", + "role": "seed", + } + ], + } + from services.mesh.mesh_bootstrap_manifest import build_bootstrap_manifest_payload, sign_bootstrap_manifest_payload + + signed_payload = build_bootstrap_manifest_payload( + signer_id="test", + peers=payload["peers"], + issued_at=1, + valid_until=2, + ) + signature = sign_bootstrap_manifest_payload( + signed_payload, + signer_private_key_b64=signer["private_key_b64"], + ) + raw = dict(signed_payload) + raw["signature"] = signature + with pytest.raises(BootstrapManifestError, match="expired"): + parse_bootstrap_manifest_dict( + raw, + signer_public_key_b64=signer["public_key_b64"], + now=time.time(), + ) + + +@pytest.mark.asyncio +async def test_bootstrap_manifest_endpoint_serves_live_registry(tmp_path, monkeypatch): + import main + + signer = generate_bootstrap_signer() + registry_path = tmp_path / "peer_registry.json" + manifest_path = tmp_path / "bootstrap_peers.json" + monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", signer["public_key_b64"]) + monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY", signer["private_key_b64"]) + monkeypatch.setenv("MESH_PEER_REGISTRY_ENABLED", "true") + monkeypatch.setenv("MESH_BOOTSTRAP_MANIFEST_PATH", str(manifest_path)) + monkeypatch.setenv("MESH_BOOTSTRAP_SEED_PEERS", "http://seedpeer.onion:8000") + monkeypatch.setattr("services.mesh.mesh_peer_registry.DEFAULT_PEER_REGISTRY_PATH", registry_path) + from services.config import get_settings + + get_settings.cache_clear() + try: + now = int(time.time()) + record_peer_announcement( + { + "peer_url": "http://participant.onion:8000", + "transport": "onion", + "role": "participant", + }, + now=now, + ) + async with AsyncClient(transport=ASGITransport(app=main.app), base_url="http://test") as ac: + response = await ac.get("/api/mesh/infonet/bootstrap-manifest") + assert response.status_code == 200 + body = response.json() + assert body["ok"] is True + manifest = body["manifest"] + peer_urls = [peer["peer_url"] for peer in manifest["peers"]] + assert "http://participant.onion:8000" in peer_urls + assert "http://seedpeer.onion:8000" in peer_urls + finally: + get_settings.cache_clear() diff --git a/docker-compose.override.yml b/docker-compose.override.yml index d3a62f3..ad05332 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -4,6 +4,21 @@ services: build: context: . dockerfile: ./backend/Dockerfile + environment: + # Private Infonet swarm: only the seed onion is required in config. + # Other participants (e.g. Pete) are discovered via signed manifest pull. + MESH_ARTI_ENABLED: "true" + MESH_ARTI_SOCKS_PORT: "9050" + MESH_SYNC_TIMEOUT_S: "45" + MESH_RELAY_PUSH_TIMEOUT_S: "45" + MESH_SYNC_MAX_PEERS_PER_CYCLE: "5" + MESH_INFONET_ALLOW_CLEARNET_SYNC: "false" + MESH_PUBLIC_PEER_URL: "" + MESH_BOOTSTRAP_SEED_PEERS: "http://gqpbunqbgtkcqilvclm3xrkt3zowjyl3s62kkktvojgvxzizamvbrqid.onion:8000" + MESH_RELAY_PEERS: "" + MESH_DEFAULT_SYNC_PEERS: "" + MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY: "ul1d0kj/ODPIp0OhHzX8eLAVXzJ3CVvzW1vn2IC6q3I=" + MESH_SWARM_MANIFEST_PULL_INTERVAL_S: "300" frontend: build: diff --git a/frontend/src/components/InfonetTerminal/BootstrapView.tsx b/frontend/src/components/InfonetTerminal/BootstrapView.tsx index 9c0872e..05107d2 100644 --- a/frontend/src/components/InfonetTerminal/BootstrapView.tsx +++ b/frontend/src/components/InfonetTerminal/BootstrapView.tsx @@ -60,6 +60,10 @@ export default function BootstrapView({ marketId, onBack }: BootstrapViewProps) nodeStatus?.bootstrap?.bootstrap_seed_peer_count ?? nodeStatus?.bootstrap?.default_sync_peer_count ?? 0, ); const syncPeerCount = Number(nodeStatus?.bootstrap?.sync_peer_count || 0); + const swarmSyncPeerCount = Number(nodeStatus?.bootstrap?.swarm_sync_peer_count || 0); + const manifestLoaded = Boolean(nodeStatus?.bootstrap?.manifest_loaded); + const swarmPull = nodeStatus?.bootstrap?.swarm_manifest_pull; + const swarmPullOk = Boolean(swarmPull?.ok) && !swarmPull?.skipped; const lastPeerUrl = String(nodeStatus?.sync_runtime?.last_peer_url || '').trim(); const privateTransportRequired = Boolean(nodeStatus?.private_transport_required); @@ -146,7 +150,7 @@ export default function BootstrapView({ marketId, onBack }: BootstrapViewProps) Refresh -