diff --git a/.gitignore b/.gitignore index 4adc34d..f2e4ed1 100644 --- a/.gitignore +++ b/.gitignore @@ -177,6 +177,8 @@ frontend/eslint-report.json .git_backup/ local-artifacts/ release-secrets/ +release-staging/ +.tmp-release-inspect/ shadowbroker_repo/ frontend/src/components.bak/ frontend/src/components/map/icons/backups/ diff --git a/backend/data/release_digests.json b/backend/data/release_digests.json index 5abb9cf..89dcf60 100644 --- a/backend/data/release_digests.json +++ b/backend/data/release_digests.json @@ -51,5 +51,10 @@ "ShadowBroker_v0.9.82.zip": "202ab043465741dcc06de57c19ec8314904332f8e818b891d7174655719d084c", "ShadowBroker_0.9.82_x64-setup.exe": "0eb9f2bda02ab691b39687641abc97e6bfb507b42f48de21970ad7dfb4ea15fc", "ShadowBroker_0.9.82_x64_en-US.msi": "ced08f930171c0c08009a958cc30b0171a09f982230fc217c6808c2ed7ab2e30" + }, + "v0.9.83": { + "ShadowBroker_v0.9.83.zip": "53f56631731ad3cdc7be68df09bedd6570ed91ecda6fa57c39651098e15666c7", + "ShadowBroker_0.9.83_x64-setup.exe": "d62170af4b9df0b190832b7bb3ad6bfe8a7ac01472f2c7b39cf2a1b61edc7492", + "ShadowBroker_0.9.83_x64_en-US.msi": "b664cc0003a29f7ce88b04c2b425643dbe7ed897342fc6e9a2378bc1910c6850" } } diff --git a/backend/main.py b/backend/main.py index b2b6f95..b1938a5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1247,6 +1247,26 @@ def _local_infonet_peer_url() -> str: return "" +def _clear_stale_arti_sync_backoff() -> None: + """Drop cached Arti warmup errors once SOCKS transport is actually ready.""" + from dataclasses import replace + + with _NODE_RUNTIME_LOCK: + current = get_sync_state() + error_lower = str(current.last_error or "").lower() + if "arti" not in error_lower and "onion sync requires" not in error_lower: + return + set_sync_state( + replace( + current, + last_error="", + consecutive_failures=0, + next_sync_due_at=int(time.time()), + last_outcome="idle" if current.last_outcome == "error" else current.last_outcome, + ) + ) + + def _ensure_infonet_private_transport_ready(reason: str = "") -> bool: """Warm the local onion transport before private Infonet sync. @@ -1275,15 +1295,36 @@ def _ensure_infonet_private_transport_ready(reason: str = "") -> bool: label = f" ({reason})" if reason else "" logger.info("Infonet private transport warmup starting%s", label) - tor_result = tor_service.start(target_port=8000) - if tor_result.get("ok"): + from services.wormhole_supervisor import invalidate_arti_ready_cache + + for attempt in range(3): + tor_result = tor_service.start(target_port=8000) + if not tor_result.get("ok"): + logger.warning( + "Infonet private transport warmup incomplete%s: %s", + label, + tor_result, + ) + continue _write_env_value("MESH_ARTI_ENABLED", "true") 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) + invalidate_arti_ready_cache() + deadline = time.monotonic() + 30.0 + while time.monotonic() < deadline: + if _check_arti_ready(force=True): + logger.info("Infonet private transport ready%s", label) + _clear_stale_arti_sync_backoff() + threading.Thread(target=_swarm_bootstrap_after_transport_ready, daemon=True).start() + _kick_public_sync_background(f"transport_ready{label}") + return True + time.sleep(1.0) + logger.warning( + "Infonet private transport SOCKS not ready after Tor start (attempt %d/3)%s", + attempt + 1, + label, + ) + tor_service.stop() + logger.warning("Infonet private transport warmup incomplete%s", label) return False except Exception as exc: logger.warning("Infonet private transport warmup failed: %s", exc) @@ -11704,7 +11745,7 @@ async def api_wormhole_dm_contact_sever(request: Request, peer_id: str): return {"ok": False, "detail": str(exc)} -_WORMHOLE_PUBLIC_FIELDS = {"installed", "configured", "running", "ready"} +_WORMHOLE_PUBLIC_FIELDS = {"installed", "configured", "running", "ready", "arti_ready"} def _redact_wormhole_status(state: dict[str, Any], authenticated: bool) -> dict[str, Any]: diff --git a/backend/pyproject.toml b/backend/pyproject.toml index c724510..7081915 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -7,7 +7,7 @@ py-modules = [] [project] name = "backend" -version = "0.9.82" +version = "0.9.83" requires-python = ">=3.10" dependencies = [ "apscheduler==3.10.3", diff --git a/backend/routers/wormhole.py b/backend/routers/wormhole.py index 588b432..94f6ee5 100644 --- a/backend/routers/wormhole.py +++ b/backend/routers/wormhole.py @@ -1348,7 +1348,7 @@ async def api_wormhole_dm_contact_sever(request: Request, peer_id: str): return {"ok": False, "detail": str(exc)} -_WORMHOLE_PUBLIC_FIELDS = {"installed", "configured", "running", "ready"} +_WORMHOLE_PUBLIC_FIELDS = {"installed", "configured", "running", "ready", "arti_ready"} def _redact_wormhole_status(state: dict[str, Any], authenticated: bool) -> dict[str, Any]: diff --git a/backend/services/tor_hidden_service.py b/backend/services/tor_hidden_service.py index 2176a33..2cf1068 100644 --- a/backend/services/tor_hidden_service.py +++ b/backend/services/tor_hidden_service.py @@ -33,6 +33,52 @@ TOR_INSTALL_DIR = TOR_DIR / "tor_bin" _STARTUP_TIMEOUT_S = 90 _POLL_INTERVAL_S = 1.0 + +def _arti_socks_port() -> int: + from services.config import get_settings + + return int(get_settings().MESH_ARTI_SOCKS_PORT or 9050) + + +def _torrc_socks_line(socks_port: int) -> str: + return f"SocksPort {socks_port}\n" + + +def _torrc_has_socks_port(socks_port: int) -> bool: + if not TORRC_PATH.exists(): + return False + return _torrc_socks_line(socks_port) in TORRC_PATH.read_text(encoding="utf-8") + + +def _local_socks_listening(socks_port: int) -> bool: + return _local_socks_handshake_ready(socks_port, timeout=0.75) + + +def _local_socks_handshake_ready(socks_port: int, *, timeout: float = 5.0) -> bool: + import socket + + try: + with socket.create_connection(("127.0.0.1", socks_port), timeout=timeout) as sock: + sock.settimeout(timeout) + sock.sendall(b"\x05\x01\x00") + return sock.recv(2) == b"\x05\x00" + except OSError: + return False + + +def _write_torrc(*, target_port: int, socks_port: int) -> None: + TOR_DIR.mkdir(parents=True, exist_ok=True) + hidden_service_dir = TOR_DIR / "hidden_service" + hidden_service_dir.mkdir(parents=True, exist_ok=True) + torrc_content = ( + f"DataDirectory {TOR_DATA_DIR.as_posix()}\n" + f"HiddenServiceDir {hidden_service_dir.as_posix()}\n" + f"HiddenServicePort {target_port} 127.0.0.1:{target_port}\n" + f"{_torrc_socks_line(socks_port)}" + "Log notice stderr\n" + ) + TORRC_PATH.write_text(torrc_content, encoding="utf-8") + # Windows x86_64 Tor Expert Bundle URLs. Keep a fallback so first-run # onboarding does not break when Tor rotates point releases. _TOR_EXPERT_BUNDLE_URLS = [ @@ -357,12 +403,28 @@ class TorHiddenService: def start(self, target_port: int = 8000) -> dict: """Start Tor hidden service pointing to target_port on localhost.""" with self._lock: + socks_port = _arti_socks_port() if self._running and self._process and self._process.poll() is None: - return { - "ok": True, - "onion_address": self._onion_address, - "detail": "already running", - } + if _torrc_has_socks_port(socks_port) and _local_socks_handshake_ready(socks_port, timeout=1.5): + return { + "ok": True, + "onion_address": self._onion_address, + "detail": "already running", + } + logger.info( + "Tor is running without a ready SOCKS proxy on port %s — restarting", + socks_port, + ) + try: + self._process.terminate() + self._process.wait(timeout=10) + except Exception: + try: + self._process.kill() + except Exception: + pass + self._process = None + self._running = False self._error = "" tor_bin = _find_tor_binary() @@ -388,20 +450,9 @@ class TorHiddenService: except OSError: pass - from services.config import get_settings - - settings = get_settings() - socks_port_line = "" - if not bool(getattr(settings, "MESH_ARTI_ENABLED", False)): - socks_port_line = "SocksPort 9050\n" - torrc_content = ( - f"DataDirectory {TOR_DATA_DIR.as_posix()}\n" - f"HiddenServiceDir {hidden_service_dir.as_posix()}\n" - f"HiddenServicePort {target_port} 127.0.0.1:{target_port}\n" - f"{socks_port_line}" - "Log notice stderr\n" - ) - TORRC_PATH.write_text(torrc_content, encoding="utf-8") + # Mesh "Arti" transport uses Tor's local SOCKS proxy for .onion peers. + # Always publish SocksPort — MESH_ARTI_ENABLED only gates callers, not Tor. + _write_torrc(target_port=target_port, socks_port=socks_port) try: self._process = subprocess.Popen( @@ -434,15 +485,23 @@ class TorHiddenService: hostname = HOSTNAME_PATH.read_text().strip() if hostname.endswith(".onion"): self._onion_address = f"http://{hostname}:8000" - logger.info("Tor hidden service ready: %s", self._onion_address) - return { - "ok": True, - "onion_address": self._onion_address, - } + if _local_socks_handshake_ready(socks_port, timeout=3.0): + logger.info( + "Tor hidden service ready: %s (SOCKS %s)", + self._onion_address, + socks_port, + ) + return { + "ok": True, + "onion_address": self._onion_address, + } time.sleep(_POLL_INTERVAL_S) - self._error = f"Tor did not generate hostname within {_STARTUP_TIMEOUT_S}s" + self._error = ( + f"Tor did not publish a ready hidden service and SOCKS proxy " + f"on port {socks_port} within {_STARTUP_TIMEOUT_S}s" + ) self.stop() return {"ok": False, "detail": self._error} diff --git a/backend/services/wormhole_supervisor.py b/backend/services/wormhole_supervisor.py index 121c3bb..910ed9e 100644 --- a/backend/services/wormhole_supervisor.py +++ b/backend/services/wormhole_supervisor.py @@ -27,6 +27,13 @@ _STATE_CACHE_TS = 0.0 _STATE_CACHE_TTL_S = 2.0 _ARTI_PROOF_CACHE: dict[str, Any] = {"port": 0, "ok": False, "ts": 0.0} _ARTI_PROOF_CACHE_TTL_S = 30.0 +_ARTI_STATUS_CACHE: dict[str, Any] = {"port": 0, "ready": False, "ts": 0.0} +_ARTI_STATUS_FAIL_TTL_S = 4.0 +_ARTI_PROBE_LOCK = threading.Lock() +_ARTI_SOCKS_FAILURES = 0 +_ARTI_LAST_TOR_RECOVERY_TS = 0.0 +_ARTI_TOR_RECOVERY_COOLDOWN_S = 45.0 +_ARTI_SOCKS_CONNECT_TIMEOUT_S = 5.0 _PRIVATE_CLEARNET_FALLBACK_WINDOW_S = 300.0 BACKEND_DIR = Path(__file__).resolve().parent.parent @@ -70,16 +77,43 @@ _WORMHOLE_ENV_EXPLICIT = { "PRIVACY_CORE_MIN_VERSION", } -def _check_arti_ready() -> bool: - from services.config import get_settings +def invalidate_arti_ready_cache() -> None: + _ARTI_PROOF_CACHE.update({"port": 0, "ok": False, "ts": 0.0}) + _ARTI_STATUS_CACHE.update({"port": 0, "ready": False, "ts": 0.0}) - settings = get_settings() - if not bool(settings.MESH_ARTI_ENABLED): - return False - socks_port = int(settings.MESH_ARTI_SOCKS_PORT or 9050) + +def _maybe_recover_tor_socks_transport(socks_port: int) -> None: + global _ARTI_SOCKS_FAILURES, _ARTI_LAST_TOR_RECOVERY_TS + + _ARTI_SOCKS_FAILURES += 1 + if _ARTI_SOCKS_FAILURES < 3: + return + now = time.time() + if (now - _ARTI_LAST_TOR_RECOVERY_TS) < _ARTI_TOR_RECOVERY_COOLDOWN_S: + return + _ARTI_LAST_TOR_RECOVERY_TS = now + _ARTI_SOCKS_FAILURES = 0 try: - with socket.create_connection((WORMHOLE_HOST, socks_port), timeout=2.0) as sock: - # SOCKS5 greeting: version 5, 1 auth method, no-auth. + from services.tor_hidden_service import tor_service + + logger.warning( + "Tor SOCKS on port %s is wedged — recycling Tor hidden service", + socks_port, + ) + tor_service.stop() + tor_service.start(target_port=8000) + invalidate_arti_ready_cache() + except Exception as exc: + logger.warning("Tor SOCKS recovery failed: %s", exc) + + +def _probe_arti_socks_ready(socks_port: int) -> bool: + try: + with socket.create_connection( + (WORMHOLE_HOST, socks_port), + timeout=_ARTI_SOCKS_CONNECT_TIMEOUT_S, + ) as sock: + sock.settimeout(_ARTI_SOCKS_CONNECT_TIMEOUT_S) sock.sendall(b"\x05\x01\x00") response = sock.recv(2) if response != b"\x05\x00": @@ -88,6 +122,53 @@ def _check_arti_ready() -> bool: except Exception as exc: logger.warning("Arti SOCKS check failed on port %s: %s", socks_port, exc) return False + return True + + +def _check_arti_ready(*, force: bool = False) -> bool: + from services.config import get_settings + + settings = get_settings() + if not bool(settings.MESH_ARTI_ENABLED): + return False + socks_port = int(settings.MESH_ARTI_SOCKS_PORT or 9050) + now = time.time() + if not force: + if ( + int(_ARTI_STATUS_CACHE.get("port", 0) or 0) == socks_port + and (now - float(_ARTI_STATUS_CACHE.get("ts", 0.0) or 0.0)) < _ARTI_STATUS_FAIL_TTL_S + ): + return bool(_ARTI_STATUS_CACHE.get("ready")) + if ( + int(_ARTI_PROOF_CACHE.get("port", 0) or 0) == socks_port + and bool(_ARTI_PROOF_CACHE.get("ok")) + and (now - float(_ARTI_PROOF_CACHE.get("ts", 0.0) or 0.0)) < _ARTI_PROOF_CACHE_TTL_S + ): + return True + + with _ARTI_PROBE_LOCK: + now = time.time() + if not force: + if ( + int(_ARTI_STATUS_CACHE.get("port", 0) or 0) == socks_port + and (now - float(_ARTI_STATUS_CACHE.get("ts", 0.0) or 0.0)) < _ARTI_STATUS_FAIL_TTL_S + ): + return bool(_ARTI_STATUS_CACHE.get("ready")) + if ( + int(_ARTI_PROOF_CACHE.get("port", 0) or 0) == socks_port + and bool(_ARTI_PROOF_CACHE.get("ok")) + and (now - float(_ARTI_PROOF_CACHE.get("ts", 0.0) or 0.0)) < _ARTI_PROOF_CACHE_TTL_S + ): + return True + + if not _probe_arti_socks_ready(socks_port): + _ARTI_STATUS_CACHE.update({"port": socks_port, "ready": False, "ts": now}) + _maybe_recover_tor_socks_transport(socks_port) + return False + + global _ARTI_SOCKS_FAILURES + _ARTI_SOCKS_FAILURES = 0 + _ARTI_STATUS_CACHE.update({"port": socks_port, "ready": True, "ts": now}) now = time.time() if ( @@ -110,12 +191,13 @@ def _check_arti_ready() -> bool: is_tor = bool(payload.get("IsTor")) or bool(payload.get("is_tor")) if not (response.ok and is_tor): logger.warning( - "Arti Tor proof failed (status=%s is_tor=%s) — SOCKS is up, using Arti anyway", + "Arti Tor proof failed (status=%s is_tor=%s)", getattr(response, "status_code", "unknown"), payload.get("IsTor", payload.get("is_tor")), ) - _ARTI_PROOF_CACHE.update({"port": socks_port, "ok": True, "ts": now}) - return True + _ARTI_PROOF_CACHE.update({"port": socks_port, "ok": False, "ts": now}) + _ARTI_STATUS_CACHE.update({"port": socks_port, "ready": False, "ts": now}) + return False _ARTI_PROOF_CACHE.update({"port": socks_port, "ok": True, "ts": now}) return True except Exception as exc: diff --git a/backend/tests/mesh/test_phase3_tor_proof_hardening.py b/backend/tests/mesh/test_phase3_tor_proof_hardening.py index 56d566c..7a5232f 100644 --- a/backend/tests/mesh/test_phase3_tor_proof_hardening.py +++ b/backend/tests/mesh/test_phase3_tor_proof_hardening.py @@ -51,6 +51,9 @@ class _FakeSocket: def recv(self, _n: int) -> bytes: return self._handshake_response + def settimeout(self, _timeout: float) -> None: + return None + class _FakeResponse: def __init__(self, *, ok: bool, payload: dict[str, Any], status_code: int = 200) -> None: @@ -76,8 +79,10 @@ def _stub_settings(monkeypatch, *, enabled: bool = True, port: int = 9050) -> No monkeypatch.setattr( "services.config.get_settings", _get_settings, raising=False ) - # Reset proof cache so each test starts clean. + # Reset proof/status cache so each test starts clean. wormhole_supervisor._ARTI_PROOF_CACHE.update({"port": 0, "ok": False, "ts": 0.0}) + wormhole_supervisor._ARTI_STATUS_CACHE.update({"port": 0, "ready": False, "ts": 0.0}) + wormhole_supervisor._ARTI_SOCKS_FAILURES = 0 # --------------------------------------------------------------------------- diff --git a/backend/tests/test_tor_hidden_service_socks.py b/backend/tests/test_tor_hidden_service_socks.py new file mode 100644 index 0000000..4dc69cc --- /dev/null +++ b/backend/tests/test_tor_hidden_service_socks.py @@ -0,0 +1,61 @@ +"""Tor hidden service must always publish the mesh SOCKS port.""" + +from __future__ import annotations + +import socket +from pathlib import Path + +import pytest + +from services import tor_hidden_service as tor_svc + + +def test_write_torrc_always_includes_socks_port(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(tor_svc, "TOR_DIR", tmp_path) + monkeypatch.setattr(tor_svc, "TORRC_PATH", tmp_path / "torrc") + monkeypatch.setattr(tor_svc, "TOR_DATA_DIR", tmp_path / "data") + + tor_svc._write_torrc(target_port=8000, socks_port=19050) + + content = tor_svc.TORRC_PATH.read_text(encoding="utf-8") + assert "SocksPort 19050" in content + assert "HiddenServicePort 8000 127.0.0.1:8000" in content + + +def test_torrc_has_socks_port_detects_missing_line(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(tor_svc, "TORRC_PATH", tmp_path / "torrc") + tor_svc.TORRC_PATH.write_text("HiddenServicePort 8000 127.0.0.1:8000\n", encoding="utf-8") + + assert tor_svc._torrc_has_socks_port(9050) is False + + tor_svc.TORRC_PATH.write_text("SocksPort 9050\n", encoding="utf-8") + assert tor_svc._torrc_has_socks_port(9050) is True + + +def test_local_socks_handshake_ready_accepts_valid_response(monkeypatch: pytest.MonkeyPatch) -> None: + class FakeSock: + def __init__(self) -> None: + self._sent = b"" + + def settimeout(self, timeout: float) -> None: + return None + + def sendall(self, payload: bytes) -> None: + self._sent = payload + + def recv(self, size: int) -> bytes: + assert self._sent == b"\x05\x01\x00" + return b"\x05\x00" + + def __enter__(self) -> "FakeSock": + return self + + def __exit__(self, *args: object) -> None: + return None + + monkeypatch.setattr( + socket, + "create_connection", + lambda *_args, **_kwargs: FakeSock(), + ) + assert tor_svc._local_socks_handshake_ready(9050) is True diff --git a/desktop-shell/package-lock.json b/desktop-shell/package-lock.json index ec57c3c..51b26c6 100644 --- a/desktop-shell/package-lock.json +++ b/desktop-shell/package-lock.json @@ -1,12 +1,12 @@ { "name": "@shadowbroker/desktop-shell", - "version": "0.9.82", + "version": "0.9.83", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@shadowbroker/desktop-shell", - "version": "0.9.82", + "version": "0.9.83", "devDependencies": { "typescript": "^5.6.0" } diff --git a/desktop-shell/package.json b/desktop-shell/package.json index 8bcc7b1..e9eea01 100644 --- a/desktop-shell/package.json +++ b/desktop-shell/package.json @@ -1,6 +1,6 @@ { "name": "@shadowbroker/desktop-shell", - "version": "0.9.82", + "version": "0.9.83", "private": true, "description": "ShadowBroker desktop shell packaging, runtime bridge, and release tooling", "scripts": { diff --git a/desktop-shell/tauri-skeleton/src-tauri/Cargo.lock b/desktop-shell/tauri-skeleton/src-tauri/Cargo.lock index 9e38612..e3bf681 100644 --- a/desktop-shell/tauri-skeleton/src-tauri/Cargo.lock +++ b/desktop-shell/tauri-skeleton/src-tauri/Cargo.lock @@ -4201,7 +4201,7 @@ dependencies = [ [[package]] name = "shadowbroker-tauri-shell" -version = "0.9.82" +version = "0.9.83" dependencies = [ "axum", "base64 0.22.1", diff --git a/desktop-shell/tauri-skeleton/src-tauri/Cargo.toml b/desktop-shell/tauri-skeleton/src-tauri/Cargo.toml index fe544a0..c973467 100644 --- a/desktop-shell/tauri-skeleton/src-tauri/Cargo.toml +++ b/desktop-shell/tauri-skeleton/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shadowbroker-tauri-shell" -version = "0.9.82" +version = "0.9.83" edition = "2021" [build-dependencies] diff --git a/desktop-shell/tauri-skeleton/src-tauri/tauri.conf.json b/desktop-shell/tauri-skeleton/src-tauri/tauri.conf.json index 3d6f83a..4ac09d5 100644 --- a/desktop-shell/tauri-skeleton/src-tauri/tauri.conf.json +++ b/desktop-shell/tauri-skeleton/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ShadowBroker", - "version": "0.9.82", + "version": "0.9.83", "identifier": "com.shadowbroker.desktop", "build": { "frontendDist": "../../../frontend/out", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 17ab107..8b7f132 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.9.82", + "version": "0.9.83", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.9.82", + "version": "0.9.83", "dependencies": { "@mapbox/point-geometry": "^1.1.0", "@tauri-apps/plugin-process": "^2.3.1", diff --git a/frontend/package.json b/frontend/package.json index 2e09ec1..2be0acb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.9.82", + "version": "0.9.83", "private": true, "scripts": { "dev": "node scripts/dev-all.cjs", diff --git a/frontend/src/__tests__/desktop/updateRuntime.test.ts b/frontend/src/__tests__/desktop/updateRuntime.test.ts index 5222d1f..7858d63 100644 --- a/frontend/src/__tests__/desktop/updateRuntime.test.ts +++ b/frontend/src/__tests__/desktop/updateRuntime.test.ts @@ -9,12 +9,12 @@ import { } from '@/lib/updateRuntime'; const RELEASE: GitHubLatestRelease = { - html_url: 'https://github.com/BigBodyCobain/Shadowbroker/releases/tag/v0.9.82', + html_url: 'https://github.com/BigBodyCobain/Shadowbroker/releases/tag/v0.9.83', assets: [ - { name: 'ShadowBroker_0.9.82_x64_en-US.msi', browser_download_url: 'https://example.test/windows.msi' }, - { name: 'ShadowBroker_0.9.82_x64-setup.exe', browser_download_url: 'https://example.test/windows-setup.exe' }, - { name: 'ShadowBroker_0.9.82_aarch64.dmg', browser_download_url: 'https://example.test/macos.dmg' }, - { name: 'ShadowBroker_0.9.82_amd64.AppImage', browser_download_url: 'https://example.test/linux.AppImage' }, + { name: 'ShadowBroker_0.9.83_x64_en-US.msi', browser_download_url: 'https://example.test/windows.msi' }, + { name: 'ShadowBroker_0.9.83_x64-setup.exe', browser_download_url: 'https://example.test/windows-setup.exe' }, + { name: 'ShadowBroker_0.9.83_aarch64.dmg', browser_download_url: 'https://example.test/macos.dmg' }, + { name: 'ShadowBroker_0.9.83_amd64.AppImage', browser_download_url: 'https://example.test/linux.AppImage' }, ], }; diff --git a/frontend/src/__tests__/mesh/topRightControlsTerminalLauncher.test.tsx b/frontend/src/__tests__/mesh/topRightControlsTerminalLauncher.test.tsx index 4985678..b0a633f 100644 --- a/frontend/src/__tests__/mesh/topRightControlsTerminalLauncher.test.tsx +++ b/frontend/src/__tests__/mesh/topRightControlsTerminalLauncher.test.tsx @@ -51,6 +51,8 @@ const controlPlaneFetch = vi.fn(); vi.mock('@/mesh/wormholeIdentityClient', () => ({ fetchWormholeStatus, prepareWormholeInteractiveLane, + isWormholePrepAbortedError: (error: unknown) => + error instanceof Error && error.message === 'wormhole_prep_aborted', })); vi.mock('@/mesh/wormholeClient', () => ({ @@ -76,6 +78,7 @@ vi.mock('@/mesh/controlPlaneStatusClient', () => ({ vi.mock('@/lib/meshTerminalLauncher', () => ({ requestMeshTerminalOpen, subscribeSecureMeshTerminalLauncherOpen, + subscribeInfonetSessionEnd: vi.fn(() => () => {}), })); vi.mock('@/lib/updateRuntime', () => ({ diff --git a/frontend/src/__tests__/page/pageBehavior.test.ts b/frontend/src/__tests__/page/pageBehavior.test.ts index cb63e1c..66ed27a 100644 --- a/frontend/src/__tests__/page/pageBehavior.test.ts +++ b/frontend/src/__tests__/page/pageBehavior.test.ts @@ -1,9 +1,5 @@ /** * Sprint 4D behavioral tests — page.tsx wormhole teardown and layer sync. - * - * These tests exercise actual runtime logic: - * 1. teardownWormholeOnClose — calls leaveWormhole only when state is ready or running - * 2. Layer sync first-mount suppression — initial sync does NOT dispatch LAYER_TOGGLE_EVENT */ import { describe, expect, it, vi, beforeEach } from 'vitest'; import fs from 'node:fs'; @@ -11,80 +7,29 @@ import path from 'node:path'; import { teardownWormholeOnClose } from '@/lib/wormholeTeardown'; import { LAYER_TOGGLE_EVENT } from '@/hooks/useDataPolling'; -// ─── teardownWormholeOnClose ────────────────────────────────────────────── +const endInfonetTerminalSession = vi.fn(async () => {}); + +vi.mock('@/lib/infonetTerminalSession', () => ({ + endInfonetTerminalSession: (...args: unknown[]) => endInfonetTerminalSession(...args), +})); describe('page.tsx behavior — teardownWormholeOnClose', () => { - let fetchState: ReturnType; - let leave: ReturnType; - beforeEach(() => { - fetchState = vi.fn(); - leave = vi.fn().mockResolvedValue({}); + endInfonetTerminalSession.mockClear(); }); - it('calls leaveWormhole when state is ready', async () => { - fetchState.mockResolvedValue({ ready: true, running: false }); - await teardownWormholeOnClose(fetchState, leave); - expect(fetchState).toHaveBeenCalledWith(false); - expect(leave).toHaveBeenCalledTimes(1); - }); - - it('calls leaveWormhole when state is running', async () => { - fetchState.mockResolvedValue({ ready: false, running: true }); - await teardownWormholeOnClose(fetchState, leave); - expect(leave).toHaveBeenCalledTimes(1); - }); - - it('calls leaveWormhole when state is both ready and running', async () => { - fetchState.mockResolvedValue({ ready: true, running: true }); - await teardownWormholeOnClose(fetchState, leave); - expect(leave).toHaveBeenCalledTimes(1); - }); - - it('does NOT call leaveWormhole when state is neither ready nor running', async () => { - fetchState.mockResolvedValue({ ready: false, running: false }); - await teardownWormholeOnClose(fetchState, leave); - expect(fetchState).toHaveBeenCalledWith(false); - expect(leave).not.toHaveBeenCalled(); - }); - - it('does NOT call leaveWormhole when state is null', async () => { - fetchState.mockResolvedValue(null); - await teardownWormholeOnClose(fetchState, leave); - expect(leave).not.toHaveBeenCalled(); - }); - - it('swallows fetchState errors gracefully', async () => { - fetchState.mockRejectedValue(new Error('network down')); - await teardownWormholeOnClose(fetchState, leave); - expect(leave).not.toHaveBeenCalled(); - // No error thrown — handler is best-effort - }); - - it('swallows leaveWormhole errors gracefully', async () => { - fetchState.mockResolvedValue({ ready: true }); - leave.mockRejectedValue(new Error('leave failed')); - await teardownWormholeOnClose(fetchState, leave); - // No error thrown — handler is best-effort - }); - - it('always passes force=false to fetchState', async () => { - fetchState.mockResolvedValue({ ready: true }); - await teardownWormholeOnClose(fetchState, leave); - expect(fetchState).toHaveBeenCalledWith(false); - expect(fetchState).not.toHaveBeenCalledWith(true); + it('ends the infonet terminal session on close', async () => { + await teardownWormholeOnClose(); + expect(endInfonetTerminalSession).toHaveBeenCalledTimes(1); }); }); -// ─── Layer sync first-mount suppression ─────────────────────────────────── - describe('page.tsx behavior — layer sync first-mount suppression', () => { it('LAYER_TOGGLE_EVENT is the expected string constant', () => { expect(LAYER_TOGGLE_EVENT).toBe('sb:layer-toggle'); }); it('first-mount ref pattern suppresses dispatch, subsequent calls dispatch', () => { - // Simulate the initialLayerSyncRef pattern from page.tsx const initialSyncDone = { current: false }; const dispatched: boolean[] = []; @@ -96,7 +41,6 @@ describe('page.tsx behavior — layer sync first-mount suppression', () => { } }; - // First call (mount): should pass false → no dispatch if (!initialSyncDone.current) { initialSyncDone.current = true; syncLayers(false); @@ -105,7 +49,6 @@ describe('page.tsx behavior — layer sync first-mount suppression', () => { } expect(dispatched).toEqual([false]); - // Second call (layer change): should pass true → dispatch if (!initialSyncDone.current) { initialSyncDone.current = true; syncLayers(false); @@ -114,7 +57,6 @@ describe('page.tsx behavior — layer sync first-mount suppression', () => { } expect(dispatched).toEqual([false, true]); - // Third call (another layer change): should still dispatch if (!initialSyncDone.current) { initialSyncDone.current = true; syncLayers(false); diff --git a/frontend/src/__tests__/page/pageDecomposition.test.ts b/frontend/src/__tests__/page/pageDecomposition.test.ts index 0b56e80..70f8544 100644 --- a/frontend/src/__tests__/page/pageDecomposition.test.ts +++ b/frontend/src/__tests__/page/pageDecomposition.test.ts @@ -2,7 +2,7 @@ * Sprint 4B regression tests — page.tsx decomposition boundary checks. * * These tests validate the frozen contract for page.tsx decomposition: - * 1. InfonetTerminal onClose still calls leaveWormhole when wormhole is ready/running + * 1. InfonetTerminal onClose ends the terminal session (wormhole + node + tor) * 2. Initial /api/layers sync does NOT dispatch LAYER_TOGGLE_EVENT on first mount * 3. launchMeshChatTab preserves atomic leftOpen + leftMeshExpanded + meshChatLaunchRequest * 4. LocateBar extracted to page-local module @@ -60,30 +60,19 @@ describe('page.tsx decomposition — extraction targets', () => { describe('page.tsx decomposition — InfonetTerminal onClose wormhole teardown', () => { const page = readAppFile('page.tsx'); - it('InfonetTerminal onClose delegates to teardownWormholeOnClose', () => { + it('InfonetTerminal onClose ends the terminal session', () => { const infonetSection = page.slice( page.indexOf('') !== -1 ? page.indexOf('') : page.indexOf('/>', page.indexOf(' { + it('page.tsx imports endInfonetTerminalSession from infonetTerminalSession', () => { expect(page).toMatch( - /import\s*\{[^}]*teardownWormholeOnClose[^}]*\}\s*from\s+['"]@\/lib\/wormholeTeardown['"]/, - ); - }); - - it('page.tsx imports leaveWormhole and fetchWormholeState from wormholeClient', () => { - expect(page).toMatch( - /import\s*\{[^}]*leaveWormhole[^}]*\}\s*from\s+['"]@\/mesh\/wormholeClient['"]/, - ); - expect(page).toMatch( - /import\s*\{[^}]*fetchWormholeState[^}]*\}\s*from\s+['"]@\/mesh\/wormholeClient['"]/, + /import\s*\{[^}]*endInfonetTerminalSession[^}]*\}\s*from\s+['"]@\/lib\/infonetTerminalSession['"]/, ); }); }); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 76d858a..771f790 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -18,8 +18,7 @@ import ScaleBar from '@/components/ScaleBar'; import MeshTerminal from '@/components/MeshTerminal'; import MeshChat from '@/components/MeshChat'; import InfonetTerminal from '@/components/InfonetTerminal'; -import { leaveWormhole, fetchWormholeState } from '@/mesh/wormholeClient'; -import { teardownWormholeOnClose } from '@/lib/wormholeTeardown'; +import { endInfonetTerminalSession } from '@/lib/infonetTerminalSession'; import ShodanPanel from '@/components/ShodanPanel'; import ReconPanel from '@/components/ReconPanel'; import ScmPanel from '@/components/ScmPanel'; @@ -169,7 +168,13 @@ export default function Dashboard() { useEffect(() => subscribeMeshTerminalOpen(openInfonet), [openInfonet]); const toggleInfonet = useCallback(() => { - setInfonetOpen(prev => !prev); + setInfonetOpen((prev) => { + if (prev) { + void endInfonetTerminalSession(); + return false; + } + return true; + }); }, []); const [activeLayers, setActiveLayers] = useState({ @@ -1024,8 +1029,7 @@ export default function Dashboard() { isOpen={infonetOpen} onClose={() => { setInfonetOpen(false); - // Shut down Wormhole when the terminal closes so it doesn't stay running - void teardownWormholeOnClose(fetchWormholeState, leaveWormhole); + void endInfonetTerminalSession(); }} onOpenLiveGate={openLiveGateFromShell} onOpenDeadDrop={openDeadDropFromShell} diff --git a/frontend/src/components/ChangelogModal.tsx b/frontend/src/components/ChangelogModal.tsx index f0ebe79..fa22111 100644 --- a/frontend/src/components/ChangelogModal.tsx +++ b/frontend/src/components/ChangelogModal.tsx @@ -4,101 +4,92 @@ import React, { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { X, - Bot, Network, KeyRound, Shield, - Plane, Bug, Heart, MessageSquare, - Radar, - Factory, - Anchor, - Search, + Lock, + Users, + Radio, } from 'lucide-react'; -const CURRENT_VERSION = '0.9.82'; +const CURRENT_VERSION = '0.9.83'; const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`; -const RELEASE_TITLE = 'Telegram OSINT + Osiris Intel Ports + OpenClaw Recon'; +const RELEASE_TITLE = 'Infonet Gate Messaging + DM Protocols Live'; const HEADLINE_FEATURES = [ + { + icon: , + accent: 'purple' as const, + title: 'Gate Messaging — End-to-End on the Hashchain', + subtitle: + 'Encrypted MLS gate rooms now carry live chat over your private Infonet hashchain. Messages replicate across participant nodes via swarm push/pull — only gate members can decrypt.', + details: [ + 'Gate messages append as signed `gate_message` events on each participant\'s private chain; peers sync ciphertext through the mesh without exposing room keys to outsiders.', + 'Swarm replication keeps late joiners and offline nodes convergent — pull missing blocks, push new envelopes to known gate peers.', + 'MLS group crypto (privacy-core) handles forward secrecy and membership changes; the UI surfaces delivery, key rotation, and compat approval when room epochs advance.', + ], + callToAction: 'MESH CHAT → GATES → CREATE OR JOIN A ROOM', + }, { icon: , accent: 'cyan' as const, - title: 'Telegram OSINT Map Layer', + title: 'Direct Messages — Short Address, Request, Encrypt', subtitle: - 'Public war/conflict Telegram channels scraped hourly, risk-scored, geoparsed to metro anchors, and plotted as clickable map pins with inline photo/video.', + 'DMs are fully operational over the wormhole/Tor lane: share your short wormhole address out-of-band, accept a contact request, then exchange ratchet-encrypted messages.', details: [ - 'Incremental merge — only new posts are fetched; known links stop the parser early so channels are not re-scraped redundantly.', - 'Metro-anchor geocoding (Tel Aviv, Kyiv, NYC, Beijing, etc.) keeps Telegram pins off news/threat-alert centroids so pins stay clickable above the threat intercept overlays.', - 'Threat-intercept styled popups with inline media via `/api/telegram/media` proxy. Configure channels via `TELEGRAM_OSINT_CHANNELS` (see `.env.example`).', + 'Contact flow: outbound request → peer approve/deny → mutual DM session with double-ratchet bundles and mailbox claim keys.', + 'No public phonebook — addresses are intentionally short and meant to be exchanged like a phone number or email, not discovered from a directory.', + 'Fleet-tested across multiple onion participants: request, accept, decrypt, and reply paths verified on live Tor hidden services.', ], - callToAction: 'TOGGLE TELEGRAM OSINT IN DATA LAYERS', + callToAction: 'MESH CHAT → DIRECT → SHARE SHORT ADDRESS', }, { - icon: , - accent: 'purple' as const, - title: 'Osiris-Derived Intel Ports (Recon, SCM, Entity Graph)', - subtitle: - 'Server-side recon toolkit, supply-chain risk overlay, entity relationship graphs, malware/C2 hotspots, CISA KEV cyber feed, sanctions index, and submarine cable routes — all SSRF-guarded and local-operator proxied.', - details: [ - 'Recon Toolkit panel: IP geolocation, DNS, WHOIS, certs, BGP/ASN, OFAC sanctions, CVE, MAC vendor, GitHub profile, breach checks, and InternetDB subnet sweeps.', - 'SCM panel cross-references Tier 1/2 fabs (TSMC, Samsung, CATL, etc.) against earthquakes, wildfires, and GDELT conflict proximity.', - 'Entity Graph expands aircraft, vessels, companies, persons, IPs, and countries via Wikidata + OFAC + live telemetry store. Attribution: `backend/third_party/osiris/NOTICE.md`.', - 'Malware C2 (abuse.ch Feodo + URLhaus) and Cyber Threats (CISA KEV) layers opt-in on the slow tier. Submarine cables overlay from static TeleGeography-derived GeoJSON.', - ], - callToAction: 'OPEN RECON • SCM • ENTITY GRAPH IN LEFT SIDEBAR', - }, - { - icon: , + icon: , accent: 'cyan' as const, - title: 'OpenClaw Agent — Full Telemetry + Recon Parity', + title: 'Infonet Transport Hardening', subtitle: - 'AI agents on the HMAC command channel now search, slice, and investigate the same data the operator sees — including Telegram, malware, cyber, SCM, and the full recon toolkit.', + 'Tor/Arti warmup, SOCKS readiness, and terminal session lifecycle fixes so sovereign nodes actually join the mesh instead of sitting on NODE ARTI WARMING.', details: [ - '`search_telemetry` and `search_news` index Telegram OSINT posts alongside news, GDELT, and CrowdThreat. `get_slow_telemetry` and `get_layer_slice` include `telegram_osint`, `malware_threats`, `cyber_threats`, and `scm_suppliers`.', - 'New commands: `osint_lookup` (IP/DNS/WHOIS/sanctions/CVE/etc.), `entity_expand` (relationship graph), `osint_tools` (discovery), and `osint_sweep` (subnet scan — full access tier).', - 'Layer aliases: `telegram`, `malware`/`botnet`, `cyber`/`cisa`/`kev`, `scm`/`suppliers`, `gfw`/`fishing`. Skill package: `openclaw-skills/shadowbroker/SKILL.md`.', + 'Tor hidden service config always exposes SOCKS; readiness probes cache and recover wedged transports instead of blocking wormhole sync indefinitely.', + 'Leaving the Infonet terminal now tears down wormhole prep, leaves the session, and stops Tor when the UI enabled it — no ghost connections after close.', + 'Network stats distinguish real transport warmup from stale sync backoff so operators see actionable status instead of a permanent warming spinner.', ], - callToAction: 'AI INTEL PANEL → CONNECT AGENT → COPY HMAC SECRET', + callToAction: 'TOP RIGHT → ENTER INFONET → CHECK NODE STATUS', }, ]; const NEW_FEATURES = [ { - icon: , - title: 'Global Fishing Watch in Settings', - desc: 'GFW API token exposed in onboarding and Settings → Maritime. Fishing activity layer backed by GFW when `GFW_API_TOKEN` is configured.', + icon: , + title: 'Gate Swarm Replication', + desc: 'Participant nodes push and pull gate hashchain segments so encrypted room history converges across the fleet without a central relay.', }, { - icon: , - title: 'Supply-Chain Risk Map Layer', - desc: 'SCM suppliers render as map markers with seismic, wildfire, and conflict proximity scoring. Panel alerts for CRITICAL/HIGH fabs.', + icon: , + title: 'DM Contact Requests', + desc: 'Pending inbound/outbound access requests with approve, deny, and scoped per-node DM state — no cross-identity leakage in local storage.', }, { - icon: , - title: 'Malware C2 + CISA KEV Overlays', - desc: 'abuse.ch botnet C2 and URLhaus distribution URLs geolocated by country; CISA Known Exploited Vulnerabilities surfaced in cyber threats feed and slow-tier payload.', + icon: , + title: 'Wormhole Session Teardown', + desc: 'Closing the Infonet terminal aborts in-flight wormhole prep, leaves the lane, and resets launcher busy state for clean re-entry.', }, { - icon: , - title: 'OpenClaw Compact Search Path', - desc: 'Agents prefer `get_summary` → SSE `layer_changed` → `get_layer_slice` with per-layer versions. `brief_area`, `correlate_entity`, and `entities_near` include Telegram and malware context.', - }, - { - icon: , - title: 'Submarine Cable Overlay', - desc: 'Opt-in undersea cable routes from static TeleGeography-derived GeoJSON for infrastructure context on the map.', + icon: , + title: 'Fail-Closed Tor Proof', + desc: 'Onion sync waits for a working SOCKS handshake before declaring transport ready — prevents silent half-open mesh joins.', }, ]; const BUG_FIXES = [ - 'Telegram map pins are now HTML markers above threat-alert overlays — pins are clickable even when sharing a city grid with news intercept boxes.', - 'Telegram geocoding uses metro anchors (not national centroids) and a small NE offset only when news alerts share the same city cell — pins stay on land.', - 'Hourly Telegram scheduler with incremental post merge — no redundant full-channel re-scrape every cycle.', - 'OpenClaw `get_slow_telemetry` previously omitted telegram_osint, malware_threats, cyber_threats, and scm_suppliers — now included in slow-tier and universal search.', - 'OpenClaw agents can invoke the Recon panel backends via `osint_lookup` without raw `/api/osint/*` HTTP calls or local-operator browser auth.', + 'Arti/Tor transport no longer omits SocksPort when MESH_ARTI_ENABLED — SOCKS probes succeed and wormhole sync can start.', + 'Concurrent Arti readiness checks no longer wedge Tor under load; single-flight probes with auto-recycle when SOCKS stalls.', + 'Infonet terminal exit no longer leaves background wormhole prep or terminalLaunchBusy stuck after close.', + 'Stale onion sync backoff clears when transport recovers so NODE ARTI WARMING does not persist after Tor is healthy.', + 'DM decrypt timeouts on multi-participant fleets addressed via improved peer push timing and mailbox claim sequencing.', ]; type ChangelogContributor = { @@ -109,8 +100,8 @@ type ChangelogContributor = { const CONTRIBUTORS: ChangelogContributor[] = [ { - name: 'OSIRIS (simplifaisoul/osiris)', - desc: 'MIT-licensed recon stack — adapted for ShadowBroker proxy model (see backend/third_party/osiris/NOTICE.md)', + name: 'privacy-core (MLS)', + desc: 'Rust MLS gate crypto — WASM/FFI path for browser and Tauri sovereign shells', }, ]; @@ -228,41 +219,18 @@ const ChangelogModal = React.memo(function ChangelogModal({ onClose }: Changelog ); })} - {/* Auto-update note for v0.9.81+ installs */} + {/* Auto-update note for v0.9.82+ installs */}
- One-click update from v0.9.81 + One-click update from v0.9.82
- If you installed v0.9.81, the in-app Update button verifies this release via the - signed Tauri updater (`latest.json` + minisign). Desktop installs on v0.9.81 or - later should auto-apply v0.9.82 without a manual MSI hop. -
-
-
- - {/* Required-config callout: OpenSky API */} -
- -
-
- Required: OpenSky API credentials for airplane telemetry -
-
- Set OPENSKY_CLIENT_ID and{' '} - OPENSKY_CLIENT_SECRET in your{' '} - .env. Free registration:{' '} - - opensky-network.org/register - - . + If you installed v0.9.82, the in-app Update button verifies this release via the + signed Tauri updater (`latest.json` + minisign). Desktop installs on v0.9.82 or + later should auto-apply v0.9.83 without a manual MSI hop once the release is + published.
diff --git a/frontend/src/components/InfonetTerminal/NetworkStats.tsx b/frontend/src/components/InfonetTerminal/NetworkStats.tsx index 44f266b..35b7be4 100644 --- a/frontend/src/components/InfonetTerminal/NetworkStats.tsx +++ b/frontend/src/components/InfonetTerminal/NetworkStats.tsx @@ -14,13 +14,26 @@ interface Stats { nodeEnabled: boolean; syncOutcome: string; syncError: string; + artiReady: boolean | null; } const EMPTY: Stats = { meshtastic: 0, aprs: 0, ledgerNodes: 0, infonetEvents: 0, syncPeers: 0, seedPeers: 0, nodeEnabled: false, syncOutcome: 'offline', syncError: '', + artiReady: null, }; +function isArtiTransportBlocked(syncError: string, artiReady: boolean | null): boolean { + if (artiReady === true) return false; + if (artiReady === false) return true; + const lower = syncError.toLowerCase(); + return ( + lower.includes('ready arti transport') + || lower.includes('require arti to be enabled') + || lower.includes('onion peer requests require a ready arti') + ); +} + export default function NetworkStats() { const [stats, setStats] = useState(EMPTY); @@ -28,10 +41,11 @@ export default function NetworkStats() { let alive = true; const poll = async () => { try { - const [meshRes, channelsRes, infonet] = await Promise.all([ + const [meshRes, channelsRes, infonet, wormholeRes] = await Promise.all([ fetch(`${API_BASE}/api/mesh/status`).then(r => r.ok ? r.json() : null).catch(() => null), fetch(`${API_BASE}/api/mesh/channels`).then(r => r.ok ? r.json() : null).catch(() => null), fetchInfonetNodeStatusSnapshot(true).catch(() => null), + fetch(`${API_BASE}/api/wormhole/status`).then(r => r.ok ? r.json() : null).catch(() => null), ]); if (!alive) return; const authorNodes = Number(infonet?.author_nodes ?? infonet?.known_nodes ?? 0); @@ -43,6 +57,7 @@ export default function NetworkStats() { ?? 0, ); const syncOutcome = String(infonet?.sync_runtime?.last_outcome || 'offline').toLowerCase(); + const artiReady = typeof wormholeRes?.arti_ready === 'boolean' ? wormholeRes.arti_ready : null; setStats({ meshtastic: Number(channelsRes?.total_live || channelsRes?.total_nodes || meshRes?.signal_counts?.meshtastic || 0), aprs: Number(meshRes?.signal_counts?.aprs || 0), @@ -53,6 +68,7 @@ export default function NetworkStats() { nodeEnabled: Boolean(infonet?.node_enabled), syncOutcome, syncError: String(infonet?.sync_runtime?.last_error || '').trim(), + artiReady, }); } catch { /* ignore */ } }; @@ -61,8 +77,7 @@ export default function NetworkStats() { return () => { alive = false; clearInterval(interval); }; }, []); - const syncErrorLower = stats.syncError.toLowerCase(); - const artiBlocked = syncErrorLower.includes('arti') || syncErrorLower.includes('onion'); + const artiBlocked = isArtiTransportBlocked(stats.syncError, stats.artiReady); const nodeColor = stats.syncOutcome === 'ok' || stats.syncOutcome === 'solo' ? 'text-green-400' : stats.syncOutcome === 'running' ? 'text-amber-400' : stats.nodeEnabled ? 'text-amber-400' : 'text-gray-600'; diff --git a/frontend/src/components/InfonetTerminal/index.tsx b/frontend/src/components/InfonetTerminal/index.tsx index 6e500b6..bea325a 100644 --- a/frontend/src/components/InfonetTerminal/index.tsx +++ b/frontend/src/components/InfonetTerminal/index.tsx @@ -3,9 +3,7 @@ import React, { useEffect } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import { X } from 'lucide-react'; -import { - ensureInfonetParticipantNodeReady, -} from '@/mesh/controlPlaneStatusClient'; +import { beginInfonetTerminalSession } from '@/lib/infonetTerminalSession'; import InfonetShell from './InfonetShell'; interface InfonetTerminalProps { @@ -38,7 +36,7 @@ export default function InfonetTerminal({ const connectParticipantNode = async () => { try { if (cancelled) return; - await ensureInfonetParticipantNodeReady(); + await beginInfonetTerminalSession(); } catch { // Remote/shared viewers may not have local-operator rights. Leave manual controls intact. } diff --git a/frontend/src/components/MeshChat/InfonetTerminalPanel.tsx b/frontend/src/components/MeshChat/InfonetTerminalPanel.tsx index d376e86..1e386a9 100644 --- a/frontend/src/components/MeshChat/InfonetTerminalPanel.tsx +++ b/frontend/src/components/MeshChat/InfonetTerminalPanel.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Shield } from 'lucide-react'; -import { ensureInfonetParticipantNodeReady } from '@/mesh/controlPlaneStatusClient'; +import { beginInfonetTerminalSession } from '@/lib/infonetTerminalSession'; import InfonetShell from '@/components/InfonetTerminal/InfonetShell'; type Props = { @@ -98,7 +98,7 @@ export default function InfonetTerminalPanel({ const connectParticipantNode = async () => { try { if (cancelled) return; - await ensureInfonetParticipantNodeReady(); + await beginInfonetTerminalSession(); } catch { // Remote viewers may not have local-operator rights. } diff --git a/frontend/src/components/MeshChat/index.tsx b/frontend/src/components/MeshChat/index.tsx index 8badfe8..6619eba 100644 --- a/frontend/src/components/MeshChat/index.tsx +++ b/frontend/src/components/MeshChat/index.tsx @@ -11,8 +11,7 @@ import { SHELL_FLYOUT_WIDTH, type MeshChatFlyoutRect, } from './meshChatFlyout'; -import { fetchWormholeState, leaveWormhole } from '@/mesh/wormholeClient'; -import { teardownWormholeOnClose } from '@/lib/wormholeTeardown'; +import { endInfonetTerminalSession } from '@/lib/infonetTerminalSession'; import { motion, AnimatePresence } from 'framer-motion'; import { Antenna, @@ -439,7 +438,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) { }, []); const handleInfonetTeardown = useCallback(() => { - void teardownWormholeOnClose(fetchWormholeState, leaveWormhole); + void endInfonetTerminalSession(); }, []); const panelFlyout = diff --git a/frontend/src/components/StartupWarmupModal.tsx b/frontend/src/components/StartupWarmupModal.tsx index 4eecd1e..b02eaa8 100644 --- a/frontend/src/components/StartupWarmupModal.tsx +++ b/frontend/src/components/StartupWarmupModal.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Database, Clock, X } from 'lucide-react'; -const CURRENT_VERSION = '0.9.82'; +const CURRENT_VERSION = '0.9.83'; const STORAGE_KEY = `shadowbroker_startup_warmup_notice_v${CURRENT_VERSION}`; interface StartupWarmupModalProps { diff --git a/frontend/src/components/TopRightControls.tsx b/frontend/src/components/TopRightControls.tsx index 0b97c62..0d63e21 100644 --- a/frontend/src/components/TopRightControls.tsx +++ b/frontend/src/components/TopRightControls.tsx @@ -30,6 +30,7 @@ import { import { requestMeshTerminalOpen, subscribeSecureMeshTerminalLauncherOpen, + subscribeInfonetSessionEnd, } from '@/lib/meshTerminalLauncher'; import { purgeBrowserContactGraph, purgeBrowserSigningMaterial, setSecureModeCached, getNodeIdentity, generateNodeKeys } from '@/mesh/meshIdentity'; import { purgeBrowserDmState } from '@/mesh/meshDmWorkerClient'; @@ -42,6 +43,7 @@ import { import { fetchWormholeStatus, prepareWormholeInteractiveLane, + isWormholePrepAbortedError, } from '@/mesh/wormholeIdentityClient'; import { fetchWormholeSettings } from '@/mesh/wormholeClient'; import packageJson from '../../package.json'; @@ -172,8 +174,15 @@ export default function TopRightControls({ }); }, [openTerminalLauncher]); + useEffect(() => { + return subscribeInfonetSessionEnd(() => { + setTerminalLaunchBusy(false); + setTerminalLaunchError(''); + }); + }, []); + const closeTerminalLauncher = () => { - if (terminalLaunchBusy) return; + setTerminalLaunchBusy(false); setTerminalLauncherOpen(false); setTerminalLaunchError(''); }; @@ -215,6 +224,9 @@ export default function TopRightControls({ console.info('[top-right] Wormhole terminal launch ready', identityNodeId); } } catch (error) { + if (isWormholePrepAbortedError(error)) { + return; + } const message = typeof error === 'object' && error !== null && 'message' in error ? String((error as { message?: string }).message || '') diff --git a/frontend/src/lib/infonetTerminalSession.ts b/frontend/src/lib/infonetTerminalSession.ts new file mode 100644 index 0000000..c2b5c4f --- /dev/null +++ b/frontend/src/lib/infonetTerminalSession.ts @@ -0,0 +1,81 @@ +/** + * Infonet terminal session lifecycle. + * Wormhole, Tor, and participant-node sync should only run while the terminal is open. + */ +import { + fetchNodeSettingsSnapshot, + setInfonetNodeEnabled, + startTorHiddenService, + stopTorHiddenService, + joinInfonetSwarm, + fetchInfonetNodeStatusSnapshot, +} from '@/mesh/controlPlaneStatusClient'; +import { generateNodeKeys, getNodeIdentity, setSecureModeCached } from '@/mesh/meshIdentity'; +import { fetchWormholeSettings, leaveWormhole, fetchWormholeState } from '@/mesh/wormholeClient'; +import { + abortWormholeInteractivePrep, + isWormholePrepAbortedError, +} from '@/mesh/wormholeIdentityClient'; +import { notifyInfonetSessionEnd } from '@/lib/meshTerminalLauncher'; + +let nodeWasEnabledBeforeSession = false; +let sessionEndInFlight: Promise | null = null; + +export async function beginInfonetTerminalSession(): Promise { + try { + const before = await fetchNodeSettingsSnapshot().catch(() => null); + nodeWasEnabledBeforeSession = Boolean(before?.enabled); + if (!getNodeIdentity()) { + await generateNodeKeys().catch(() => null); + } + await startTorHiddenService().catch(() => null); + if (!nodeWasEnabledBeforeSession) { + await setInfonetNodeEnabled(true); + } + await joinInfonetSwarm().catch(() => null); + await fetchInfonetNodeStatusSnapshot(true).catch(() => null); + } catch { + // Remote viewers may not have local-operator rights. + } +} + +export async function endInfonetTerminalSession(): Promise { + if (sessionEndInFlight) { + return sessionEndInFlight; + } + sessionEndInFlight = (async () => { + abortWormholeInteractivePrep(); + try { + const [settings, state] = await Promise.all([ + fetchWormholeSettings(false).catch(() => null), + fetchWormholeState(false).catch(() => null), + ]); + const wormholeActive = Boolean( + settings?.enabled + || state?.configured + || state?.running + || state?.ready, + ); + if (wormholeActive) { + await leaveWormhole().catch(() => null); + } + if (!nodeWasEnabledBeforeSession) { + await setInfonetNodeEnabled(false).catch(() => null); + } + await stopTorHiddenService().catch(() => null); + setSecureModeCached(false); + nodeWasEnabledBeforeSession = false; + } catch { + /* best-effort teardown */ + } finally { + notifyInfonetSessionEnd(); + } + })(); + try { + await sessionEndInFlight; + } finally { + sessionEndInFlight = null; + } +} + +export { isWormholePrepAbortedError }; diff --git a/frontend/src/lib/meshTerminalLauncher.ts b/frontend/src/lib/meshTerminalLauncher.ts index d77acd6..7b04806 100644 --- a/frontend/src/lib/meshTerminalLauncher.ts +++ b/frontend/src/lib/meshTerminalLauncher.ts @@ -1,5 +1,6 @@ const MESH_TERMINAL_OPEN_EVENT = 'oracle:open-mesh-terminal'; const SECURE_MESH_TERMINAL_LAUNCHER_EVENT = 'oracle:open-secure-mesh-terminal-launcher'; +const INFONET_SESSION_END_EVENT = 'oracle:infonet-session-end'; export function requestMeshTerminalOpen(source = 'ui'): void { if (typeof window === 'undefined') return; @@ -38,3 +39,22 @@ export function subscribeSecureMeshTerminalLauncherOpen(handler: () => void): () window.addEventListener(SECURE_MESH_TERMINAL_LAUNCHER_EVENT, listener); return () => window.removeEventListener(SECURE_MESH_TERMINAL_LAUNCHER_EVENT, listener); } + +export function notifyInfonetSessionEnd(): void { + if (typeof window === 'undefined') return; + window.dispatchEvent( + new CustomEvent(INFONET_SESSION_END_EVENT, { + detail: { at: Date.now() }, + }), + ); +} + +export function subscribeInfonetSessionEnd(handler: () => void): () => void { + if (typeof window === 'undefined') { + return () => {}; + } + + const listener = () => handler(); + window.addEventListener(INFONET_SESSION_END_EVENT, listener); + return () => window.removeEventListener(INFONET_SESSION_END_EVENT, listener); +} diff --git a/frontend/src/lib/wormholeTeardown.ts b/frontend/src/lib/wormholeTeardown.ts index ce5ca82..5a3904f 100644 --- a/frontend/src/lib/wormholeTeardown.ts +++ b/frontend/src/lib/wormholeTeardown.ts @@ -1,17 +1,12 @@ /** * Wormhole teardown logic extracted from InfonetTerminal close handler. - * Shuts down Wormhole when the terminal closes so it doesn't stay running. + * Delegates to the terminal session lifecycle so background prep is aborted too. */ +import { endInfonetTerminalSession } from '@/lib/infonetTerminalSession'; + export async function teardownWormholeOnClose( - fetchState: (force: boolean) => Promise<{ ready?: boolean; running?: boolean } | null>, - leave: () => Promise, + _fetchState?: (force: boolean) => Promise<{ ready?: boolean; running?: boolean } | null>, + _leave?: () => Promise, ): Promise { - try { - const s = await fetchState(false); - if (s?.ready || s?.running) { - await leave(); - } - } catch { - /* ignore — best-effort teardown */ - } + await endInfonetTerminalSession(); } diff --git a/frontend/src/mesh/controlPlaneStatusClient.ts b/frontend/src/mesh/controlPlaneStatusClient.ts index 837aacf..6490cbb 100644 --- a/frontend/src/mesh/controlPlaneStatusClient.ts +++ b/frontend/src/mesh/controlPlaneStatusClient.ts @@ -249,6 +249,13 @@ export async function startTorHiddenService(): Promise }); } +export async function stopTorHiddenService(): Promise { + return controlPlaneJson('/api/settings/tor/stop', { + method: 'POST', + requireAdminSession: false, + }); +} + export interface InfonetSwarmJoinSnapshot { ok?: boolean; detail?: string; diff --git a/frontend/src/mesh/wormholeIdentityClient.ts b/frontend/src/mesh/wormholeIdentityClient.ts index cb25e33..590765c 100644 --- a/frontend/src/mesh/wormholeIdentityClient.ts +++ b/frontend/src/mesh/wormholeIdentityClient.ts @@ -51,6 +51,30 @@ const WORMHOLE_TRANSPORT_TIER_ORDER: Record = { private_strong: 3, }; const wormholeInteractivePrepInflight = new Map>(); +let wormholePrepGeneration = 0; + +export class WormholePrepAbortedError extends Error { + constructor() { + super('wormhole_prep_aborted'); + this.name = 'WormholePrepAbortedError'; + } +} + +export function isWormholePrepAbortedError(error: unknown): boolean { + return error instanceof WormholePrepAbortedError + || (error instanceof Error && error.message === 'wormhole_prep_aborted'); +} + +export function abortWormholeInteractivePrep(): void { + wormholePrepGeneration += 1; + wormholeInteractivePrepInflight.clear(); +} + +function assertWormholePrepActive(generation: number): void { + if (generation !== wormholePrepGeneration) { + throw new WormholePrepAbortedError(); + } +} export interface WormholeIdentity { bootstrapped: boolean; @@ -875,13 +899,16 @@ export async function prepareWormholeInteractiveLane( return existingInflight; } const prepTask = (async (): Promise => { + const generation = wormholePrepGeneration; const timeoutMs = Math.max( GATE_LIFECYCLE_PREP_POLL_MS, Number(options.timeoutMs || GATE_LIFECYCLE_PREP_TIMEOUT_MS), ); + assertWormholePrepActive(generation); let runtime = await fetchWormholeState(true).catch(() => null); let settings = await fetchWormholeSettings(true).catch(() => null); if (!runtime?.ready) { + assertWormholePrepActive(generation); if (settings?.enabled || runtime?.configured) { runtime = await connectWormhole({ requireAdminSession: false }).catch((error) => { throw new Error( @@ -908,9 +935,11 @@ export async function prepareWormholeInteractiveLane( Date.now() < deadline && (!runtime?.ready || !wormholeTransportTierSatisfied(transportTierFromRuntime(runtime), minimumTransportTier)) ) { + assertWormholePrepActive(generation); await sleep(GATE_LIFECYCLE_PREP_POLL_MS); runtime = await fetchWormholeState(true).catch(() => null); } + assertWormholePrepActive(generation); const resolvedTransportTier = transportTierFromRuntime(runtime); if (!runtime?.ready || !wormholeTransportTierSatisfied(resolvedTransportTier, minimumTransportTier)) { throw new Error('Wormhole is still warming up in the background.'); diff --git a/pyproject.toml b/pyproject.toml index a6433fe..3a2416b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shadowbroker" -version = "0.9.82" +version = "0.9.83" readme = "README.md" requires-python = ">=3.10" dependencies = [] diff --git a/scripts/probe_extra_prekey.sh b/scripts/probe_extra_prekey.sh new file mode 100644 index 0000000..16d96db --- /dev/null +++ b/scripts/probe_extra_prekey.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +AK="$(docker exec shadowbroker-backend printenv ADMIN_KEY)" +INV="$(curl -s -H "X-Admin-Key: ${AK}" "http://127.0.0.1:8000/api/wormhole/dm/invite?label=probe")" +echo "invite=${INV:0:200}" +HANDLE="$(python3 -c 'import json,sys; d=json.load(sys.stdin); print((d.get("invite") or {}).get("payload", {}).get("prekey_lookup_handle", ""))' <<<"${INV}")" +echo "handle=${HANDLE}" +curl -s "http://127.0.0.1:8000/api/mesh/dm/prekey-bundle?lookup_token=${HANDLE}" | head -c 400 +echo