Skip to content

Commit 5ede669

Browse files
committed
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.
1 parent 8fcb012 commit 5ede669

35 files changed

Lines changed: 586 additions & 260 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ frontend/eslint-report.json
177177
.git_backup/
178178
local-artifacts/
179179
release-secrets/
180+
release-staging/
181+
.tmp-release-inspect/
180182
shadowbroker_repo/
181183
frontend/src/components.bak/
182184
frontend/src/components/map/icons/backups/

backend/data/release_digests.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,10 @@
5151
"ShadowBroker_v0.9.82.zip": "202ab043465741dcc06de57c19ec8314904332f8e818b891d7174655719d084c",
5252
"ShadowBroker_0.9.82_x64-setup.exe": "0eb9f2bda02ab691b39687641abc97e6bfb507b42f48de21970ad7dfb4ea15fc",
5353
"ShadowBroker_0.9.82_x64_en-US.msi": "ced08f930171c0c08009a958cc30b0171a09f982230fc217c6808c2ed7ab2e30"
54+
},
55+
"v0.9.83": {
56+
"ShadowBroker_v0.9.83.zip": "53f56631731ad3cdc7be68df09bedd6570ed91ecda6fa57c39651098e15666c7",
57+
"ShadowBroker_0.9.83_x64-setup.exe": "d62170af4b9df0b190832b7bb3ad6bfe8a7ac01472f2c7b39cf2a1b61edc7492",
58+
"ShadowBroker_0.9.83_x64_en-US.msi": "b664cc0003a29f7ce88b04c2b425643dbe7ed897342fc6e9a2378bc1910c6850"
5459
}
5560
}

backend/main.py

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1247,6 +1247,26 @@ def _local_infonet_peer_url() -> str:
12471247
return ""
12481248

12491249

