diff --git a/backend/main.py b/backend/main.py index 191727f..5f830b6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1489,9 +1489,21 @@ def _refresh_node_peer_store(*, now: float | None = None) -> dict[str, Any]: 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"]) + swarm_pull_ok = bool(swarm_pull.get("ok")) and not bool(swarm_pull.get("skipped")) + if swarm_pull_ok or manifest is not None or swarm_sync_peer_count > 0: + bootstrap_state = "ready" + bootstrap_detail = "Bootstrap peer discovery is ready." + elif bootstrap_seed_peers: + bootstrap_state = "connecting" + bootstrap_detail = "Bootstrap seeds are configured; local node stays active and retries in the background." + else: + bootstrap_state = "solo" + bootstrap_detail = "No bootstrap seeds configured; local node is running in solo mode." snapshot = { "node_mode": mode, "private_transport_required": private_transport_required, + "bootstrap_state": bootstrap_state, + "bootstrap_detail": bootstrap_detail, "skipped_clearnet_peer_count": skipped_clearnet_peers, "pruned_clearnet_peer_count": pruned_clearnet_peers, "manifest_loaded": manifest is not None, diff --git a/backend/node_state.py b/backend/node_state.py index fad34fd..9782ac8 100644 --- a/backend/node_state.py +++ b/backend/node_state.py @@ -148,12 +148,15 @@ def _is_private_infonet_transport(transport: str) -> bool: def _configured_bootstrap_seed_peer_urls() -> list[str]: from services.config import get_settings + from services.mesh.mesh_fleet_defaults import configured_bootstrap_seed_peers_with_fleet_default from services.mesh.mesh_router import parse_configured_relay_peers 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) + return configured_bootstrap_seed_peers_with_fleet_default( + parse_configured_relay_peers(primary or legacy) + ) def _refresh_node_peer_store(*, now: float | None = None) -> dict[str, Any]: diff --git a/backend/services/config.py b/backend/services/config.py index 0bedeeb..3b09ff7 100644 --- a/backend/services/config.py +++ b/backend/services/config.py @@ -39,7 +39,10 @@ class Settings(BaseSettings): MESH_PUBLIC_PEER_URL: str = "" # Bootstrap seeds are discovery hints, not authoritative network roots. # Nodes promote healthy discovered peers from the store/manifest over time. - MESH_BOOTSTRAP_SEED_PEERS: str = "http://gqpbunqbgtkcqilvclm3xrkt3zowjyl3s62kkktvojgvxzizamvbrqid.onion:8000" + MESH_BOOTSTRAP_SEED_PEERS: str = "" + # Optional comma-separated override for the public fleet's bundled onion seeds. + # This lets seed rotation/addition ship as env/config without weakening private sync. + MESH_FLEET_SEED_PEERS: str = "" # Legacy name kept for older compose/.env files. MESH_DEFAULT_SYNC_PEERS: str = "" # Infonet/Wormhole must fail closed to private transports by default. diff --git a/backend/services/mesh/mesh_fleet_defaults.py b/backend/services/mesh/mesh_fleet_defaults.py index 97e4a3b..32f452d 100644 --- a/backend/services/mesh/mesh_fleet_defaults.py +++ b/backend/services/mesh/mesh_fleet_defaults.py @@ -10,6 +10,9 @@ FLEET_NETWORK_ID = "sb-testnet-0" FLEET_SEED_ONION_URL = ( "http://gqpbunqbgtkcqilvclm3xrkt3zowjyl3s62kkktvojgvxzizamvbrqid.onion:8000" ) +FLEET_SEED_ONION_URLS = ( + FLEET_SEED_ONION_URL, +) FLEET_BOOTSTRAP_SIGNER_PUBLIC_KEY_B64 = ( "ul1d0kj/ODPIp0OhHzX8eLAVXzJ3CVvzW1vn2IC6q3I=" ) @@ -56,9 +59,35 @@ def effective_peer_push_secret() -> str: return "" +def _dedupe_peer_urls(peers: list[str]) -> list[str]: + from services.mesh.mesh_router import parse_configured_relay_peers + + seen: set[str] = set() + normalized: list[str] = [] + for peer in parse_configured_relay_peers(",".join(str(item or "") for item in peers)): + if peer in seen: + continue + seen.add(peer) + normalized.append(peer) + return normalized + + +def effective_fleet_seed_peers() -> list[str]: + try: + from services.config import get_settings + + configured = str(getattr(get_settings(), "MESH_FLEET_SEED_PEERS", "") or "").strip() + if configured: + return _dedupe_peer_urls([configured]) + except Exception: + pass + return _dedupe_peer_urls(list(FLEET_SEED_ONION_URLS)) + + def configured_bootstrap_seed_peers_with_fleet_default(peers: list[str]) -> list[str]: - if peers: - return peers + normalized_peers = _dedupe_peer_urls(peers) + if normalized_peers: + return normalized_peers if infonet_fleet_join_enabled(): - return [FLEET_SEED_ONION_URL] + return effective_fleet_seed_peers() return [] diff --git a/backend/services/mesh/mesh_swarm_runtime.py b/backend/services/mesh/mesh_swarm_runtime.py index 3e9901f..1f6d9e5 100644 --- a/backend/services/mesh/mesh_swarm_runtime.py +++ b/backend/services/mesh/mesh_swarm_runtime.py @@ -301,8 +301,9 @@ def refresh_swarm_manifest_from_seeds(*, now: float | None = None, force: bool = 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(): + seed_urls = _configured_seed_peer_urls() + last_error = "bootstrap seeds unreachable; local node will retry" + for seed_url in seed_urls: manifest = fetch_remote_bootstrap_manifest(seed_url, now=timestamp) if manifest is None: continue @@ -311,12 +312,18 @@ def refresh_swarm_manifest_from_seeds(*, now: float | None = None, force: bool = return { "ok": True, "seed_peer_url": seed_url, + "tried_seed_count": len(seed_urls), "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} + return { + "ok": False, + "detail": last_error, + "tried_seed_count": len(seed_urls), + "retrying": bool(seed_urls), + } def announce_local_peer_to_seeds(*, now: float | None = None, force: bool = False) -> dict[str, Any]: diff --git a/backend/services/mesh/mesh_wormhole_prekey.py b/backend/services/mesh/mesh_wormhole_prekey.py index e7e80ee..d8ec069 100644 --- a/backend/services/mesh/mesh_wormhole_prekey.py +++ b/backend/services/mesh/mesh_wormhole_prekey.py @@ -103,11 +103,12 @@ def _invite_lookup_request_timeout(peer_url: str) -> tuple[int, int]: def _bootstrap_seed_peer_urls() -> set[str]: try: from services.config import get_settings + from services.mesh.mesh_fleet_defaults import configured_bootstrap_seed_peers_with_fleet_default from services.mesh.mesh_router import parse_configured_relay_peers seeds: set[str] = set() raw = str(getattr(get_settings(), "MESH_BOOTSTRAP_SEED_PEERS", "") or "") - for peer in parse_configured_relay_peers(raw): + for peer in configured_bootstrap_seed_peers_with_fleet_default(parse_configured_relay_peers(raw)): normalized = str(peer or "").strip().rstrip("/") if normalized: seeds.add(normalized) @@ -318,6 +319,8 @@ def _configured_public_lookup_peer_urls() -> list[str]: from services.mesh.mesh_router import active_sync_peer_urls, parse_configured_relay_peers settings = get_settings() + from services.mesh.mesh_fleet_defaults import configured_bootstrap_seed_peers_with_fleet_default + candidates: list[str] = [] # Operator-configured peers first, then recently active fleet nodes. # Invite handles are minted on a specific node; cold bootstrap seeds @@ -330,7 +333,11 @@ def _configured_public_lookup_peer_urls() -> list[str]: for raw in ( getattr(settings, "MESH_BOOTSTRAP_SEED_PEERS", ""), ): - candidates.extend(parse_configured_relay_peers(str(raw or ""))) + candidates.extend( + configured_bootstrap_seed_peers_with_fleet_default( + parse_configured_relay_peers(str(raw or "")) + ) + ) except Exception: return [] diff --git a/backend/tests/mesh/test_mesh_fleet_defaults.py b/backend/tests/mesh/test_mesh_fleet_defaults.py index 22491f4..2a7a2f1 100644 --- a/backend/tests/mesh/test_mesh_fleet_defaults.py +++ b/backend/tests/mesh/test_mesh_fleet_defaults.py @@ -1,6 +1,9 @@ from services.mesh.mesh_fleet_defaults import ( + FLEET_SEED_ONION_URL, FLEET_PEER_PUSH_SECRET, + configured_bootstrap_seed_peers_with_fleet_default, effective_bootstrap_signer_public_key_b64, + effective_fleet_seed_peers, effective_peer_push_secret, infonet_fleet_join_enabled, ) @@ -9,8 +12,8 @@ from services.mesh.mesh_fleet_defaults import ( def test_fleet_defaults_apply_when_join_enabled(monkeypatch): from services.config import get_settings - monkeypatch.delenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", raising=False) - monkeypatch.delenv("MESH_PEER_PUSH_SECRET", raising=False) + monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", "") + monkeypatch.setenv("MESH_PEER_PUSH_SECRET", "") monkeypatch.setenv("MESH_INFONET_FLEET_JOIN", "true") get_settings.cache_clear() try: @@ -21,6 +24,52 @@ def test_fleet_defaults_apply_when_join_enabled(monkeypatch): get_settings.cache_clear() +def test_empty_bootstrap_peers_use_fleet_seed_defaults(monkeypatch): + from services.config import get_settings + + monkeypatch.delenv("MESH_FLEET_SEED_PEERS", raising=False) + monkeypatch.setenv("MESH_INFONET_FLEET_JOIN", "true") + get_settings.cache_clear() + try: + assert configured_bootstrap_seed_peers_with_fleet_default([]) == [FLEET_SEED_ONION_URL] + finally: + get_settings.cache_clear() + + +def test_configured_bootstrap_peers_override_fleet_defaults(monkeypatch): + from services.config import get_settings + + monkeypatch.setenv("MESH_INFONET_FLEET_JOIN", "true") + get_settings.cache_clear() + try: + assert configured_bootstrap_seed_peers_with_fleet_default( + ["http://alphaexample.onion:8000", "http://alphaexample.onion:8000"] + ) == ["http://alphaexample.onion:8000"] + finally: + get_settings.cache_clear() + + +def test_fleet_seed_override_can_ship_multiple_seeds(monkeypatch): + from services.config import get_settings + + monkeypatch.setenv( + "MESH_FLEET_SEED_PEERS", + "http://alphaexample.onion:8000,http://betaexample.onion:8000,http://alphaexample.onion:8000", + ) + get_settings.cache_clear() + try: + assert effective_fleet_seed_peers() == [ + "http://alphaexample.onion:8000", + "http://betaexample.onion:8000", + ] + assert configured_bootstrap_seed_peers_with_fleet_default([]) == [ + "http://alphaexample.onion:8000", + "http://betaexample.onion:8000", + ] + finally: + get_settings.cache_clear() + + def test_fleet_defaults_disabled(monkeypatch): from services.config import get_settings diff --git a/backend/tests/mesh/test_mesh_node_bootstrap_runtime.py b/backend/tests/mesh/test_mesh_node_bootstrap_runtime.py index dc4ed2a..476dcd3 100644 --- a/backend/tests/mesh/test_mesh_node_bootstrap_runtime.py +++ b/backend/tests/mesh/test_mesh_node_bootstrap_runtime.py @@ -479,4 +479,4 @@ def test_meshnode_scripts_enable_private_hashchain_runtime(): assert "MESH_INFONET_ALLOW_CLEARNET_SYNC=false" in script assert "MESH_ARTI_ENABLED=true" in script assert "MESH_DM_HASHCHAIN_SPOOL_LIMIT=2" in script - assert "gqpbunqbgtkcqilvclm3xrkt3zowjyl3s62kkktvojgvxzizamvbrqid.onion:8000" in script + assert "sb-testnet fleet defaults" in script diff --git a/backend/tests/mesh/test_mesh_swarm_runtime.py b/backend/tests/mesh/test_mesh_swarm_runtime.py index 909e3a9..15a95e2 100644 --- a/backend/tests/mesh/test_mesh_swarm_runtime.py +++ b/backend/tests/mesh/test_mesh_swarm_runtime.py @@ -16,6 +16,7 @@ from services.mesh.mesh_swarm_runtime import ( merge_manifest_into_peer_store, peer_registry_enabled, publish_registry_manifest, + refresh_swarm_manifest_from_seeds, record_peer_announcement, ) @@ -180,6 +181,30 @@ def test_parse_bootstrap_manifest_dict_rejects_expired(): ) +def test_refresh_swarm_manifest_reports_retrying_when_all_seeds_unreachable(monkeypatch): + from services.config import get_settings + + monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", "ul1d0kj/ODPIp0OhHzX8eLAVXzJ3CVvzW1vn2IC6q3I=") + monkeypatch.setenv( + "MESH_BOOTSTRAP_SEED_PEERS", + "http://seed-a.onion:8000,http://seed-b.onion:8000", + ) + monkeypatch.setattr( + "services.mesh.mesh_swarm_runtime.fetch_remote_bootstrap_manifest", + lambda *_args, **_kwargs: None, + ) + get_settings.cache_clear() + try: + result = refresh_swarm_manifest_from_seeds(force=True, now=1_750_000_000) + finally: + get_settings.cache_clear() + + assert result["ok"] is False + assert result["retrying"] is True + assert result["tried_seed_count"] == 2 + assert result["detail"] == "bootstrap seeds unreachable; local node will retry" + + @pytest.mark.asyncio async def test_bootstrap_manifest_endpoint_serves_live_registry(tmp_path, monkeypatch): import main diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 15ceb5c..2169316 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -14,7 +14,10 @@ services: 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" + # Empty means: use the sb-testnet fleet seed list from code/config. + # Operators can rotate/add public fleet seeds with MESH_FLEET_SEED_PEERS. + MESH_BOOTSTRAP_SEED_PEERS: "" + MESH_FLEET_SEED_PEERS: "${MESH_FLEET_SEED_PEERS:-}" MESH_RELAY_PEERS: "" MESH_DEFAULT_SYNC_PEERS: "" MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY: "ul1d0kj/ODPIp0OhHzX8eLAVXzJ3CVvzW1vn2IC6q3I=" diff --git a/frontend/src/components/InfonetTerminal/BootstrapView.tsx b/frontend/src/components/InfonetTerminal/BootstrapView.tsx index 05107d2..ab69618 100644 --- a/frontend/src/components/InfonetTerminal/BootstrapView.tsx +++ b/frontend/src/components/InfonetTerminal/BootstrapView.tsx @@ -66,6 +66,8 @@ export default function BootstrapView({ marketId, onBack }: BootstrapViewProps) const swarmPullOk = Boolean(swarmPull?.ok) && !swarmPull?.skipped; const lastPeerUrl = String(nodeStatus?.sync_runtime?.last_peer_url || '').trim(); const privateTransportRequired = Boolean(nodeStatus?.private_transport_required); + const bootstrapState = String(nodeStatus?.bootstrap?.bootstrap_state || '').toLowerCase(); + const bootstrapDetail = String(nodeStatus?.bootstrap?.bootstrap_detail || '').trim(); const toggleNode = useCallback(async (enabled: boolean) => { setNodeToggleBusy(true); @@ -179,14 +181,16 @@ export default function BootstrapView({ marketId, onBack }: BootstrapViewProps) ? 'LOCAL FILE' : swarmPull?.skipped ? 'WAITING' - : 'NOT LOADED'} + : bootstrapState === 'connecting' + ? 'CONNECTING' + : 'LOCAL ONLY'}
{nodeEnabled - ? `Infonet sync is ${syncOutcome || 'active'}${lastPeerUrl ? ` via ${lastPeerUrl}` : ''}.` + ? (bootstrapDetail || `Infonet sync is ${syncOutcome || 'active'}${lastPeerUrl ? ` via ${lastPeerUrl}` : ''}.`) : 'Start a local participant node to sync through available Wormhole onion/RNS peers while this backend is running.'}