mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-22 14:00:06 +02:00
Ship ShadowBroker v0.9.83 with live Infonet gate messaging and DM protocols.
Gate hashchain replication, Tor/SOCKS transport hardening, terminal session teardown, v0.9.83 UI/changelog, and release digest pins for seamless updater verification.
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+49
-8
@@ -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]:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
Generated
+2
-2
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+1
-1
@@ -4201,7 +4201,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shadowbroker-tauri-shell"
|
||||
version = "0.9.82"
|
||||
version = "0.9.83"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shadowbroker-tauri-shell"
|
||||
version = "0.9.82"
|
||||
version = "0.9.83"
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.9.82",
|
||||
"version": "0.9.83",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev-all.cjs",
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
let leave: ReturnType<typeof vi.fn>;
|
||||
|
||||
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);
|
||||
|
||||
@@ -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('<InfonetTerminal'),
|
||||
page.indexOf('</InfonetTerminal>') !== -1
|
||||
? page.indexOf('</InfonetTerminal>')
|
||||
: page.indexOf('/>', page.indexOf('<InfonetTerminal')) + 2,
|
||||
);
|
||||
expect(infonetSection).toContain('teardownWormholeOnClose');
|
||||
expect(infonetSection).toContain('fetchWormholeState');
|
||||
expect(infonetSection).toContain('leaveWormhole');
|
||||
expect(infonetSection).toContain('endInfonetTerminalSession');
|
||||
});
|
||||
|
||||
it('page.tsx imports teardownWormholeOnClose from wormholeTeardown', () => {
|
||||
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['"]/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ActiveLayers>({
|
||||
@@ -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}
|
||||
|
||||
@@ -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: <Lock size={20} className="text-purple-400" />,
|
||||
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: <MessageSquare size={20} className="text-cyan-400" />,
|
||||
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: <Radar size={20} className="text-purple-400" />,
|
||||
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: <Bot size={20} className="text-amber-400" />,
|
||||
icon: <Network size={20} className="text-amber-400" />,
|
||||
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: <Anchor size={18} className="text-cyan-400" />,
|
||||
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: <Users size={18} className="text-purple-400" />,
|
||||
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: <Factory size={18} className="text-orange-400" />,
|
||||
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: <KeyRound size={18} className="text-cyan-400" />,
|
||||
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: <Shield size={18} className="text-red-400" />,
|
||||
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: <Radio size={18} className="text-green-400" />,
|
||||
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: <Search size={18} className="text-green-400" />,
|
||||
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: <Network size={18} className="text-purple-400" />,
|
||||
title: 'Submarine Cable Overlay',
|
||||
desc: 'Opt-in undersea cable routes from static TeleGeography-derived GeoJSON for infrastructure context on the map.',
|
||||
icon: <Shield size={18} className="text-amber-400" />,
|
||||
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 */}
|
||||
<div className="border border-green-500/30 bg-green-950/15 p-3 flex items-start gap-3">
|
||||
<KeyRound size={18} className="text-green-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-mono text-green-300 font-bold tracking-wide uppercase">
|
||||
One-click update from v0.9.81
|
||||
One-click update from v0.9.82
|
||||
</div>
|
||||
<div className="text-xs font-mono text-green-200/80 leading-relaxed">
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Required-config callout: OpenSky API */}
|
||||
<div className="border border-amber-500/40 bg-amber-950/20 p-3 flex items-start gap-3">
|
||||
<Plane size={18} className="text-amber-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-mono text-amber-300 font-bold tracking-wide uppercase">
|
||||
Required: OpenSky API credentials for airplane telemetry
|
||||
</div>
|
||||
<div className="text-xs font-mono text-amber-200/80 leading-relaxed">
|
||||
Set <span className="text-amber-100 font-bold">OPENSKY_CLIENT_ID</span> and{' '}
|
||||
<span className="text-amber-100 font-bold">OPENSKY_CLIENT_SECRET</span> in your{' '}
|
||||
<span className="text-amber-100 font-bold">.env</span>. Free registration:{' '}
|
||||
<a
|
||||
href="https://opensky-network.org/index.php?option=com_users&view=registration"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-amber-100 font-bold underline underline-offset-2 hover:text-amber-50"
|
||||
>
|
||||
opensky-network.org/register
|
||||
</a>
|
||||
.
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<Stats>(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';
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 || '')
|
||||
|
||||
@@ -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<void> | null = null;
|
||||
|
||||
export async function beginInfonetTerminalSession(): Promise<void> {
|
||||
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<void> {
|
||||
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 };
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<unknown>,
|
||||
_fetchState?: (force: boolean) => Promise<{ ready?: boolean; running?: boolean } | null>,
|
||||
_leave?: () => Promise<unknown>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const s = await fetchState(false);
|
||||
if (s?.ready || s?.running) {
|
||||
await leave();
|
||||
}
|
||||
} catch {
|
||||
/* ignore — best-effort teardown */
|
||||
}
|
||||
await endInfonetTerminalSession();
|
||||
}
|
||||
|
||||
@@ -249,6 +249,13 @@ export async function startTorHiddenService(): Promise<TorHiddenServiceSnapshot>
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopTorHiddenService(): Promise<TorHiddenServiceSnapshot> {
|
||||
return controlPlaneJson<TorHiddenServiceSnapshot>('/api/settings/tor/stop', {
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
});
|
||||
}
|
||||
|
||||
export interface InfonetSwarmJoinSnapshot {
|
||||
ok?: boolean;
|
||||
detail?: string;
|
||||
|
||||
@@ -51,6 +51,30 @@ const WORMHOLE_TRANSPORT_TIER_ORDER: Record<string, number> = {
|
||||
private_strong: 3,
|
||||
};
|
||||
const wormholeInteractivePrepInflight = new Map<string, Promise<PreparedWormholeInteractiveLane>>();
|
||||
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<PreparedWormholeInteractiveLane> => {
|
||||
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.');
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "shadowbroker"
|
||||
version = "0.9.82"
|
||||
version = "0.9.83"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = []
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user