1250+
def _clear_stale_arti_sync_backoff() -> None:
1251+
"""Drop cached Arti warmup errors once SOCKS transport is actually ready."""
1252+
from dataclasses import replace
1253+
1254+
with _NODE_RUNTIME_LOCK:
1255+
current = get_sync_state()
1256+
error_lower = str(current.last_error or "").lower()
1257+
if "arti" not in error_lower and "onion sync requires" not in error_lower:
1258+
return
1259+
set_sync_state(
1260+
replace(
1261+
current,
1262+
last_error="",
1263+
consecutive_failures=0,
1264+
next_sync_due_at=int(time.time()),
1265+
last_outcome="idle" if current.last_outcome == "error" else current.last_outcome,
1266+
)
1267+
)
1268+
1269+
12501270
def _ensure_infonet_private_transport_ready(reason: str = "") -> bool:
12511271
"""Warm the local onion transport before private Infonet sync.
12521272
@@ -1275,15 +1295,36 @@ def _ensure_infonet_private_transport_ready(reason: str = "") -> bool:
12751295

12761296
label = f" ({reason})" if reason else ""
12771297
logger.info("Infonet private transport warmup starting%s", label)
1278-
tor_result = tor_service.start(target_port=8000)
1279-
if tor_result.get("ok"):
1298+
from services.wormhole_supervisor import invalidate_arti_ready_cache
1299+
1300+
for attempt in range(3):
1301+
tor_result = tor_service.start(target_port=8000)
1302+
if not tor_result.get("ok"):
1303+
logger.warning(
1304+
"Infonet private transport warmup incomplete%s: %s",
1305+
label,
1306+
tor_result,
1307+
)
1308+
continue
12801309
_write_env_value("MESH_ARTI_ENABLED", "true")
12811310
get_settings.cache_clear()
1282-
if _check_arti_ready():
1283-
logger.info("Infonet private transport ready%s", label)
1284-
threading.Thread(target=_swarm_bootstrap_after_transport_ready, daemon=True).start()
1285-
return True
1286-
logger.warning("Infonet private transport warmup incomplete%s: %s", label, tor_result)
1311+
invalidate_arti_ready_cache()
1312+
deadline = time.monotonic() + 30.0
1313+
while time.monotonic() < deadline:
1314+
if _check_arti_ready(force=True):
1315+
logger.info("Infonet private transport ready%s", label)
1316+
_clear_stale_arti_sync_backoff()
1317+
threading.Thread(target=_swarm_bootstrap_after_transport_ready, daemon=True).start()
1318+
_kick_public_sync_background(f"transport_ready{label}")
1319+
return True
1320+
time.sleep(1.0)
1321+
logger.warning(
1322+
"Infonet private transport SOCKS not ready after Tor start (attempt %d/3)%s",
1323+
attempt + 1,
1324+
label,
1325+
)
1326+
tor_service.stop()
1327+
logger.warning("Infonet private transport warmup incomplete%s", label)
12871328
return False
12881329
except Exception as exc:
12891330
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):
1170411745
return {"ok": False, "detail": str(exc)}
1170511746

1170611747

11707-
_WORMHOLE_PUBLIC_FIELDS = {"installed", "configured", "running", "ready"}
11748+
_WORMHOLE_PUBLIC_FIELDS = {"installed", "configured", "running", "ready", "arti_ready"}
1170811749

1170911750

1171011751
def _redact_wormhole_status(state: dict[str, Any], authenticated: bool) -> dict[str, Any]:

backend/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ py-modules = []
77

88
[project]
99
name = "backend"
10-
version = "0.9.82"
10+
version = "0.9.83"
1111
requires-python = ">=3.10"
1212
dependencies = [
1313
"apscheduler==3.10.3",

backend/routers/wormhole.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1348,7 +1348,7 @@ async def api_wormhole_dm_contact_sever(request: Request, peer_id: str):
13481348
return {"ok": False, "detail": str(exc)}
13491349

13501350

1351-
_WORMHOLE_PUBLIC_FIELDS = {"installed", "configured", "running", "ready"}
1351+
_WORMHOLE_PUBLIC_FIELDS = {"installed", "configured", "running", "ready", "arti_ready"}
13521352

13531353

13541354
def _redact_wormhole_status(state: dict[str, Any], authenticated: bool) -> dict[str, Any]:

backend/services/tor_hidden_service.py

Lines changed: 84 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,52 @@
3333
_STARTUP_TIMEOUT_S = 90
3434
_POLL_INTERVAL_S = 1.0
3535

36+
37+
def _arti_socks_port() -> int:
38+
from services.config import get_settings
39+
40+
return int(get_settings().MESH_ARTI_SOCKS_PORT or 9050)
41+
42+
43+
def _torrc_socks_line(socks_port: int) -> str:
44+
return f"SocksPort {socks_port}\n"
45+
46+
47+
def _torrc_has_socks_port(socks_port: int) -> bool:
48+
if not TORRC_PATH.exists():
49+
return False
50+
return _torrc_socks_line(socks_port) in TORRC_PATH.read_text(encoding="utf-8")
51+
52+
53+
def _local_socks_listening(socks_port: int) -> bool:
54+
return _local_socks_handshake_ready(socks_port, timeout=0.75)
55+
56+
57+
def _local_socks_handshake_ready(socks_port: int, *, timeout: float = 5.0) -> bool:
58+
import socket
59+
60+
try:
61+
with socket.create_connection(("127.0.0.1", socks_port), timeout=timeout) as sock:
62+
sock.settimeout(timeout)
63+
sock.sendall(b"\x05\x01\x00")
64+
return sock.recv(2) == b"\x05\x00"
65+
except OSError:
66+
return False
67+
68+
69+
def _write_torrc(*, target_port: int, socks_port: int) -> None:
70+
TOR_DIR.mkdir(parents=True, exist_ok=True)
71+
hidden_service_dir = TOR_DIR / "hidden_service"
72+
hidden_service_dir.mkdir(parents=True, exist_ok=True)
73+
torrc_content = (
74+
f"DataDirectory {TOR_DATA_DIR.as_posix()}\n"
75+
f"HiddenServiceDir {hidden_service_dir.as_posix()}\n"
76+
f"HiddenServicePort {target_port} 127.0.0.1:{target_port}\n"
77+
f"{_torrc_socks_line(socks_port)}"
78+
"Log notice stderr\n"
79+
)
80+
TORRC_PATH.write_text(torrc_content, encoding="utf-8")
81+
3682
# Windows x86_64 Tor Expert Bundle URLs. Keep a fallback so first-run
3783
# onboarding does not break when Tor rotates point releases.
3884
_TOR_EXPERT_BUNDLE_URLS = [
@@ -357,12 +403,28 @@ def status(self) -> dict:
357403
def start(self, target_port: int = 8000) -> dict:
358404
"""Start Tor hidden service pointing to target_port on localhost."""
359405
with self._lock:
406+
socks_port = _arti_socks_port()
360407
if self._running and self._process and self._process.poll() is None:
361-
return {
362-
"ok": True,
363-
"onion_address": self._onion_address,
364-
"detail": "already running",
365-
}
408+
if _torrc_has_socks_port(socks_port) and _local_socks_handshake_ready(socks_port, timeout=1.5):
409+
return {
410+
"ok": True,
411+
"onion_address": self._onion_address,
412+
"detail": "already running",
413+
}
414+
logger.info(
415+
"Tor is running without a ready SOCKS proxy on port %s — restarting",
416+
socks_port,
417+
)
418+
try:
419+
self._process.terminate()
420+
self._process.wait(timeout=10)
421+
except Exception:
422+
try:
423+
self._process.kill()
424+
except Exception:
425+
pass
426+
self._process = None
427+
self._running = False
366428

367429
self._error = ""
368430
tor_bin = _find_tor_binary()
@@ -388,20 +450,9 @@ def start(self, target_port: int = 8000) -> dict:
388450
except OSError:
389451
pass
390452

391-
from services.config import get_settings
392-
393-
settings = get_settings()
394-
socks_port_line = ""
395-
if not bool(getattr(settings, "MESH_ARTI_ENABLED", False)):
396-
socks_port_line = "SocksPort 9050\n"
397-
torrc_content = (
398-
f"DataDirectory {TOR_DATA_DIR.as_posix()}\n"
399-
f"HiddenServiceDir {hidden_service_dir.as_posix()}\n"
400-
f"HiddenServicePort {target_port} 127.0.0.1:{target_port}\n"
401-
f"{socks_port_line}"
402-
"Log notice stderr\n"
403-
)
404-
TORRC_PATH.write_text(torrc_content, encoding="utf-8")
453+
# Mesh "Arti" transport uses Tor's local SOCKS proxy for .onion peers.
454+
# Always publish SocksPort — MESH_ARTI_ENABLED only gates callers, not Tor.
455+
_write_torrc(target_port=target_port, socks_port=socks_port)
405456

406457
try:
407458
self._process = subprocess.Popen(
@@ -434,15 +485,23 @@ def start(self, target_port: int = 8000) -> dict:
434485
hostname = HOSTNAME_PATH.read_text().strip()
435486
if hostname.endswith(".onion"):
436487
self._onion_address = f"http://{hostname}:8000"
437-
logger.info("Tor hidden service ready: %s", self._onion_address)
438-
return {
439-
"ok": True,
440-
"onion_address": self._onion_address,
441-
}
488+
if _local_socks_handshake_ready(socks_port, timeout=3.0):
489+
logger.info(
490+
"Tor hidden service ready: %s (SOCKS %s)",
491+
self._onion_address,
492+
socks_port,
493+
)
494+
return {
495+
"ok": True,
496+
"onion_address": self._onion_address,
497+
}
442498

443499
time.sleep(_POLL_INTERVAL_S)
444500

445-
self._error = f"Tor did not generate hostname within {_STARTUP_TIMEOUT_S}s"
501+
self._error = (
502+
f"Tor did not publish a ready hidden service and SOCKS proxy "
503+
f"on port {socks_port} within {_STARTUP_TIMEOUT_S}s"
504+
)
446505
self.stop()
447506
return {"ok": False, "detail": self._error}
448507

backend/services/wormhole_supervisor.py

Lines changed: 93 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@
2727
_STATE_CACHE_TTL_S = 2.0
2828
_ARTI_PROOF_CACHE: dict[str, Any] = {"port": 0, "ok": False, "ts": 0.0}
2929
_ARTI_PROOF_CACHE_TTL_S = 30.0
30+
_ARTI_STATUS_CACHE: dict[str, Any] = {"port": 0, "ready": False, "ts": 0.0}
31+
_ARTI_STATUS_FAIL_TTL_S = 4.0
32+
_ARTI_PROBE_LOCK = threading.Lock()
33+
_ARTI_SOCKS_FAILURES = 0
34+
_ARTI_LAST_TOR_RECOVERY_TS = 0.0
35+
_ARTI_TOR_RECOVERY_COOLDOWN_S = 45.0
36+
_ARTI_SOCKS_CONNECT_TIMEOUT_S = 5.0
3037
_PRIVATE_CLEARNET_FALLBACK_WINDOW_S = 300.0
3138

3239
BACKEND_DIR = Path(__file__).resolve().parent.parent
@@ -70,16 +77,43 @@
7077
"PRIVACY_CORE_MIN_VERSION",
7178
}
7279

73-
def _check_arti_ready() -> bool:
74-
from services.config import get_settings
80+
def invalidate_arti_ready_cache() -> None:
81+
_ARTI_PROOF_CACHE.update({"port": 0, "ok": False, "ts": 0.0})
82+
_ARTI_STATUS_CACHE.update({"port": 0, "ready": False, "ts": 0.0})
7583

76-
settings = get_settings()
77-
if not bool(settings.MESH_ARTI_ENABLED):
78-
return False
79-
socks_port = int(settings.MESH_ARTI_SOCKS_PORT or 9050)
84+
85+
def _maybe_recover_tor_socks_transport(socks_port: int) -> None:
86+
global _ARTI_SOCKS_FAILURES, _ARTI_LAST_TOR_RECOVERY_TS
87+
88+
_ARTI_SOCKS_FAILURES += 1
89+
if _ARTI_SOCKS_FAILURES < 3:
90+
return
91+
now = time.time()
92+
if (now - _ARTI_LAST_TOR_RECOVERY_TS) < _ARTI_TOR_RECOVERY_COOLDOWN_S:
93+
return
94+
_ARTI_LAST_TOR_RECOVERY_TS = now
95+
_ARTI_SOCKS_FAILURES = 0
8096
try:
81-
with socket.create_connection((WORMHOLE_HOST, socks_port), timeout=2.0) as sock:
82-
# SOCKS5 greeting: version 5, 1 auth method, no-auth.
97+
from services.tor_hidden_service import tor_service
98+
99+
logger.warning(
100+
"Tor SOCKS on port %s is wedged — recycling Tor hidden service",
101+
socks_port,
102+
)
103+
tor_service.stop()
104+
tor_service.start(target_port=8000)
105+
invalidate_arti_ready_cache()
106+
except Exception as exc:
107+
logger.warning("Tor SOCKS recovery failed: %s", exc)
108+
109+
110+
def _probe_arti_socks_ready(socks_port: int) -> bool:
111+
try:
112+
with socket.create_connection(
113+
(WORMHOLE_HOST, socks_port),
114+
timeout=_ARTI_SOCKS_CONNECT_TIMEOUT_S,
115+
) as sock:
116+
sock.settimeout(_ARTI_SOCKS_CONNECT_TIMEOUT_S)
83117
sock.sendall(b"\x05\x01\x00")
84118
response = sock.recv(2)
85119
if response != b"\x05\x00":
@@ -88,6 +122,53 @@ def _check_arti_ready() -> bool:
88122
except Exception as exc:
89123
logger.warning("Arti SOCKS check failed on port %s: %s", socks_port, exc)
90124
return False
125+
return True
126+
127+
128+
def _check_arti_ready(*, force: bool = False) -> bool:
129+
from services.config import get_settings
130+
131+
settings = get_settings()
132+
if not bool(settings.MESH_ARTI_ENABLED):
133+
return False
134+
socks_port = int(settings.MESH_ARTI_SOCKS_PORT or 9050)
135+
now = time.time()
136+
if not force:
137+
if (
138+
int(_ARTI_STATUS_CACHE.get("port", 0) or 0) == socks_port
139+
and (now - float(_ARTI_STATUS_CACHE.get("ts", 0.0) or 0.0)) < _ARTI_STATUS_FAIL_TTL_S
140+
):
141+
return bool(_ARTI_STATUS_CACHE.get("ready"))
142+
if (
143+
int(_ARTI_PROOF_CACHE.get("port", 0) or 0) == socks_port
144+
and bool(_ARTI_PROOF_CACHE.get("ok"))
145+
and (now - float(_ARTI_PROOF_CACHE.get("ts", 0.0) or 0.0)) < _ARTI_PROOF_CACHE_TTL_S
146+
):
147+
return True
148+
149+
with _ARTI_PROBE_LOCK:
150+
now = time.time()
151+
if not force:
152+
if (
153+
int(_ARTI_STATUS_CACHE.get("port", 0) or 0) == socks_port
154+
and (now - float(_ARTI_STATUS_CACHE.get("ts", 0.0) or 0.0)) < _ARTI_STATUS_FAIL_TTL_S
155+
):
156+
return bool(_ARTI_STATUS_CACHE.get("ready"))
157+
if (
158+
int(_ARTI_PROOF_CACHE.get("port", 0) or 0) == socks_port
159+
and bool(_ARTI_PROOF_CACHE.get("ok"))
160+
and (now - float(_ARTI_PROOF_CACHE.get("ts", 0.0) or 0.0)) < _ARTI_PROOF_CACHE_TTL_S
161+
):
162+
return True
163+
164+
if not _probe_arti_socks_ready(socks_port):
165+
_ARTI_STATUS_CACHE.update({"port": socks_port, "ready": False, "ts": now})
166+
_maybe_recover_tor_socks_transport(socks_port)
167+
return False
168+
169+
global _ARTI_SOCKS_FAILURES
170+
_ARTI_SOCKS_FAILURES = 0
171+
_ARTI_STATUS_CACHE.update({"port": socks_port, "ready": True, "ts": now})
91172

92173
now = time.time()
93174
if (
@@ -110,12 +191,13 @@ def _check_arti_ready() -> bool:
110191
is_tor = bool(payload.get("IsTor")) or bool(payload.get("is_tor"))
111192
if not (response.ok and is_tor):
112193
logger.warning(
113-
"Arti Tor proof failed (status=%s is_tor=%s) — SOCKS is up, using Arti anyway",
194+
"Arti Tor proof failed (status=%s is_tor=%s)",
114195
getattr(response, "status_code", "unknown"),
115196
payload.get("IsTor", payload.get("is_tor")),
116197
)
117-
_ARTI_PROOF_CACHE.update({"port": socks_port, "ok": True, "ts": now})
118-
return True
198+
_ARTI_PROOF_CACHE.update({"port": socks_port, "ok": False, "ts": now})
199+
_ARTI_STATUS_CACHE.update({"port": socks_port, "ready": False, "ts": now})
200+
return False
119201
_ARTI_PROOF_CACHE.update({"port": socks_port, "ok": True, "ts": now})
120202
return True
121203
except Exception as exc:

0 commit comments

Comments
 (0)