mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-17 11:30:13 +02:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9c9a5262c | |||
| 9c5a4054f6 | |||
| 71a2ef4ce7 | |||
| 51f377f03d | |||
| 5ede669a12 | |||
| 8fcb01276c | |||
| 10dc9450be | |||
| bef462cdcf | |||
| 5135b771f5 | |||
| 7151563a41 | |||
| 52a28967a0 | |||
| 96182fe66d | |||
| 174031479c | |||
| f1cd9eb4b9 | |||
| c266c5ff5e | |||
| 52a0968092 | |||
| 89d6bb8fb9 | |||
| d48a0cdace | |||
| df76f6f147 | |||
| 776c89bfcf | |||
| d3006df57a | |||
| e78e4d186d | |||
| d1e1be4016 | |||
| 0afb85e241 | |||
| 039a0f9d0c | |||
| b9b99c1fa8 | |||
| a8fd33a758 | |||
| 7346129d0e | |||
| eb8f39f84e |
@@ -26,6 +26,8 @@ AIS_API_KEY=
|
||||
# Telegram OSINT map layer — scrapes public t.me/s channel previews (no bot token).
|
||||
# TELEGRAM_OSINT_ENABLED=true
|
||||
# TELEGRAM_OSINT_CHANNELS=osintdefender,insiderpaper,aljazeeraenglish,nexta_live,war_monitor
|
||||
# TELEGRAM_OSINT_TRANSLATE=true
|
||||
# TELEGRAM_OSINT_TRANSLATE_TO=en
|
||||
|
||||
# Admin key to protect sensitive endpoints (settings, updates).
|
||||
# If blank, loopback/localhost requests still work for local single-host dev.
|
||||
|
||||
@@ -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/
|
||||
@@ -261,6 +263,11 @@ frontend/.desktop-export-stash-*/
|
||||
backend/data/wormhole_stderr.log
|
||||
backend/data/wormhole_stdout.log
|
||||
|
||||
# Hermes Agent (operator-local runtime install — not project source)
|
||||
.hermes/
|
||||
**/.hermes/
|
||||
hermes-agent/
|
||||
|
||||
# Runtime caches that already slip through the backend/data/* blanket
|
||||
# (these are caught by the wildcard but listing for clarity)
|
||||
|
||||
|
||||
@@ -91,6 +91,8 @@ Both paths produce identical containers — same source, same CI, same images by
|
||||
|
||||
Open `http://localhost:3000` to view the dashboard! *(Requires [Docker Desktop](https://www.docker.com/products/docker-desktop/) or Docker Engine)*
|
||||
|
||||
> **Join the private InfoNet swarm (sb-testnet-0):** Click **NODE** in the dashboard, or run `./meshnode.sh` for a headless participant. No manual peer list — fleet defaults discover the seed and pull the signed manifest automatically. Set `MESH_INFONET_FLEET_JOIN=false` in `.env` for a private solo node.
|
||||
|
||||
> **Backend port already in use?** The browser only needs port `3000`, but the backend API is also published on host port `8000` for local diagnostics. If another app already uses `8000`, create or edit `.env` next to `docker-compose.yml` and set `BACKEND_PORT=8001`, then run `docker compose up -d`.
|
||||
|
||||
> **Blank news/UAP/bases/wastewater after several minutes?** Check for backend OOM restarts with `docker events --since 30m --filter container=shadowbroker-backend --filter event=oom`. The default compose file gives the backend 4GB; if your host has less memory, reduce enabled feeds or set `BACKEND_MEMORY_LIMIT=3G` and expect slower/heavier layers to warm more gradually.
|
||||
|
||||
+17
-1
@@ -227,7 +227,23 @@ AIS_API_KEY= # https://aisstream.io/ — free tier WebSocket key
|
||||
# MESH_GATE_SESSION_STREAM_MAX_GATES=16
|
||||
# MESH_BOOTSTRAP_DISABLED=false
|
||||
# MESH_BOOTSTRAP_MANIFEST_PATH=data/bootstrap_peers.json
|
||||
# MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY=
|
||||
# Swarm discovery (signed peer manifest). Participants need only the public key;
|
||||
# the seed operator sets MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY (never commit it).
|
||||
# Generate a fleet keypair: uv run python backend/scripts/bootstrap_manifest_helper.py generate-keypair
|
||||
# Public sb-testnet fleet defaults (auto-used when MESH_INFONET_FLEET_JOIN=true).
|
||||
# MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY=ul1d0kj/ODPIp0OhHzX8eLAVXzJ3CVvzW1vn2IC6q3I=
|
||||
# MESH_INFONET_FLEET_JOIN=true
|
||||
# MESH_INFONET_FLEET_JOIN_DISABLED=false
|
||||
# MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY= # seed only
|
||||
# MESH_BOOTSTRAP_SIGNER_ID=shadowbroker-seed
|
||||
# MESH_PEER_REGISTRY_ENABLED=true # seed only (auto-enabled when private key is set)
|
||||
# Headless relay compose sets MESH_INFONET_RELAY_AUTO_WORMHOLE=true; seed nodes with
|
||||
# MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY also auto-enable Tor wormhole on startup.
|
||||
# MESH_INFONET_RELAY_AUTO_WORMHOLE=false
|
||||
# MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED=false
|
||||
# MESH_SWARM_MANIFEST_TTL_S=14400
|
||||
# MESH_SWARM_MANIFEST_PULL_INTERVAL_S=300
|
||||
# MESH_PEER_REGISTRY_STALE_S=604800
|
||||
# Infonet/Wormhole fails closed to onion/RNS by default. Only enable clearnet
|
||||
# sync for local relay development or an explicitly public testnet.
|
||||
# MESH_INFONET_ALLOW_CLEARNET_SYNC=false
|
||||
|
||||
+2
-1
@@ -27,6 +27,7 @@ WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
tor \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
@@ -72,7 +73,7 @@ ENV PRIVACY_CORE_LIB=/app/libprivacy_core.so
|
||||
# Create a non-root user for security
|
||||
# Grant write access to /app so the auto-updater can extract files
|
||||
# Pre-create /app/data so mounted volumes inherit correct ownership
|
||||
RUN adduser --system --uid 1001 backenduser \
|
||||
RUN adduser --system --uid 1001 --home /app backenduser \
|
||||
&& mkdir -p /app/data \
|
||||
&& chown -R backenduser /app \
|
||||
&& chmod -R u+w /app
|
||||
|
||||
@@ -862,7 +862,9 @@ _ROUTE_TRANSPORT_POLICY: dict[tuple[str, str], RouteTransportPolicy] = {
|
||||
("POST", "/api/wormhole/gate/messages/decrypt"): _local_only_route_policy("private_control_only"),
|
||||
# ── Wormhole DM (strong) ──────────────────────────────────────────
|
||||
("POST", "/api/wormhole/dm/compose"): _local_only_route_policy("private_control_only"),
|
||||
("POST", "/api/wormhole/dm/connect-contact"): _local_only_route_policy("private_control_only"),
|
||||
("POST", "/api/wormhole/dm/decrypt"): _local_only_route_policy("private_control_only"),
|
||||
("POST", "/api/wormhole/dm/mls-key-package"): _local_only_route_policy("private_control_only"),
|
||||
("POST", "/api/wormhole/dm/register-key"): _local_only_route_policy("private_control_only"),
|
||||
("POST", "/api/wormhole/dm/prekey/register"): _local_only_route_policy("private_control_only"),
|
||||
("POST", "/api/wormhole/dm/bootstrap-encrypt"): _local_only_route_policy("private_control_only"),
|
||||
@@ -1404,6 +1406,27 @@ def _peer_hmac_url_from_request(request: Request) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _verify_peer_transport_hmac(request: Request, body_bytes: bytes) -> bool:
|
||||
"""Verify HMAC-SHA256 peer authentication without an allowlist check."""
|
||||
provided = str(request.headers.get("x-peer-hmac", "") or "").strip()
|
||||
if not provided:
|
||||
return False
|
||||
|
||||
peer_url = _peer_hmac_url_from_request(request)
|
||||
if not peer_url:
|
||||
return False
|
||||
peer_key = resolve_peer_key_for_url(peer_url)
|
||||
if not peer_key:
|
||||
return False
|
||||
|
||||
expected = _hmac_mod.new(
|
||||
peer_key,
|
||||
body_bytes,
|
||||
_hashlib_mod.sha256,
|
||||
).hexdigest()
|
||||
return _hmac_mod.compare_digest(provided.lower(), expected.lower())
|
||||
|
||||
|
||||
def _verify_peer_push_hmac(request: Request, body_bytes: bytes) -> bool:
|
||||
"""Verify HMAC-SHA256 peer authentication on push requests.
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+408
-37
@@ -244,6 +244,7 @@ from services.mesh.mesh_protocol import (
|
||||
PROTOCOL_VERSION,
|
||||
normalize_payload,
|
||||
)
|
||||
from services.mesh.mesh_hashchain import GENESIS_HASH
|
||||
from services.mesh.mesh_signed_events import (
|
||||
MeshWriteExemption,
|
||||
SignedWriteKind,
|
||||
@@ -324,6 +325,7 @@ from auth import (
|
||||
_validate_insecure_admin_startup,
|
||||
_validate_peer_push_secret,
|
||||
_verify_peer_push_hmac,
|
||||
_verify_peer_transport_hmac,
|
||||
)
|
||||
from node_state import (
|
||||
_NODE_BOOTSTRAP_STATE,
|
||||
@@ -370,6 +372,7 @@ osint_router = _load_optional_router("routers.osint")
|
||||
scm_router = _load_optional_router("routers.scm")
|
||||
entity_graph_router = _load_optional_router("routers.entity_graph")
|
||||
intel_feeds_router = _load_optional_router("routers.intel_feeds")
|
||||
agent_shell_router = _load_optional_router("routers.agent_shell")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1069,6 +1072,10 @@ def _release_gate_status(
|
||||
|
||||
|
||||
def _validate_privacy_core_startup() -> None:
|
||||
# The wormhole child agent reuses this app on WORMHOLE_PORT; the parent
|
||||
# backend already validated privacy-core before spawning it.
|
||||
if os.environ.get("WORMHOLE_PORT"):
|
||||
return
|
||||
from services.privacy_core_attestation import validate_privacy_core_startup
|
||||
|
||||
validate_privacy_core_startup()
|
||||
@@ -1240,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.
|
||||
|
||||
@@ -1268,14 +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)
|
||||
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)
|
||||
@@ -1285,10 +1334,14 @@ def _ensure_infonet_private_transport_ready(reason: str = "") -> bool:
|
||||
|
||||
|
||||
def _configured_bootstrap_seed_peer_urls() -> list[str]:
|
||||
from services.mesh.mesh_fleet_defaults import configured_bootstrap_seed_peers_with_fleet_default
|
||||
|
||||
settings = get_settings()
|
||||
primary = str(getattr(settings, "MESH_BOOTSTRAP_SEED_PEERS", "") or "").strip()
|
||||
legacy = str(getattr(settings, "MESH_DEFAULT_SYNC_PEERS", "") or "").strip()
|
||||
return parse_configured_relay_peers(primary or legacy)
|
||||
return configured_bootstrap_seed_peers_with_fleet_default(
|
||||
parse_configured_relay_peers(primary or legacy)
|
||||
)
|
||||
|
||||
|
||||
def _refresh_node_peer_store(*, now: float | None = None) -> dict[str, Any]:
|
||||
@@ -1415,6 +1468,16 @@ def _refresh_node_peer_store(*, now: float | None = None) -> dict[str, Any]:
|
||||
if private_transport_required and skipped_clearnet_peers and not bootstrap_error:
|
||||
bootstrap_error = _infonet_private_transport_error()
|
||||
|
||||
swarm_pull: dict[str, Any] = {}
|
||||
try:
|
||||
from services.mesh.mesh_swarm_runtime import refresh_swarm_manifest_from_seeds
|
||||
|
||||
swarm_pull = refresh_swarm_manifest_from_seeds(now=timestamp)
|
||||
if swarm_pull.get("ok") and not swarm_pull.get("skipped"):
|
||||
store.load()
|
||||
except Exception as exc:
|
||||
swarm_pull = {"ok": False, "detail": str(exc or type(exc).__name__)}
|
||||
|
||||
store.save()
|
||||
bootstrap_records = store.records_for_bucket("bootstrap")
|
||||
sync_records = store.records_for_bucket("sync")
|
||||
@@ -1423,6 +1486,8 @@ def _refresh_node_peer_store(*, now: float | None = None) -> dict[str, Any]:
|
||||
bootstrap_records = [record for record in bootstrap_records if _is_private_infonet_transport(record.transport)]
|
||||
sync_records = [record for record in sync_records if _is_private_infonet_transport(record.transport)]
|
||||
push_records = [record for record in push_records if _is_private_infonet_transport(record.transport)]
|
||||
swarm_sync_peer_count = len([record for record in sync_records if str(record.source or "") == "swarm"])
|
||||
swarm_push_peer_count = len([record for record in push_records if str(record.source or "") == "swarm"])
|
||||
snapshot = {
|
||||
"node_mode": mode,
|
||||
"private_transport_required": private_transport_required,
|
||||
@@ -1434,16 +1499,29 @@ def _refresh_node_peer_store(*, now: float | None = None) -> dict[str, Any]:
|
||||
"bootstrap_peer_count": len(bootstrap_records),
|
||||
"sync_peer_count": len(sync_records),
|
||||
"push_peer_count": len(push_records),
|
||||
"swarm_sync_peer_count": swarm_sync_peer_count,
|
||||
"swarm_push_peer_count": swarm_push_peer_count,
|
||||
"operator_peer_count": len(operator_peers),
|
||||
"bootstrap_seed_peer_count": len(bootstrap_seed_peers),
|
||||
"default_sync_peer_count": len(bootstrap_seed_peers),
|
||||
"last_bootstrap_error": bootstrap_error,
|
||||
"swarm_manifest_pull": swarm_pull,
|
||||
}
|
||||
with _NODE_RUNTIME_LOCK:
|
||||
_NODE_BOOTSTRAP_STATE.update(snapshot)
|
||||
return snapshot
|
||||
|
||||
|
||||
def _swarm_bootstrap_after_transport_ready() -> None:
|
||||
try:
|
||||
from services.mesh.mesh_swarm_runtime import join_swarm_with_retries
|
||||
|
||||
join_swarm_with_retries(attempts=4, delay_s=15.0, force=True)
|
||||
_refresh_node_peer_store()
|
||||
except Exception:
|
||||
logger.warning("swarm bootstrap after transport ready failed", exc_info=True)
|
||||
|
||||
|
||||
def _materialize_local_infonet_state() -> None:
|
||||
from services.mesh.mesh_hashchain import infonet
|
||||
|
||||
@@ -1591,6 +1669,12 @@ def _hydrate_dm_relay_from_chain(events: list[dict]) -> int:
|
||||
sender_token_hash = hashlib.sha256(
|
||||
f"hashchain-dm-sender|{event_id}|{canonical.get('node_id', '')}".encode("utf-8")
|
||||
).hexdigest()
|
||||
try:
|
||||
from services.mesh.mesh_dm_connect_delivery import relay_push_peer_urls_for_payload
|
||||
|
||||
replication_urls = relay_push_peer_urls_for_payload(dict(payload))
|
||||
except Exception:
|
||||
replication_urls = []
|
||||
try:
|
||||
result = dm_relay.deposit(
|
||||
sender_id=str(canonical.get("node_id", "") or ""),
|
||||
@@ -1604,6 +1688,7 @@ def _hydrate_dm_relay_from_chain(events: list[dict]) -> int:
|
||||
sender_token_hash=sender_token_hash,
|
||||
payload_format=str(payload.get("format", "dm1") or "dm1"),
|
||||
session_welcome=str(payload.get("session_welcome", "") or ""),
|
||||
replication_peer_urls=replication_urls,
|
||||
)
|
||||
if result.get("ok"):
|
||||
count += 1
|
||||
@@ -1668,7 +1753,29 @@ def _sync_from_peer(
|
||||
_hydrate_dm_relay_from_chain(events)
|
||||
rejected = list(result.get("rejected", []) or [])
|
||||
if rejected:
|
||||
return False, f"sync ingest rejected {len(rejected)} event(s)", False, 0
|
||||
reasons = [
|
||||
str((item or {}).get("reason", "") or "").strip()
|
||||
for item in rejected
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
reason_summary = ", ".join(reason for reason in reasons if reason)
|
||||
detail = f"sync ingest rejected {len(rejected)} event(s)"
|
||||
if reason_summary:
|
||||
detail = f"{detail}: {reason_summary}"
|
||||
local_empty = len(infonet.events) == 0
|
||||
stale_genesis = (
|
||||
local_empty
|
||||
and bool(events)
|
||||
and str((events[0] or {}).get("prev_hash", "") or "") == GENESIS_HASH
|
||||
and any("timestamp outside freshness window" in reason.lower() for reason in reasons)
|
||||
)
|
||||
if stale_genesis:
|
||||
detail = (
|
||||
f"{detail}; peer appears to be serving an expired genesis chain. "
|
||||
"Refresh or reset the peer chain, or perform an explicit one-time migration "
|
||||
"with MESH_INGEST_EVENT_MAX_AGE_S=0."
|
||||
)
|
||||
return False, detail, False, 0
|
||||
if int(result.get("accepted", 0) or 0) == 0 and int(result.get("duplicates", 0) or 0) >= len(events):
|
||||
return True, "", False, 0
|
||||
if len(events) < page_limit:
|
||||
@@ -1921,9 +2028,22 @@ def _propagate_public_event_to_peers(event_dict: dict[str, Any]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _propagate_ledger_event_to_peers(event_dict: dict[str, Any]) -> None:
|
||||
if not _participant_node_enabled():
|
||||
return
|
||||
event_type = str(event_dict.get("event_type") or "")
|
||||
if event_type in {"gate_message", "dm_message"}:
|
||||
from services.mesh.mesh_swarm_runtime import push_infonet_events_to_http_peers
|
||||
|
||||
push_infonet_events_to_http_peers([event_dict])
|
||||
_kick_public_sync_background("ledger_event")
|
||||
return
|
||||
_propagate_public_event_to_peers(event_dict)
|
||||
|
||||
|
||||
def _schedule_public_event_propagation(event_dict: dict[str, Any]) -> None:
|
||||
threading.Thread(
|
||||
target=_propagate_public_event_to_peers,
|
||||
target=_propagate_ledger_event_to_peers,
|
||||
args=(dict(event_dict),),
|
||||
daemon=True,
|
||||
).start()
|
||||
@@ -1959,6 +2079,7 @@ def _start_infonet_node_runtime(reason: str = "startup") -> None:
|
||||
threading.Thread(target=_http_peer_push_loop, daemon=True).start()
|
||||
threading.Thread(target=_http_gate_push_loop, daemon=True).start()
|
||||
threading.Thread(target=_http_gate_pull_loop, daemon=True).start()
|
||||
threading.Thread(target=_swarm_manifest_pull_loop, daemon=True).start()
|
||||
_NODE_RUNTIME_THREADS_STARTED = True
|
||||
_kick_public_sync_background(reason)
|
||||
if not _NODE_PUBLIC_EVENT_HOOK_REGISTERED:
|
||||
@@ -2066,6 +2187,22 @@ def _http_peer_push_loop() -> None:
|
||||
_NODE_SYNC_STOP.wait(_PEER_PUSH_INTERVAL_S)
|
||||
|
||||
|
||||
def _swarm_manifest_pull_loop() -> None:
|
||||
"""Background thread: pull signed peer manifests from bootstrap seeds."""
|
||||
while not _NODE_SYNC_STOP.is_set():
|
||||
try:
|
||||
if _participant_node_enabled():
|
||||
from services.mesh.mesh_swarm_runtime import refresh_swarm_manifest_from_seeds
|
||||
|
||||
result = refresh_swarm_manifest_from_seeds()
|
||||
if result.get("ok") and not result.get("skipped"):
|
||||
_refresh_node_peer_store()
|
||||
except Exception:
|
||||
logger.exception("swarm manifest pull loop error")
|
||||
interval_s = int(getattr(get_settings(), "MESH_SWARM_MANIFEST_PULL_INTERVAL_S", 0) or 300)
|
||||
_NODE_SYNC_STOP.wait(max(30, interval_s))
|
||||
|
||||
|
||||
# ─── Background Gate Message Pull Worker ─────────────────────────────────
|
||||
# Periodically pulls gate events from relay peers that this node is missing.
|
||||
# Complements the push loop: push sends OUR events to peers, pull fetches
|
||||
@@ -2610,8 +2747,10 @@ async def lifespan(app: FastAPI):
|
||||
if not _MESH_ONLY:
|
||||
def _startup_wormhole_runtime():
|
||||
try:
|
||||
from services.mesh.mesh_infonet_relay_bootstrap import ensure_infonet_relay_wormhole_ready
|
||||
from services.wormhole_supervisor import get_wormhole_state, sync_wormhole_with_settings
|
||||
|
||||
ensure_infonet_relay_wormhole_ready(reason="startup_relay")
|
||||
sync_wormhole_with_settings()
|
||||
_resume_private_delivery_background_work(
|
||||
current_tier=_current_private_lane_tier(get_wormhole_state()),
|
||||
@@ -3386,7 +3525,10 @@ def _request_private_surface_warmup(*, path: str, method: str, current_tier: str
|
||||
|
||||
|
||||
def _is_invite_scoped_prekey_bundle_lookup(request: Request, path: str) -> bool:
|
||||
if request.method.upper() != "GET" or str(path or "").strip() != "/api/mesh/dm/prekey-bundle":
|
||||
if request.method.upper() != "GET":
|
||||
return False
|
||||
normalized_path = str(path or "").strip()
|
||||
if normalized_path not in {"/api/mesh/dm/prekey-bundle", "/api/mesh/dm/pubkey"}:
|
||||
return False
|
||||
try:
|
||||
lookup_token = str(request.query_params.get("lookup_token", "") or "").strip()
|
||||
@@ -3487,6 +3629,14 @@ async def enforce_high_privacy_mesh(request: Request, call_next):
|
||||
except Exception:
|
||||
logger.debug("Private surface warm-up request failed", exc_info=True)
|
||||
required_tier = _minimum_transport_tier(path, request.method)
|
||||
if required_tier:
|
||||
from services.mesh.mesh_privacy_policy import runtime_route_enforcement_tier
|
||||
|
||||
required_tier = runtime_route_enforcement_tier(
|
||||
path,
|
||||
request.method,
|
||||
static_tier=required_tier,
|
||||
)
|
||||
if required_tier:
|
||||
if not _transport_tier_is_sufficient(current_tier, required_tier):
|
||||
if request.method.upper() == "POST" and path == "/api/mesh/dm/send":
|
||||
@@ -3651,6 +3801,7 @@ app.include_router(osint_router)
|
||||
app.include_router(scm_router)
|
||||
app.include_router(entity_graph_router)
|
||||
app.include_router(intel_feeds_router)
|
||||
app.include_router(agent_shell_router)
|
||||
|
||||
from services.data_fetcher import update_all_data
|
||||
|
||||
@@ -5495,6 +5646,65 @@ async def infonet_ingest(request: Request):
|
||||
return {"ok": True, **result}
|
||||
|
||||
|
||||
@app.get("/api/mesh/infonet/peer-registry", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("30/minute")
|
||||
async def infonet_peer_registry(request: Request):
|
||||
"""Operator view of the live swarm peer registry (seed nodes only)."""
|
||||
from services.mesh.mesh_peer_registry import DEFAULT_PEER_REGISTRY_PATH, PeerRegistry
|
||||
from services.mesh.mesh_swarm_runtime import peer_registry_enabled
|
||||
|
||||
if not peer_registry_enabled():
|
||||
return {"ok": False, "detail": "peer registry is not enabled on this node"}
|
||||
registry = PeerRegistry(DEFAULT_PEER_REGISTRY_PATH)
|
||||
try:
|
||||
peers = registry.load()
|
||||
except Exception as exc:
|
||||
return {"ok": False, "detail": str(exc or type(exc).__name__)}
|
||||
return {
|
||||
"ok": True,
|
||||
"peer_count": len(peers),
|
||||
"peers": [peer.to_dict() for peer in peers],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/mesh/infonet/bootstrap-manifest")
|
||||
@limiter.limit(_INFONET_SYNC_RATE_LIMIT)
|
||||
async def infonet_bootstrap_manifest(request: Request):
|
||||
"""Return the current signed bootstrap/swarm peer manifest."""
|
||||
from services.mesh.mesh_swarm_runtime import load_live_bootstrap_manifest
|
||||
|
||||
manifest = load_live_bootstrap_manifest()
|
||||
if manifest is None:
|
||||
return {"ok": False, "detail": "bootstrap manifest unavailable"}
|
||||
return {"ok": True, "manifest": manifest.to_dict()}
|
||||
|
||||
|
||||
@app.post("/api/mesh/infonet/peer-announce")
|
||||
@limiter.limit("30/minute")
|
||||
@mesh_write_exempt(MeshWriteExemption.PEER_GOSSIP)
|
||||
async def infonet_peer_announce(request: Request):
|
||||
"""Register a participant onion peer in the swarm registry (HMAC-authenticated)."""
|
||||
from auth import _peer_hmac_url_from_request
|
||||
from services.mesh.mesh_swarm_runtime import peer_registry_enabled, record_peer_announcement
|
||||
|
||||
body_bytes = await request.body()
|
||||
if not _verify_peer_transport_hmac(request, body_bytes):
|
||||
return Response(
|
||||
content='{"ok":false,"detail":"Invalid or missing peer HMAC"}',
|
||||
status_code=403,
|
||||
media_type="application/json",
|
||||
)
|
||||
if not peer_registry_enabled():
|
||||
return {"ok": False, "detail": "peer registry is not enabled on this node"}
|
||||
body = json_mod.loads(body_bytes or b"{}")
|
||||
announced_url = normalize_peer_url(str(body.get("peer_url", "") or ""))
|
||||
header_url = _peer_hmac_url_from_request(request)
|
||||
if not announced_url or announced_url != header_url:
|
||||
return {"ok": False, "detail": "peer_url must match X-Peer-Url"}
|
||||
peer = record_peer_announcement(body)
|
||||
return {"ok": True, "peer_url": peer.peer_url, "role": peer.role, "transport": peer.transport}
|
||||
|
||||
|
||||
@app.post("/api/mesh/infonet/peer-push")
|
||||
@limiter.limit("30/minute")
|
||||
@mesh_write_exempt(MeshWriteExemption.PEER_GOSSIP)
|
||||
@@ -5533,6 +5743,8 @@ async def infonet_peer_push(request: Request):
|
||||
result = infonet.ingest_events(events)
|
||||
_hydrate_gate_store_from_chain(events)
|
||||
_hydrate_dm_relay_from_chain(events)
|
||||
if any(str(event.get("event_type") or "") in {"gate_message", "dm_message"} for event in events):
|
||||
_kick_public_sync_background("peer_push_ingest")
|
||||
return {"ok": True, **result}
|
||||
|
||||
|
||||
@@ -6717,12 +6929,22 @@ def _queue_dm_release(*, current_tier: str, payload: dict[str, Any]) -> dict[str
|
||||
required_tier=release_lane_required_tier("dm"),
|
||||
)
|
||||
_wake_private_release_worker()
|
||||
outbox_id = str(item.get("id", "") or "")
|
||||
auto_release: dict[str, Any] = {"ok": True, "skipped": True}
|
||||
if outbox_id:
|
||||
try:
|
||||
from services.mesh.mesh_dm_connect_delivery import auto_release_connect_dm_outbox
|
||||
|
||||
auto_release = auto_release_connect_dm_outbox(outbox_id=outbox_id, payload=payload)
|
||||
except Exception as exc:
|
||||
auto_release = {"ok": False, "detail": str(exc) or type(exc).__name__}
|
||||
return {
|
||||
"ok": True,
|
||||
"msg_id": str(payload.get("msg_id", "") or ""),
|
||||
"outbox_id": str(item.get("id", "") or ""),
|
||||
"outbox_id": outbox_id,
|
||||
"queued": True,
|
||||
"detail": str((item.get("status") or {}).get("label", "") or "Queued for private delivery"),
|
||||
"auto_release": auto_release,
|
||||
"delivery": {
|
||||
"state": canonical_release_state(str(item.get("release_state", "") or "queued")),
|
||||
"internal_state": str(item.get("release_state", "") or "queued"),
|
||||
@@ -6895,7 +7117,8 @@ async def _dm_send_from_signed_request(request: Request):
|
||||
return {"ok": False, "detail": "DM timestamp is too far from current time"}
|
||||
if delivery_class not in ("request", "shared"):
|
||||
return {"ok": False, "detail": "delivery_class must be request or shared"}
|
||||
if delivery_class == "request":
|
||||
# Contact requests are the first-contact handshake — do not require prior verification.
|
||||
if delivery_class == "shared":
|
||||
try:
|
||||
from services.mesh.mesh_wormhole_contacts import verified_first_contact_requirement
|
||||
|
||||
@@ -6979,6 +7202,8 @@ async def _dm_send_from_signed_request(request: Request):
|
||||
|
||||
relay_salt_hex = _os.urandom(16).hex()
|
||||
|
||||
connect_intent = str(body.get("connect_intent", "") or "").strip().lower()
|
||||
lookup_peer_url = str(body.get("lookup_peer_url", "") or "").strip().rstrip("/")
|
||||
release_payload = {
|
||||
"sender_id": sender_id,
|
||||
"sender_token_hash": sender_token_hash,
|
||||
@@ -6993,6 +7218,16 @@ async def _dm_send_from_signed_request(request: Request):
|
||||
"sender_seal": sender_seal,
|
||||
"relay_salt": relay_salt_hex,
|
||||
}
|
||||
if connect_intent:
|
||||
release_payload["connect_intent"] = connect_intent
|
||||
if lookup_peer_url:
|
||||
release_payload["lookup_peer_url"] = lookup_peer_url
|
||||
try:
|
||||
from services.mesh.mesh_dm_connect_delivery import enrich_connect_release_payload
|
||||
|
||||
release_payload = enrich_connect_release_payload(release_payload)
|
||||
except Exception:
|
||||
pass
|
||||
hashchain_spool: dict[str, Any] = {"ok": False, "detail": "not attempted"}
|
||||
try:
|
||||
from services.mesh.mesh_hashchain import infonet
|
||||
@@ -7009,6 +7244,10 @@ async def _dm_send_from_signed_request(request: Request):
|
||||
"format": payload_format,
|
||||
}
|
||||
chain_payload["transport_lock"] = "private_strong"
|
||||
if connect_intent:
|
||||
chain_payload["connect_intent"] = connect_intent
|
||||
if lookup_peer_url:
|
||||
chain_payload["lookup_peer_url"] = lookup_peer_url
|
||||
chain_event = infonet.append_private_dm_message(
|
||||
node_id=sender_id,
|
||||
payload=chain_payload,
|
||||
@@ -7024,7 +7263,8 @@ async def _dm_send_from_signed_request(request: Request):
|
||||
or PROTOCOL_VERSION,
|
||||
timestamp=float(timestamp or time.time()),
|
||||
)
|
||||
_hydrate_dm_relay_from_chain([chain_event])
|
||||
# Relay deposit is deferred to the private release worker so scoped
|
||||
# connect traffic can synchronously replicate to lookup_peer_url once.
|
||||
hashchain_spool = {
|
||||
"ok": True,
|
||||
"event_id": str(chain_event.get("event_id", "") or ""),
|
||||
@@ -7279,7 +7519,12 @@ async def dm_register_key(request: Request):
|
||||
|
||||
@app.get("/api/mesh/dm/pubkey")
|
||||
@limiter.limit("30/minute")
|
||||
async def dm_get_pubkey(request: Request, agent_id: str = "", lookup_token: str = ""):
|
||||
async def dm_get_pubkey(
|
||||
request: Request,
|
||||
agent_id: str = "",
|
||||
lookup_token: str = "",
|
||||
lookup_peer_url: str = "",
|
||||
):
|
||||
"""Fetch an agent's DH public key for key exchange."""
|
||||
exposure = metadata_exposure_for_request(
|
||||
request,
|
||||
@@ -7299,11 +7544,49 @@ async def dm_get_pubkey(request: Request, agent_id: str = "", lookup_token: str
|
||||
if resolved_lookup:
|
||||
key_bundle, resolved_id = dm_relay.get_dh_key_by_lookup(resolved_lookup)
|
||||
if key_bundle is None:
|
||||
return dm_lookup_response_view(
|
||||
{"ok": False, "detail": "Agent not found or has no DH key", "lookup_mode": "invite_lookup_handle"},
|
||||
exposure=exposure,
|
||||
lookup_token_present=True,
|
||||
# Invite handles are minted on the owner's node. When a remote peer
|
||||
# pastes a short address, resolve it across the private fleet before
|
||||
# failing — same path as prekey-bundle import.
|
||||
from services.mesh.mesh_wormhole_prekey import fetch_dm_prekey_bundle
|
||||
|
||||
preferred_lookup_peer = str(lookup_peer_url or "").strip().rstrip("/")
|
||||
remote_bundle = fetch_dm_prekey_bundle(
|
||||
agent_id="",
|
||||
lookup_token=resolved_lookup,
|
||||
lookup_peer_urls=[preferred_lookup_peer] if preferred_lookup_peer else None,
|
||||
)
|
||||
if remote_bundle.get("ok"):
|
||||
bundle = dict(remote_bundle.get("bundle") or remote_bundle)
|
||||
dh_pub = str(
|
||||
bundle.get("identity_dh_pub_key", "")
|
||||
or remote_bundle.get("identity_dh_pub_key", "")
|
||||
or ""
|
||||
).strip()
|
||||
if dh_pub:
|
||||
resolved_id = str(remote_bundle.get("agent_id", "") or resolved_id or "").strip()
|
||||
key_bundle = {
|
||||
"dh_pub_key": dh_pub,
|
||||
"dh_algo": str(remote_bundle.get("dh_algo", "X25519") or "X25519"),
|
||||
"timestamp": int(remote_bundle.get("timestamp", 0) or 0),
|
||||
"public_key": str(remote_bundle.get("public_key", "") or ""),
|
||||
"public_key_algo": str(remote_bundle.get("public_key_algo", "") or ""),
|
||||
"signature": str(remote_bundle.get("signature", "") or ""),
|
||||
"sequence": int(remote_bundle.get("sequence", 0) or 0),
|
||||
"prekey_transparency_head": str(
|
||||
remote_bundle.get("prekey_transparency_head", "") or ""
|
||||
),
|
||||
"prekey_transparency_size": int(
|
||||
remote_bundle.get("prekey_transparency_size", 0) or 0
|
||||
),
|
||||
"witness_count": int(remote_bundle.get("witness_count", 0) or 0),
|
||||
"witness_latest_at": int(remote_bundle.get("witness_latest_at", 0) or 0),
|
||||
}
|
||||
if key_bundle is None:
|
||||
return dm_lookup_response_view(
|
||||
{"ok": False, "detail": "Agent not found or has no DH key", "lookup_mode": "invite_lookup_handle"},
|
||||
exposure=exposure,
|
||||
lookup_token_present=True,
|
||||
)
|
||||
lookup_mode = "invite_lookup_handle"
|
||||
if key_bundle is None and resolved_id:
|
||||
blocked = legacy_agent_id_lookup_blocked()
|
||||
@@ -7339,7 +7622,12 @@ async def dm_get_pubkey(request: Request, agent_id: str = "", lookup_token: str
|
||||
|
||||
@app.get("/api/mesh/dm/prekey-bundle")
|
||||
@limiter.limit("30/minute")
|
||||
async def dm_get_prekey_bundle(request: Request, agent_id: str = "", lookup_token: str = ""):
|
||||
async def dm_get_prekey_bundle(
|
||||
request: Request,
|
||||
agent_id: str = "",
|
||||
lookup_token: str = "",
|
||||
lookup_peer_url: str = "",
|
||||
):
|
||||
exposure = metadata_exposure_for_request(
|
||||
request,
|
||||
authenticated=_scoped_view_authenticated(request, "mesh"),
|
||||
@@ -7351,7 +7639,12 @@ async def dm_get_prekey_bundle(request: Request, agent_id: str = "", lookup_toke
|
||||
lookup_token_present=bool(lookup_token),
|
||||
)
|
||||
resolved_id, resolved_lookup = _preferred_dm_lookup_target(agent_id, lookup_token)
|
||||
result = fetch_dm_prekey_bundle(agent_id=resolved_id, lookup_token=resolved_lookup)
|
||||
preferred_lookup_peer = str(lookup_peer_url or "").strip().rstrip("/")
|
||||
result = fetch_dm_prekey_bundle(
|
||||
agent_id=resolved_id,
|
||||
lookup_token=resolved_lookup,
|
||||
lookup_peer_urls=[preferred_lookup_peer] if preferred_lookup_peer else None,
|
||||
)
|
||||
return dm_lookup_response_view(
|
||||
result,
|
||||
exposure=exposure,
|
||||
@@ -9094,9 +9387,35 @@ async def api_set_node_settings(request: Request, body: NodeSettingsUpdate):
|
||||
if bool(body.enabled):
|
||||
_start_infonet_node_runtime("operator_enable")
|
||||
_kick_public_sync_background("operator_enable")
|
||||
threading.Thread(target=_swarm_bootstrap_after_transport_ready, daemon=True).start()
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/mesh/infonet/swarm/join")
|
||||
@limiter.limit("10/minute")
|
||||
async def infonet_swarm_join(request: Request):
|
||||
"""Announce this node to the fleet seed and pull the signed peer manifest."""
|
||||
if not _participant_node_enabled():
|
||||
return {"ok": False, "detail": "participant node is disabled"}
|
||||
if _infonet_private_transport_required() and not _ensure_infonet_private_transport_ready("swarm_join"):
|
||||
return JSONResponse(
|
||||
{"ok": False, "detail": _infonet_private_transport_error()},
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
from services.mesh.mesh_swarm_runtime import announce_local_peer_to_seeds, refresh_swarm_manifest_from_seeds
|
||||
|
||||
announce = await asyncio.to_thread(announce_local_peer_to_seeds, force=True)
|
||||
manifest = await asyncio.to_thread(refresh_swarm_manifest_from_seeds, force=True)
|
||||
if manifest.get("ok"):
|
||||
await asyncio.to_thread(_refresh_node_peer_store)
|
||||
return {
|
||||
"ok": bool(announce.get("ok")) or bool(manifest.get("ok")),
|
||||
"announce": announce,
|
||||
"manifest_pull": manifest,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/settings/wormhole")
|
||||
@limiter.limit("30/minute")
|
||||
async def api_get_wormhole_settings(request: Request):
|
||||
@@ -9175,7 +9494,8 @@ class WormholeDmResetRequest(BaseModel):
|
||||
|
||||
|
||||
class WormholeDmBootstrapEncryptRequest(BaseModel):
|
||||
peer_id: str
|
||||
peer_id: str = ""
|
||||
lookup_token: str = ""
|
||||
plaintext: str
|
||||
|
||||
|
||||
@@ -9400,6 +9720,43 @@ def _get_contact_trust_level(peer_id: str) -> str:
|
||||
return "unpinned"
|
||||
|
||||
|
||||
def _compose_bundle_matches_invite_pin(peer_id: str, bundle: dict[str, Any]) -> bool:
|
||||
"""True when an invite-pinned contact already matches the supplied bundle."""
|
||||
try:
|
||||
from services.mesh.mesh_wormhole_contacts import list_wormhole_dm_contacts
|
||||
from services.mesh.mesh_wormhole_prekey import trust_fingerprint_for_bundle_record
|
||||
|
||||
contact = dict(list_wormhole_dm_contacts().get(str(peer_id or "").strip()) or {})
|
||||
if str(contact.get("trust_level", "") or "") != "invite_pinned":
|
||||
return False
|
||||
pinned = str(
|
||||
contact.get("remotePrekeyFingerprint", "")
|
||||
or contact.get("invitePinnedTrustFingerprint", "")
|
||||
or ""
|
||||
).strip().lower()
|
||||
if not pinned:
|
||||
return False
|
||||
bundle_record = dict(bundle or {})
|
||||
bundle_payload = dict(bundle_record.get("bundle") or bundle_record)
|
||||
candidate = str(bundle_record.get("trust_fingerprint", "") or "").strip().lower()
|
||||
if not candidate:
|
||||
candidate = str(
|
||||
trust_fingerprint_for_bundle_record(
|
||||
{
|
||||
"agent_id": str(peer_id or "").strip(),
|
||||
"bundle": bundle_payload,
|
||||
"public_key": str(bundle_record.get("public_key", "") or ""),
|
||||
"public_key_algo": str(bundle_record.get("public_key_algo", "") or "Ed25519"),
|
||||
"protocol_version": str(bundle_record.get("protocol_version", "") or ""),
|
||||
}
|
||||
)
|
||||
or ""
|
||||
).strip().lower()
|
||||
return bool(candidate and pinned == candidate)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def compose_wormhole_dm(
|
||||
*,
|
||||
peer_id: str,
|
||||
@@ -9464,8 +9821,11 @@ def compose_wormhole_dm(
|
||||
bundle = fetched_bundle
|
||||
if bundle and str(peer_id or "").strip():
|
||||
try:
|
||||
trust_state = observe_remote_prekey_bundle(str(peer_id or "").strip(), bundle)
|
||||
_compose_trust_level = str(trust_state.get("trust_level", "") or "")
|
||||
if _compose_bundle_matches_invite_pin(str(peer_id or "").strip(), bundle):
|
||||
_compose_trust_level = "invite_pinned"
|
||||
else:
|
||||
trust_state = observe_remote_prekey_bundle(str(peer_id or "").strip(), bundle)
|
||||
_compose_trust_level = str(trust_state.get("trust_level", "") or "")
|
||||
from services.mesh.mesh_wormhole_contacts import verified_first_contact_requirement
|
||||
|
||||
verified_first_contact = verified_first_contact_requirement(
|
||||
@@ -9646,21 +10006,11 @@ def decrypt_wormhole_dm_envelope(
|
||||
if not has_session.get("ok"):
|
||||
return has_session
|
||||
if not has_session.get("exists"):
|
||||
local_dh_secret = ""
|
||||
local_identity_alias = ""
|
||||
try:
|
||||
local_identity = read_wormhole_identity()
|
||||
local_dh_secret = str(local_identity.get("dh_private_key", "") or "")
|
||||
local_identity_alias = str(local_identity.get("node_id", "") or "")
|
||||
except Exception:
|
||||
local_dh_secret = ""
|
||||
local_identity_alias = ""
|
||||
ensured = ensure_mls_dm_session(
|
||||
resolved_local,
|
||||
resolved_remote,
|
||||
str(session_welcome or ""),
|
||||
local_dh_secret=local_dh_secret,
|
||||
identity_alias=local_identity_alias,
|
||||
identity_alias=resolved_local,
|
||||
)
|
||||
if not ensured.get("ok"):
|
||||
return ensured
|
||||
@@ -11137,9 +11487,12 @@ async def api_wormhole_dm_bootstrap_encrypt(request: Request, body: WormholeDmBo
|
||||
result = bootstrap_encrypt_for_peer(
|
||||
peer_id=str(body.peer_id or ""),
|
||||
plaintext=str(body.plaintext or ""),
|
||||
lookup_token=str(body.lookup_token or ""),
|
||||
)
|
||||
if isinstance(result, dict) and "trust_level" not in result:
|
||||
result["trust_level"] = _get_contact_trust_level(str(body.peer_id or ""))
|
||||
result["trust_level"] = _get_contact_trust_level(
|
||||
str(result.get("peer_id", "") or body.peer_id or "")
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -11155,7 +11508,7 @@ async def api_wormhole_dm_bootstrap_decrypt(request: Request, body: WormholeDmBo
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/wormhole/dm/sender-token", dependencies=[Depends(require_admin)])
|
||||
@app.post("/api/wormhole/dm/sender-token", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("60/minute")
|
||||
async def api_wormhole_dm_sender_token(request: Request, body: WormholeDmSenderTokenRequest):
|
||||
if _safe_int(body.count or 1, 1) > 1:
|
||||
@@ -11374,7 +11727,25 @@ async def api_wormhole_dm_contact_delete(request: Request, peer_id: str):
|
||||
return {"ok": True, "peer_id": peer_id, "deleted": deleted}
|
||||
|
||||
|
||||
_WORMHOLE_PUBLIC_FIELDS = {"installed", "configured", "running", "ready"}
|
||||
@app.post("/api/wormhole/dm/contact/{peer_id}/sever", dependencies=[Depends(require_admin)])
|
||||
@limiter.limit("60/minute")
|
||||
async def api_wormhole_dm_contact_sever(request: Request, peer_id: str):
|
||||
from services.mesh.mesh_wormhole_contacts import sever_wormhole_dm_contact
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
if not isinstance(body, dict):
|
||||
body = {}
|
||||
block = bool(body.get("block", False))
|
||||
try:
|
||||
return sever_wormhole_dm_contact(peer_id, block=block)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "detail": str(exc)}
|
||||
|
||||
|
||||
_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",
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Local-operator PTY WebSocket for the Mesh Chat agent shell."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import fcntl
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import signal
|
||||
import struct
|
||||
import sys
|
||||
import termios
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from auth import (
|
||||
_current_admin_key,
|
||||
_debug_mode_enabled,
|
||||
_is_trusted_local_runtime_host,
|
||||
require_local_operator,
|
||||
)
|
||||
from services.agent_shell_settings import (
|
||||
get_agent_shell_settings,
|
||||
set_agent_shell_working_directory,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["agent-shell"])
|
||||
|
||||
|
||||
class AgentShellSettingsUpdate(BaseModel):
|
||||
working_directory: str = Field(min_length=1)
|
||||
|
||||
|
||||
def _set_winsize(fd: int, rows: int, cols: int) -> None:
|
||||
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
||||
|
||||
|
||||
def _published_local_dashboard_ws(ws: WebSocket) -> bool:
|
||||
"""Browser → published Docker port appears as a bridge IP, not loopback.
|
||||
|
||||
For the operator shell only, also accept when the upgrade request clearly
|
||||
targets the local dashboard (Host/Origin on localhost).
|
||||
"""
|
||||
host_header = str(ws.headers.get("host") or "").strip().lower()
|
||||
host_name = host_header.split(":", 1)[0]
|
||||
if host_name in {"127.0.0.1", "localhost", "::1"}:
|
||||
return True
|
||||
|
||||
origin = str(ws.headers.get("origin") or "").strip().lower()
|
||||
if origin.startswith("http://127.0.0.1:") or origin.startswith("http://localhost:"):
|
||||
return True
|
||||
if origin.startswith("https://127.0.0.1:") or origin.startswith("https://localhost:"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _authorize_agent_shell_ws(ws: WebSocket, admin_key_query: str = "") -> None:
|
||||
host = (ws.client.host or "").lower() if ws.client else ""
|
||||
if (
|
||||
_is_trusted_local_runtime_host(host)
|
||||
or _published_local_dashboard_ws(ws)
|
||||
or (_debug_mode_enabled() and host == "test")
|
||||
):
|
||||
return
|
||||
admin_key = _current_admin_key()
|
||||
presented = str(admin_key_query or ws.headers.get("x-admin-key", "") or "").strip()
|
||||
if admin_key and presented and hmac.compare_digest(presented.encode(), admin_key.encode()):
|
||||
return
|
||||
await ws.close(code=4403, reason="local operator access only")
|
||||
raise WebSocketDisconnect()
|
||||
|
||||
|
||||
def _resolve_shell_cwd(requested: str) -> str:
|
||||
requested = str(requested or "").strip()
|
||||
if requested:
|
||||
resolved = os.path.abspath(os.path.expanduser(requested))
|
||||
if os.path.isdir(resolved):
|
||||
return resolved
|
||||
return get_agent_shell_settings()["working_directory"]
|
||||
|
||||
|
||||
def _default_shell() -> str:
|
||||
if sys.platform == "win32":
|
||||
return os.environ.get("COMSPEC", "cmd.exe")
|
||||
return os.environ.get("SHELL", "/bin/bash")
|
||||
|
||||
|
||||
async def _relay_pty(master_fd: int, proc: asyncio.subprocess.Process, ws: WebSocket) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
while True:
|
||||
if proc.returncode is not None:
|
||||
break
|
||||
try:
|
||||
readable, _, _ = await loop.run_in_executor(
|
||||
None, lambda: select.select([master_fd], [], [], 0.05)
|
||||
)
|
||||
except Exception:
|
||||
break
|
||||
if master_fd in readable:
|
||||
try:
|
||||
chunk = os.read(master_fd, 4096)
|
||||
except OSError:
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
await ws.send_bytes(chunk)
|
||||
try:
|
||||
message = await asyncio.wait_for(ws.receive(), timeout=0.05)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
if message.get("type") == "websocket.disconnect":
|
||||
break
|
||||
if message.get("type") != "websocket.receive":
|
||||
continue
|
||||
if message.get("bytes"):
|
||||
os.write(master_fd, message["bytes"])
|
||||
continue
|
||||
text = message.get("text")
|
||||
if not text:
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
os.write(master_fd, text.encode("utf-8", errors="replace"))
|
||||
continue
|
||||
if payload.get("type") == "resize":
|
||||
rows = int(payload.get("rows") or 24)
|
||||
cols = int(payload.get("cols") or 80)
|
||||
_set_winsize(master_fd, max(rows, 2), max(cols, 2))
|
||||
|
||||
|
||||
@router.get("/api/agent-shell/settings", dependencies=[Depends(require_local_operator)])
|
||||
async def read_agent_shell_settings() -> dict[str, Any]:
|
||||
return get_agent_shell_settings()
|
||||
|
||||
|
||||
@router.put("/api/agent-shell/settings", dependencies=[Depends(require_local_operator)])
|
||||
async def write_agent_shell_settings(body: AgentShellSettingsUpdate) -> dict[str, Any]:
|
||||
try:
|
||||
return set_agent_shell_working_directory(body.working_directory)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
if detail == "working_directory_not_found":
|
||||
raise HTTPException(status_code=400, detail="Working directory does not exist") from exc
|
||||
raise HTTPException(status_code=400, detail="Working directory is required") from exc
|
||||
|
||||
|
||||
@router.websocket("/api/agent-shell/ws")
|
||||
async def agent_shell_websocket(
|
||||
ws: WebSocket,
|
||||
cwd: str = Query(default=""),
|
||||
cols: int = Query(default=80),
|
||||
rows: int = Query(default=24),
|
||||
admin_key: str = Query(default=""),
|
||||
) -> None:
|
||||
await ws.accept()
|
||||
try:
|
||||
await _authorize_agent_shell_ws(ws, admin_key)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
|
||||
if sys.platform == "win32":
|
||||
await ws.send_text(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "error",
|
||||
"message": "Host PTY is not available on Windows backend builds yet. Use the ShadowBroker desktop app or run the backend in Docker/Linux for an embedded shell.",
|
||||
}
|
||||
)
|
||||
)
|
||||
await ws.close(code=1011)
|
||||
return
|
||||
|
||||
shell_cwd = _resolve_shell_cwd(cwd)
|
||||
shell = _default_shell()
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
_set_winsize(master_fd, max(rows, 2), max(cols, 2))
|
||||
|
||||
env = os.environ.copy()
|
||||
env.setdefault("TERM", "xterm-256color")
|
||||
env.setdefault("COLORTERM", "truecolor")
|
||||
home = shell_cwd if os.path.isdir(shell_cwd) else "/app"
|
||||
env["HOME"] = home
|
||||
env["USER"] = env.get("USER") or "operator"
|
||||
path_prefixes = [
|
||||
os.path.join(home, ".local", "bin"),
|
||||
os.path.join(home, ".hermes", "bin"),
|
||||
]
|
||||
path = env.get("PATH", "")
|
||||
for prefix in path_prefixes:
|
||||
if os.path.isdir(prefix):
|
||||
path = f"{prefix}:{path}" if path else prefix
|
||||
env["PATH"] = path
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
shell,
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
cwd=shell_cwd,
|
||||
env=env,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
os.close(slave_fd)
|
||||
|
||||
try:
|
||||
await _relay_pty(master_fd, proc, ws)
|
||||
finally:
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
if proc.returncode is None:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGHUP)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=2.0)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
@@ -2276,12 +2276,14 @@ async def agent_tool_manifest(request: Request):
|
||||
async def api_capabilities(request: Request):
|
||||
"""Return full API manifest so the agent knows every available endpoint."""
|
||||
from services.openclaw_channel import READ_COMMANDS, WRITE_COMMANDS, detect_tier
|
||||
from services.openclaw_routing import routing_manifest
|
||||
from services.config import get_settings
|
||||
tier = detect_tier()
|
||||
access_tier = str(get_settings().OPENCLAW_ACCESS_TIER or "restricted").strip().lower()
|
||||
return {
|
||||
"ok": True,
|
||||
"version": "0.9.82",
|
||||
"routing": routing_manifest(),
|
||||
"auth": {
|
||||
"method": "HMAC-SHA256",
|
||||
"headers": ["X-SB-Timestamp", "X-SB-Nonce", "X-SB-Signature"],
|
||||
@@ -2397,8 +2399,16 @@ async def api_capabilities(request: Request):
|
||||
"description": "Compact server-side ship search by MMSI/IMO/name/query, including yacht-owner enrichment.",
|
||||
},
|
||||
"find_entity": {
|
||||
"args": {"query": "str (optional)", "entity_type": "aircraft|ship|person|event|infrastructure (optional)", "callsign": "str (optional)", "registration": "str (optional)", "icao24": "str (optional)", "mmsi": "str (optional)", "imo": "str (optional)", "name": "str (optional)", "owner": "str (optional)", "layers": "list[str] (optional)", "limit": "int (default 10)"},
|
||||
"description": "Exact-first resolver for planes, ships, operators, callsigns, registrations, MMSI/IMO, and named entities. Use before tracking to avoid fuzzy prompt matching.",
|
||||
"args": {"query": "str (optional)", "entity_type": "aircraft|ship|person|event|infrastructure (optional)", "callsign": "str (optional)", "registration": "str (optional)", "icao24": "str (optional)", "mmsi": "str (optional)", "imo": "str (optional)", "name": "str (optional)", "owner": "str (optional)", "layers": "list[str] (optional)", "limit": "int (default 10)", "fallback_search": "bool (default false)", "confirm_fuzzy": "bool (alias for fallback_search)"},
|
||||
"description": "Exact-first resolver for planes, ships, operators, callsigns, registrations, MMSI/IMO, and named entities. Skips fuzzy search unless fallback_search=true or no exact match.",
|
||||
},
|
||||
"route_query": {
|
||||
"args": {"text": "str", "lat": "float (optional)", "lng": "float (optional)", "radius_km": "float (default 50)", "compact": "bool (default true)"},
|
||||
"description": "Deterministic intent router — returns recommended fast command, alternates, and latency estimate. Preferred entry for natural-language reads.",
|
||||
},
|
||||
"run_playbook": {
|
||||
"args": {"name": "str", "query": "str (optional)", "lat": "float (optional)", "lng": "float (optional)"},
|
||||
"description": "Execute a named batch plan (hot_snapshot, morning_brief, monitor_heartbeat, track_snapshot, area_brief, entity_recon).",
|
||||
},
|
||||
"correlate_entity": {
|
||||
"args": {"query": "str (optional)", "entity_type": "str (optional)", "callsign": "str (optional)", "registration": "str (optional)", "icao24": "str (optional)", "mmsi": "str (optional)", "imo": "str (optional)", "name": "str (optional)", "owner": "str (optional)", "radius_km": "float (default 100)", "limit": "int (default 10)"},
|
||||
@@ -2578,7 +2588,8 @@ async def api_capabilities(request: Request):
|
||||
"layers are serialized, unchanged layers transfer zero bytes. The client tracks versions "
|
||||
"automatically from SSE events and previous responses. "
|
||||
"3) Pass compact=true on every read command for compressed_v1 responses (~60-90% smaller). "
|
||||
"4) Use targeted commands first (find_flights, search_telemetry, entities_near). "
|
||||
"4) Use route_query / find_entity / run_playbook before search_telemetry. "
|
||||
"Expensive commands require confirm_expensive=true. "
|
||||
"Reserve get_telemetry/get_slow_telemetry for rare full-context pulls.",
|
||||
"pins": "Pins are server-side, NOT localStorage. Use place_pin command or POST /api/ai/pins. The agent can place and delete pins.",
|
||||
"tracking": "To track a specific aircraft without polling: use add_watch with track_callsign or track_registration. Over SSE, you'll get instant push alerts.",
|
||||
@@ -2708,6 +2719,7 @@ def _connect_info_metadata(settings) -> dict:
|
||||
"get_telemetry", "get_pins", "satellite_images",
|
||||
"news_near", "ai_summary", "ai_report",
|
||||
"timemachine_list", "timemachine_view",
|
||||
"infonet_status", "list_gates", "read_gate_messages", "poll_dms",
|
||||
],
|
||||
},
|
||||
"full": {
|
||||
@@ -2718,6 +2730,8 @@ def _connect_info_metadata(settings) -> dict:
|
||||
"satellite_images", "news_near", "data_injection",
|
||||
"ai_summary", "ai_report", "timemachine_snapshot",
|
||||
"timemachine_list", "timemachine_view", "timemachine_diff",
|
||||
"ensure_infonet_ready", "join_infonet_swarm",
|
||||
"post_gate_message", "cast_vote", "send_dm",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ from services.fetchers._store import get_latest_data_subset_refs
|
||||
from services.fetchers.telegram_osint import telegram_media_host_allowed
|
||||
from services.intel_feeds.country_risk import build_country_risk_payload
|
||||
from services.network_utils import outbound_user_agent
|
||||
from services.telegram_translate import apply_posts_translations, normalize_translate_target
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,12 +46,19 @@ async def country_risk(request: Request) -> dict:
|
||||
|
||||
@router.get("/api/telegram-feed")
|
||||
@limiter.limit("30/minute")
|
||||
async def telegram_feed(request: Request) -> dict:
|
||||
async def telegram_feed(request: Request, lang: str | None = Query(default=None)) -> dict:
|
||||
snap = get_latest_data_subset_refs("telegram_osint")
|
||||
payload = snap.get("telegram_osint")
|
||||
if isinstance(payload, dict) and payload.get("posts") is not None:
|
||||
return payload
|
||||
return {"posts": [], "total": 0, "geolocated": 0, "timestamp": None}
|
||||
if not isinstance(payload, dict) or payload.get("posts") is None:
|
||||
return {"posts": [], "total": 0, "geolocated": 0, "timestamp": None}
|
||||
|
||||
if lang:
|
||||
target = normalize_translate_target(lang)
|
||||
localized = dict(payload)
|
||||
localized["posts"] = apply_posts_translations(list(payload.get("posts") or []), target)
|
||||
localized["translate_locale"] = target
|
||||
return localized
|
||||
return payload
|
||||
|
||||
|
||||
def _infer_telegram_media_type(target_url: str, content_type: str) -> str:
|
||||
|
||||
@@ -65,6 +65,10 @@ def _hydrate_dm_relay_from_chain(events: list) -> int:
|
||||
@limiter.limit("30/minute")
|
||||
async def infonet_peer_push(request: Request):
|
||||
"""Accept pushed Infonet events from relay peers (HMAC-authenticated)."""
|
||||
from services.mesh.mesh_fleet_defaults import infonet_fleet_join_enabled
|
||||
|
||||
if not infonet_fleet_join_enabled():
|
||||
return {"ok": True, "accepted": 0, "duplicates": 0, "rejected": [], "skipped": "fleet_join_disabled"}
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length:
|
||||
try:
|
||||
@@ -154,6 +158,10 @@ async def dm_replicate_envelope(request: Request):
|
||||
@limiter.limit("30/minute")
|
||||
async def gate_peer_push(request: Request):
|
||||
"""Accept pushed gate events from relay peers (private plane)."""
|
||||
from services.mesh.mesh_fleet_defaults import infonet_fleet_join_enabled
|
||||
|
||||
if not infonet_fleet_join_enabled():
|
||||
return {"ok": True, "accepted": 0, "duplicates": 0, "skipped": "fleet_join_disabled"}
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length:
|
||||
try:
|
||||
|
||||
@@ -308,6 +308,10 @@ class WormholeDmDecryptRequest(BaseModel):
|
||||
session_welcome: str | None = None
|
||||
|
||||
|
||||
class WormholeDmMlsKeyPackageRequest(BaseModel):
|
||||
alias: str
|
||||
|
||||
|
||||
class WormholeDmResetRequest(BaseModel):
|
||||
peer_id: str | None = None
|
||||
|
||||
@@ -326,6 +330,14 @@ class WormholeDmBootstrapDecryptRequest(BaseModel):
|
||||
ciphertext: str
|
||||
|
||||
|
||||
class WormholeDmConnectContactRequest(BaseModel):
|
||||
lookup_token: str = ""
|
||||
peer_id: str = ""
|
||||
note: str = ""
|
||||
lookup_peer_url: str = ""
|
||||
cached_prekey_bundle: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class WormholeDmInviteImportRequest(BaseModel):
|
||||
invite: dict[str, Any]
|
||||
alias: str = ""
|
||||
@@ -1085,7 +1097,21 @@ async def api_wormhole_dm_bootstrap_decrypt(request: Request, body: WormholeDmBo
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/wormhole/dm/sender-token", dependencies=[Depends(require_admin)])
|
||||
@router.post("/api/wormhole/dm/connect-contact", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_dm_connect_contact(request: Request, body: WormholeDmConnectContactRequest):
|
||||
from services.openclaw_infonet import send_contact_request
|
||||
|
||||
return send_contact_request(
|
||||
lookup_token=str(body.lookup_token or ""),
|
||||
peer_id=str(body.peer_id or ""),
|
||||
note=str(body.note or ""),
|
||||
lookup_peer_url=str(body.lookup_peer_url or ""),
|
||||
cached_prekey_bundle=dict(body.cached_prekey_bundle or {}) if body.cached_prekey_bundle else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/wormhole/dm/sender-token", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("60/minute")
|
||||
async def api_wormhole_dm_sender_token(request: Request, body: WormholeDmSenderTokenRequest):
|
||||
if _safe_int(body.count or 1, 1) > 1:
|
||||
@@ -1228,6 +1254,23 @@ async def api_wormhole_dm_decrypt(request: Request, body: WormholeDmDecryptReque
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/wormhole/dm/mls-key-package", dependencies=[Depends(require_admin)])
|
||||
@limiter.limit("60/minute")
|
||||
async def api_wormhole_dm_mls_key_package(request: Request, body: WormholeDmMlsKeyPackageRequest):
|
||||
from services.mesh.mesh_dm_mls import export_dm_key_package_for_alias
|
||||
|
||||
return export_dm_key_package_for_alias(str(body.alias or "").strip())
|
||||
|
||||
|
||||
@router.post("/api/wormhole/dm/mls-reset", dependencies=[Depends(require_admin)])
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_dm_mls_reset(request: Request):
|
||||
from services.mesh.mesh_dm_mls import reset_dm_mls_state
|
||||
|
||||
reset_dm_mls_state(clear_privacy_core=True, clear_persistence=True)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/api/wormhole/dm/reset", dependencies=[Depends(require_admin)])
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_dm_reset(request: Request, body: WormholeDmResetRequest):
|
||||
@@ -1287,7 +1330,25 @@ async def api_wormhole_dm_contact_delete(request: Request, peer_id: str):
|
||||
return {"ok": True, "peer_id": peer_id, "deleted": deleted}
|
||||
|
||||
|
||||
_WORMHOLE_PUBLIC_FIELDS = {"installed", "configured", "running", "ready"}
|
||||
@router.post("/api/wormhole/dm/contact/{peer_id}/sever", dependencies=[Depends(require_admin)])
|
||||
@limiter.limit("60/minute")
|
||||
async def api_wormhole_dm_contact_sever(request: Request, peer_id: str):
|
||||
from services.mesh.mesh_wormhole_contacts import sever_wormhole_dm_contact
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
if not isinstance(body, dict):
|
||||
body = {}
|
||||
block = bool(body.get("block", False))
|
||||
try:
|
||||
return sever_wormhole_dm_contact(peer_id, block=block)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "detail": str(exc)}
|
||||
|
||||
|
||||
_WORMHOLE_PUBLIC_FIELDS = {"installed", "configured", "running", "ready", "arti_ready"}
|
||||
|
||||
|
||||
def _redact_wormhole_status(state: dict[str, Any], authenticated: bool) -> dict[str, Any]:
|
||||
@@ -1308,6 +1369,25 @@ async def api_wormhole_status(request: Request):
|
||||
return await _m.api_wormhole_status(request)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/wormhole/private-delivery/{item_id}",
|
||||
dependencies=[Depends(require_local_operator)],
|
||||
)
|
||||
@limiter.limit("120/minute")
|
||||
async def api_wormhole_private_delivery_item(request: Request, item_id: str):
|
||||
from services.mesh.mesh_metadata_exposure import metadata_exposure_for_request
|
||||
from services.mesh.mesh_private_outbox import private_delivery_outbox
|
||||
|
||||
exposure = metadata_exposure_for_request(
|
||||
request,
|
||||
authenticated=True,
|
||||
)
|
||||
item = private_delivery_outbox.get_item(item_id, exposure=exposure)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="private_delivery_item_not_found")
|
||||
return {"ok": True, "item": item}
|
||||
|
||||
|
||||
@router.post("/api/wormhole/private-delivery/{item_id}/action", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_private_delivery_action(
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Operator settings for the embedded agent shell (working directory)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SETTINGS_FILE = Path(__file__).resolve().parent.parent / "data" / "agent_shell_settings.json"
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _default_working_directory() -> str:
|
||||
explicit = str(os.environ.get("AGENT_SHELL_DEFAULT_CWD") or "").strip()
|
||||
if explicit and os.path.isdir(explicit):
|
||||
return explicit
|
||||
home = str(os.environ.get("HOME") or "").strip()
|
||||
if home and home != "/nonexistent" and os.path.isdir(home):
|
||||
return home
|
||||
return "/app"
|
||||
|
||||
|
||||
def get_agent_shell_settings() -> dict[str, Any]:
|
||||
with _LOCK:
|
||||
if not _SETTINGS_FILE.exists():
|
||||
return {"working_directory": _default_working_directory()}
|
||||
try:
|
||||
payload = json.loads(_SETTINGS_FILE.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
logger.warning("agent_shell_settings_unreadable")
|
||||
return {"working_directory": _default_working_directory()}
|
||||
cwd = str(payload.get("working_directory") or "").strip() or _default_working_directory()
|
||||
return {"working_directory": cwd}
|
||||
|
||||
|
||||
def set_agent_shell_working_directory(path: str) -> dict[str, Any]:
|
||||
normalized = str(path or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError("working_directory_required")
|
||||
resolved = os.path.abspath(os.path.expanduser(normalized))
|
||||
if not os.path.isdir(resolved):
|
||||
raise ValueError("working_directory_not_found")
|
||||
with _LOCK:
|
||||
_SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
_SETTINGS_FILE.write_text(
|
||||
json.dumps({"working_directory": resolved}, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return {"working_directory": resolved}
|
||||
@@ -30,6 +30,10 @@ class Settings(BaseSettings):
|
||||
MESH_MQTT_INCLUDE_DEFAULT_ROOTS: bool = True
|
||||
MESH_RNS_ENABLED: bool = False
|
||||
MESH_ARTI_ENABLED: bool = False
|
||||
# When true, trust wormhole_status.json ready bit if the child process is
|
||||
# alive — avoids transport-tier flapping when /api/health probes time out
|
||||
# under Tor load (common during live DM E2E).
|
||||
MESH_WORMHOLE_TRUST_FILE_READY: bool = False
|
||||
MESH_ARTI_SOCKS_PORT: int = 9050
|
||||
MESH_RELAY_PEERS: str = ""
|
||||
MESH_PUBLIC_PEER_URL: str = ""
|
||||
@@ -43,7 +47,24 @@ class Settings(BaseSettings):
|
||||
MESH_INFONET_ALLOW_CLEARNET_SYNC: bool = False
|
||||
MESH_BOOTSTRAP_DISABLED: bool = False
|
||||
MESH_BOOTSTRAP_MANIFEST_PATH: str = "data/bootstrap_peers.json"
|
||||
MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY: str = ""
|
||||
# Public sb-testnet-0 fleet signer (participants). Seed operator holds the private key.
|
||||
MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY: str = (
|
||||
"ul1d0kj/ODPIp0OhHzX8eLAVXzJ3CVvzW1vn2IC6q3I="
|
||||
)
|
||||
MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY: str = ""
|
||||
# When true, empty MESH_PEER_PUSH_SECRET uses the public fleet HMAC for seed join/announce.
|
||||
MESH_INFONET_FLEET_JOIN: bool = True
|
||||
MESH_INFONET_FLEET_JOIN_DISABLED: bool = False
|
||||
# Headless relay/seed compose: auto-enable Tor wormhole on startup so
|
||||
# docker compose redeploys keep the fleet onion reachable.
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE: bool = False
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED: bool = False
|
||||
MESH_BOOTSTRAP_SIGNER_ID: str = ""
|
||||
MESH_PEER_REGISTRY_ENABLED: bool = False
|
||||
MESH_PEER_REGISTRY_DISABLED: bool = False
|
||||
MESH_PEER_REGISTRY_STALE_S: int = 604800
|
||||
MESH_SWARM_MANIFEST_TTL_S: int = 14400
|
||||
MESH_SWARM_MANIFEST_PULL_INTERVAL_S: int = 300
|
||||
MESH_NODE_MODE: str = "participant"
|
||||
MESH_SYNC_INTERVAL_S: int = 300
|
||||
MESH_SYNC_FAILURE_BACKOFF_S: int = 60
|
||||
|
||||
@@ -265,10 +265,27 @@ def get_latest_data_subset(*keys: str) -> DashboardData:
|
||||
|
||||
|
||||
def get_latest_data_deepcopy_snapshot() -> DashboardData:
|
||||
"""Deep-copy the full dashboard for legacy /api/live-data consumers."""
|
||||
with _data_lock:
|
||||
items = list(latest_data.items())
|
||||
return {key: copy.deepcopy(value) for key, value in items}
|
||||
"""Deep-copy the full dashboard for /api/health and legacy /api/live-data.
|
||||
|
||||
The per-value deepcopy runs OUTSIDE ``_data_lock`` so a large clone cannot
|
||||
block fetcher writers (#375). The store contract is replace-don't-mutate,
|
||||
but a writer that mutates a nested object in place (e.g. a live bridge
|
||||
updating an entry that is also published in this store) can race the
|
||||
deepcopy and raise ``RuntimeError: dictionary changed size during
|
||||
iteration`` — surfacing a 500 on the health/live-data path. The racing
|
||||
mutation window is tiny, so retry a few times rather than fail; a fresh
|
||||
attempt almost always lands on a quiescent moment. Defense-in-depth on top
|
||||
of fixing the offending writers, not a substitute for it.
|
||||
"""
|
||||
attempts = 4
|
||||
for attempt in range(attempts):
|
||||
with _data_lock:
|
||||
items = list(latest_data.items())
|
||||
try:
|
||||
return {key: copy.deepcopy(value) for key, value in items}
|
||||
except RuntimeError:
|
||||
if attempt == attempts - 1:
|
||||
raise
|
||||
|
||||
|
||||
def get_latest_data_subset_refs(*keys: str) -> DashboardData:
|
||||
|
||||
@@ -21,12 +21,21 @@ def _merge_sigint_snapshot(
|
||||
because they include fresher region/channel metadata.
|
||||
"""
|
||||
|
||||
merged = list(live_signals)
|
||||
# Shallow-copy every entry so the published list owns its own dicts. The
|
||||
# inputs alias objects that other threads keep mutating in place: live
|
||||
# signals are the SIGINT bridge's own dicts (updated as packets arrive),
|
||||
# and api_nodes are the same objects published under latest_data
|
||||
# ["meshtastic_map_nodes"]. Publishing those references into
|
||||
# latest_data["sigint"] lets a concurrent mutation race the lock-free
|
||||
# deepcopy in get_latest_data_deepcopy_snapshot() (/api/health, /api/live-
|
||||
# data) and raise "dictionary changed size during iteration". Copying
|
||||
# honors the replace-don't-mutate contract in fetchers/_store.py.
|
||||
merged = [dict(s) for s in live_signals]
|
||||
live_callsigns = {s["callsign"] for s in merged if s.get("source") == "meshtastic"}
|
||||
for node in api_nodes:
|
||||
if node.get("callsign") in live_callsigns:
|
||||
continue
|
||||
merged.append(node)
|
||||
merged.append(dict(node))
|
||||
merged.sort(key=lambda item: str(item.get("timestamp", "") or ""), reverse=True)
|
||||
return merged
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import html
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -11,6 +12,7 @@ from typing import Any
|
||||
from services.fetchers._store import _data_lock, _mark_fresh, is_any_active, latest_data
|
||||
from services.fetchers.news import resolve_coords_match
|
||||
from services.network_utils import fetch_with_curl, outbound_user_agent
|
||||
from services.telegram_translate import apply_post_translation, apply_posts_translations
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -174,13 +176,7 @@ def _extract_media(block: str, link: str) -> dict[str, Any]:
|
||||
def _strip_html(text: str) -> str:
|
||||
cleaned = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
|
||||
cleaned = re.sub(r"<[^>]+>", "", cleaned)
|
||||
return (
|
||||
cleaned.replace(""", '"')
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.strip()
|
||||
)
|
||||
return html.unescape(cleaned).strip()
|
||||
|
||||
|
||||
def _score_risk(text: str) -> int:
|
||||
@@ -293,20 +289,19 @@ def parse_telegram_channel_html(html: str, channel: str) -> list[dict[str, Any]]
|
||||
post_id = hashlib.sha1(f"{link}|{published}".encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
media = _extract_media(block, link)
|
||||
posts.append(
|
||||
{
|
||||
"id": post_id,
|
||||
"title": title,
|
||||
"description": text[:1200],
|
||||
"link": link,
|
||||
"published": published,
|
||||
"source": f"t.me/{channel}",
|
||||
"channel": channel,
|
||||
"risk_score": risk_score,
|
||||
"coords": [coords[0], coords[1]] if coords else None,
|
||||
**media,
|
||||
}
|
||||
)
|
||||
post = {
|
||||
"id": post_id,
|
||||
"title": title,
|
||||
"description": text[:1200],
|
||||
"link": link,
|
||||
"published": published,
|
||||
"source": f"t.me/{channel}",
|
||||
"channel": channel,
|
||||
"risk_score": risk_score,
|
||||
"coords": [coords[0], coords[1]] if coords else None,
|
||||
**media,
|
||||
}
|
||||
posts.append(apply_post_translation(post))
|
||||
return posts
|
||||
|
||||
|
||||
@@ -358,6 +353,7 @@ def fetch_telegram_osint() -> dict[str, Any]:
|
||||
|
||||
merged_posts, added = _merge_telegram_posts(existing_posts, incoming)
|
||||
merged_posts = [_refresh_post_coords(post) for post in merged_posts]
|
||||
merged_posts = apply_posts_translations(merged_posts)
|
||||
geolocated = sum(1 for p in merged_posts if p.get("coords"))
|
||||
|
||||
payload = {
|
||||
|
||||
@@ -606,8 +606,19 @@ def _build_feature_html(features, fetched_titles=None):
|
||||
|
||||
|
||||
def _enrich_gdelt_titles_background(features, all_article_urls):
|
||||
"""Background thread: fetch real article titles then update features in-place."""
|
||||
"""Background thread: fetch real article titles, then publish enriched COPIES.
|
||||
|
||||
The ``features`` handed to us were already published into
|
||||
``latest_data["gdelt"]`` by ``fetch_gdelt()``. Per the store's thread-safety
|
||||
contract (see ``get_latest_data_subset_refs`` in fetchers/_store.py), HTTP
|
||||
readers hold live references to these nested ``properties`` dicts and
|
||||
serialize them OUTSIDE the data lock. Mutating the published dicts in place
|
||||
here races that serialization and raises
|
||||
``RuntimeError: dictionary changed size during iteration``. So we enrich
|
||||
copies and atomically swap the top-level key under the lock instead.
|
||||
"""
|
||||
import html as html_mod
|
||||
from services.fetchers._store import latest_data, _data_lock, _mark_fresh
|
||||
|
||||
try:
|
||||
logger.info(f"[BG] Fetching real article titles for {len(all_article_urls)} URLs...")
|
||||
@@ -615,28 +626,44 @@ def _enrich_gdelt_titles_background(features, all_article_urls):
|
||||
fetched_count = sum(1 for v in fetched_titles.values() if v)
|
||||
logger.info(f"[BG] Resolved {fetched_count}/{len(all_article_urls)} article titles")
|
||||
|
||||
# Update features in-place with real titles and snippets
|
||||
# Build enriched copies — never touch the already-published objects.
|
||||
enriched = []
|
||||
for f in features:
|
||||
urls = f["properties"].get("_urls_list", [])
|
||||
if not urls:
|
||||
continue
|
||||
headlines = []
|
||||
snippets = []
|
||||
for u in urls:
|
||||
real_title = fetched_titles.get(u)
|
||||
headlines.append(real_title if real_title else _url_to_headline(u))
|
||||
snippets.append(_article_snippet_cache.get(u) or "")
|
||||
f["properties"]["_headlines_list"] = headlines
|
||||
f["properties"]["_snippets_list"] = snippets
|
||||
links = []
|
||||
for u, h in zip(urls, headlines):
|
||||
safe_url = u if u.startswith(("http://", "https://")) else "about:blank"
|
||||
safe_h = html_mod.escape(h)
|
||||
links.append(
|
||||
f'<div style="margin-bottom:6px;"><a href="{safe_url}" target="_blank" rel="noopener noreferrer">{safe_h}</a></div>'
|
||||
)
|
||||
f["properties"]["html"] = "".join(links)
|
||||
logger.info(f"[BG] GDELT title enrichment complete")
|
||||
nf = dict(f)
|
||||
props = dict(f.get("properties", {}))
|
||||
urls = props.get("_urls_list", [])
|
||||
if urls:
|
||||
headlines = []
|
||||
snippets = []
|
||||
for u in urls:
|
||||
real_title = fetched_titles.get(u)
|
||||
headlines.append(real_title if real_title else _url_to_headline(u))
|
||||
snippets.append(_article_snippet_cache.get(u) or "")
|
||||
props["_headlines_list"] = headlines
|
||||
props["_snippets_list"] = snippets
|
||||
links = []
|
||||
for u, h in zip(urls, headlines):
|
||||
safe_url = u if u.startswith(("http://", "https://")) else "about:blank"
|
||||
safe_h = html_mod.escape(h)
|
||||
links.append(
|
||||
f'<div style="margin-bottom:6px;"><a href="{safe_url}" target="_blank" rel="noopener noreferrer">{safe_h}</a></div>'
|
||||
)
|
||||
props["html"] = "".join(links)
|
||||
nf["properties"] = props
|
||||
enriched.append(nf)
|
||||
|
||||
# Atomically publish — but only if a newer fetch_gdelt() hasn't already
|
||||
# replaced the layer while we were fetching titles (identity guard).
|
||||
published = False
|
||||
with _data_lock:
|
||||
if latest_data.get("gdelt") is features:
|
||||
latest_data["gdelt"] = enriched
|
||||
published = True
|
||||
if published:
|
||||
_mark_fresh("gdelt")
|
||||
logger.info(f"[BG] GDELT title enrichment complete ({len(enriched)} features)")
|
||||
else:
|
||||
logger.info("[BG] GDELT layer changed under us; skipping stale enrichment swap")
|
||||
except Exception as e:
|
||||
logger.error(f"[BG] GDELT title enrichment failed: {e}")
|
||||
|
||||
|
||||
@@ -287,28 +287,18 @@ def write_signed_bootstrap_manifest(
|
||||
return manifest
|
||||
|
||||
|
||||
def load_bootstrap_manifest(
|
||||
path: str | Path,
|
||||
def parse_bootstrap_manifest_dict(
|
||||
raw: dict[str, Any],
|
||||
*,
|
||||
signer_public_key_b64: str,
|
||||
now: float | None = None,
|
||||
) -> BootstrapManifest:
|
||||
manifest_path = _resolve_manifest_path(str(path))
|
||||
try:
|
||||
raw = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
raise BootstrapManifestError(f"bootstrap manifest not found: {manifest_path}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise BootstrapManifestError("bootstrap manifest is not valid JSON") from exc
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise BootstrapManifestError("bootstrap manifest root must be an object")
|
||||
|
||||
signature = str(raw.get("signature", "") or "").strip()
|
||||
payload = {key: value for key, value in raw.items() if key != "signature"}
|
||||
if not signature:
|
||||
raise BootstrapManifestError("bootstrap manifest signature is required")
|
||||
|
||||
_verify_manifest_signature(
|
||||
payload,
|
||||
signature_b64=signature,
|
||||
@@ -325,11 +315,36 @@ def load_bootstrap_manifest(
|
||||
)
|
||||
|
||||
|
||||
def load_bootstrap_manifest(
|
||||
path: str | Path,
|
||||
*,
|
||||
signer_public_key_b64: str,
|
||||
now: float | None = None,
|
||||
) -> BootstrapManifest:
|
||||
manifest_path = _resolve_manifest_path(str(path))
|
||||
try:
|
||||
raw = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
raise BootstrapManifestError(f"bootstrap manifest not found: {manifest_path}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise BootstrapManifestError("bootstrap manifest is not valid JSON") from exc
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise BootstrapManifestError("bootstrap manifest root must be an object")
|
||||
return parse_bootstrap_manifest_dict(
|
||||
raw,
|
||||
signer_public_key_b64=signer_public_key_b64,
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
def load_bootstrap_manifest_from_settings(*, now: float | None = None) -> BootstrapManifest | None:
|
||||
settings = get_settings()
|
||||
if bool(getattr(settings, "MESH_BOOTSTRAP_DISABLED", False)):
|
||||
return None
|
||||
signer_public_key_b64 = str(getattr(settings, "MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", "") or "").strip()
|
||||
from services.mesh.mesh_fleet_defaults import effective_bootstrap_signer_public_key_b64
|
||||
|
||||
signer_public_key_b64 = effective_bootstrap_signer_public_key_b64()
|
||||
if not signer_public_key_b64:
|
||||
return None
|
||||
manifest_path = _resolve_manifest_path(str(getattr(settings, "MESH_BOOTSTRAP_MANIFEST_PATH", "") or ""))
|
||||
|
||||
@@ -168,9 +168,9 @@ def resolve_peer_key_for_url(peer_url: str) -> bytes:
|
||||
try:
|
||||
from services.config import get_settings
|
||||
|
||||
global_secret = str(
|
||||
getattr(get_settings(), "MESH_PEER_PUSH_SECRET", "") or ""
|
||||
).strip()
|
||||
from services.mesh.mesh_fleet_defaults import effective_peer_push_secret
|
||||
|
||||
global_secret = effective_peer_push_secret()
|
||||
except Exception:
|
||||
return b""
|
||||
if not global_secret:
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Invite-scoped DM connect delivery: auto relay release and contact severance."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
CONNECT_AUTO_RELEASE_INTENTS = frozenset(
|
||||
{
|
||||
"invite_short_address",
|
||||
"invite_import",
|
||||
"contact_request",
|
||||
"contact_accept",
|
||||
"contact_offer",
|
||||
}
|
||||
)
|
||||
|
||||
INVITE_CONNECT_TRUST_LEVELS = frozenset({"invite_pinned", "sas_verified"})
|
||||
|
||||
|
||||
def _release_profile() -> str:
|
||||
try:
|
||||
from services.release_profiles import current_release_profile
|
||||
|
||||
return str(current_release_profile() or "dev")
|
||||
except Exception:
|
||||
return "dev"
|
||||
|
||||
|
||||
def grant_connect_relay_policy(
|
||||
recipient_id: str,
|
||||
*,
|
||||
reason: str = "connect_scoped_auto_release",
|
||||
) -> dict[str, Any]:
|
||||
"""Pre-authorize hidden relay delivery for an explicit connect target."""
|
||||
peer_key = str(recipient_id or "").strip()
|
||||
if not peer_key:
|
||||
return {"ok": False, "detail": "recipient_id required"}
|
||||
try:
|
||||
from services.mesh.mesh_relay_policy import grant_relay_policy
|
||||
|
||||
return grant_relay_policy(
|
||||
scope_type="dm_contact",
|
||||
scope_id=peer_key,
|
||||
profile=_release_profile(),
|
||||
hidden_transport_required=True,
|
||||
reason=str(reason or "connect_scoped_auto_release"),
|
||||
)
|
||||
except Exception as exc:
|
||||
return {"ok": False, "detail": str(exc) or type(exc).__name__}
|
||||
|
||||
|
||||
def revoke_connect_relay_policy(recipient_id: str) -> dict[str, Any]:
|
||||
peer_key = str(recipient_id or "").strip()
|
||||
if not peer_key:
|
||||
return {"ok": False, "detail": "recipient_id required"}
|
||||
try:
|
||||
from services.mesh.mesh_relay_policy import revoke_relay_policy
|
||||
|
||||
revoked = int(
|
||||
revoke_relay_policy(
|
||||
scope_type="dm_contact",
|
||||
scope_id=peer_key,
|
||||
profile=_release_profile(),
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return {"ok": True, "revoked": revoked}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "detail": str(exc) or type(exc).__name__}
|
||||
|
||||
|
||||
def recipient_has_invite_connect_scope(recipient_id: str) -> bool:
|
||||
peer_key = str(recipient_id or "").strip()
|
||||
if not peer_key:
|
||||
return False
|
||||
try:
|
||||
from services.mesh.mesh_wormhole_contacts import get_wormhole_dm_contact
|
||||
|
||||
contact = get_wormhole_dm_contact(peer_key) or {}
|
||||
except Exception:
|
||||
return False
|
||||
if str(contact.get("invitePinnedPrekeyLookupHandle", "") or "").strip():
|
||||
return True
|
||||
if str(contact.get("invitePinnedLookupPeerUrl", "") or "").strip():
|
||||
return True
|
||||
trust = str(contact.get("trust_level", "") or "").strip().lower()
|
||||
return trust in INVITE_CONNECT_TRUST_LEVELS
|
||||
|
||||
|
||||
def relay_push_peer_urls_for_payload(payload: dict[str, Any]) -> list[str]:
|
||||
urls: list[str] = []
|
||||
for raw in list(payload.get("relay_push_peer_urls") or []):
|
||||
normalized = str(raw or "").strip().rstrip("/")
|
||||
if normalized and normalized not in urls:
|
||||
urls.append(normalized)
|
||||
lookup_peer_url = str(payload.get("lookup_peer_url", "") or "").strip().rstrip("/")
|
||||
if lookup_peer_url:
|
||||
urls = [url for url in urls if url != lookup_peer_url]
|
||||
urls.insert(0, lookup_peer_url)
|
||||
recipient_id = str(payload.get("recipient_id", "") or "").strip()
|
||||
if recipient_id and not urls:
|
||||
try:
|
||||
from services.mesh.mesh_wormhole_contacts import get_wormhole_dm_contact
|
||||
|
||||
contact = get_wormhole_dm_contact(recipient_id) or {}
|
||||
pinned = str(contact.get("invitePinnedLookupPeerUrl", "") or "").strip().rstrip("/")
|
||||
if pinned:
|
||||
urls.append(pinned)
|
||||
except Exception:
|
||||
pass
|
||||
return urls
|
||||
|
||||
|
||||
def should_auto_release_dm_payload(payload: dict[str, Any]) -> bool:
|
||||
if str(payload.get("delivery_class", "") or "").strip().lower() != "request":
|
||||
return False
|
||||
intent = str(payload.get("connect_intent", "") or "").strip().lower()
|
||||
if intent in CONNECT_AUTO_RELEASE_INTENTS:
|
||||
return True
|
||||
if str(payload.get("lookup_peer_url", "") or "").strip():
|
||||
return True
|
||||
recipient_id = str(payload.get("recipient_id", "") or "").strip()
|
||||
return bool(recipient_id and recipient_has_invite_connect_scope(recipient_id))
|
||||
|
||||
|
||||
def enrich_connect_release_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Attach invite-owner relay hints used during private release."""
|
||||
enriched = dict(payload or {})
|
||||
recipient_id = str(enriched.get("recipient_id", "") or "").strip()
|
||||
lookup_peer_url = str(enriched.get("lookup_peer_url", "") or "").strip().rstrip("/")
|
||||
if not lookup_peer_url and recipient_id:
|
||||
try:
|
||||
from services.mesh.mesh_wormhole_contacts import get_wormhole_dm_contact
|
||||
|
||||
contact = get_wormhole_dm_contact(recipient_id) or {}
|
||||
lookup_peer_url = str(contact.get("invitePinnedLookupPeerUrl", "") or "").strip().rstrip("/")
|
||||
except Exception:
|
||||
lookup_peer_url = ""
|
||||
if lookup_peer_url:
|
||||
enriched["lookup_peer_url"] = lookup_peer_url
|
||||
push_urls = relay_push_peer_urls_for_payload(enriched)
|
||||
if push_urls:
|
||||
enriched["relay_push_peer_urls"] = push_urls
|
||||
return enriched
|
||||
|
||||
|
||||
def auto_release_connect_dm_outbox(*, outbox_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Grant scoped relay policy and approve release for invite-scoped connect traffic."""
|
||||
normalized_outbox = str(outbox_id or "").strip()
|
||||
enriched = enrich_connect_release_payload(payload)
|
||||
if not normalized_outbox:
|
||||
return {"ok": False, "detail": "missing outbox_id"}
|
||||
if not should_auto_release_dm_payload(enriched):
|
||||
return {"ok": True, "skipped": True, "reason": "not_connect_scoped"}
|
||||
recipient_id = str(enriched.get("recipient_id", "") or "").strip()
|
||||
if not recipient_id:
|
||||
return {"ok": False, "detail": "missing recipient_id"}
|
||||
grant = grant_connect_relay_policy(recipient_id)
|
||||
try:
|
||||
from services.mesh.mesh_private_outbox import private_delivery_outbox
|
||||
from services.mesh.mesh_private_release_worker import private_release_worker
|
||||
|
||||
private_delivery_outbox.approve_relay_release(normalized_outbox)
|
||||
private_release_worker.ensure_started()
|
||||
private_release_worker.wake()
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"detail": str(exc) or type(exc).__name__,
|
||||
"grant": grant,
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"auto_released": True,
|
||||
"outbox_id": normalized_outbox,
|
||||
"recipient_id": recipient_id,
|
||||
"grant": grant,
|
||||
"relay_push_peer_urls": relay_push_peer_urls_for_payload(enriched),
|
||||
}
|
||||
@@ -1506,6 +1506,7 @@ class DMRelay:
|
||||
sender_token_hash: str = "",
|
||||
payload_format: str = "dm1",
|
||||
session_welcome: str = "",
|
||||
replication_peer_urls: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
self._refresh_from_shared_relay()
|
||||
@@ -1573,46 +1574,214 @@ class DMRelay:
|
||||
}
|
||||
if not msg_id:
|
||||
msg_id = f"dm_{int(time.time() * 1000)}_{secrets.token_hex(6)}"
|
||||
elif any(m.msg_id == msg_id for m in self._mailboxes[mailbox_key]):
|
||||
return {"ok": True, "msg_id": msg_id}
|
||||
relay_sender_id = (
|
||||
f"sender_token:{sender_token_hash}"
|
||||
if sender_token_hash
|
||||
else sender_id
|
||||
)
|
||||
self._mailboxes[mailbox_key].append(
|
||||
DMMessage(
|
||||
sender_id=relay_sender_id,
|
||||
ciphertext=ciphertext,
|
||||
timestamp=time.time(),
|
||||
msg_id=msg_id,
|
||||
delivery_class=delivery_class,
|
||||
sender_seal=sender_seal,
|
||||
sender_block_ref=sender_block_ref,
|
||||
payload_format=str(payload_format or "dm1"),
|
||||
session_welcome=str(session_welcome or ""),
|
||||
duplicate_hit = any(m.msg_id == msg_id for m in self._mailboxes[mailbox_key])
|
||||
if not duplicate_hit:
|
||||
relay_sender_id = (
|
||||
f"sender_token:{sender_token_hash}"
|
||||
if sender_token_hash
|
||||
else sender_id
|
||||
)
|
||||
)
|
||||
self._stats["messages_in_memory"] = sum(len(v) for v in self._mailboxes.values())
|
||||
self._save()
|
||||
# Cross-node mailbox replication: push the freshly-stored
|
||||
# envelope to every authenticated relay peer so the recipient
|
||||
# can log into ANY node and find their messages. The push is
|
||||
# async (fire-and-forget thread) so deposit() returns
|
||||
# immediately — slow Tor peers can't block the sender's UX.
|
||||
# Each receiving peer re-enforces the per-sender cap on
|
||||
# acceptance, so hostile relays can't widen the cap.
|
||||
self._mailboxes[mailbox_key].append(
|
||||
DMMessage(
|
||||
sender_id=relay_sender_id,
|
||||
ciphertext=ciphertext,
|
||||
timestamp=time.time(),
|
||||
msg_id=msg_id,
|
||||
delivery_class=delivery_class,
|
||||
sender_seal=sender_seal,
|
||||
sender_block_ref=sender_block_ref,
|
||||
payload_format=str(payload_format or "dm1"),
|
||||
session_welcome=str(session_welcome or ""),
|
||||
)
|
||||
)
|
||||
self._stats["messages_in_memory"] = sum(len(v) for v in self._mailboxes.values())
|
||||
self._save()
|
||||
preferred_urls = list(replication_peer_urls or [])
|
||||
envelope_for_push: dict[str, Any] | None = None
|
||||
try:
|
||||
envelope_for_push = self.envelope_for_replication(
|
||||
mailbox_key=mailbox_key, msg_id=msg_id,
|
||||
mailbox_key=mailbox_key,
|
||||
msg_id=msg_id,
|
||||
recipient_id=recipient_id,
|
||||
recipient_token=recipient_token,
|
||||
)
|
||||
if envelope_for_push:
|
||||
self._replicate_envelope_to_peers_async(
|
||||
envelope=envelope_for_push,
|
||||
)
|
||||
except Exception:
|
||||
metrics_inc("dm_replication_push_error")
|
||||
return {"ok": True, "msg_id": msg_id}
|
||||
deposit_result = {"ok": True, "msg_id": msg_id}
|
||||
if duplicate_hit:
|
||||
deposit_result["duplicate"] = True
|
||||
|
||||
if envelope_for_push:
|
||||
# Invite-scoped connect traffic names an explicit recipient relay
|
||||
# (lookup_peer_url). Block until that push completes so the
|
||||
# recipient can poll their own node; fleet-wide fan-out stays
|
||||
# async so dead manifest peers cannot wedge deposit().
|
||||
if preferred_urls:
|
||||
logger.info(
|
||||
"DM deposit awaiting scoped replicate to %d peer(s)",
|
||||
len(preferred_urls),
|
||||
)
|
||||
deposit_result["replicate"] = self._replicate_envelope_to_peers(
|
||||
envelope=envelope_for_push,
|
||||
preferred_peer_urls=preferred_urls,
|
||||
)
|
||||
else:
|
||||
self._replicate_envelope_to_peers_async(
|
||||
envelope=envelope_for_push,
|
||||
preferred_peer_urls=[],
|
||||
)
|
||||
elif preferred_urls:
|
||||
logger.warning(
|
||||
"DM deposit skipped scoped replicate: envelope missing for msg_id=%s",
|
||||
msg_id,
|
||||
)
|
||||
return deposit_result
|
||||
|
||||
def _replicate_envelope_to_peers(
|
||||
self,
|
||||
*,
|
||||
envelope: dict[str, Any],
|
||||
preferred_peer_urls: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Push an envelope to relay peers. Returns per-peer results."""
|
||||
import hashlib
|
||||
import hmac
|
||||
import requests as _requests
|
||||
|
||||
from services.mesh.mesh_crypto import (
|
||||
normalize_peer_url,
|
||||
resolve_peer_key_for_url,
|
||||
)
|
||||
from services.mesh.mesh_router import authenticated_push_peer_urls
|
||||
|
||||
peers: list[str] = []
|
||||
for raw_url in list(preferred_peer_urls or []):
|
||||
normalized_preferred = normalize_peer_url(str(raw_url or "").strip())
|
||||
if normalized_preferred and normalized_preferred not in peers:
|
||||
peers.append(normalized_preferred)
|
||||
if not peers:
|
||||
for peer_url in authenticated_push_peer_urls():
|
||||
normalized_peer = normalize_peer_url(str(peer_url or "").strip())
|
||||
if normalized_peer and normalized_peer not in peers:
|
||||
peers.append(normalized_peer)
|
||||
if not peers:
|
||||
return {"ok": False, "detail": "no_relay_peers", "pushed": [], "failed": []}
|
||||
|
||||
logger.info(
|
||||
"DM replicate push starting for %d peer(s): %s",
|
||||
len(peers),
|
||||
", ".join(peers[:3]) + ("..." if len(peers) > 3 else ""),
|
||||
)
|
||||
|
||||
payload = json.dumps(
|
||||
{"envelope": envelope},
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
|
||||
base_timeout = max(
|
||||
1,
|
||||
int(getattr(self._settings(), "MESH_RELAY_PUSH_TIMEOUT_S", 10) or 10),
|
||||
)
|
||||
|
||||
from main import _infonet_peer_requests_proxies
|
||||
|
||||
preferred_set = {
|
||||
normalize_peer_url(str(raw_url or "").strip())
|
||||
for raw_url in list(preferred_peer_urls or [])
|
||||
}
|
||||
preferred_set.discard("")
|
||||
|
||||
pushed: list[str] = []
|
||||
failed: list[dict[str, str]] = []
|
||||
for peer_url in peers:
|
||||
try:
|
||||
normalized = normalize_peer_url(peer_url)
|
||||
timeout = max(180 if ".onion" in normalized else 1, base_timeout)
|
||||
headers = {"Content-Type": "application/json"}
|
||||
peer_key = resolve_peer_key_for_url(normalized)
|
||||
if peer_key:
|
||||
headers["X-Peer-Url"] = normalized
|
||||
headers["X-Peer-HMAC"] = hmac.new(
|
||||
peer_key, payload, hashlib.sha256
|
||||
).hexdigest()
|
||||
url = f"{peer_url}/api/mesh/dm/replicate-envelope"
|
||||
request_kwargs: dict[str, Any] = {
|
||||
"data": payload,
|
||||
"timeout": timeout,
|
||||
"headers": headers,
|
||||
}
|
||||
proxies = _infonet_peer_requests_proxies(normalized)
|
||||
if proxies:
|
||||
request_kwargs["proxies"] = proxies
|
||||
resp = None
|
||||
max_attempts = 3 if normalized in preferred_set else 2
|
||||
last_exc = ""
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
resp = _requests.post(url, **request_kwargs)
|
||||
break
|
||||
except Exception as exc:
|
||||
last_exc = str(exc) or type(exc).__name__
|
||||
if attempt + 1 < max_attempts:
|
||||
time.sleep(5.0 * (attempt + 1))
|
||||
continue
|
||||
logger.warning(
|
||||
"DM replicate push to %s failed: %s",
|
||||
peer_url,
|
||||
last_exc,
|
||||
)
|
||||
metrics_inc("dm_replication_push_error")
|
||||
resp = None
|
||||
break
|
||||
if resp is None:
|
||||
failed.append({"url": peer_url, "detail": last_exc or "request_failed"})
|
||||
continue
|
||||
if resp.status_code == 200:
|
||||
body_ok = True
|
||||
detail = ""
|
||||
try:
|
||||
body = resp.json()
|
||||
if isinstance(body, dict) and body.get("ok") is False:
|
||||
body_ok = False
|
||||
detail = str(body.get("detail", "") or "replicate rejected")[:200]
|
||||
except Exception:
|
||||
body_ok = True
|
||||
if body_ok:
|
||||
logger.info("DM replicate push to %s succeeded", peer_url)
|
||||
metrics_inc("dm_replication_push_ok")
|
||||
pushed.append(peer_url)
|
||||
else:
|
||||
logger.warning(
|
||||
"DM replicate push to %s rejected: %s",
|
||||
peer_url,
|
||||
detail,
|
||||
)
|
||||
metrics_inc("dm_replication_push_rejected")
|
||||
failed.append({"url": peer_url, "detail": detail or "replicate_rejected"})
|
||||
else:
|
||||
detail = (resp.text or "")[:200]
|
||||
logger.warning(
|
||||
"DM replicate push to %s -> %s: %s",
|
||||
peer_url,
|
||||
resp.status_code,
|
||||
detail,
|
||||
)
|
||||
metrics_inc("dm_replication_push_rejected")
|
||||
failed.append({"url": peer_url, "detail": f"http_{resp.status_code}: {detail}"})
|
||||
except Exception as exc:
|
||||
logger.warning("DM replicate push outer failure for %s: %s", peer_url, exc)
|
||||
metrics_inc("dm_replication_push_error")
|
||||
failed.append({"url": peer_url, "detail": str(exc) or type(exc).__name__})
|
||||
|
||||
scoped = bool(preferred_set)
|
||||
ok = bool(pushed) if scoped else bool(pushed) or not failed
|
||||
return {
|
||||
"ok": ok,
|
||||
"scoped": scoped,
|
||||
"pushed": pushed,
|
||||
"failed": failed,
|
||||
}
|
||||
|
||||
def accept_replica(
|
||||
self,
|
||||
@@ -1645,6 +1814,33 @@ class DMRelay:
|
||||
mailbox_key = str(envelope.get("mailbox_key", "") or "").strip()
|
||||
sender_block_ref = str(envelope.get("sender_block_ref", "") or "").strip()
|
||||
ciphertext = str(envelope.get("ciphertext", "") or "")
|
||||
delivery_class = str(envelope.get("delivery_class", "") or "").strip().lower()
|
||||
recipient_id = str(envelope.get("recipient_id", "") or "").strip()
|
||||
recipient_token = str(envelope.get("recipient_token", "") or "").strip()
|
||||
if delivery_class not in ("request", "shared", "self"):
|
||||
if recipient_id and not recipient_token:
|
||||
delivery_class = "request"
|
||||
elif recipient_token:
|
||||
delivery_class = "shared"
|
||||
if delivery_class == "request":
|
||||
if not recipient_id:
|
||||
try:
|
||||
from services.mesh.mesh_wormhole_persona import get_dm_identity
|
||||
|
||||
recipient_id = str((get_dm_identity() or {}).get("node_id") or "").strip()
|
||||
except Exception:
|
||||
recipient_id = ""
|
||||
if recipient_id:
|
||||
mailbox_key = self.mailbox_key_for_delivery(
|
||||
recipient_id=recipient_id,
|
||||
delivery_class="request",
|
||||
)
|
||||
elif delivery_class == "shared" and recipient_token:
|
||||
mailbox_key = self.mailbox_key_for_delivery(
|
||||
recipient_id=recipient_id,
|
||||
delivery_class="shared",
|
||||
recipient_token=recipient_token,
|
||||
)
|
||||
if not msg_id or not mailbox_key or not sender_block_ref or not ciphertext:
|
||||
return {"ok": False, "detail": "envelope missing required fields"}
|
||||
|
||||
@@ -1662,7 +1858,6 @@ class DMRelay:
|
||||
# Same per-class cap as the deposit path — defense in depth
|
||||
# against a peer that wraps a "deposit" as a "replica" to
|
||||
# bypass the class limit.
|
||||
delivery_class = str(envelope.get("delivery_class", "") or "")
|
||||
if delivery_class in ("request", "shared", "self"):
|
||||
class_limit = self._mailbox_limit_for_class(delivery_class)
|
||||
else:
|
||||
@@ -1716,82 +1911,18 @@ class DMRelay:
|
||||
self,
|
||||
*,
|
||||
envelope: dict[str, Any],
|
||||
preferred_peer_urls: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Push an outbound DM envelope to every authenticated relay peer.
|
||||
|
||||
Fire-and-forget: spawned in a background thread so ``deposit``
|
||||
returns to the caller immediately. Per-peer errors are logged
|
||||
and swallowed — the sender's UX must not block on slow Tor
|
||||
peers, and a peer that's down today gets the next message
|
||||
whenever it comes back. Inbound recipient polling from a healthy
|
||||
peer keeps the system functional during peer failures.
|
||||
|
||||
Each peer is authed with the existing per-peer HMAC pattern
|
||||
(#256) — same headers and key resolver gate-message replication
|
||||
uses, so a hostile node that doesn't know any peer's HMAC key
|
||||
can't impersonate a legitimate relay.
|
||||
"""
|
||||
"""Fire-and-forget fleet-wide replicate push (non-scoped traffic)."""
|
||||
import threading
|
||||
|
||||
def _do_push():
|
||||
def _do_push() -> None:
|
||||
try:
|
||||
import hashlib
|
||||
import hmac
|
||||
import requests as _requests
|
||||
|
||||
from services.mesh.mesh_crypto import (
|
||||
normalize_peer_url,
|
||||
resolve_peer_key_for_url,
|
||||
self._replicate_envelope_to_peers(
|
||||
envelope=envelope,
|
||||
preferred_peer_urls=preferred_peer_urls,
|
||||
)
|
||||
from services.mesh.mesh_router import (
|
||||
authenticated_push_peer_urls,
|
||||
)
|
||||
|
||||
peers = authenticated_push_peer_urls()
|
||||
if not peers:
|
||||
return
|
||||
|
||||
payload = json.dumps(
|
||||
{"envelope": envelope},
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
|
||||
timeout = max(
|
||||
1,
|
||||
int(getattr(self._settings(), "MESH_RELAY_PUSH_TIMEOUT_S", 10) or 10),
|
||||
)
|
||||
|
||||
for peer_url in peers:
|
||||
try:
|
||||
normalized = normalize_peer_url(peer_url)
|
||||
headers = {"Content-Type": "application/json"}
|
||||
peer_key = resolve_peer_key_for_url(normalized)
|
||||
if peer_key:
|
||||
headers["X-Peer-Url"] = normalized
|
||||
headers["X-Peer-HMAC"] = hmac.new(
|
||||
peer_key, payload, hashlib.sha256
|
||||
).hexdigest()
|
||||
url = f"{peer_url}/api/mesh/dm/replicate-envelope"
|
||||
resp = _requests.post(
|
||||
url, data=payload, timeout=timeout, headers=headers,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
metrics_inc("dm_replication_push_ok")
|
||||
else:
|
||||
# 4xx including the structured cap_violation
|
||||
# rejection from accept_replica — sender's
|
||||
# relay learns and stops retrying this msg_id.
|
||||
metrics_inc("dm_replication_push_rejected")
|
||||
except Exception:
|
||||
# Per-peer failure is non-fatal — log to metrics
|
||||
# but don't break the loop. Other peers and a
|
||||
# future retry can still propagate the envelope.
|
||||
metrics_inc("dm_replication_push_error")
|
||||
continue
|
||||
except Exception:
|
||||
# Outer guard — never let replication errors propagate
|
||||
# back to the sender's deposit() caller.
|
||||
metrics_inc("dm_replication_push_error")
|
||||
|
||||
thread = threading.Thread(
|
||||
@@ -1806,6 +1937,8 @@ class DMRelay:
|
||||
*,
|
||||
mailbox_key: str,
|
||||
msg_id: str,
|
||||
recipient_id: str = "",
|
||||
recipient_token: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the wire-form envelope for a stored message, suitable
|
||||
for POSTing to a peer relay's replicate-envelope endpoint.
|
||||
@@ -1822,6 +1955,8 @@ class DMRelay:
|
||||
return {
|
||||
"msg_id": m.msg_id,
|
||||
"mailbox_key": mailbox_key,
|
||||
"recipient_id": str(recipient_id or "").strip(),
|
||||
"recipient_token": str(recipient_token or "").strip(),
|
||||
"sender_id": m.sender_id,
|
||||
"sender_block_ref": m.sender_block_ref,
|
||||
"sender_seal": m.sender_seal,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Public Infonet fleet defaults for sb-testnet-0 participants.
|
||||
|
||||
Operators who run private single-node installs can set ``MESH_INFONET_FLEET_JOIN=false``
|
||||
and provide their own signer keys / peer secrets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
FLEET_NETWORK_ID = "sb-testnet-0"
|
||||
FLEET_SEED_ONION_URL = (
|
||||
"http://gqpbunqbgtkcqilvclm3xrkt3zowjyl3s62kkktvojgvxzizamvbrqid.onion:8000"
|
||||
)
|
||||
FLEET_BOOTSTRAP_SIGNER_PUBLIC_KEY_B64 = (
|
||||
"ul1d0kj/ODPIp0OhHzX8eLAVXzJ3CVvzW1vn2IC6q3I="
|
||||
)
|
||||
# Shared fleet HMAC for sb-testnet peer announce/push/sync. Public testnet join model.
|
||||
FLEET_PEER_PUSH_SECRET = "b7GoqsvoUD9MV7tyt0ZOzMptLA84QG6KCfaV9nDqz5Y"
|
||||
|
||||
|
||||
def infonet_fleet_join_enabled() -> bool:
|
||||
try:
|
||||
from services.config import get_settings
|
||||
|
||||
if bool(getattr(get_settings(), "MESH_INFONET_FLEET_JOIN_DISABLED", False)):
|
||||
return False
|
||||
return bool(getattr(get_settings(), "MESH_INFONET_FLEET_JOIN", True))
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def effective_bootstrap_signer_public_key_b64() -> str:
|
||||
try:
|
||||
from services.config import get_settings
|
||||
|
||||
configured = str(getattr(get_settings(), "MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", "") or "").strip()
|
||||
if configured:
|
||||
return configured
|
||||
except Exception:
|
||||
pass
|
||||
if infonet_fleet_join_enabled():
|
||||
return FLEET_BOOTSTRAP_SIGNER_PUBLIC_KEY_B64
|
||||
return ""
|
||||
|
||||
|
||||
def effective_peer_push_secret() -> str:
|
||||
try:
|
||||
from services.config import get_settings
|
||||
|
||||
configured = str(getattr(get_settings(), "MESH_PEER_PUSH_SECRET", "") or "").strip()
|
||||
if configured:
|
||||
return configured
|
||||
except Exception:
|
||||
pass
|
||||
if infonet_fleet_join_enabled():
|
||||
return FLEET_PEER_PUSH_SECRET
|
||||
return ""
|
||||
|
||||
|
||||
def configured_bootstrap_seed_peers_with_fleet_default(peers: list[str]) -> list[str]:
|
||||
if peers:
|
||||
return peers
|
||||
if infonet_fleet_join_enabled():
|
||||
return [FLEET_SEED_ONION_URL]
|
||||
return []
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Auto-enable Tor wormhole transport on Infonet relay/seed nodes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from services.config import get_settings
|
||||
from services.wormhole_settings import read_wormhole_settings, write_wormhole_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def infonet_relay_auto_wormhole_requested() -> bool:
|
||||
settings = get_settings()
|
||||
if bool(settings.MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED):
|
||||
return False
|
||||
if bool(settings.MESH_INFONET_RELAY_AUTO_WORMHOLE):
|
||||
return True
|
||||
if str(settings.MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY or "").strip():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _relay_tor_wormhole_target_settings() -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
socks_port = int(settings.MESH_ARTI_SOCKS_PORT or 9050)
|
||||
return {
|
||||
"enabled": True,
|
||||
"transport": "tor_arti",
|
||||
"socks_proxy": f"socks5h://127.0.0.1:{socks_port}",
|
||||
"socks_dns": True,
|
||||
"anonymous_mode": True,
|
||||
}
|
||||
|
||||
|
||||
def _wormhole_settings_match(existing: dict[str, Any], target: dict[str, Any]) -> bool:
|
||||
return (
|
||||
bool(existing.get("enabled")) is bool(target["enabled"])
|
||||
and str(existing.get("transport", "")) == str(target["transport"])
|
||||
and str(existing.get("socks_proxy", "")) == str(target["socks_proxy"])
|
||||
and bool(existing.get("socks_dns", True)) is bool(target["socks_dns"])
|
||||
and bool(existing.get("anonymous_mode", False)) is bool(target["anonymous_mode"])
|
||||
)
|
||||
|
||||
|
||||
def ensure_infonet_relay_wormhole_ready(*, reason: str = "relay_auto") -> dict[str, Any]:
|
||||
"""Persist Tor wormhole settings and connect on relay/seed startup."""
|
||||
if not infonet_relay_auto_wormhole_requested():
|
||||
return {"ok": True, "skipped": True, "reason": "not_requested"}
|
||||
|
||||
from routers.ai_intel import _write_env_value
|
||||
from services.tor_hidden_service import tor_service
|
||||
from services.wormhole_supervisor import connect_wormhole, restart_wormhole
|
||||
|
||||
existing = read_wormhole_settings()
|
||||
target = _relay_tor_wormhole_target_settings()
|
||||
settings_updated = not _wormhole_settings_match(existing, target)
|
||||
updated = write_wormhole_settings(**target) if settings_updated else existing
|
||||
|
||||
tor_result: dict[str, Any] = {"ok": False, "detail": "not started"}
|
||||
try:
|
||||
tor_result = tor_service.start(target_port=8000)
|
||||
if tor_result.get("ok"):
|
||||
_write_env_value("MESH_ARTI_ENABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
except Exception as exc:
|
||||
tor_result = {"ok": False, "detail": str(exc or type(exc).__name__)}
|
||||
|
||||
runtime = (
|
||||
restart_wormhole(reason=reason)
|
||||
if settings_updated
|
||||
else connect_wormhole(reason=reason)
|
||||
)
|
||||
|
||||
if settings_updated:
|
||||
logger.info("Infonet relay auto-wormhole enabled (%s)", reason)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"skipped": False,
|
||||
"settings_updated": settings_updated,
|
||||
"tor": tor_result,
|
||||
"runtime": runtime,
|
||||
"settings": updated,
|
||||
}
|
||||
@@ -125,8 +125,8 @@ def dm_lookup_response_view(
|
||||
view.pop("lookup_mode", None)
|
||||
view.pop("removal_target", None)
|
||||
return view
|
||||
if invite_lookup:
|
||||
view.pop("agent_id", None)
|
||||
# Successful invite lookups keep agent_id: the handle is the capability and
|
||||
# first-contact messaging needs a delivery target. Failures stay generic.
|
||||
return view
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Operator-signed peer registry for private Infonet swarm discovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from services.mesh.mesh_crypto import normalize_peer_url
|
||||
from services.mesh.mesh_router import peer_transport_kind
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[2]
|
||||
DATA_DIR = BACKEND_DIR / "data"
|
||||
DEFAULT_PEER_REGISTRY_PATH = DATA_DIR / "peer_registry.json"
|
||||
REGISTRY_VERSION = 1
|
||||
ALLOWED_REGISTRY_ROLES = {"participant", "relay", "seed"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegistryPeer:
|
||||
peer_url: str
|
||||
transport: str
|
||||
role: str
|
||||
node_id: str = ""
|
||||
label: str = ""
|
||||
announced_at: int = 0
|
||||
last_seen_at: int = 0
|
||||
failure_count: int = 0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
def manifest_peer(self) -> dict[str, str]:
|
||||
return {
|
||||
"peer_url": self.peer_url,
|
||||
"transport": self.transport,
|
||||
"role": self.role,
|
||||
"label": self.label or self.node_id[:16],
|
||||
}
|
||||
|
||||
|
||||
class PeerRegistry:
|
||||
def __init__(self, path: str | Path = DEFAULT_PEER_REGISTRY_PATH):
|
||||
self.path = Path(path)
|
||||
self._peers: dict[str, RegistryPeer] = {}
|
||||
|
||||
def load(self) -> list[RegistryPeer]:
|
||||
if not self.path.exists():
|
||||
self._peers = {}
|
||||
return []
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("peer registry root must be an object")
|
||||
version = int(raw.get("version", 0) or 0)
|
||||
if version != REGISTRY_VERSION:
|
||||
raise ValueError(f"unsupported peer registry version: {version}")
|
||||
entries = raw.get("peers", [])
|
||||
if not isinstance(entries, list):
|
||||
raise ValueError("peer registry peers must be a list")
|
||||
peers: dict[str, RegistryPeer] = {}
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
peer = self._normalize_entry(entry)
|
||||
peers[peer.peer_url] = peer
|
||||
self._peers = peers
|
||||
return self.records()
|
||||
|
||||
def save(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"version": REGISTRY_VERSION,
|
||||
"updated_at": int(time.time()),
|
||||
"peers": [peer.to_dict() for peer in self.records()],
|
||||
}
|
||||
self.path.write_text(
|
||||
json.dumps(payload, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def records(self) -> list[RegistryPeer]:
|
||||
return sorted(self._peers.values(), key=lambda item: (item.role, item.peer_url))
|
||||
|
||||
def upsert_announcement(
|
||||
self,
|
||||
*,
|
||||
peer_url: str,
|
||||
transport: str,
|
||||
role: str,
|
||||
node_id: str = "",
|
||||
label: str = "",
|
||||
now: float | None = None,
|
||||
) -> RegistryPeer:
|
||||
normalized = normalize_peer_url(peer_url)
|
||||
if not normalized:
|
||||
raise ValueError("peer_url is required")
|
||||
resolved_transport = str(transport or "").strip().lower() or str(peer_transport_kind(normalized) or "")
|
||||
if resolved_transport not in {"onion", "clearnet"}:
|
||||
raise ValueError("unsupported peer transport")
|
||||
resolved_role = str(role or "participant").strip().lower()
|
||||
if resolved_role not in ALLOWED_REGISTRY_ROLES:
|
||||
raise ValueError("unsupported peer role")
|
||||
timestamp = int(now if now is not None else time.time())
|
||||
existing = self._peers.get(normalized)
|
||||
peer = RegistryPeer(
|
||||
peer_url=normalized,
|
||||
transport=resolved_transport,
|
||||
role=resolved_role,
|
||||
node_id=str(node_id or (existing.node_id if existing else "") or "").strip(),
|
||||
label=str(label or (existing.label if existing else "") or "").strip(),
|
||||
announced_at=int(existing.announced_at if existing and existing.announced_at else timestamp),
|
||||
last_seen_at=timestamp,
|
||||
failure_count=int(existing.failure_count if existing else 0),
|
||||
)
|
||||
self._peers[normalized] = peer
|
||||
return peer
|
||||
|
||||
def prune_stale(self, *, max_age_s: int, now: float | None = None) -> int:
|
||||
timestamp = int(now if now is not None else time.time())
|
||||
removed = 0
|
||||
for peer_url, peer in list(self._peers.items()):
|
||||
if peer.role == "seed":
|
||||
continue
|
||||
last_seen = int(peer.last_seen_at or peer.announced_at or 0)
|
||||
if last_seen > 0 and timestamp - last_seen > max(60, int(max_age_s or 0)):
|
||||
del self._peers[peer_url]
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
def manifest_peers(self) -> list[dict[str, str]]:
|
||||
return [peer.manifest_peer() for peer in self.records()]
|
||||
|
||||
def _normalize_entry(self, entry: dict[str, Any]) -> RegistryPeer:
|
||||
peer_url = normalize_peer_url(str(entry.get("peer_url", "") or ""))
|
||||
if not peer_url:
|
||||
raise ValueError("registry peer_url is required")
|
||||
transport = str(entry.get("transport", "") or peer_transport_kind(peer_url) or "").strip().lower()
|
||||
role = str(entry.get("role", "participant") or "participant").strip().lower()
|
||||
if role not in ALLOWED_REGISTRY_ROLES:
|
||||
raise ValueError("registry role unsupported")
|
||||
return RegistryPeer(
|
||||
peer_url=peer_url,
|
||||
transport=transport,
|
||||
role=role,
|
||||
node_id=str(entry.get("node_id", "") or "").strip(),
|
||||
label=str(entry.get("label", "") or "").strip(),
|
||||
announced_at=int(entry.get("announced_at", 0) or 0),
|
||||
last_seen_at=int(entry.get("last_seen_at", 0) or entry.get("announced_at", 0) or 0),
|
||||
failure_count=int(entry.get("failure_count", 0) or 0),
|
||||
)
|
||||
@@ -16,7 +16,7 @@ DATA_DIR = BACKEND_DIR / "data"
|
||||
DEFAULT_PEER_STORE_PATH = DATA_DIR / "peer_store.json"
|
||||
PEER_STORE_VERSION = 1
|
||||
ALLOWED_PEER_BUCKETS = {"bootstrap", "sync", "push"}
|
||||
ALLOWED_PEER_SOURCES = {"bundle", "operator", "bootstrap_promoted", "runtime"}
|
||||
ALLOWED_PEER_SOURCES = {"bundle", "operator", "bootstrap_promoted", "runtime", "swarm"}
|
||||
ALLOWED_PEER_TRANSPORTS = {"clearnet", "onion"}
|
||||
ALLOWED_PEER_ROLES = {"participant", "relay", "seed"}
|
||||
|
||||
|
||||
@@ -140,10 +140,24 @@ def transport_tier_from_state(state: dict[str, Any] | None) -> str:
|
||||
snapshot = state or {}
|
||||
if not bool(snapshot.get("configured")):
|
||||
return "public_degraded"
|
||||
if not bool(snapshot.get("ready")):
|
||||
return "public_degraded"
|
||||
arti_ready = bool(snapshot.get("arti_ready"))
|
||||
rns_ready = bool(snapshot.get("rns_ready"))
|
||||
running = bool(snapshot.get("running"))
|
||||
transport_usable = bool(snapshot.get("ready"))
|
||||
if not transport_usable:
|
||||
try:
|
||||
from services.config import get_settings
|
||||
|
||||
if (
|
||||
bool(getattr(get_settings(), "MESH_WORMHOLE_TRUST_FILE_READY", False))
|
||||
and running
|
||||
and arti_ready
|
||||
):
|
||||
transport_usable = True
|
||||
except Exception:
|
||||
pass
|
||||
if not transport_usable:
|
||||
return "public_degraded"
|
||||
if arti_ready and rns_ready:
|
||||
return "private_strong"
|
||||
if arti_ready or rns_ready:
|
||||
@@ -157,8 +171,45 @@ def transport_tier_is_sufficient(current_tier: str | None, required_tier: str |
|
||||
return TRANSPORT_TIER_ORDER[current] >= TRANSPORT_TIER_ORDER[required]
|
||||
|
||||
|
||||
def release_lane_required_tier(lane: str) -> str:
|
||||
return network_release_required_tier(lane)
|
||||
_DM_RUNTIME_ENFORCEMENT_ROUTES = {
|
||||
("POST", "/api/mesh/dm/send"),
|
||||
("POST", "/api/mesh/dm/poll"),
|
||||
("GET", "/api/mesh/dm/poll"),
|
||||
("GET", "/api/mesh/dm/count"),
|
||||
("POST", "/api/mesh/dm/count"),
|
||||
}
|
||||
|
||||
|
||||
def runtime_route_enforcement_tier(path: str, method: str, *, static_tier: str) -> str:
|
||||
"""Adjust static route tiers for Tor-only nodes that never reach private_strong."""
|
||||
normalized_path = str(path or "").strip()
|
||||
normalized_method = str(method or "").strip().upper()
|
||||
static = normalize_transport_tier(static_tier)
|
||||
if (normalized_method, normalized_path) not in _DM_RUNTIME_ENFORCEMENT_ROUTES:
|
||||
return static
|
||||
if static != "private_strong":
|
||||
return static
|
||||
return release_lane_required_tier("dm")
|
||||
|
||||
|
||||
def release_lane_required_tier(lane: str, *, wormhole_state: dict[str, Any] | None = None) -> str:
|
||||
normalized_lane = str(lane or "").strip().lower()
|
||||
required = network_release_required_tier(normalized_lane)
|
||||
if normalized_lane != "dm":
|
||||
return required
|
||||
state = wormhole_state
|
||||
if state is None:
|
||||
try:
|
||||
from services.wormhole_supervisor import get_wormhole_state
|
||||
|
||||
state = get_wormhole_state()
|
||||
except Exception:
|
||||
state = {}
|
||||
# Tor-only nodes never reach private_strong (needs Arti + RNS). Encrypted
|
||||
# relay over Arti still preserves ciphertext privacy for offline delivery.
|
||||
if not bool((state or {}).get("rns_enabled")):
|
||||
return "private_transitional"
|
||||
return required
|
||||
|
||||
|
||||
def private_delivery_status(status_code: str, *, reason_code: str = "", plain_reason: str = "") -> dict[str, str]:
|
||||
|
||||
@@ -386,6 +386,20 @@ def _dispatch_dm(
|
||||
sampled=sampled,
|
||||
)
|
||||
|
||||
replication_peer_urls: list[str] = []
|
||||
try:
|
||||
from services.mesh.mesh_dm_connect_delivery import relay_push_peer_urls_for_payload
|
||||
|
||||
replication_peer_urls = [
|
||||
str(raw or "").strip().rstrip("/")
|
||||
for raw in list(payload.get("relay_push_peer_urls") or [])
|
||||
if str(raw or "").strip()
|
||||
]
|
||||
if not replication_peer_urls:
|
||||
replication_peer_urls = relay_push_peer_urls_for_payload(payload)
|
||||
except Exception:
|
||||
replication_peer_urls = []
|
||||
|
||||
apply_dm_relay_jitter()
|
||||
relay_result = dm_relay.deposit(
|
||||
sender_id=relay_sender_id,
|
||||
@@ -399,7 +413,25 @@ def _dispatch_dm(
|
||||
sender_token_hash=sender_token_hash,
|
||||
payload_format=payload_format,
|
||||
session_welcome=session_welcome,
|
||||
replication_peer_urls=replication_peer_urls,
|
||||
)
|
||||
replicate_info = dict(relay_result.get("replicate") or {})
|
||||
if replication_peer_urls and not replicate_info.get("ok"):
|
||||
return _dispatch_result(
|
||||
ok=False,
|
||||
lane="dm",
|
||||
selected_transport="relay",
|
||||
selected_carrier="relay",
|
||||
dispatch_reason="scoped_relay_replicate_failed",
|
||||
hidden_transport_effective=bool(hidden_relay),
|
||||
no_acceptable_path=False,
|
||||
detail=(
|
||||
"Scoped relay replicate did not reach the recipient node: "
|
||||
+ str(replicate_info.get("failed") or replicate_info.get("detail") or "unknown")
|
||||
),
|
||||
msg_id=msg_id,
|
||||
replicate=replicate_info,
|
||||
)
|
||||
if not relay_result.get("ok"):
|
||||
return _dispatch_result(
|
||||
ok=False,
|
||||
@@ -436,6 +468,7 @@ def _dispatch_dm(
|
||||
else str(relay_result.get("detail", "") or "Delivered privately")
|
||||
),
|
||||
msg_id=str(relay_result.get("msg_id", "") or msg_id),
|
||||
replicate=replicate_info,
|
||||
)
|
||||
|
||||
|
||||
@@ -600,8 +633,15 @@ def attempt_private_release(
|
||||
policy_reason_code=str(decision.reason_code or ""),
|
||||
)
|
||||
if normalized_lane == "dm":
|
||||
dm_payload = dict(payload or {})
|
||||
try:
|
||||
from services.mesh.mesh_dm_connect_delivery import enrich_connect_release_payload
|
||||
|
||||
dm_payload = enrich_connect_release_payload(dm_payload)
|
||||
except Exception:
|
||||
pass
|
||||
return _dispatch_dm(
|
||||
dict(payload or {}),
|
||||
dm_payload,
|
||||
secure_dm_enabled=secure_dm_enabled or _secure_dm_enabled,
|
||||
rns_private_dm_ready=rns_private_dm_ready or _rns_private_dm_ready,
|
||||
anonymous_dm_hidden_transport_enforced=(
|
||||
|
||||
@@ -36,6 +36,22 @@ def _require_fields(payload: dict[str, Any], fields: tuple[str, ...]) -> tuple[b
|
||||
return True, "ok"
|
||||
|
||||
|
||||
_SEALED_CIPHERTEXT_PREFIXES = ("x3dh1:", "dm1:", "mls1:", "sealed:")
|
||||
|
||||
|
||||
def _strip_sealed_ciphertext_prefix(value: str) -> str:
|
||||
lowered = value.lower()
|
||||
for prefix in _SEALED_CIPHERTEXT_PREFIXES:
|
||||
if lowered.startswith(prefix):
|
||||
return value[len(prefix) :]
|
||||
return value
|
||||
|
||||
|
||||
def _sealed_ciphertext_has_known_prefix(value: str) -> bool:
|
||||
lowered = str(value or "").strip().lower()
|
||||
return any(lowered.startswith(prefix) for prefix in _SEALED_CIPHERTEXT_PREFIXES)
|
||||
|
||||
|
||||
def _decode_base64ish(value: Any) -> bytes | None:
|
||||
raw = str(value or "").strip()
|
||||
if not raw or any(ch.isspace() for ch in raw):
|
||||
@@ -49,6 +65,13 @@ def _decode_base64ish(value: Any) -> bytes | None:
|
||||
return None
|
||||
|
||||
|
||||
def _decode_sealed_ciphertext_value(value: Any) -> bytes | None:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
return _decode_base64ish(_strip_sealed_ciphertext_prefix(raw))
|
||||
|
||||
|
||||
def _byte_entropy(data: bytes) -> float:
|
||||
if not data:
|
||||
return 0.0
|
||||
@@ -66,12 +89,19 @@ def _validate_sealed_bytes_field(
|
||||
min_bytes: int = 8,
|
||||
entropy_floor: float = 2.5,
|
||||
) -> tuple[bool, str]:
|
||||
data = _decode_base64ish(payload.get(field, ""))
|
||||
raw = str(payload.get(field, "") or "").strip()
|
||||
prefixed = _sealed_ciphertext_has_known_prefix(raw)
|
||||
data = _decode_sealed_ciphertext_value(raw)
|
||||
if data is None:
|
||||
return False, f"{field} must be base64-encoded sealed bytes"
|
||||
if len(data) < min_bytes:
|
||||
return False, f"{field} is too short"
|
||||
|
||||
# X3DH / MLS envelopes are structured JSON or ratchet frames — skip
|
||||
# plaintext heuristics once a known wire prefix is present.
|
||||
if prefixed:
|
||||
return True, "ok"
|
||||
|
||||
# Short test vectors and compact envelopes can be low entropy; only apply
|
||||
# heuristics once there is enough material to distinguish a sealed blob
|
||||
# from accidental base64-encoded plaintext.
|
||||
|
||||
@@ -463,8 +463,26 @@ def _apply_content_private_transport_lock_policy(prepared: "PreparedSignedWrite"
|
||||
except Exception:
|
||||
current_tier = "public_degraded"
|
||||
|
||||
lock_to_satisfy = normalized
|
||||
if prepared.kind in {
|
||||
SignedWriteKind.DM_POLL,
|
||||
SignedWriteKind.DM_COUNT,
|
||||
SignedWriteKind.DM_SEND,
|
||||
SignedWriteKind.DM_REGISTER,
|
||||
SignedWriteKind.DM_BLOCK,
|
||||
SignedWriteKind.DM_WITNESS,
|
||||
}:
|
||||
from services.mesh.mesh_privacy_policy import release_lane_required_tier
|
||||
|
||||
lane_cap = release_lane_required_tier("dm")
|
||||
# Clients sign private_strong; Tor-only nodes cap DM at
|
||||
# private_transitional. Accept when live transport meets the
|
||||
# strongest tier this node can offer on the DM lane.
|
||||
if not transport_tier_is_sufficient(lane_cap, normalized):
|
||||
lock_to_satisfy = lane_cap
|
||||
|
||||
if (
|
||||
not transport_tier_is_sufficient(current_tier, normalized)
|
||||
not transport_tier_is_sufficient(current_tier, lock_to_satisfy)
|
||||
and prepared.kind not in _QUEUEABLE_CONTENT_PRIVATE_KINDS
|
||||
):
|
||||
metrics_inc("signed_write_transport_lock_tier_mismatch")
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
"""Private Infonet swarm discovery and immediate ledger propagation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from services.config import get_settings
|
||||
from services.mesh.mesh_bootstrap_manifest import (
|
||||
BootstrapManifest,
|
||||
BootstrapManifestError,
|
||||
BootstrapPeer,
|
||||
build_bootstrap_manifest_payload,
|
||||
load_bootstrap_manifest,
|
||||
parse_bootstrap_manifest_dict,
|
||||
sign_bootstrap_manifest_payload,
|
||||
write_signed_bootstrap_manifest,
|
||||
)
|
||||
from services.mesh.mesh_crypto import normalize_peer_url, resolve_peer_key_for_url
|
||||
from services.mesh.mesh_peer_registry import DEFAULT_PEER_REGISTRY_PATH, PeerRegistry, RegistryPeer
|
||||
from services.mesh.mesh_peer_store import (
|
||||
DEFAULT_PEER_STORE_PATH,
|
||||
PeerStore,
|
||||
make_push_peer_record,
|
||||
make_sync_peer_record,
|
||||
)
|
||||
from services.mesh.mesh_router import parse_configured_relay_peers, peer_transport_kind
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SWARM_LOCK = threading.Lock()
|
||||
_LAST_MANIFEST_PULL_AT = 0.0
|
||||
_LAST_ANNOUNCE_AT = 0.0
|
||||
|
||||
|
||||
def peer_registry_enabled() -> bool:
|
||||
settings = get_settings()
|
||||
if bool(getattr(settings, "MESH_PEER_REGISTRY_DISABLED", False)):
|
||||
return False
|
||||
if str(getattr(settings, "MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY", "") or "").strip():
|
||||
return True
|
||||
return bool(getattr(settings, "MESH_PEER_REGISTRY_ENABLED", False))
|
||||
|
||||
|
||||
def _manifest_path() -> str:
|
||||
return str(getattr(get_settings(), "MESH_BOOTSTRAP_MANIFEST_PATH", "") or "data/bootstrap_peers.json")
|
||||
|
||||
|
||||
def _signer_public_key_b64() -> str:
|
||||
from services.mesh.mesh_fleet_defaults import effective_bootstrap_signer_public_key_b64
|
||||
|
||||
return effective_bootstrap_signer_public_key_b64()
|
||||
|
||||
|
||||
def _signer_private_key_b64() -> str:
|
||||
return str(getattr(settings, "MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY", "") or "").strip() if (settings := get_settings()) else ""
|
||||
|
||||
|
||||
def _signer_id() -> str:
|
||||
configured = str(getattr(get_settings(), "MESH_BOOTSTRAP_SIGNER_ID", "") or "").strip()
|
||||
return configured or "shadowbroker-seed"
|
||||
|
||||
|
||||
def _private_transport_required() -> bool:
|
||||
return not bool(getattr(get_settings(), "MESH_INFONET_ALLOW_CLEARNET_SYNC", False))
|
||||
|
||||
|
||||
def _configured_seed_peer_urls() -> list[str]:
|
||||
from services.mesh.mesh_fleet_defaults import configured_bootstrap_seed_peers_with_fleet_default
|
||||
|
||||
settings = get_settings()
|
||||
primary = str(getattr(settings, "MESH_BOOTSTRAP_SEED_PEERS", "") or "").strip()
|
||||
legacy = str(getattr(settings, "MESH_DEFAULT_SYNC_PEERS", "") or "").strip()
|
||||
return configured_bootstrap_seed_peers_with_fleet_default(
|
||||
parse_configured_relay_peers(primary or legacy)
|
||||
)
|
||||
|
||||
|
||||
def _seed_manifest_peers() -> list[dict[str, str]]:
|
||||
peers: list[dict[str, str]] = []
|
||||
for peer_url in _configured_seed_peer_urls():
|
||||
transport = str(peer_transport_kind(peer_url) or "")
|
||||
if _private_transport_required() and transport != "onion":
|
||||
continue
|
||||
peers.append(
|
||||
{
|
||||
"peer_url": peer_url,
|
||||
"transport": transport,
|
||||
"role": "seed",
|
||||
"label": "ShadowBroker bootstrap seed",
|
||||
}
|
||||
)
|
||||
return peers
|
||||
|
||||
|
||||
def publish_registry_manifest(*, now: float | None = None, persist: bool = True) -> BootstrapManifest:
|
||||
private_key = _signer_private_key_b64()
|
||||
public_key = _signer_public_key_b64()
|
||||
if not private_key or not public_key:
|
||||
raise BootstrapManifestError("bootstrap signer keys are required to publish swarm manifest")
|
||||
|
||||
timestamp = int(now if now is not None else time.time())
|
||||
registry = PeerRegistry(DEFAULT_PEER_REGISTRY_PATH)
|
||||
try:
|
||||
registry.load()
|
||||
except Exception:
|
||||
registry = PeerRegistry(DEFAULT_PEER_REGISTRY_PATH)
|
||||
stale_s = int(getattr(get_settings(), "MESH_PEER_REGISTRY_STALE_S", 0) or 7 * 86400)
|
||||
if stale_s > 0:
|
||||
registry.prune_stale(max_age_s=stale_s, now=timestamp)
|
||||
|
||||
peers = _seed_manifest_peers() + registry.manifest_peers()
|
||||
ttl_s = int(getattr(get_settings(), "MESH_SWARM_MANIFEST_TTL_S", 0) or 4 * 3600)
|
||||
payload = build_bootstrap_manifest_payload(
|
||||
signer_id=_signer_id(),
|
||||
peers=peers,
|
||||
issued_at=timestamp,
|
||||
valid_until=timestamp + max(300, ttl_s),
|
||||
)
|
||||
signature = sign_bootstrap_manifest_payload(payload, signer_private_key_b64=private_key)
|
||||
manifest = BootstrapManifest(
|
||||
version=int(payload["version"]),
|
||||
issued_at=int(payload["issued_at"]),
|
||||
valid_until=int(payload["valid_until"]),
|
||||
signer_id=str(payload["signer_id"]),
|
||||
peers=tuple(BootstrapPeer(**dict(peer)) for peer in peers),
|
||||
signature=signature,
|
||||
)
|
||||
if persist:
|
||||
registry.save()
|
||||
write_signed_bootstrap_manifest(
|
||||
_manifest_path(),
|
||||
signer_id=manifest.signer_id,
|
||||
signer_private_key_b64=private_key,
|
||||
peers=[peer.to_dict() for peer in manifest.peers],
|
||||
issued_at=manifest.issued_at,
|
||||
valid_until=manifest.valid_until,
|
||||
)
|
||||
return manifest
|
||||
|
||||
|
||||
def load_live_bootstrap_manifest(*, now: float | None = None) -> BootstrapManifest | None:
|
||||
public_key = _signer_public_key_b64()
|
||||
if not public_key:
|
||||
return None
|
||||
if peer_registry_enabled():
|
||||
try:
|
||||
return publish_registry_manifest(now=now, persist=False)
|
||||
except BootstrapManifestError:
|
||||
logger.warning("live registry manifest unavailable", exc_info=True)
|
||||
try:
|
||||
return load_bootstrap_manifest(_manifest_path(), signer_public_key_b64=public_key, now=now)
|
||||
except BootstrapManifestError:
|
||||
return None
|
||||
|
||||
|
||||
def _upsert_swarm_peer_into_store(
|
||||
*,
|
||||
peer_url: str,
|
||||
transport: str,
|
||||
role: str,
|
||||
label: str = "",
|
||||
signer_id: str = "",
|
||||
now: float | None = None,
|
||||
) -> None:
|
||||
timestamp = int(now if now is not None else time.time())
|
||||
if _private_transport_required() and transport != "onion":
|
||||
return
|
||||
store = PeerStore(DEFAULT_PEER_STORE_PATH)
|
||||
try:
|
||||
store.load()
|
||||
except Exception:
|
||||
store = PeerStore(DEFAULT_PEER_STORE_PATH)
|
||||
store.upsert(
|
||||
make_sync_peer_record(
|
||||
peer_url=peer_url,
|
||||
transport=transport,
|
||||
role=role,
|
||||
source="swarm",
|
||||
label=label,
|
||||
signer_id=signer_id,
|
||||
now=timestamp,
|
||||
)
|
||||
)
|
||||
store.upsert(
|
||||
make_push_peer_record(
|
||||
peer_url=peer_url,
|
||||
transport=transport,
|
||||
role=role if role != "seed" else "relay",
|
||||
source="swarm",
|
||||
label=label,
|
||||
now=timestamp,
|
||||
)
|
||||
)
|
||||
store.save()
|
||||
|
||||
|
||||
def record_peer_announcement(body: dict[str, Any], *, now: float | None = None) -> RegistryPeer:
|
||||
if not peer_registry_enabled():
|
||||
raise ValueError("peer registry is not enabled on this node")
|
||||
registry = PeerRegistry(DEFAULT_PEER_REGISTRY_PATH)
|
||||
try:
|
||||
registry.load()
|
||||
except Exception:
|
||||
registry = PeerRegistry(DEFAULT_PEER_REGISTRY_PATH)
|
||||
peer = registry.upsert_announcement(
|
||||
peer_url=str(body.get("peer_url", "") or ""),
|
||||
transport=str(body.get("transport", "") or ""),
|
||||
role=str(body.get("role", "participant") or "participant"),
|
||||
node_id=str(body.get("node_id", "") or ""),
|
||||
label=str(body.get("label", "") or ""),
|
||||
now=now,
|
||||
)
|
||||
registry.save()
|
||||
_upsert_swarm_peer_into_store(
|
||||
peer_url=peer.peer_url,
|
||||
transport=peer.transport,
|
||||
role=peer.role,
|
||||
label=peer.label,
|
||||
signer_id=_signer_id(),
|
||||
now=now,
|
||||
)
|
||||
try:
|
||||
publish_registry_manifest(now=now, persist=True)
|
||||
except Exception:
|
||||
logger.warning("failed to republish swarm manifest after announce", exc_info=True)
|
||||
return peer
|
||||
|
||||
|
||||
def merge_manifest_into_peer_store(manifest: BootstrapManifest, *, now: float | None = None) -> int:
|
||||
timestamp = int(now if now is not None else time.time())
|
||||
merged = 0
|
||||
for peer in manifest.peers:
|
||||
if _private_transport_required() and peer.transport != "onion":
|
||||
continue
|
||||
_upsert_swarm_peer_into_store(
|
||||
peer_url=peer.peer_url,
|
||||
transport=peer.transport,
|
||||
role=peer.role,
|
||||
label=peer.label,
|
||||
signer_id=manifest.signer_id,
|
||||
now=timestamp,
|
||||
)
|
||||
merged += 1
|
||||
return merged
|
||||
|
||||
|
||||
def fetch_remote_bootstrap_manifest(seed_peer_url: str, *, now: float | None = None) -> BootstrapManifest | None:
|
||||
import requests
|
||||
|
||||
public_key = _signer_public_key_b64()
|
||||
if not public_key:
|
||||
return None
|
||||
normalized = normalize_peer_url(seed_peer_url)
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
from main import _infonet_peer_requests_proxies
|
||||
|
||||
proxies = _infonet_peer_requests_proxies(normalized)
|
||||
timeout = int(getattr(get_settings(), "MESH_SYNC_TIMEOUT_S", 0) or 45)
|
||||
request_kwargs: dict[str, Any] = {"timeout": timeout}
|
||||
if proxies:
|
||||
request_kwargs["proxies"] = proxies
|
||||
try:
|
||||
response = requests.get(f"{normalized}/api/mesh/infonet/bootstrap-manifest", **request_kwargs)
|
||||
except Exception as exc:
|
||||
logger.debug("swarm manifest fetch failed for %s: %s", normalized, exc)
|
||||
return None
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
try:
|
||||
raw = response.json()
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(raw, dict) or raw.get("ok") is False:
|
||||
return None
|
||||
manifest_body = dict(raw.get("manifest") or raw)
|
||||
try:
|
||||
return parse_bootstrap_manifest_dict(
|
||||
manifest_body,
|
||||
signer_public_key_b64=public_key,
|
||||
now=now,
|
||||
)
|
||||
except BootstrapManifestError:
|
||||
return None
|
||||
|
||||
|
||||
def refresh_swarm_manifest_from_seeds(*, now: float | None = None, force: bool = False) -> dict[str, Any]:
|
||||
global _LAST_MANIFEST_PULL_AT
|
||||
interval_s = int(getattr(get_settings(), "MESH_SWARM_MANIFEST_PULL_INTERVAL_S", 0) or 300)
|
||||
timestamp = float(now if now is not None else time.time())
|
||||
with _SWARM_LOCK:
|
||||
if not force and _LAST_MANIFEST_PULL_AT and timestamp - _LAST_MANIFEST_PULL_AT < max(30, interval_s):
|
||||
return {"ok": True, "skipped": True, "reason": "manifest_pull_interval"}
|
||||
_LAST_MANIFEST_PULL_AT = timestamp
|
||||
|
||||
if not _signer_public_key_b64():
|
||||
return {"ok": False, "detail": "MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY is not configured"}
|
||||
|
||||
last_error = "manifest fetch failed"
|
||||
for seed_url in _configured_seed_peer_urls():
|
||||
manifest = fetch_remote_bootstrap_manifest(seed_url, now=timestamp)
|
||||
if manifest is None:
|
||||
continue
|
||||
try:
|
||||
merged = merge_manifest_into_peer_store(manifest, now=timestamp)
|
||||
return {
|
||||
"ok": True,
|
||||
"seed_peer_url": seed_url,
|
||||
"peer_count": len(manifest.peers),
|
||||
"merged_peer_count": merged,
|
||||
}
|
||||
except Exception as exc:
|
||||
last_error = str(exc or type(exc).__name__)
|
||||
return {"ok": False, "detail": last_error}
|
||||
|
||||
|
||||
def announce_local_peer_to_seeds(*, now: float | None = None, force: bool = False) -> dict[str, Any]:
|
||||
global _LAST_ANNOUNCE_AT
|
||||
import hashlib as _hashlib_mod
|
||||
import hmac as _hmac_mod
|
||||
import requests
|
||||
|
||||
from main import _infonet_peer_requests_proxies, _local_infonet_peer_url, _participant_node_enabled
|
||||
|
||||
if not _participant_node_enabled():
|
||||
return {"ok": False, "detail": "participant node disabled"}
|
||||
peer_url = _local_infonet_peer_url()
|
||||
if not peer_url:
|
||||
return {"ok": False, "detail": "local peer URL is not ready"}
|
||||
peer_key = resolve_peer_key_for_url(peer_url)
|
||||
if not peer_key:
|
||||
return {"ok": False, "detail": "peer HMAC secret is not configured"}
|
||||
|
||||
timestamp = float(now if now is not None else time.time())
|
||||
with _SWARM_LOCK:
|
||||
if not force and _LAST_ANNOUNCE_AT and timestamp - _LAST_ANNOUNCE_AT < 300:
|
||||
return {"ok": True, "skipped": True, "reason": "announce_interval"}
|
||||
_LAST_ANNOUNCE_AT = timestamp
|
||||
|
||||
transport = str(peer_transport_kind(peer_url) or "onion")
|
||||
body = {
|
||||
"peer_url": peer_url,
|
||||
"transport": transport,
|
||||
"role": "participant",
|
||||
"node_id": "",
|
||||
"label": "",
|
||||
"ts": int(timestamp),
|
||||
}
|
||||
body_bytes = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
hmac_hex = _hmac_mod.new(peer_key, body_bytes, _hashlib_mod.sha256).hexdigest()
|
||||
timeout = int(getattr(get_settings(), "MESH_RELAY_PUSH_TIMEOUT_S", 0) or 45)
|
||||
results: list[dict[str, Any]] = []
|
||||
for seed_url in _configured_seed_peer_urls():
|
||||
normalized = normalize_peer_url(seed_url)
|
||||
if not normalized:
|
||||
continue
|
||||
proxies = _infonet_peer_requests_proxies(normalized)
|
||||
request_kwargs: dict[str, Any] = {
|
||||
"data": body_bytes,
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"X-Peer-Url": peer_url,
|
||||
"X-Peer-HMAC": hmac_hex,
|
||||
},
|
||||
"timeout": timeout,
|
||||
}
|
||||
if proxies:
|
||||
request_kwargs["proxies"] = proxies
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{normalized}/api/mesh/infonet/peer-announce",
|
||||
**request_kwargs,
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"seed_peer_url": normalized,
|
||||
"status_code": int(response.status_code),
|
||||
"ok": response.status_code == 200,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
results.append({"seed_peer_url": normalized, "ok": False, "detail": str(exc)})
|
||||
ok = any(bool(item.get("ok")) for item in results)
|
||||
return {"ok": ok, "peer_url": peer_url, "results": results}
|
||||
|
||||
|
||||
def _announce_succeeded(announce: dict[str, Any]) -> bool:
|
||||
if not bool(announce.get("ok")):
|
||||
return False
|
||||
results = announce.get("results") or []
|
||||
return any(bool(item.get("ok")) and int(item.get("status_code") or 0) == 200 for item in results)
|
||||
|
||||
|
||||
def _manifest_succeeded(manifest: dict[str, Any]) -> bool:
|
||||
if not bool(manifest.get("ok")):
|
||||
return False
|
||||
peer_count = int(manifest.get("merged_peer_count") or manifest.get("peer_count") or 0)
|
||||
return peer_count >= 1
|
||||
|
||||
|
||||
def join_swarm_with_retries(
|
||||
*,
|
||||
attempts: int = 6,
|
||||
delay_s: float = 15.0,
|
||||
force: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Announce to seed and pull manifest, retrying while Tor circuits warm up."""
|
||||
last_announce: dict[str, Any] = {"ok": False, "detail": "not attempted"}
|
||||
last_manifest: dict[str, Any] = {"ok": False, "detail": "not attempted"}
|
||||
tries = max(1, int(attempts))
|
||||
pause_s = max(1.0, float(delay_s))
|
||||
for attempt in range(tries):
|
||||
last_announce = announce_local_peer_to_seeds(force=force)
|
||||
last_manifest = refresh_swarm_manifest_from_seeds(force=force)
|
||||
if _announce_succeeded(last_announce) and _manifest_succeeded(last_manifest):
|
||||
return {
|
||||
"ok": True,
|
||||
"attempts": attempt + 1,
|
||||
"announce": last_announce,
|
||||
"manifest_pull": last_manifest,
|
||||
}
|
||||
if attempt + 1 < tries:
|
||||
time.sleep(pause_s)
|
||||
return {
|
||||
"ok": False,
|
||||
"attempts": tries,
|
||||
"announce": last_announce,
|
||||
"manifest_pull": last_manifest,
|
||||
"detail": "swarm join incomplete after retries",
|
||||
}
|
||||
|
||||
|
||||
def push_infonet_events_to_http_peers(events: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
import hashlib as _hashlib_mod
|
||||
import hmac as _hmac_mod
|
||||
import requests
|
||||
|
||||
from main import (
|
||||
_filter_infonet_peer_urls,
|
||||
_infonet_peer_requests_proxies,
|
||||
_local_infonet_peer_url,
|
||||
_participant_node_enabled,
|
||||
_record_public_push_result,
|
||||
)
|
||||
from services.mesh.mesh_router import authenticated_push_peer_urls
|
||||
|
||||
if not _participant_node_enabled() or not events:
|
||||
return {"ok": False, "detail": "nothing to push"}
|
||||
peers = _filter_infonet_peer_urls(authenticated_push_peer_urls())
|
||||
if not peers:
|
||||
return {"ok": False, "detail": "no push peers configured"}
|
||||
|
||||
sender_url = _local_infonet_peer_url()
|
||||
peer_key = resolve_peer_key_for_url(sender_url)
|
||||
if not peer_key:
|
||||
return {"ok": False, "detail": "peer HMAC secret is not configured"}
|
||||
|
||||
body_bytes = json.dumps(
|
||||
{"events": events},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
hmac_hex = _hmac_mod.new(peer_key, body_bytes, _hashlib_mod.sha256).hexdigest()
|
||||
timeout = int(getattr(get_settings(), "MESH_RELAY_PUSH_TIMEOUT_S", 0) or 45)
|
||||
results: list[dict[str, Any]] = []
|
||||
for peer_url in peers:
|
||||
normalized = normalize_peer_url(peer_url)
|
||||
if not normalized:
|
||||
continue
|
||||
proxies = _infonet_peer_requests_proxies(normalized)
|
||||
request_kwargs: dict[str, Any] = {
|
||||
"data": body_bytes,
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"X-Peer-Url": sender_url,
|
||||
"X-Peer-HMAC": hmac_hex,
|
||||
},
|
||||
"timeout": timeout,
|
||||
}
|
||||
if proxies:
|
||||
request_kwargs["proxies"] = proxies
|
||||
try:
|
||||
response = requests.post(f"{normalized}/api/mesh/infonet/peer-push", **request_kwargs)
|
||||
results.append(
|
||||
{
|
||||
"peer_url": normalized,
|
||||
"ok": response.status_code == 200,
|
||||
"status_code": int(response.status_code),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
results.append({"peer_url": normalized, "ok": False, "detail": str(exc)})
|
||||
ok = any(bool(item.get("ok")) for item in results)
|
||||
event_id = str((events[-1] or {}).get("event_id", "") or "")
|
||||
_record_public_push_result(
|
||||
event_id,
|
||||
ok=ok,
|
||||
error="" if ok else "immediate peer push failed",
|
||||
results=results,
|
||||
)
|
||||
return {"ok": ok, "results": results}
|
||||
@@ -929,6 +929,85 @@ def list_wormhole_dm_contacts() -> dict[str, dict[str, Any]]:
|
||||
return _read_contacts()
|
||||
|
||||
|
||||
def get_wormhole_dm_contact(peer_id: str) -> dict[str, Any] | None:
|
||||
peer_key = str(peer_id or "").strip()
|
||||
if not peer_key:
|
||||
return None
|
||||
contacts = _read_contacts()
|
||||
if peer_key not in contacts:
|
||||
return None
|
||||
return dict(_normalize_contact(contacts[peer_key]))
|
||||
|
||||
|
||||
def sever_wormhole_dm_contact(peer_id: str, *, block: bool = False) -> dict[str, Any]:
|
||||
"""Close the shared DM lane; a fresh contact request + accept is required to reopen."""
|
||||
peer_key = str(peer_id or "").strip()
|
||||
if not peer_key:
|
||||
return {"ok": False, "detail": "peer_id required"}
|
||||
|
||||
contacts = _read_contacts()
|
||||
current = _normalize_contact(contacts.get(peer_key))
|
||||
now = int(time.time())
|
||||
current["sharedAlias"] = ""
|
||||
current["sharedAliasCounter"] = 0
|
||||
current["sharedAliasPublicKey"] = ""
|
||||
current["sharedAliasPublicKeyAlgo"] = "Ed25519"
|
||||
current["previousSharedAliases"] = []
|
||||
current["pendingSharedAlias"] = ""
|
||||
current["pendingSharedAliasCounter"] = 0
|
||||
current["pendingSharedAliasPublicKey"] = ""
|
||||
current["pendingSharedAliasPublicKeyAlgo"] = "Ed25519"
|
||||
current["pendingSharedAliasGraceMs"] = 0
|
||||
current["sharedAliasGraceUntil"] = 0
|
||||
current["sharedAliasRotatedAt"] = 0
|
||||
current["acceptedPreviousAlias"] = ""
|
||||
current["acceptedPreviousAliasCounter"] = 0
|
||||
current["acceptedPreviousAliasPublicKey"] = ""
|
||||
current["acceptedPreviousAliasPublicKeyAlgo"] = "Ed25519"
|
||||
current["acceptedPreviousGraceUntil"] = 0
|
||||
current["acceptedPreviousHardGraceUntil"] = 0
|
||||
current["acceptedPreviousAwaitingReply"] = False
|
||||
current["aliasBindingSeq"] = 0
|
||||
current["aliasBindingPendingReason"] = ""
|
||||
current["aliasBindingPreparedAt"] = 0
|
||||
current["aliasGateJoinAppliedSeq"] = 0
|
||||
if block:
|
||||
current["blocked"] = True
|
||||
current["updated_at"] = now
|
||||
contacts[peer_key] = _normalize_contact(current)
|
||||
_write_contacts(contacts)
|
||||
|
||||
relay_policy = {}
|
||||
try:
|
||||
from services.mesh.mesh_dm_connect_delivery import revoke_connect_relay_policy
|
||||
|
||||
relay_policy = revoke_connect_relay_policy(peer_key)
|
||||
except Exception:
|
||||
relay_policy = {"ok": False}
|
||||
|
||||
relay_block = {"ok": False}
|
||||
if block:
|
||||
try:
|
||||
from services.mesh.mesh_dm_relay import dm_relay
|
||||
from services.mesh.mesh_wormhole_persona import get_dm_identity
|
||||
|
||||
local_id = str(get_dm_identity().get("node_id", "") or "").strip()
|
||||
if local_id:
|
||||
dm_relay.block(local_id, peer_key)
|
||||
relay_block = {"ok": True, "local_id": local_id}
|
||||
except Exception as exc:
|
||||
relay_block = {"ok": False, "detail": str(exc) or type(exc).__name__}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"peer_id": peer_key,
|
||||
"severed": True,
|
||||
"blocked": bool(block),
|
||||
"relay_policy": relay_policy,
|
||||
"relay_block": relay_block,
|
||||
}
|
||||
|
||||
|
||||
def _promote_invite_lookup_mode(contact: dict[str, Any], *, now: int | None = None) -> bool:
|
||||
current = dict(contact or {})
|
||||
lookup_handle = str(current.get("invitePinnedPrekeyLookupHandle", "") or "").strip()
|
||||
@@ -1070,11 +1149,14 @@ def pin_wormhole_dm_invite(
|
||||
identity_dh_pub_key = str(payload.get("identity_dh_pub_key", "") or "")
|
||||
dh_algo = str(payload.get("dh_algo", "X25519") or "X25519")
|
||||
prekey_lookup_handle = str(payload.get("prekey_lookup_handle", "") or "")
|
||||
lookup_peer_url = str(payload.get("lookup_peer_url", "") or "").strip().rstrip("/")
|
||||
if str(alias or "").strip():
|
||||
current["alias"] = str(alias or "").strip()
|
||||
current["dhPubKey"] = identity_dh_pub_key
|
||||
current["dhAlgo"] = dh_algo
|
||||
current["invitePinnedPrekeyLookupHandle"] = prekey_lookup_handle
|
||||
if lookup_peer_url:
|
||||
current["invitePinnedLookupPeerUrl"] = lookup_peer_url
|
||||
current["invitePinnedRootFingerprint"] = str(payload.get("root_fingerprint", "") or "").strip().lower()
|
||||
current["invitePinnedRootManifestFingerprint"] = str(
|
||||
payload.get("root_manifest_fingerprint", "") or ""
|
||||
@@ -1170,6 +1252,12 @@ def pin_wormhole_dm_invite(
|
||||
current["updated_at"] = now
|
||||
contacts[peer_key] = _normalize_contact(current)
|
||||
_write_contacts(contacts)
|
||||
try:
|
||||
from services.mesh.mesh_dm_connect_delivery import grant_connect_relay_policy
|
||||
|
||||
grant_connect_relay_policy(peer_key, reason="invite_import")
|
||||
except Exception:
|
||||
pass
|
||||
return contacts[peer_key]
|
||||
|
||||
|
||||
|
||||
@@ -549,6 +549,27 @@ def invite_identity_commitment_for_identity_material(
|
||||
return hashlib.sha256(_stable_json(material).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _local_dm_lookup_peer_url() -> str:
|
||||
"""Return this node's fleet-reachable URL for invite-scoped prekey lookup."""
|
||||
try:
|
||||
from services.config import get_settings
|
||||
from services.mesh.mesh_crypto import normalize_peer_url
|
||||
|
||||
configured = normalize_peer_url(str(getattr(get_settings(), "MESH_PUBLIC_PEER_URL", "") or ""))
|
||||
if configured:
|
||||
return configured
|
||||
from services.tor_hidden_service import tor_service
|
||||
|
||||
onion = str(getattr(tor_service, "onion_address", "") or "").strip()
|
||||
if onion:
|
||||
if "://" not in onion:
|
||||
onion = f"http://{onion}:8000"
|
||||
return normalize_peer_url(onion)
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _dm_invite_payload(
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
@@ -930,6 +951,9 @@ def export_wormhole_dm_invite(*, label: str = "", expires_in_s: int = 0) -> dict
|
||||
# fetch our prekey bundle without using our stable agent_id.
|
||||
lookup_handle = secrets.token_hex(24)
|
||||
payload["prekey_lookup_handle"] = lookup_handle
|
||||
lookup_peer_url = _local_dm_lookup_peer_url()
|
||||
if lookup_peer_url:
|
||||
payload["lookup_peer_url"] = lookup_peer_url
|
||||
|
||||
# Persist the handle so it is included in future prekey registrations.
|
||||
existing_handles, _ = _normalize_prekey_lookup_handles(
|
||||
|
||||
@@ -79,6 +79,164 @@ def _warn_legacy_prekey_lookup(agent_id: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _fleet_peer_lookup_user_agent() -> str:
|
||||
custom = str(os.environ.get("SHADOWBROKER_MESH_PEER_USER_AGENT") or "").strip()
|
||||
if custom:
|
||||
return custom
|
||||
return "Mozilla/5.0 (compatible; ShadowbrokerMesh/1.0)"
|
||||
|
||||
|
||||
_INVITE_LOOKUP_MAX_ELAPSED_S = 120
|
||||
_INVITE_LOOKUP_MAX_BOOTSTRAP_PEERS = 3
|
||||
_INVITE_LOOKUP_MAX_PUSH_PEERS = 16
|
||||
_INVITE_LOOKUP_PARALLEL_WORKERS = 8
|
||||
|
||||
|
||||
def _invite_lookup_request_timeout(peer_url: str) -> tuple[int, int]:
|
||||
from services.mesh.mesh_router import peer_transport_kind
|
||||
|
||||
if peer_transport_kind(peer_url) == "onion":
|
||||
return (10, 35)
|
||||
return (5, 15)
|
||||
|
||||
|
||||
def _bootstrap_seed_peer_urls() -> set[str]:
|
||||
try:
|
||||
from services.config import get_settings
|
||||
from services.mesh.mesh_router import parse_configured_relay_peers
|
||||
|
||||
seeds: set[str] = set()
|
||||
raw = str(getattr(get_settings(), "MESH_BOOTSTRAP_SEED_PEERS", "") or "")
|
||||
for peer in parse_configured_relay_peers(raw):
|
||||
normalized = str(peer or "").strip().rstrip("/")
|
||||
if normalized:
|
||||
seeds.add(normalized)
|
||||
return seeds
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def _discovered_push_peer_urls(*, limit: int = _INVITE_LOOKUP_MAX_PUSH_PEERS) -> list[str]:
|
||||
try:
|
||||
from services.mesh.mesh_router import authenticated_push_peer_urls
|
||||
|
||||
seeds = _bootstrap_seed_peer_urls()
|
||||
peers: list[str] = []
|
||||
for peer in authenticated_push_peer_urls():
|
||||
normalized = str(peer or "").strip().rstrip("/")
|
||||
if not normalized or normalized in seeds:
|
||||
continue
|
||||
peers.append(normalized)
|
||||
if len(peers) >= max(1, int(limit or 1)):
|
||||
break
|
||||
return peers
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _prioritized_invite_lookup_peer_urls(*, preferred: list[str] | None = None) -> list[str]:
|
||||
preferred_urls = [
|
||||
str(peer or "").strip().rstrip("/")
|
||||
for peer in list(preferred or [])
|
||||
if str(peer or "").strip()
|
||||
]
|
||||
configured = _configured_public_lookup_peer_urls()
|
||||
seeds = _bootstrap_seed_peer_urls()
|
||||
active: list[str] = []
|
||||
bootstrap: list[str] = []
|
||||
push_discovery: list[str] = []
|
||||
seen = set(preferred_urls)
|
||||
for peer in configured:
|
||||
if peer in seen:
|
||||
continue
|
||||
seen.add(peer)
|
||||
if peer in seeds:
|
||||
bootstrap.append(peer)
|
||||
else:
|
||||
active.append(peer)
|
||||
for peer in _discovered_push_peer_urls():
|
||||
if peer in seen:
|
||||
continue
|
||||
seen.add(peer)
|
||||
push_discovery.append(peer)
|
||||
ordered = list(preferred_urls)
|
||||
ordered.extend(active)
|
||||
ordered.extend(push_discovery)
|
||||
ordered.extend(bootstrap[:_INVITE_LOOKUP_MAX_BOOTSTRAP_PEERS])
|
||||
return ordered
|
||||
|
||||
|
||||
def _preferred_invite_lookup_peer_urls(lookup_token: str) -> list[str]:
|
||||
token = str(lookup_token or "").strip()
|
||||
if not token:
|
||||
return []
|
||||
try:
|
||||
from services.mesh.mesh_wormhole_contacts import list_wormhole_dm_contacts
|
||||
except Exception:
|
||||
return []
|
||||
peers: list[str] = []
|
||||
for contact in list_wormhole_dm_contacts() or []:
|
||||
if not isinstance(contact, dict):
|
||||
continue
|
||||
if str(contact.get("invitePinnedPrekeyLookupHandle", "") or "").strip() != token:
|
||||
continue
|
||||
peer_url = str(contact.get("invitePinnedLookupPeerUrl", "") or "").strip().rstrip("/")
|
||||
if peer_url and peer_url not in peers:
|
||||
peers.append(peer_url)
|
||||
return peers
|
||||
|
||||
|
||||
def _peer_http_request(
|
||||
method: str,
|
||||
peer_url: str,
|
||||
*,
|
||||
body_bytes: bytes | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: int | tuple[int, int] = 45,
|
||||
):
|
||||
"""HTTP to a fleet peer, using Tor SOCKS when the URL is an onion address."""
|
||||
import requests
|
||||
|
||||
from services.mesh.mesh_crypto import normalize_peer_url
|
||||
from urllib.parse import urlparse
|
||||
|
||||
raw_peer_url = str(peer_url or "").strip()
|
||||
parsed = urlparse(raw_peer_url)
|
||||
if parsed.path and parsed.path not in {"", "/"}:
|
||||
# Full request URLs include invite lookup query params; do not
|
||||
# normalize them away when deriving the peer base URL.
|
||||
normalized = raw_peer_url
|
||||
else:
|
||||
normalized = normalize_peer_url(raw_peer_url)
|
||||
if not normalized:
|
||||
raise OSError("invalid peer url")
|
||||
if isinstance(timeout, tuple):
|
||||
connect_timeout, read_timeout = timeout
|
||||
resolved_timeout: int | tuple[int, int] = (
|
||||
max(1, int(connect_timeout or 5)),
|
||||
max(1, int(read_timeout or 15)),
|
||||
)
|
||||
else:
|
||||
resolved_timeout = max(1, int(timeout or 45))
|
||||
request_kwargs: dict[str, Any] = {
|
||||
"headers": dict(headers or {}),
|
||||
"timeout": resolved_timeout,
|
||||
}
|
||||
try:
|
||||
from main import _infonet_peer_requests_proxies
|
||||
|
||||
proxy_peer_url = normalize_peer_url(f"{parsed.scheme}://{parsed.netloc}")
|
||||
proxies = _infonet_peer_requests_proxies(proxy_peer_url)
|
||||
if proxies:
|
||||
request_kwargs["proxies"] = proxies
|
||||
except Exception:
|
||||
pass
|
||||
if method.upper() == "GET":
|
||||
return requests.get(normalized, **request_kwargs)
|
||||
request_kwargs["data"] = body_bytes or b""
|
||||
return requests.post(normalized, **request_kwargs)
|
||||
|
||||
|
||||
def _fetch_dm_prekey_bundle_from_peer_lookup(lookup_token: str) -> dict[str, Any]:
|
||||
"""Fetch an invite-scoped prekey bundle from configured authenticated peers.
|
||||
|
||||
@@ -95,12 +253,12 @@ def _fetch_dm_prekey_bundle_from_peer_lookup(lookup_token: str) -> dict[str, Any
|
||||
normalize_peer_url,
|
||||
resolve_peer_key_for_url,
|
||||
)
|
||||
from services.mesh.mesh_router import configured_relay_peer_urls
|
||||
from services.mesh.mesh_router import authenticated_push_peer_urls
|
||||
|
||||
settings = get_settings()
|
||||
# Issue #256: secret check moved per-peer below. We still bail out
|
||||
# cleanly when there are no peers configured at all.
|
||||
peers = configured_relay_peer_urls()
|
||||
peers = authenticated_push_peer_urls()
|
||||
if not peers:
|
||||
return {"ok": False, "detail": "peer prekey lookup unavailable"}
|
||||
timeout = max(1, _safe_int(getattr(settings, "MESH_RELAY_PUSH_TIMEOUT_S", 10) or 10, 10))
|
||||
@@ -132,17 +290,17 @@ def _fetch_dm_prekey_bundle_from_peer_lookup(lookup_token: str) -> dict[str, Any
|
||||
"X-Peer-Url": sender_peer_url,
|
||||
"X-Peer-HMAC": hmac.new(peer_key, body, hashlib.sha256).hexdigest(),
|
||||
}
|
||||
request = urllib.request.Request(
|
||||
f"{normalized_peer_url}/api/mesh/dm/prekey-peer-lookup",
|
||||
data=body,
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
raw = response.read(256 * 1024)
|
||||
response = _peer_http_request(
|
||||
"POST",
|
||||
f"{normalized_peer_url}/api/mesh/dm/prekey-peer-lookup",
|
||||
body_bytes=body,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
)
|
||||
raw = response.content[: 256 * 1024]
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc:
|
||||
except (json.JSONDecodeError, OSError, Exception) as exc:
|
||||
last_detail = str(exc) or type(exc).__name__
|
||||
continue
|
||||
if isinstance(payload, dict) and payload.get("ok"):
|
||||
@@ -161,12 +319,18 @@ def _configured_public_lookup_peer_urls() -> list[str]:
|
||||
|
||||
settings = get_settings()
|
||||
candidates: list[str] = []
|
||||
# Operator-configured peers first, then recently active fleet nodes.
|
||||
# Invite handles are minted on a specific node; cold bootstrap seeds
|
||||
# rarely have them cached and should not be tried before contacts.
|
||||
for raw in (
|
||||
getattr(settings, "MESH_BOOTSTRAP_SEED_PEERS", ""),
|
||||
getattr(settings, "MESH_DEFAULT_SYNC_PEERS", ""),
|
||||
):
|
||||
candidates.extend(parse_configured_relay_peers(str(raw or "")))
|
||||
candidates.extend(active_sync_peer_urls())
|
||||
for raw in (
|
||||
getattr(settings, "MESH_BOOTSTRAP_SEED_PEERS", ""),
|
||||
):
|
||||
candidates.extend(parse_configured_relay_peers(str(raw or "")))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@@ -204,7 +368,50 @@ def _normalize_remote_lookup_bundle(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return data
|
||||
|
||||
|
||||
def _fetch_dm_prekey_bundle_from_public_lookup(lookup_token: str) -> dict[str, Any]:
|
||||
def _try_public_prekey_lookup_peer(
|
||||
peer_url: str,
|
||||
encoded: str,
|
||||
*,
|
||||
timeout: int | tuple[int, int] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
normalized_peer_url = str(peer_url or "").strip().rstrip("/")
|
||||
if not normalized_peer_url:
|
||||
return {"ok": False, "detail": "invalid peer url"}
|
||||
resolved_timeout = timeout or _invite_lookup_request_timeout(normalized_peer_url)
|
||||
try:
|
||||
response = _peer_http_request(
|
||||
"GET",
|
||||
f"{normalized_peer_url}/api/mesh/dm/prekey-bundle?{encoded}",
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": _fleet_peer_lookup_user_agent(),
|
||||
},
|
||||
timeout=resolved_timeout,
|
||||
)
|
||||
raw = response.content[: 256 * 1024]
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
except (json.JSONDecodeError, OSError, Exception) as exc:
|
||||
logger.debug("public prekey lookup failed for %s: %s", normalized_peer_url, type(exc).__name__)
|
||||
return {"ok": False, "detail": "peer prekey lookup unavailable"}
|
||||
if not isinstance(payload, dict):
|
||||
return {"ok": False, "detail": "invalid peer response"}
|
||||
if payload.get("pending") or str(payload.get("status", "") or "") == "preparing_private_lane":
|
||||
return {"ok": False, "detail": "peer prekey lookup still preparing"}
|
||||
if not payload.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
"detail": str(payload.get("detail", "") or "Prekey bundle not found"),
|
||||
}
|
||||
if not isinstance(payload.get("bundle"), dict):
|
||||
return {"ok": False, "detail": "Prekey bundle not found"}
|
||||
return _normalize_remote_lookup_bundle(payload)
|
||||
|
||||
|
||||
def _fetch_dm_prekey_bundle_from_public_lookup(
|
||||
lookup_token: str,
|
||||
*,
|
||||
extra_preferred_peer_urls: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch an invite-scoped prekey bundle from bootstrap/sync peers.
|
||||
|
||||
The token is high-entropy and invite-scoped. This path does not expose a
|
||||
@@ -212,61 +419,69 @@ def _fetch_dm_prekey_bundle_from_public_lookup(lookup_token: str) -> dict[str, A
|
||||
derive it from the signed identity public key and validate the bundle before
|
||||
accepting it.
|
||||
"""
|
||||
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
||||
|
||||
token = str(lookup_token or "").strip()
|
||||
if not token:
|
||||
return {"ok": False, "detail": "lookup token required"}
|
||||
peers = _configured_public_lookup_peer_urls()
|
||||
preferred = list(_preferred_invite_lookup_peer_urls(token))
|
||||
for peer in list(extra_preferred_peer_urls or []):
|
||||
normalized = str(peer or "").strip().rstrip("/")
|
||||
if normalized and normalized not in preferred:
|
||||
preferred.insert(0, normalized)
|
||||
peers = _prioritized_invite_lookup_peer_urls(preferred=preferred)
|
||||
if not peers:
|
||||
return {"ok": False, "detail": "peer prekey lookup unavailable"}
|
||||
try:
|
||||
from services.config import get_settings
|
||||
|
||||
timeout = max(1, _safe_int(getattr(get_settings(), "MESH_SYNC_TIMEOUT_S", 5) or 5, 5))
|
||||
except Exception:
|
||||
timeout = 5
|
||||
|
||||
encoded = urllib.parse.urlencode({"lookup_token": token})
|
||||
last_detail = ""
|
||||
for peer_url in peers:
|
||||
normalized_peer_url = str(peer_url or "").strip().rstrip("/")
|
||||
if not normalized_peer_url:
|
||||
continue
|
||||
# Generic UA: any peer-facing crypto request should not carry a
|
||||
# fork-specific identifier — that turns prekey lookups into a
|
||||
# software-fingerprinting beacon.
|
||||
from services.network_utils import default_user_agent
|
||||
request = urllib.request.Request(
|
||||
f"{normalized_peer_url}/api/mesh/dm/prekey-bundle?{encoded}",
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": default_user_agent(),
|
||||
},
|
||||
method="GET",
|
||||
hinted_only = bool(list(extra_preferred_peer_urls or []))
|
||||
hint_timeout = (5, 20)
|
||||
for peer_url in preferred:
|
||||
hinted = _try_public_prekey_lookup_peer(
|
||||
peer_url,
|
||||
encoded,
|
||||
timeout=hint_timeout if hinted_only else None,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
raw = response.read(256 * 1024)
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc:
|
||||
logger.debug("public prekey lookup failed for %s: %s", normalized_peer_url, type(exc).__name__)
|
||||
last_detail = "peer prekey lookup unavailable"
|
||||
continue
|
||||
if not isinstance(payload, dict):
|
||||
last_detail = "invalid peer response"
|
||||
continue
|
||||
if payload.get("pending") or str(payload.get("status", "") or "") == "preparing_private_lane":
|
||||
last_detail = "peer prekey lookup still preparing"
|
||||
continue
|
||||
if not payload.get("ok"):
|
||||
last_detail = str(payload.get("detail", "") or last_detail or "Prekey bundle not found")
|
||||
continue
|
||||
if not isinstance(payload.get("bundle"), dict):
|
||||
last_detail = "Prekey bundle not found"
|
||||
continue
|
||||
normalized = _normalize_remote_lookup_bundle(payload)
|
||||
if normalized.get("ok"):
|
||||
return normalized
|
||||
last_detail = str(normalized.get("detail", "") or last_detail)
|
||||
if hinted.get("ok"):
|
||||
return hinted
|
||||
if isinstance(hinted, dict):
|
||||
last_detail = str(hinted.get("detail", "") or last_detail)
|
||||
remaining_peers = [peer for peer in peers if peer not in set(preferred)]
|
||||
if not remaining_peers:
|
||||
return {"ok": False, "detail": last_detail or "Prekey bundle not found"}
|
||||
if hinted_only:
|
||||
return {"ok": False, "detail": last_detail or "Prekey bundle not found"}
|
||||
deadline = time.time() + _INVITE_LOOKUP_MAX_ELAPSED_S
|
||||
workers = min(_INVITE_LOOKUP_PARALLEL_WORKERS, max(1, len(remaining_peers)))
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
futures = {
|
||||
executor.submit(_try_public_prekey_lookup_peer, peer_url, encoded): peer_url
|
||||
for peer_url in remaining_peers
|
||||
}
|
||||
while futures and time.time() < deadline:
|
||||
done, _ = wait(
|
||||
futures,
|
||||
timeout=max(0.1, deadline - time.time()),
|
||||
return_when=FIRST_COMPLETED,
|
||||
)
|
||||
if not done:
|
||||
break
|
||||
for future in done:
|
||||
futures.pop(future, None)
|
||||
try:
|
||||
result = future.result()
|
||||
except Exception as exc:
|
||||
last_detail = str(exc) or type(exc).__name__
|
||||
continue
|
||||
if isinstance(result, dict) and result.get("ok"):
|
||||
for pending in futures:
|
||||
pending.cancel()
|
||||
return result
|
||||
if isinstance(result, dict):
|
||||
last_detail = str(result.get("detail", "") or last_detail)
|
||||
for pending in futures:
|
||||
pending.cancel()
|
||||
return {"ok": False, "detail": last_detail or "Prekey bundle not found"}
|
||||
|
||||
|
||||
@@ -1019,6 +1234,7 @@ def fetch_dm_prekey_bundle(
|
||||
lookup_token: str = "",
|
||||
*,
|
||||
allow_peer_lookup: bool = True,
|
||||
lookup_peer_urls: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from services.mesh.mesh_dm_relay import dm_relay
|
||||
|
||||
@@ -1043,12 +1259,18 @@ def fetch_dm_prekey_bundle(
|
||||
resolved_id = found_id
|
||||
lookup_mode = "invite_lookup_handle"
|
||||
elif allow_peer_lookup:
|
||||
peer_found = _fetch_dm_prekey_bundle_from_peer_lookup(resolved_lookup)
|
||||
if peer_found.get("ok"):
|
||||
return peer_found
|
||||
public_found = _fetch_dm_prekey_bundle_from_public_lookup(resolved_lookup)
|
||||
preferred_peer_urls = list(lookup_peer_urls or [])
|
||||
public_found = _fetch_dm_prekey_bundle_from_public_lookup(
|
||||
resolved_lookup,
|
||||
extra_preferred_peer_urls=preferred_peer_urls,
|
||||
)
|
||||
if public_found.get("ok"):
|
||||
return public_found
|
||||
peer_found: dict[str, Any] = {"ok": False, "detail": ""}
|
||||
if not preferred_peer_urls:
|
||||
peer_found = _fetch_dm_prekey_bundle_from_peer_lookup(resolved_lookup)
|
||||
if peer_found.get("ok"):
|
||||
return peer_found
|
||||
if str(public_found.get("detail", "") or "").strip():
|
||||
return {"ok": False, "detail": str(public_found.get("detail", "") or "Prekey bundle not found")}
|
||||
return {"ok": False, "detail": str(peer_found.get("detail", "") or "Prekey bundle not found")}
|
||||
@@ -1134,12 +1356,24 @@ def _classify_root_attestation_failure(peer_id: str) -> tuple[str, bool]:
|
||||
return "", False
|
||||
|
||||
|
||||
def bootstrap_encrypt_for_peer(peer_id: str, plaintext: str) -> dict[str, Any]:
|
||||
fetched_bundle = fetch_dm_prekey_bundle(str(peer_id or "").strip())
|
||||
def bootstrap_encrypt_for_peer(
|
||||
peer_id: str,
|
||||
plaintext: str,
|
||||
*,
|
||||
lookup_token: str = "",
|
||||
fetched_bundle: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
token = str(lookup_token or "").strip()
|
||||
peer = str(peer_id or "").strip()
|
||||
if fetched_bundle is None:
|
||||
fetched_bundle = fetch_dm_prekey_bundle(
|
||||
agent_id=peer if not token else "",
|
||||
lookup_token=token,
|
||||
)
|
||||
if not fetched_bundle.get("ok"):
|
||||
detail = str(fetched_bundle.get("detail", "") or "")
|
||||
if "root attestation" in detail.lower():
|
||||
trust_level, trust_changed = _classify_root_attestation_failure(str(peer_id or "").strip())
|
||||
trust_level, trust_changed = _classify_root_attestation_failure(peer or token)
|
||||
if trust_level:
|
||||
return {
|
||||
"ok": False,
|
||||
@@ -1152,32 +1386,68 @@ def bootstrap_encrypt_for_peer(peer_id: str, plaintext: str) -> dict[str, Any]:
|
||||
|
||||
from services.mesh.mesh_dm_relay import dm_relay
|
||||
|
||||
resolved_peer_id = str(fetched_bundle.get("agent_id", peer_id) or peer_id).strip()
|
||||
resolved_peer_id = str(fetched_bundle.get("agent_id", peer) or peer).strip()
|
||||
stored = dm_relay.get_prekey_bundle(resolved_peer_id)
|
||||
if not stored:
|
||||
return {"ok": False, "detail": "Peer prekey bundle not found"}
|
||||
remote_bundle = dict(fetched_bundle.get("bundle") or {})
|
||||
if not remote_bundle and fetched_bundle.get("identity_dh_pub_key"):
|
||||
remote_bundle = fetched_bundle
|
||||
if remote_bundle:
|
||||
stored = {
|
||||
"bundle": remote_bundle,
|
||||
"signature": str(fetched_bundle.get("signature", "") or ""),
|
||||
"public_key": str(fetched_bundle.get("public_key", "") or ""),
|
||||
"public_key_algo": str(fetched_bundle.get("public_key_algo", "") or ""),
|
||||
"sequence": _safe_int(fetched_bundle.get("sequence", 0) or 0),
|
||||
}
|
||||
else:
|
||||
return {"ok": False, "detail": "Peer prekey bundle not found"}
|
||||
validated_record = {**dict(stored), "agent_id": resolved_peer_id}
|
||||
ok, reason = _validate_bundle_record(validated_record)
|
||||
if not ok:
|
||||
return {"ok": False, "detail": reason}
|
||||
trust_state = observe_remote_prekey_bundle(resolved_peer_id, validated_record)
|
||||
trust_level = str(trust_state.get("trust_level", "") or "")
|
||||
from services.mesh.mesh_wormhole_contacts import verified_first_contact_requirement
|
||||
consent_handshake = False
|
||||
try:
|
||||
from services.mesh.mesh_wormhole_dead_drop import parse_contact_consent
|
||||
|
||||
verified_first_contact = verified_first_contact_requirement(
|
||||
resolved_peer_id,
|
||||
trust_level=trust_level,
|
||||
)
|
||||
if not verified_first_contact.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
"peer_id": resolved_peer_id,
|
||||
"detail": str(verified_first_contact.get("detail", "") or "verified first contact required"),
|
||||
"trust_changed": trust_level in ("mismatch", "continuity_broken"),
|
||||
"trust_level": str(verified_first_contact.get("trust_level", "") or trust_level or "unpinned"),
|
||||
consent = parse_contact_consent(str(plaintext or "")) or {}
|
||||
consent_handshake = str(consent.get("kind", "") or "") in {
|
||||
"contact_offer",
|
||||
"contact_accept",
|
||||
"contact_deny",
|
||||
}
|
||||
except Exception:
|
||||
consent_handshake = False
|
||||
if not consent_handshake:
|
||||
from services.mesh.mesh_wormhole_contacts import verified_first_contact_requirement
|
||||
|
||||
verified_first_contact = verified_first_contact_requirement(
|
||||
resolved_peer_id,
|
||||
trust_level=trust_level,
|
||||
)
|
||||
if not verified_first_contact.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
"peer_id": resolved_peer_id,
|
||||
"detail": str(
|
||||
verified_first_contact.get("detail", "") or "verified first contact required"
|
||||
),
|
||||
"trust_changed": trust_level in ("mismatch", "continuity_broken"),
|
||||
"trust_level": str(
|
||||
verified_first_contact.get("trust_level", "") or trust_level or "unpinned"
|
||||
),
|
||||
}
|
||||
peer_bundle_stored = dm_relay.consume_one_time_prekey(resolved_peer_id)
|
||||
if not peer_bundle_stored:
|
||||
remote_bundle = dict(stored.get("bundle") or {})
|
||||
otks = list(remote_bundle.get("one_time_prekeys") or [])
|
||||
peer_bundle_stored = {
|
||||
"bundle": remote_bundle,
|
||||
"claimed_one_time_prekey": dict(otks[0] or {}) if otks else {},
|
||||
}
|
||||
if not peer_bundle_stored.get("bundle"):
|
||||
return {"ok": False, "detail": "Peer prekey bundle not found"}
|
||||
peer_bundle = dict(peer_bundle_stored.get("bundle") or {})
|
||||
peer_static = str(peer_bundle.get("identity_dh_pub_key", "") or "")
|
||||
|
||||
@@ -87,6 +87,14 @@ READ_COMMANDS = frozenset({
|
||||
"osint_lookup",
|
||||
"osint_tools",
|
||||
"entity_expand",
|
||||
# Agent routing helpers
|
||||
"route_query",
|
||||
"run_playbook",
|
||||
# Private Infonet reads (operator-delegated)
|
||||
"infonet_status",
|
||||
"list_gates",
|
||||
"read_gate_messages",
|
||||
"poll_dms",
|
||||
})
|
||||
|
||||
WRITE_COMMANDS = frozenset({
|
||||
@@ -118,6 +126,12 @@ WRITE_COMMANDS = frozenset({
|
||||
"clear_analysis_zones",
|
||||
# Active recon (subnet device discovery)
|
||||
"osint_sweep",
|
||||
# Private Infonet writes (operator wormhole identity)
|
||||
"ensure_infonet_ready",
|
||||
"join_infonet_swarm",
|
||||
"post_gate_message",
|
||||
"cast_vote",
|
||||
"send_dm",
|
||||
})
|
||||
|
||||
|
||||
@@ -643,6 +657,19 @@ def _compact_query_result(result: Any) -> Any:
|
||||
# Command dispatcher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _expensive_gate(cmd: str, args: dict[str, Any]) -> dict[str, Any] | None:
|
||||
from services.openclaw_routing import EXPENSIVE_GATE_MESSAGE, requires_expensive_confirm
|
||||
|
||||
if requires_expensive_confirm(cmd, args):
|
||||
return {
|
||||
"ok": False,
|
||||
"detail": EXPENSIVE_GATE_MESSAGE,
|
||||
"code": "expensive_command_blocked",
|
||||
"hint": "route_query",
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _dispatch_command(cmd: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Route a command to the appropriate AI Intel function.
|
||||
|
||||
@@ -650,6 +677,43 @@ def _dispatch_command(cmd: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
Commands run in an isolated thread (via _execute_command) so they
|
||||
do not need or touch the caller's event loop.
|
||||
"""
|
||||
blocked = _expensive_gate(cmd, args)
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
|
||||
if cmd == "route_query":
|
||||
from services.openclaw_routing import route_query
|
||||
|
||||
result = route_query(
|
||||
text=str(args.get("text", "") or args.get("query", "") or ""),
|
||||
lat=args.get("lat"),
|
||||
lng=args.get("lng"),
|
||||
radius_km=float(args.get("radius_km", 50) or 50),
|
||||
compact=bool(args.get("compact", True)),
|
||||
)
|
||||
return {"ok": True, "data": result}
|
||||
|
||||
if cmd == "run_playbook":
|
||||
from services.openclaw_routing import plan_playbook
|
||||
|
||||
plan = plan_playbook(str(args.get("name", "") or args.get("playbook", "")), args)
|
||||
if not plan.get("ok"):
|
||||
return plan
|
||||
batch_results: list[dict[str, Any]] = []
|
||||
for item in plan.get("batch", []):
|
||||
inner_cmd = str(item.get("cmd", "")).strip().lower()
|
||||
inner_args = item.get("args") or {}
|
||||
inner_result = _dispatch_command(inner_cmd, inner_args)
|
||||
batch_results.append({"cmd": inner_cmd, **inner_result})
|
||||
return {
|
||||
"ok": True,
|
||||
"data": {
|
||||
"playbook": plan.get("playbook"),
|
||||
"description": plan.get("description", ""),
|
||||
"results": batch_results,
|
||||
},
|
||||
}
|
||||
|
||||
if cmd == "get_telemetry":
|
||||
from services.telemetry import get_cached_telemetry_refs
|
||||
data = get_cached_telemetry_refs()
|
||||
@@ -731,6 +795,7 @@ def _dispatch_command(cmd: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
owner=str(args.get("owner", "") or args.get("operator", "") or ""),
|
||||
layers=args.get("layers") if isinstance(args.get("layers"), (list, tuple)) else None,
|
||||
limit=args.get("limit", 10),
|
||||
fallback_search=bool(args.get("fallback_search") or args.get("confirm_fuzzy")),
|
||||
)
|
||||
if _wants_compact(args):
|
||||
compact = dict(result)
|
||||
@@ -1092,6 +1157,7 @@ def _dispatch_command(cmd: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
owner=str(args.get("owner", "") or args.get("operator", "") or ""),
|
||||
layers=args.get("layers") if isinstance(args.get("layers"), (list, tuple)) else None,
|
||||
limit=5,
|
||||
fallback_search=True,
|
||||
)
|
||||
best = lookup.get("best_match") if isinstance(lookup.get("best_match"), dict) else {}
|
||||
group = str(best.get("group", "") or entity_type).lower()
|
||||
@@ -1543,6 +1609,85 @@ def _dispatch_command(cmd: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
count = clear_zones(source="openclaw")
|
||||
return {"ok": True, "data": {"removed_count": count}}
|
||||
|
||||
# -- Infonet / gate / DM (operator-delegated, full tier for writes) ------
|
||||
|
||||
if cmd == "infonet_status":
|
||||
from services.openclaw_infonet import get_infonet_status
|
||||
|
||||
return get_infonet_status()
|
||||
|
||||
if cmd == "ensure_infonet_ready":
|
||||
from services.openclaw_infonet import ensure_infonet_ready
|
||||
|
||||
return ensure_infonet_ready(join_swarm=bool(args.get("join_swarm", True)))
|
||||
|
||||
if cmd == "join_infonet_swarm":
|
||||
from services.openclaw_infonet import join_infonet_swarm
|
||||
|
||||
return join_infonet_swarm()
|
||||
|
||||
if cmd == "list_gates":
|
||||
from services.openclaw_infonet import list_gates
|
||||
|
||||
return list_gates()
|
||||
|
||||
if cmd == "read_gate_messages":
|
||||
from services.openclaw_infonet import read_gate_messages
|
||||
|
||||
gate_id = str(args.get("gate_id", "") or args.get("gate", "")).strip()
|
||||
return read_gate_messages(
|
||||
gate_id,
|
||||
limit=int(args.get("limit", 20) or 20),
|
||||
decrypt=bool(args.get("decrypt", False)),
|
||||
)
|
||||
|
||||
if cmd == "post_gate_message":
|
||||
from services.openclaw_infonet import post_gate_message
|
||||
|
||||
gate_id = str(args.get("gate_id", "") or args.get("gate", "")).strip()
|
||||
plaintext = str(args.get("plaintext", "") or args.get("message", "")).strip()
|
||||
return post_gate_message(
|
||||
gate_id,
|
||||
plaintext,
|
||||
reply_to=str(args.get("reply_to", "") or ""),
|
||||
)
|
||||
|
||||
if cmd == "cast_vote":
|
||||
from services.openclaw_infonet import cast_vote
|
||||
|
||||
target_id = str(args.get("target_id", "") or args.get("target", "")).strip()
|
||||
vote_raw = args.get("vote", args.get("direction"))
|
||||
try:
|
||||
vote_val = int(vote_raw)
|
||||
except (TypeError, ValueError):
|
||||
return {"ok": False, "detail": "vote must be 1 or -1"}
|
||||
return cast_vote(
|
||||
target_id,
|
||||
vote_val,
|
||||
gate=str(args.get("gate", "") or args.get("gate_id", "")).strip(),
|
||||
)
|
||||
|
||||
if cmd == "send_dm":
|
||||
from services.openclaw_infonet import send_dm
|
||||
|
||||
peer_id = str(
|
||||
args.get("peer_id", "")
|
||||
or args.get("recipient_id", "")
|
||||
or args.get("recipient", "")
|
||||
).strip()
|
||||
plaintext = str(args.get("plaintext", "") or args.get("message", "")).strip()
|
||||
return send_dm(
|
||||
peer_id,
|
||||
plaintext,
|
||||
delivery_class=str(args.get("delivery_class", "shared") or "shared"),
|
||||
recipient_token=str(args.get("recipient_token", "") or ""),
|
||||
)
|
||||
|
||||
if cmd == "poll_dms":
|
||||
from services.openclaw_infonet import poll_dms
|
||||
|
||||
return poll_dms(limit=int(args.get("limit", 20) or 20))
|
||||
|
||||
return {"ok": False, "detail": f"unhandled command: {cmd}"}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,796 @@
|
||||
"""OpenClaw agent delegation for private Infonet / gate / DM actions.
|
||||
|
||||
Agents authenticate with OpenClaw HMAC on the command channel. Write
|
||||
commands require ``OPENCLAW_ACCESS_TIER=full``. Actions use the operator's
|
||||
local wormhole persona and node runtime — the agent posts on behalf of the
|
||||
user who configured the skill, not as a separate fleet identity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _run_async(coro):
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro)
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def _local_agent_request(path: str, *, method: str = "POST") -> Request:
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": method.upper(),
|
||||
"path": path,
|
||||
"headers": [],
|
||||
"client": ("127.0.0.1", 52421),
|
||||
}
|
||||
request = Request(scope)
|
||||
request.state._private_lane_current_tier = "private_strong"
|
||||
request.state._transport_tier = "private_strong"
|
||||
return request
|
||||
|
||||
|
||||
def ensure_infonet_ready(*, join_swarm: bool = True) -> dict[str, Any]:
|
||||
"""Warm Tor, enable the participant node, and optionally join the swarm."""
|
||||
from routers.ai_intel import _write_env_value
|
||||
from services.config import get_settings
|
||||
from services.mesh.mesh_swarm_runtime import join_swarm_with_retries
|
||||
from services.node_settings import read_node_settings, write_node_settings
|
||||
from services.tor_hidden_service import tor_service
|
||||
from services.wormhole_supervisor import _check_arti_ready
|
||||
|
||||
steps: dict[str, Any] = {}
|
||||
|
||||
tor_result = tor_service.start(target_port=8000)
|
||||
steps["tor"] = tor_result
|
||||
if tor_result.get("ok"):
|
||||
try:
|
||||
_write_env_value("MESH_ARTI_ENABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
except Exception as exc:
|
||||
logger.debug("failed to persist MESH_ARTI_ENABLED: %s", exc)
|
||||
|
||||
if not _check_arti_ready():
|
||||
return {
|
||||
"ok": False,
|
||||
"detail": "Tor/Arti transport is not ready yet",
|
||||
"steps": steps,
|
||||
}
|
||||
|
||||
if not bool(read_node_settings().get("enabled")):
|
||||
write_node_settings(enabled=True)
|
||||
steps["node_enabled"] = True
|
||||
try:
|
||||
import main as main_mod
|
||||
|
||||
main_mod._refresh_node_peer_store()
|
||||
main_mod._start_infonet_node_runtime("openclaw_agent")
|
||||
except Exception as exc:
|
||||
logger.warning("node runtime start after agent enable failed: %s", exc)
|
||||
else:
|
||||
steps["node_enabled"] = True
|
||||
|
||||
if join_swarm:
|
||||
joined = join_swarm_with_retries()
|
||||
steps["announce"] = joined.get("announce") or {}
|
||||
steps["manifest_pull"] = joined.get("manifest_pull") or {}
|
||||
steps["swarm_attempts"] = joined.get("attempts")
|
||||
ok = bool(joined.get("ok"))
|
||||
else:
|
||||
ok = True
|
||||
|
||||
return {
|
||||
"ok": ok,
|
||||
"detail": "Infonet participant runtime ready" if ok else "swarm join incomplete",
|
||||
"steps": steps,
|
||||
"onion_address": str(tor_result.get("onion_address") or ""),
|
||||
}
|
||||
|
||||
|
||||
def join_infonet_swarm() -> dict[str, Any]:
|
||||
from services.mesh.mesh_swarm_runtime import join_swarm_with_retries
|
||||
|
||||
joined = join_swarm_with_retries()
|
||||
return {
|
||||
"ok": bool(joined.get("ok")),
|
||||
"announce": joined.get("announce") or {},
|
||||
"manifest_pull": joined.get("manifest_pull") or {},
|
||||
"attempts": joined.get("attempts"),
|
||||
"detail": joined.get("detail"),
|
||||
}
|
||||
|
||||
|
||||
def get_infonet_status() -> dict[str, Any]:
|
||||
from services.mesh.mesh_hashchain import infonet
|
||||
from services.wormhole_supervisor import get_wormhole_state
|
||||
|
||||
info = infonet.get_info()
|
||||
valid, reason = infonet.validate_chain(verify_signatures=False)
|
||||
try:
|
||||
wormhole = get_wormhole_state()
|
||||
except Exception:
|
||||
wormhole = {"configured": False, "ready": False, "arti_ready": False, "rns_ready": False}
|
||||
try:
|
||||
import main as main_mod
|
||||
|
||||
runtime = main_mod._node_runtime_snapshot()
|
||||
private_tier = main_mod._current_private_lane_tier(wormhole)
|
||||
except Exception:
|
||||
runtime = {}
|
||||
private_tier = "public_degraded"
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"chain": info,
|
||||
"valid": valid,
|
||||
"validation": reason,
|
||||
"private_lane_tier": private_tier,
|
||||
"wormhole": wormhole,
|
||||
"runtime": runtime,
|
||||
}
|
||||
|
||||
|
||||
def list_gates() -> dict[str, Any]:
|
||||
from services.mesh.mesh_reputation import gate_manager
|
||||
|
||||
return {"ok": True, "gates": gate_manager.list_gates()}
|
||||
|
||||
|
||||
def read_gate_messages(
|
||||
gate_id: str,
|
||||
*,
|
||||
limit: int = 20,
|
||||
decrypt: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
from services.mesh.mesh_hashchain import gate_store
|
||||
|
||||
gate_key = str(gate_id or "").strip().lower()
|
||||
if not gate_key:
|
||||
return {"ok": False, "detail": "gate_id required"}
|
||||
|
||||
messages, cursor = gate_store.get_messages_with_cursor(gate_key, limit=max(1, min(int(limit), 100)))
|
||||
out = []
|
||||
if decrypt:
|
||||
from services.mesh.mesh_gate_repair import decrypt_gate_message_with_repair
|
||||
|
||||
for msg in messages:
|
||||
item = dict(msg)
|
||||
try:
|
||||
decrypted = decrypt_gate_message_with_repair(
|
||||
gate_id=gate_key,
|
||||
epoch=int(item.get("epoch") or 0),
|
||||
ciphertext=str(item.get("ciphertext") or ""),
|
||||
nonce=str(item.get("nonce") or item.get("iv") or ""),
|
||||
sender_ref=str(item.get("sender_ref") or ""),
|
||||
gate_envelope=str(item.get("gate_envelope") or ""),
|
||||
envelope_hash=str(item.get("envelope_hash") or ""),
|
||||
event_id=str(item.get("event_id") or ""),
|
||||
)
|
||||
if decrypted.get("ok"):
|
||||
item["plaintext"] = decrypted.get("plaintext", "")
|
||||
except Exception as exc:
|
||||
item["decrypt_error"] = str(exc)
|
||||
out.append(item)
|
||||
else:
|
||||
out = [dict(m) for m in messages]
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"gate": gate_key,
|
||||
"count": len(out),
|
||||
"cursor": cursor,
|
||||
"messages": out,
|
||||
}
|
||||
|
||||
|
||||
def post_gate_message(
|
||||
gate_id: str,
|
||||
plaintext: str,
|
||||
*,
|
||||
reply_to: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Compose, sign, and post an MLS gate message using the operator persona."""
|
||||
from services.mesh.mesh_gate_repair import (
|
||||
compose_gate_message_with_repair,
|
||||
sign_gate_message_with_repair,
|
||||
)
|
||||
from services.mesh.mesh_wormhole_persona import bootstrap_wormhole_persona_state, create_gate_persona
|
||||
|
||||
gate_key = str(gate_id or "").strip().lower()
|
||||
if not gate_key:
|
||||
return {"ok": False, "detail": "gate_id required"}
|
||||
if not str(plaintext or "").strip():
|
||||
return {"ok": False, "detail": "plaintext required"}
|
||||
|
||||
bootstrap_wormhole_persona_state(force=False)
|
||||
try:
|
||||
create_gate_persona(gate_key, label="openclaw-agent")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
composed = compose_gate_message_with_repair(
|
||||
gate_id=gate_key,
|
||||
plaintext=str(plaintext),
|
||||
reply_to=str(reply_to or ""),
|
||||
)
|
||||
if not composed.get("ok"):
|
||||
return composed
|
||||
|
||||
signed = sign_gate_message_with_repair(
|
||||
gate_id=gate_key,
|
||||
epoch=int(composed.get("epoch") or 0),
|
||||
ciphertext=str(composed.get("ciphertext") or ""),
|
||||
nonce=str(composed.get("nonce") or ""),
|
||||
payload_format=str(composed.get("format") or "mls1"),
|
||||
reply_to=str(reply_to or ""),
|
||||
envelope_hash=str(composed.get("envelope_hash") or ""),
|
||||
transport_lock="private_strong",
|
||||
)
|
||||
if not signed.get("ok"):
|
||||
return signed
|
||||
|
||||
body = {
|
||||
"sender_id": str(signed.get("sender_id") or composed.get("sender_id") or ""),
|
||||
"public_key": str(signed.get("public_key") or composed.get("public_key") or ""),
|
||||
"public_key_algo": str(signed.get("public_key_algo") or composed.get("public_key_algo") or ""),
|
||||
"signature": str(signed.get("signature") or ""),
|
||||
"sequence": int(signed.get("sequence") or composed.get("sequence") or 0),
|
||||
"protocol_version": str(signed.get("protocol_version") or composed.get("protocol_version") or ""),
|
||||
"epoch": int(signed.get("epoch") or composed.get("epoch") or 0),
|
||||
"ciphertext": str(signed.get("ciphertext") or composed.get("ciphertext") or ""),
|
||||
"nonce": str(signed.get("nonce") or composed.get("nonce") or ""),
|
||||
"sender_ref": str(signed.get("sender_ref") or composed.get("sender_ref") or ""),
|
||||
"format": str(signed.get("format") or composed.get("format") or "mls1"),
|
||||
"gate_envelope": str(signed.get("gate_envelope") or composed.get("gate_envelope") or ""),
|
||||
"envelope_hash": str(signed.get("envelope_hash") or composed.get("envelope_hash") or ""),
|
||||
"transport_lock": "private_strong",
|
||||
"reply_to": str(signed.get("reply_to") or reply_to or ""),
|
||||
}
|
||||
|
||||
import main as main_mod
|
||||
|
||||
path = f"/api/mesh/gate/{gate_key}/message"
|
||||
request = _local_agent_request(path)
|
||||
return main_mod._submit_gate_message_envelope(request, gate_key, body)
|
||||
|
||||
|
||||
def cast_vote(
|
||||
target_id: str,
|
||||
vote: int,
|
||||
*,
|
||||
gate: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Cast a signed reputation vote using the operator gate/transport persona."""
|
||||
from services.mesh.mesh_hashchain import infonet
|
||||
from services.mesh.mesh_protocol import PROTOCOL_VERSION, normalize_payload
|
||||
from services.mesh.mesh_reputation import gate_manager, reputation_ledger
|
||||
from services.mesh.mesh_wormhole_persona import (
|
||||
bootstrap_wormhole_persona_state,
|
||||
sign_gate_wormhole_event,
|
||||
sign_public_wormhole_event,
|
||||
)
|
||||
|
||||
voter_gate = str(gate or "").strip().lower()
|
||||
target = str(target_id or "").strip()
|
||||
vote_val = int(vote)
|
||||
if not target:
|
||||
return {"ok": False, "detail": "target_id required"}
|
||||
if vote_val not in (1, -1):
|
||||
return {"ok": False, "detail": "vote must be 1 or -1"}
|
||||
|
||||
bootstrap_wormhole_persona_state(force=False)
|
||||
vote_payload = {"target_id": target, "vote": vote_val, "gate": voter_gate}
|
||||
normalized = normalize_payload("vote", vote_payload)
|
||||
ok_payload, reason = True, "ok"
|
||||
from services.mesh.mesh_schema import validate_event_payload
|
||||
|
||||
ok_payload, reason = validate_event_payload("vote", normalized)
|
||||
if not ok_payload:
|
||||
return {"ok": False, "detail": reason}
|
||||
|
||||
if voter_gate:
|
||||
signed = sign_gate_wormhole_event(
|
||||
gate_id=voter_gate,
|
||||
event_type="vote",
|
||||
payload=normalized,
|
||||
)
|
||||
else:
|
||||
signed = sign_public_wormhole_event(event_type="vote", payload=normalized)
|
||||
|
||||
if not signed.get("ok", True):
|
||||
return signed
|
||||
|
||||
voter_id = str(signed.get("node_id") or "")
|
||||
public_key = str(signed.get("public_key") or "")
|
||||
public_key_algo = str(signed.get("public_key_algo") or "")
|
||||
signature = str(signed.get("signature") or "")
|
||||
sequence = int(signed.get("sequence") or 0)
|
||||
|
||||
if voter_gate:
|
||||
can_enter, enter_reason = gate_manager.can_enter(voter_id, voter_gate)
|
||||
if not can_enter:
|
||||
return {"ok": False, "detail": f"Gate vote denied: {enter_reason}"}
|
||||
|
||||
reputation_ledger.register_node(voter_id, public_key, public_key_algo)
|
||||
stable_voter_id = voter_id
|
||||
try:
|
||||
import main as main_mod
|
||||
|
||||
root_nid = main_mod._cached_root_node_id()
|
||||
if root_nid:
|
||||
stable_voter_id = root_nid
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ok, cast_reason, weight = reputation_ledger.cast_vote(
|
||||
stable_voter_id,
|
||||
target,
|
||||
vote_val,
|
||||
voter_gate,
|
||||
)
|
||||
if ok:
|
||||
try:
|
||||
infonet.append(
|
||||
event_type="vote",
|
||||
node_id=voter_id,
|
||||
payload=normalized,
|
||||
signature=signature,
|
||||
sequence=sequence,
|
||||
public_key=public_key,
|
||||
public_key_algo=public_key_algo,
|
||||
protocol_version=str(signed.get("protocol_version") or PROTOCOL_VERSION),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("vote recorded in ledger but infonet append failed: %s", exc)
|
||||
|
||||
return {"ok": ok, "detail": cast_reason, "weight": round(float(weight or 0), 2)}
|
||||
|
||||
|
||||
def _http_post_json(
|
||||
url: str,
|
||||
body: dict[str, Any],
|
||||
*,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
timeout: int = 120,
|
||||
) -> dict[str, Any]:
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
payload_bytes = json.dumps(body, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
req = urllib.request.Request(url, data=payload_bytes, headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
parsed = json.loads(detail)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": False, "detail": detail or f"http {exc.code}"}
|
||||
if not raw:
|
||||
return {}
|
||||
parsed = json.loads(raw)
|
||||
return parsed if isinstance(parsed, dict) else {"ok": False, "detail": "invalid json response"}
|
||||
|
||||
|
||||
def _issue_sender_token_for_http_send(
|
||||
api_base: str,
|
||||
*,
|
||||
recipient: str,
|
||||
delivery: str,
|
||||
recipient_token: str,
|
||||
) -> dict[str, Any]:
|
||||
extra_headers: dict[str, str] = {}
|
||||
admin_key = str(os.environ.get("ADMIN_KEY") or "").strip()
|
||||
if admin_key:
|
||||
extra_headers["X-Admin-Key"] = admin_key
|
||||
return _http_post_json(
|
||||
f"{api_base}/api/wormhole/dm/sender-token",
|
||||
{
|
||||
"recipient_id": recipient,
|
||||
"delivery_class": delivery,
|
||||
"recipient_token": recipient_token,
|
||||
},
|
||||
extra_headers=extra_headers or None,
|
||||
)
|
||||
|
||||
|
||||
def _submit_signed_dm_send(
|
||||
*,
|
||||
recipient: str,
|
||||
delivery_class: str,
|
||||
recipient_token: str,
|
||||
ciphertext: str,
|
||||
payload_format: str,
|
||||
session_welcome: str = "",
|
||||
connect_intent: str = "",
|
||||
lookup_peer_url: str = "",
|
||||
peer_dh_pub: str = "",
|
||||
) -> dict[str, Any]:
|
||||
import main as main_mod
|
||||
from services.mesh.mesh_protocol import (
|
||||
PROTOCOL_VERSION,
|
||||
SIGNED_CONTEXT_FIELD,
|
||||
build_signed_context,
|
||||
)
|
||||
from services.mesh.mesh_schema import validate_event_payload
|
||||
from services.mesh.mesh_wormhole_persona import get_dm_identity, sign_dm_wormhole_event
|
||||
from services.mesh.mesh_wormhole_sender_token import issue_wormhole_dm_sender_token
|
||||
|
||||
delivery = str(delivery_class or "shared").strip().lower()
|
||||
identity = get_dm_identity()
|
||||
sender_id = str(identity.get("node_id") or "")
|
||||
msg_id = secrets.token_hex(16)
|
||||
timestamp = int(time.time())
|
||||
sequence = int(identity.get("sequence", 0) or 0) + 1
|
||||
|
||||
dm_payload: dict[str, Any] = {
|
||||
"recipient_id": recipient,
|
||||
"delivery_class": delivery,
|
||||
"recipient_token": str(recipient_token or ""),
|
||||
"ciphertext": str(ciphertext or ""),
|
||||
"msg_id": msg_id,
|
||||
"timestamp": timestamp,
|
||||
"format": str(payload_format or "mls1"),
|
||||
"transport_lock": "private_strong",
|
||||
}
|
||||
if session_welcome:
|
||||
dm_payload["session_welcome"] = str(session_welcome)
|
||||
|
||||
try:
|
||||
from services.config import get_settings
|
||||
from services.mesh.mesh_wormhole_seal import build_sender_seal
|
||||
|
||||
if (
|
||||
delivery == "shared"
|
||||
and bool(get_settings().MESH_DM_REQUIRE_SENDER_SEAL_SHARED)
|
||||
and not str(dm_payload.get("sender_seal", "") or "").strip()
|
||||
):
|
||||
seal = build_sender_seal(
|
||||
recipient_id=recipient,
|
||||
recipient_dh_pub=str(peer_dh_pub or ""),
|
||||
msg_id=msg_id,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
if seal.get("ok"):
|
||||
dm_payload["sender_seal"] = str(seal.get("sender_seal") or "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ok_payload, reason = validate_event_payload("dm_message", dm_payload)
|
||||
if not ok_payload:
|
||||
return {"ok": False, "detail": reason}
|
||||
|
||||
dm_payload[SIGNED_CONTEXT_FIELD] = build_signed_context(
|
||||
event_type="dm_message",
|
||||
kind="dm_send",
|
||||
endpoint="/api/mesh/dm/send",
|
||||
lane_floor="private_strong",
|
||||
sequence_domain="dm_send",
|
||||
node_id=sender_id,
|
||||
sequence=sequence,
|
||||
payload=dm_payload,
|
||||
recipient_id=recipient,
|
||||
)
|
||||
signed = sign_dm_wormhole_event(
|
||||
event_type="dm_message",
|
||||
payload=dm_payload,
|
||||
sequence=sequence,
|
||||
)
|
||||
if not signed.get("ok", True):
|
||||
return signed
|
||||
|
||||
body = {
|
||||
"sender_id": sender_id,
|
||||
"sender_token": "",
|
||||
"recipient_id": recipient,
|
||||
"delivery_class": delivery,
|
||||
"recipient_token": str(recipient_token or ""),
|
||||
"ciphertext": str(ciphertext or ""),
|
||||
"format": str(payload_format or "mls1"),
|
||||
"transport_lock": "private_strong",
|
||||
"session_welcome": str(session_welcome or ""),
|
||||
"msg_id": msg_id,
|
||||
"timestamp": timestamp,
|
||||
"sender_seal": str(dm_payload.get("sender_seal") or ""),
|
||||
"public_key": str(signed.get("public_key") or ""),
|
||||
"public_key_algo": str(signed.get("public_key_algo") or ""),
|
||||
"signature": str(signed.get("signature") or ""),
|
||||
"sequence": int(signed.get("sequence") or 0),
|
||||
"protocol_version": str(signed.get("protocol_version") or PROTOCOL_VERSION),
|
||||
"signed_context": dict(dm_payload.get(SIGNED_CONTEXT_FIELD) or {}),
|
||||
}
|
||||
normalized_intent = str(connect_intent or "").strip().lower()
|
||||
normalized_lookup_peer = str(lookup_peer_url or "").strip().rstrip("/")
|
||||
if normalized_intent:
|
||||
body["connect_intent"] = normalized_intent
|
||||
if normalized_lookup_peer:
|
||||
body["lookup_peer_url"] = normalized_lookup_peer
|
||||
|
||||
api_base = str(os.environ.get("SB_API_BASE", "http://127.0.0.1:8000") or "http://127.0.0.1:8000").rstrip("/")
|
||||
result: dict[str, Any] = {"ok": False, "detail": "dm send failed"}
|
||||
try:
|
||||
import urllib.error
|
||||
|
||||
if delivery in ("request", "shared"):
|
||||
issued = _issue_sender_token_for_http_send(
|
||||
api_base,
|
||||
recipient=recipient,
|
||||
delivery=delivery,
|
||||
recipient_token=str(recipient_token or ""),
|
||||
)
|
||||
if not issued.get("ok"):
|
||||
return issued
|
||||
body["sender_token"] = str(issued.get("sender_token") or "")
|
||||
|
||||
result = _http_post_json(f"{api_base}/api/mesh/dm/send", body)
|
||||
except (urllib.error.URLError, TimeoutError):
|
||||
if delivery in ("request", "shared"):
|
||||
issued = issue_wormhole_dm_sender_token(
|
||||
recipient_id=recipient,
|
||||
delivery_class=delivery,
|
||||
recipient_token=str(recipient_token or ""),
|
||||
)
|
||||
if not issued.get("ok"):
|
||||
return issued
|
||||
body["sender_token"] = str(issued.get("sender_token") or "")
|
||||
|
||||
async def _send():
|
||||
import json as _json
|
||||
|
||||
raw = _json.dumps(body).encode("utf-8")
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": raw, "more_body": False}
|
||||
|
||||
req = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/api/mesh/dm/send",
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
"client": ("127.0.0.1", 52421),
|
||||
},
|
||||
receive,
|
||||
)
|
||||
req.state._private_lane_current_tier = "private_strong"
|
||||
req.state._transport_tier = "private_strong"
|
||||
return await main_mod.dm_send(req)
|
||||
|
||||
result = _run_async(_send())
|
||||
except Exception as exc:
|
||||
result = {"ok": False, "detail": str(exc) or type(exc).__name__}
|
||||
if isinstance(result, dict):
|
||||
result.setdefault("msg_id", msg_id)
|
||||
result.setdefault("sender_id", sender_id)
|
||||
result.setdefault("recipient_id", recipient)
|
||||
return result
|
||||
|
||||
|
||||
def send_contact_request(
|
||||
*,
|
||||
lookup_token: str = "",
|
||||
peer_id: str = "",
|
||||
note: str = "",
|
||||
lookup_peer_url: str = "",
|
||||
cached_prekey_bundle: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Send a first-contact request using a short address or peer id."""
|
||||
from services.mesh.mesh_wormhole_dead_drop import build_contact_offer
|
||||
from services.mesh.mesh_wormhole_persona import get_dm_identity
|
||||
from services.mesh.mesh_wormhole_prekey import bootstrap_encrypt_for_peer, fetch_dm_prekey_bundle
|
||||
|
||||
token = str(lookup_token or "").strip()
|
||||
peer = str(peer_id or "").strip()
|
||||
if not token and not peer:
|
||||
return {"ok": False, "detail": "lookup_token or peer_id required"}
|
||||
|
||||
preferred_peer = str(lookup_peer_url or "").strip().rstrip("/")
|
||||
if cached_prekey_bundle and cached_prekey_bundle.get("ok"):
|
||||
bundle = dict(cached_prekey_bundle)
|
||||
else:
|
||||
bundle = fetch_dm_prekey_bundle(
|
||||
agent_id=peer if not token else "",
|
||||
lookup_token=token,
|
||||
lookup_peer_urls=[preferred_peer] if preferred_peer else None,
|
||||
)
|
||||
if not bundle.get("ok"):
|
||||
return bundle
|
||||
recipient = str(bundle.get("agent_id") or peer).strip()
|
||||
if not recipient:
|
||||
return {"ok": False, "detail": "recipient unresolved"}
|
||||
|
||||
identity = get_dm_identity()
|
||||
offer = build_contact_offer(
|
||||
dh_pub_key=str(identity.get("dh_pub_key") or ""),
|
||||
dh_algo=str(identity.get("dh_algo") or "X25519"),
|
||||
geo_hint=str(note or ""),
|
||||
)
|
||||
encrypted = bootstrap_encrypt_for_peer(
|
||||
recipient,
|
||||
offer,
|
||||
fetched_bundle=bundle,
|
||||
)
|
||||
if not encrypted.get("ok"):
|
||||
return encrypted
|
||||
|
||||
return _submit_signed_dm_send(
|
||||
recipient=recipient,
|
||||
delivery_class="request",
|
||||
recipient_token="",
|
||||
ciphertext=str(encrypted.get("result") or ""),
|
||||
payload_format="mls1",
|
||||
connect_intent="contact_request",
|
||||
lookup_peer_url=preferred_peer,
|
||||
)
|
||||
|
||||
|
||||
def send_contact_accept(
|
||||
*,
|
||||
peer_id: str,
|
||||
peer_dh_pub: str = "",
|
||||
lookup_token: str = "",
|
||||
lookup_peer_url: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Accept a pending contact request and open the shared DM lane."""
|
||||
from services.mesh.mesh_wormhole_dead_drop import build_contact_accept, issue_pairwise_dm_alias
|
||||
from services.mesh.mesh_wormhole_prekey import bootstrap_encrypt_for_peer, fetch_dm_prekey_bundle
|
||||
|
||||
peer = str(peer_id or "").strip()
|
||||
if not peer:
|
||||
return {"ok": False, "detail": "peer_id required"}
|
||||
|
||||
token = str(lookup_token or "").strip()
|
||||
preferred_peer = str(lookup_peer_url or "").strip().rstrip("/")
|
||||
dh_pub = str(peer_dh_pub or "").strip()
|
||||
if not dh_pub:
|
||||
bundle = fetch_dm_prekey_bundle(
|
||||
agent_id=peer if not token else "",
|
||||
lookup_token=token,
|
||||
lookup_peer_urls=[preferred_peer] if preferred_peer else None,
|
||||
)
|
||||
if not bundle.get("ok"):
|
||||
return bundle
|
||||
dh_pub = str(bundle.get("dh_pub_key") or "").strip()
|
||||
if not dh_pub:
|
||||
return {"ok": False, "detail": "peer dh_pub_key unavailable"}
|
||||
|
||||
alias = issue_pairwise_dm_alias(peer_id=peer, peer_dh_pub=dh_pub)
|
||||
if not alias.get("ok"):
|
||||
return alias
|
||||
shared_alias = str(alias.get("shared_alias") or "").strip()
|
||||
if not shared_alias:
|
||||
return {"ok": False, "detail": "shared_alias unavailable"}
|
||||
|
||||
accept_plain = build_contact_accept(shared_alias=shared_alias)
|
||||
encrypted = bootstrap_encrypt_for_peer(peer, accept_plain, lookup_token=token)
|
||||
if not encrypted.get("ok"):
|
||||
return encrypted
|
||||
|
||||
sent = _submit_signed_dm_send(
|
||||
recipient=peer,
|
||||
delivery_class="request",
|
||||
recipient_token="",
|
||||
ciphertext=str(encrypted.get("result") or ""),
|
||||
payload_format="mls1",
|
||||
connect_intent="contact_accept",
|
||||
lookup_peer_url=preferred_peer,
|
||||
)
|
||||
if isinstance(sent, dict):
|
||||
sent.setdefault("shared_alias", shared_alias)
|
||||
return sent
|
||||
|
||||
|
||||
def send_dm(
|
||||
peer_id: str,
|
||||
plaintext: str,
|
||||
*,
|
||||
delivery_class: str = "shared",
|
||||
recipient_token: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Compose and send an encrypted DM on behalf of the operator."""
|
||||
import main as main_mod
|
||||
|
||||
recipient = str(peer_id or "").strip()
|
||||
if not recipient:
|
||||
return {"ok": False, "detail": "peer_id required"}
|
||||
if not str(plaintext or "").strip():
|
||||
return {"ok": False, "detail": "plaintext required"}
|
||||
|
||||
delivery = str(delivery_class or "shared").strip().lower()
|
||||
if delivery not in ("shared", "request"):
|
||||
return {"ok": False, "detail": "delivery_class must be shared or request"}
|
||||
|
||||
composed = main_mod.compose_wormhole_dm(
|
||||
peer_id=recipient,
|
||||
peer_dh_pub="",
|
||||
plaintext=str(plaintext),
|
||||
)
|
||||
if not composed.get("ok"):
|
||||
return composed
|
||||
|
||||
return _submit_signed_dm_send(
|
||||
recipient=recipient,
|
||||
delivery_class=delivery,
|
||||
recipient_token=str(recipient_token or ""),
|
||||
ciphertext=str(composed.get("ciphertext") or ""),
|
||||
payload_format=str(composed.get("format") or "mls1"),
|
||||
session_welcome=str(composed.get("session_welcome") or ""),
|
||||
)
|
||||
|
||||
|
||||
def poll_dms(*, limit: int = 20) -> dict[str, Any]:
|
||||
"""Poll encrypted DMs for the operator DM identity."""
|
||||
import json
|
||||
|
||||
import main as main_mod
|
||||
from services.mesh.mesh_protocol import PROTOCOL_VERSION
|
||||
from services.mesh.mesh_wormhole_persona import get_dm_identity, sign_dm_wormhole_event
|
||||
|
||||
identity = get_dm_identity()
|
||||
agent_id = str(identity.get("node_id") or "")
|
||||
if not agent_id:
|
||||
return {"ok": False, "detail": "dm identity is not configured"}
|
||||
|
||||
poll_payload = {"mailbox_claims": [], "agent_id": agent_id}
|
||||
signed = sign_dm_wormhole_event(event_type="dm_poll", payload=poll_payload)
|
||||
if not signed.get("ok", True):
|
||||
return signed
|
||||
|
||||
body = {
|
||||
"agent_id": agent_id,
|
||||
"mailbox_claims": [],
|
||||
"timestamp": int(time.time()),
|
||||
"nonce": secrets.token_hex(8),
|
||||
"public_key": str(signed.get("public_key") or ""),
|
||||
"public_key_algo": str(signed.get("public_key_algo") or ""),
|
||||
"signature": str(signed.get("signature") or ""),
|
||||
"sequence": int(signed.get("sequence") or 0),
|
||||
"protocol_version": str(signed.get("protocol_version") or PROTOCOL_VERSION),
|
||||
}
|
||||
|
||||
raw = json.dumps(body).encode("utf-8")
|
||||
|
||||
async def _poll():
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": raw, "more_body": False}
|
||||
|
||||
req = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/api/mesh/dm/poll",
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
"client": ("127.0.0.1", 52421),
|
||||
},
|
||||
receive,
|
||||
)
|
||||
return await main_mod.dm_poll_secure(req)
|
||||
|
||||
result = _run_async(_poll())
|
||||
if isinstance(result, dict):
|
||||
messages = list(result.get("messages") or [])
|
||||
if limit and len(messages) > int(limit):
|
||||
result = dict(result)
|
||||
result["messages"] = messages[: int(limit)]
|
||||
result["count"] = len(result["messages"])
|
||||
return result if isinstance(result, dict) else {"ok": False, "detail": "dm poll failed"}
|
||||
@@ -0,0 +1,531 @@
|
||||
"""Deterministic OpenClaw routing — intent → fastest command.
|
||||
|
||||
Keeps expensive fuzzy scans and full-layer dumps out of the default agent path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
EXPENSIVE_COMMANDS = frozenset({
|
||||
"search_telemetry",
|
||||
"get_telemetry",
|
||||
"get_slow_telemetry",
|
||||
"get_report",
|
||||
})
|
||||
|
||||
EXPENSIVE_GATE_MESSAGE = (
|
||||
"expensive command blocked — use route_query, find_entity, run_playbook, or targeted reads. "
|
||||
"Pass confirm_expensive=true only when fuzzy search or full dumps are intentional."
|
||||
)
|
||||
|
||||
LATENCY_TIER_MS: dict[str, int] = {
|
||||
"channel_status": 5,
|
||||
"route_query": 5,
|
||||
"get_summary": 10,
|
||||
"what_changed": 15,
|
||||
"search_news": 15,
|
||||
"find_flights": 25,
|
||||
"find_ships": 25,
|
||||
"find_entity": 30,
|
||||
"entities_near": 30,
|
||||
"brief_area": 30,
|
||||
"get_layer_slice": 50,
|
||||
"correlate_entity": 15,
|
||||
"entity_expand": 40,
|
||||
"osint_lookup": 200,
|
||||
"run_playbook": 120,
|
||||
"infonet_status": 20,
|
||||
"list_gates": 15,
|
||||
"read_gate_messages": 40,
|
||||
"poll_dms": 80,
|
||||
"ensure_infonet_ready": 120000,
|
||||
"join_infonet_swarm": 90000,
|
||||
"post_gate_message": 15000,
|
||||
"cast_vote": 5000,
|
||||
"send_dm": 20000,
|
||||
"search_telemetry": 8000,
|
||||
"get_telemetry": 3500,
|
||||
"get_slow_telemetry": 1500,
|
||||
"get_report": 5000,
|
||||
}
|
||||
|
||||
RE_N_NUMBER = re.compile(r"\bN\d{1,5}[A-Z]{0,2}\b", re.I)
|
||||
RE_CALLSIGN = re.compile(r"\b[A-Z]{2,4}\d{1,4}[A-Z]?\b")
|
||||
RE_MMSI = re.compile(r"\b\d{9}\b")
|
||||
RE_CVE = re.compile(r"\bCVE-\d{4}-\d+\b", re.I)
|
||||
RE_IPV4 = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
|
||||
RE_DOMAIN = re.compile(
|
||||
r"\b(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,})\b",
|
||||
re.I,
|
||||
)
|
||||
|
||||
KNOWN_CALLSIGNS = frozenset({
|
||||
"AF1", "AF2", "EXEC1", "EXEC2", "SAM", "STALK52", "SPAR19", "SPAR20",
|
||||
})
|
||||
|
||||
PLAYBOOKS: dict[str, dict[str, Any]] = {
|
||||
"hot_snapshot": {
|
||||
"description": "Summary + hot layers + what changed (one batch)",
|
||||
"batch": [
|
||||
{"cmd": "get_summary", "args": {"compact": True}},
|
||||
{
|
||||
"cmd": "get_layer_slice",
|
||||
"args": {
|
||||
"layers": [
|
||||
"news",
|
||||
"telegram_osint",
|
||||
"military_flights",
|
||||
"private_jets",
|
||||
"earthquakes",
|
||||
],
|
||||
"limit_per_layer": 10,
|
||||
"compact": True,
|
||||
},
|
||||
},
|
||||
{"cmd": "what_changed", "args": {"compact": True}},
|
||||
],
|
||||
},
|
||||
"status_check": {
|
||||
"description": "Channel health + layer counts",
|
||||
"batch": [
|
||||
{"cmd": "channel_status", "args": {}},
|
||||
{"cmd": "get_summary", "args": {"compact": True}},
|
||||
],
|
||||
},
|
||||
"morning_brief": {
|
||||
"description": "Operator morning digest layers",
|
||||
"batch": [
|
||||
{"cmd": "get_summary", "args": {"compact": True}},
|
||||
{"cmd": "what_changed", "args": {"compact": True}},
|
||||
{
|
||||
"cmd": "get_layer_slice",
|
||||
"args": {
|
||||
"layers": [
|
||||
"news",
|
||||
"telegram_osint",
|
||||
"gdelt",
|
||||
"earthquakes",
|
||||
"crowdthreat",
|
||||
"military_flights",
|
||||
],
|
||||
"limit_per_layer": 15,
|
||||
"compact": True,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"monitor_heartbeat": {
|
||||
"description": "Low-latency monitor poll (replaces full telemetry pull)",
|
||||
"batch": [
|
||||
{"cmd": "what_changed", "args": {"compact": True}},
|
||||
{
|
||||
"cmd": "get_layer_slice",
|
||||
"args": {
|
||||
"layers": [
|
||||
"military_flights",
|
||||
"ships",
|
||||
"earthquakes",
|
||||
"liveuamap",
|
||||
"crowdthreat",
|
||||
"uap_sightings",
|
||||
"firms_fires",
|
||||
"gps_jamming",
|
||||
"wastewater",
|
||||
],
|
||||
"limit_per_layer": 200,
|
||||
"compact": True,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def routing_manifest() -> dict[str, Any]:
|
||||
"""Machine-readable routing hints for /api/ai/capabilities."""
|
||||
return {
|
||||
"default_read": "find_entity",
|
||||
"preferred_entry": "route_query",
|
||||
"client_wrapper": "ShadowBrokerClient.ask",
|
||||
"batch_playbook": "run_playbook",
|
||||
"last_resort": "search_telemetry",
|
||||
"expensive_commands": sorted(EXPENSIVE_COMMANDS),
|
||||
"latency_tier_ms": LATENCY_TIER_MS,
|
||||
"anti_patterns": [
|
||||
"search_telemetry for known tail numbers, callsigns, owners, or MMSI",
|
||||
"get_telemetry for routine reads — use get_layer_slice or run_playbook hot_snapshot",
|
||||
"sequential send_command loops — use send_batch or run_playbook",
|
||||
"/api/health for liveness — use channel_status",
|
||||
"empty layers: [] on get_layer_slice — pass explicit layer names",
|
||||
],
|
||||
"recipes": [
|
||||
{
|
||||
"intent": "natural language question",
|
||||
"use": "route_query → recommended cmd, or ShadowBrokerClient.ask()",
|
||||
},
|
||||
{
|
||||
"intent": "known person/aircraft",
|
||||
"use": "find_entity(query=...) or find_flights(owner=...)",
|
||||
},
|
||||
{
|
||||
"intent": "news / telegram topic",
|
||||
"use": "search_news(query=...)",
|
||||
},
|
||||
{
|
||||
"intent": "near a point",
|
||||
"use": "entities_near or brief_area",
|
||||
},
|
||||
{
|
||||
"intent": "hot snapshot",
|
||||
"use": "run_playbook(name=hot_snapshot)",
|
||||
},
|
||||
{
|
||||
"intent": "post to infonet gate / join swarm",
|
||||
"use": "ensure_infonet_ready then post_gate_message (full tier)",
|
||||
},
|
||||
{
|
||||
"intent": "read encrypted gate traffic",
|
||||
"use": "read_gate_messages(gate_id=infonet, decrypt=true)",
|
||||
},
|
||||
{
|
||||
"intent": "dm another node",
|
||||
"use": "send_dm(peer_id=..., plaintext=...) (full tier)",
|
||||
},
|
||||
],
|
||||
"playbooks": {
|
||||
name: {"description": spec.get("description", "")}
|
||||
for name, spec in PLAYBOOKS.items()
|
||||
},
|
||||
"agent_surface": {
|
||||
"primary": ["ask", "send_batch", "channel_status"],
|
||||
"writes": [
|
||||
"place_pin",
|
||||
"add_watch",
|
||||
"inject_data",
|
||||
"place_analysis_zone",
|
||||
"ensure_infonet_ready",
|
||||
"post_gate_message",
|
||||
"cast_vote",
|
||||
"send_dm",
|
||||
],
|
||||
"infonet_reads": [
|
||||
"infonet_status",
|
||||
"list_gates",
|
||||
"read_gate_messages",
|
||||
"poll_dms",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def requires_expensive_confirm(cmd: str, args: dict[str, Any] | None) -> bool:
|
||||
if cmd not in EXPENSIVE_COMMANDS:
|
||||
return False
|
||||
if isinstance(args, dict) and args.get("confirm_expensive") is True:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _compact_args(args: dict[str, Any], *, compact: bool) -> dict[str, Any]:
|
||||
out = dict(args)
|
||||
if compact and "compact" not in out:
|
||||
out["compact"] = True
|
||||
return out
|
||||
|
||||
|
||||
def _estimate_ms(cmd: str) -> int:
|
||||
return int(LATENCY_TIER_MS.get(cmd, 100))
|
||||
|
||||
|
||||
def _news_query(text: str) -> str:
|
||||
cleaned = text
|
||||
for prefix in (
|
||||
"news about",
|
||||
"news on",
|
||||
"telegram",
|
||||
"headlines about",
|
||||
"headlines on",
|
||||
"latest on",
|
||||
"search news for",
|
||||
):
|
||||
if cleaned.lower().startswith(prefix):
|
||||
cleaned = cleaned[len(prefix):].strip()
|
||||
return cleaned.strip(" ?.")
|
||||
|
||||
|
||||
def route_query(
|
||||
text: str = "",
|
||||
*,
|
||||
lat: float | None = None,
|
||||
lng: float | None = None,
|
||||
radius_km: float = 50,
|
||||
compact: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Map natural-language intent to the fastest command (no LLM)."""
|
||||
raw = str(text or "").strip()
|
||||
lowered = raw.lower()
|
||||
avoid = ["search_telemetry", "get_telemetry", "get_slow_telemetry"]
|
||||
alternates: list[dict[str, Any]] = []
|
||||
|
||||
if not raw and lat is not None and lng is not None:
|
||||
recommended = {
|
||||
"cmd": "brief_area",
|
||||
"args": _compact_args(
|
||||
{"lat": lat, "lng": lng, "radius_km": radius_km},
|
||||
compact=compact,
|
||||
),
|
||||
}
|
||||
return {
|
||||
"intent": "area_brief",
|
||||
"recommended": recommended,
|
||||
"alternates": [{"cmd": "entities_near", "args": recommended["args"]}],
|
||||
"avoid": avoid,
|
||||
"estimated_ms": _estimate_ms("brief_area"),
|
||||
}
|
||||
|
||||
if not raw:
|
||||
recommended = {"cmd": "get_summary", "args": _compact_args({}, compact=compact)}
|
||||
return {
|
||||
"intent": "discovery",
|
||||
"recommended": recommended,
|
||||
"alternates": [{"cmd": "channel_status", "args": {}}],
|
||||
"avoid": avoid,
|
||||
"estimated_ms": _estimate_ms("get_summary"),
|
||||
}
|
||||
|
||||
cve_match = RE_CVE.search(raw)
|
||||
if cve_match:
|
||||
recommended = {
|
||||
"cmd": "osint_lookup",
|
||||
"args": _compact_args({"tool": "cve", "cve": cve_match.group(0).upper()}, compact=compact),
|
||||
}
|
||||
return _route_result("cve_lookup", recommended, avoid, alternates)
|
||||
|
||||
ip_match = RE_IPV4.search(raw)
|
||||
if ip_match and ("ip" in lowered or "address" in lowered or lowered.count(".") >= 3):
|
||||
recommended = {
|
||||
"cmd": "osint_lookup",
|
||||
"args": _compact_args({"tool": "ip", "ip": ip_match.group(0)}, compact=compact),
|
||||
}
|
||||
alternates.append({"cmd": "entity_expand", "args": {"type": "ip", "id": ip_match.group(0)}})
|
||||
return _route_result("ip_lookup", recommended, avoid, alternates)
|
||||
|
||||
if "whois" in lowered or ("dns" in lowered and RE_DOMAIN.search(raw)):
|
||||
domain = (RE_DOMAIN.search(raw) or re.search(r"\b([a-z0-9-]+\.[a-z]{2,})\b", raw, re.I))
|
||||
tool = "whois" if "whois" in lowered else "dns"
|
||||
domain_value = domain.group(0) if domain else raw
|
||||
recommended = {
|
||||
"cmd": "osint_lookup",
|
||||
"args": _compact_args({"tool": tool, "domain": domain_value}, compact=compact),
|
||||
}
|
||||
return _route_result("domain_lookup", recommended, avoid, alternates)
|
||||
|
||||
if "sanction" in lowered or "ofac" in lowered:
|
||||
recommended = {
|
||||
"cmd": "osint_lookup",
|
||||
"args": _compact_args({"tool": "sanctions", "query": raw}, compact=compact),
|
||||
}
|
||||
return _route_result("sanctions_lookup", recommended, avoid, alternates)
|
||||
|
||||
mmsi_match = RE_MMSI.search(raw)
|
||||
if mmsi_match and any(k in lowered for k in ("mmsi", "ship", "vessel", "yacht", "boat", "maritime")):
|
||||
recommended = {
|
||||
"cmd": "find_ships",
|
||||
"args": _compact_args({"mmsi": mmsi_match.group(0)}, compact=compact),
|
||||
}
|
||||
alternates.append({"cmd": "find_entity", "args": {"mmsi": mmsi_match.group(0), "entity_type": "ship"}})
|
||||
return _route_result("maritime_identifier", recommended, avoid, alternates)
|
||||
|
||||
n_match = RE_N_NUMBER.search(raw)
|
||||
if n_match:
|
||||
reg = n_match.group(0).upper()
|
||||
recommended = {
|
||||
"cmd": "find_flights",
|
||||
"args": _compact_args({"registration": reg}, compact=compact),
|
||||
}
|
||||
alternates.append({"cmd": "find_entity", "args": {"registration": reg, "entity_type": "aircraft"}})
|
||||
return _route_result("tail_number", recommended, avoid, alternates)
|
||||
|
||||
# callsign tokens
|
||||
tokens = re.findall(r"\b[A-Z0-9]{2,8}\b", raw.upper())
|
||||
for token in tokens:
|
||||
if token in KNOWN_CALLSIGNS or RE_CALLSIGN.fullmatch(token):
|
||||
recommended = {
|
||||
"cmd": "find_flights",
|
||||
"args": _compact_args({"callsign": token}, compact=compact),
|
||||
}
|
||||
alternates.append({"cmd": "find_entity", "args": {"callsign": token, "entity_type": "aircraft"}})
|
||||
return _route_result("callsign", recommended, avoid, alternates)
|
||||
|
||||
if any(k in lowered for k in ("news", "telegram", "headline", "headlines", "gdelt")):
|
||||
recommended = {
|
||||
"cmd": "search_news",
|
||||
"args": _compact_args({"query": _news_query(raw), "limit": 10}, compact=compact),
|
||||
}
|
||||
alternates.append({
|
||||
"cmd": "get_layer_slice",
|
||||
"args": {"layers": ["telegram_osint", "news"], "limit_per_layer": 10, "compact": compact},
|
||||
})
|
||||
return _route_result("news_search", recommended, avoid, alternates)
|
||||
|
||||
if lat is not None and lng is not None and any(
|
||||
k in lowered for k in ("near", "around", "within", "radius", "brief", "aoi")
|
||||
):
|
||||
recommended = {
|
||||
"cmd": "brief_area",
|
||||
"args": _compact_args(
|
||||
{"lat": lat, "lng": lng, "radius_km": radius_km, "query": raw},
|
||||
compact=compact,
|
||||
),
|
||||
}
|
||||
alternates.append({
|
||||
"cmd": "entities_near",
|
||||
"args": {"lat": lat, "lng": lng, "radius_km": radius_km, "compact": compact},
|
||||
})
|
||||
return _route_result("area_brief", recommended, avoid, alternates)
|
||||
|
||||
if any(k in lowered for k in ("what changed", "updates", "delta", "since last")):
|
||||
recommended = {"cmd": "what_changed", "args": _compact_args({}, compact=compact)}
|
||||
return _route_result("incremental_poll", recommended, avoid, alternates)
|
||||
|
||||
if any(k in lowered for k in ("summary", "status", "layers populated", "what data")):
|
||||
recommended = {"cmd": "get_summary", "args": _compact_args({}, compact=compact)}
|
||||
alternates.append({"cmd": "channel_status", "args": {}})
|
||||
return _route_result("discovery", recommended, avoid, alternates)
|
||||
|
||||
if any(k in lowered for k in ("recon", "whois", "dns lookup", "cve", "mac address")):
|
||||
recommended = {
|
||||
"cmd": "osint_tools",
|
||||
"args": {},
|
||||
}
|
||||
return _route_result("recon_discovery", recommended, avoid, alternates)
|
||||
|
||||
entity_type = ""
|
||||
if any(k in lowered for k in ("ship", "vessel", "yacht", "boat", "maritime", "carrier")):
|
||||
entity_type = "ship"
|
||||
elif any(k in lowered for k in ("jet", "plane", "flight", "aircraft", "helicopter", "tail")):
|
||||
entity_type = "aircraft"
|
||||
|
||||
owner_hint = ""
|
||||
if any(k in lowered for k in ("owner", "operated by", "'s jet", "'s yacht", "belongs to")):
|
||||
owner_hint = raw
|
||||
for phrase in ("where is", "find", "track", "locate", "jet", "yacht", "plane", "flight", "ship"):
|
||||
owner_hint = re.sub(rf"\b{phrase}\b", "", owner_hint, flags=re.I).strip()
|
||||
|
||||
entity_args: dict[str, Any] = {"query": raw, "compact": compact}
|
||||
if entity_type:
|
||||
entity_args["entity_type"] = entity_type
|
||||
if owner_hint and len(owner_hint) >= 3:
|
||||
entity_args["owner"] = owner_hint
|
||||
|
||||
recommended = {
|
||||
"cmd": "find_entity",
|
||||
"args": _compact_args(entity_args, compact=compact),
|
||||
}
|
||||
alternates = [
|
||||
{"cmd": "search_news", "args": {"query": raw, "limit": 10, "compact": compact}},
|
||||
]
|
||||
if any(k in lowered for k in ("near", "around")):
|
||||
alternates.append({
|
||||
"cmd": "search_telemetry",
|
||||
"args": {"query": raw, "limit": 10, "confirm_expensive": True, "compact": compact},
|
||||
})
|
||||
|
||||
return _route_result("entity_lookup", recommended, avoid, alternates)
|
||||
|
||||
|
||||
def _route_result(
|
||||
intent: str,
|
||||
recommended: dict[str, Any],
|
||||
avoid: list[str],
|
||||
alternates: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
cmd = str(recommended.get("cmd", ""))
|
||||
return {
|
||||
"intent": intent,
|
||||
"recommended": recommended,
|
||||
"alternates": alternates,
|
||||
"avoid": avoid,
|
||||
"estimated_ms": _estimate_ms(cmd),
|
||||
}
|
||||
|
||||
|
||||
def plan_playbook(name: str, args: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Resolve a named playbook to a command batch."""
|
||||
playbook = str(name or "").strip().lower()
|
||||
params = dict(args or {})
|
||||
if not playbook:
|
||||
return {"ok": False, "detail": "playbook name required"}
|
||||
|
||||
if playbook == "track_snapshot":
|
||||
query = str(params.get("query", "") or params.get("name", "") or "").strip()
|
||||
if not query:
|
||||
return {"ok": False, "detail": "track_snapshot requires query"}
|
||||
return {
|
||||
"ok": True,
|
||||
"playbook": playbook,
|
||||
"description": "Resolve entity for tracking",
|
||||
"batch": [
|
||||
{
|
||||
"cmd": "find_entity",
|
||||
"args": {
|
||||
"query": query,
|
||||
"entity_type": params.get("entity_type", ""),
|
||||
"fallback_search": True,
|
||||
"compact": True,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
if playbook == "area_brief":
|
||||
lat = params.get("lat")
|
||||
lng = params.get("lng")
|
||||
if lat is None or lng is None:
|
||||
return {"ok": False, "detail": "area_brief requires lat and lng"}
|
||||
return {
|
||||
"ok": True,
|
||||
"playbook": playbook,
|
||||
"description": "Brief an area of interest",
|
||||
"batch": [
|
||||
{
|
||||
"cmd": "brief_area",
|
||||
"args": {
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"radius_km": params.get("radius_km", 50),
|
||||
"query": params.get("query", ""),
|
||||
"compact": True,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
if playbook == "entity_recon":
|
||||
query = str(params.get("query", "") or params.get("ip", "") or "").strip()
|
||||
ip_match = RE_IPV4.search(query)
|
||||
if not ip_match:
|
||||
return {"ok": False, "detail": "entity_recon requires an IP in query"}
|
||||
return {
|
||||
"ok": True,
|
||||
"playbook": playbook,
|
||||
"description": "IP recon + entity graph",
|
||||
"batch": [
|
||||
{"cmd": "osint_lookup", "args": {"tool": "ip", "ip": ip_match.group(0), "compact": True}},
|
||||
{"cmd": "entity_expand", "args": {"type": "ip", "id": ip_match.group(0)}},
|
||||
],
|
||||
}
|
||||
|
||||
spec = PLAYBOOKS.get(playbook)
|
||||
if not spec:
|
||||
known = sorted(PLAYBOOKS) + ["track_snapshot", "area_brief", "entity_recon"]
|
||||
return {"ok": False, "detail": f"unknown playbook: {playbook}", "known": known}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"playbook": playbook,
|
||||
"description": spec.get("description", ""),
|
||||
"batch": [dict(item) for item in spec.get("batch", [])],
|
||||
}
|
||||
@@ -213,7 +213,7 @@ def validate_privacy_core_startup(settings: Any | None = None) -> None:
|
||||
|
||||
attestation = privacy_core_attestation(snapshot)
|
||||
state = str(attestation.get("attestation_state", "") or "").strip()
|
||||
if state == "attested_current":
|
||||
if state in {"attested_current", "development_override"}:
|
||||
return
|
||||
|
||||
logger.critical(
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Shared Telegram OSINT post text helpers for search and watchdog matching."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from services.telegram_translate import source_lang_label
|
||||
|
||||
|
||||
def iter_telegram_posts(layer_payload: Any) -> list[dict[str, Any]]:
|
||||
"""Normalize telegram_osint layer payloads into a list of post dicts."""
|
||||
if isinstance(layer_payload, list):
|
||||
return [post for post in layer_payload if isinstance(post, dict)]
|
||||
if isinstance(layer_payload, dict):
|
||||
posts = layer_payload.get("posts")
|
||||
if isinstance(posts, list):
|
||||
return [post for post in posts if isinstance(post, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def telegram_post_search_text(post: dict[str, Any]) -> str:
|
||||
"""Build a lowercase haystack for keyword matching (translated + original)."""
|
||||
parts = (
|
||||
post.get("title_translated"),
|
||||
post.get("description_translated"),
|
||||
post.get("title"),
|
||||
post.get("description"),
|
||||
post.get("source"),
|
||||
post.get("channel"),
|
||||
)
|
||||
return " ".join(str(part).strip() for part in parts if str(part or "").strip()).lower()
|
||||
|
||||
|
||||
def telegram_post_display_title(post: dict[str, Any]) -> str:
|
||||
"""Prefer translated headline for alerts and agent-facing summaries."""
|
||||
translated = str(post.get("title_translated") or post.get("description_translated") or "").strip()
|
||||
if translated:
|
||||
return translated.split("\n", 1)[0][:200]
|
||||
return str(post.get("title") or post.get("description") or "").strip()[:200]
|
||||
|
||||
|
||||
def telegram_post_match_entry(post: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Compact match record for watchdog alerts and search results."""
|
||||
lat, lng = None, None
|
||||
coords = post.get("coords")
|
||||
if isinstance(coords, (list, tuple)) and len(coords) >= 2:
|
||||
lat, lng = coords[0], coords[1]
|
||||
return {
|
||||
"source": "telegram_osint",
|
||||
"title": telegram_post_display_title(post),
|
||||
"original_title": str(post.get("title") or "").strip(),
|
||||
"url": post.get("link") or "",
|
||||
"channel": post.get("channel") or post.get("source") or "",
|
||||
"risk_score": post.get("risk_score"),
|
||||
"source_lang": post.get("source_lang"),
|
||||
"source_lang_label": post.get("source_lang_label") or source_lang_label(post.get("source_lang")),
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"id": post.get("id") or post.get("link") or "",
|
||||
}
|
||||
|
||||
|
||||
def keyword_matches_telegram_post(post: dict[str, Any], keyword: str) -> bool:
|
||||
needle = str(keyword or "").strip().lower()
|
||||
if not needle:
|
||||
return False
|
||||
return needle in telegram_post_search_text(post)
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Auto-translation for Telegram OSINT post text (server-side, cached)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import urllib.parse
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CYRILLIC_RE = re.compile(r"[\u0400-\u04FF]")
|
||||
_UKRAINIAN_MARKERS_RE = re.compile(r"[іїєґІЇЄҐ]")
|
||||
_ARABIC_RE = re.compile(r"[\u0600-\u06FF]")
|
||||
_HEBREW_RE = re.compile(r"[\u0590-\u05FF]")
|
||||
_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
|
||||
|
||||
# Common war-reporting shorthand that machine translation often transliterates.
|
||||
_POST_TRANSLATION_GLOSSARY: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
(re.compile(r"\bBpLa\b", re.IGNORECASE), "UAV"),
|
||||
(re.compile(r"\bБпЛА\b", re.IGNORECASE), "UAV"),
|
||||
(re.compile(r"\bбпла\b"), "UAV"),
|
||||
(re.compile(r"\bБПЛА\b"), "UAV"),
|
||||
(re.compile(r"\bрсзв\b", re.IGNORECASE), "MLRS"),
|
||||
(re.compile(r"\bРСЗВ\b"), "MLRS"),
|
||||
)
|
||||
|
||||
_SOURCE_LANG_LABELS = {
|
||||
"uk": "Ukrainian",
|
||||
"ru": "Russian",
|
||||
"en": "English",
|
||||
"ar": "Arabic",
|
||||
"he": "Hebrew",
|
||||
"zh-cn": "Chinese",
|
||||
"fr": "French",
|
||||
"de": "German",
|
||||
"pl": "Polish",
|
||||
}
|
||||
|
||||
_CACHE: dict[str, tuple[str, str]] = {}
|
||||
_CACHE_LOCK = Lock()
|
||||
_CACHE_MAX = 512
|
||||
|
||||
_LOCALE_TO_GOOGLE = {
|
||||
"en": "en",
|
||||
"fr": "fr",
|
||||
"zh-cn": "zh-CN",
|
||||
"zh": "zh-CN",
|
||||
}
|
||||
|
||||
|
||||
def telegram_translate_enabled() -> bool:
|
||||
return str(os.environ.get("TELEGRAM_OSINT_TRANSLATE", "true")).strip().lower() not in {
|
||||
"0",
|
||||
"false",
|
||||
"no",
|
||||
"off",
|
||||
"",
|
||||
}
|
||||
|
||||
|
||||
def telegram_translate_target() -> str:
|
||||
raw = str(os.environ.get("TELEGRAM_OSINT_TRANSLATE_TO", "en")).strip().lower()
|
||||
return _LOCALE_TO_GOOGLE.get(raw, raw or "en")
|
||||
|
||||
|
||||
def normalize_translate_target(locale: str | None) -> str:
|
||||
raw = str(locale or telegram_translate_target()).strip().lower().replace("_", "-")
|
||||
return _LOCALE_TO_GOOGLE.get(raw, raw or "en")
|
||||
|
||||
|
||||
def _looks_english(text: str) -> bool:
|
||||
letters = [char for char in text if char.isalpha()]
|
||||
if not letters:
|
||||
return True
|
||||
ascii_letters = sum(1 for char in letters if ord(char) < 128)
|
||||
return ascii_letters / len(letters) > 0.9
|
||||
|
||||
|
||||
def contains_cyrillic(text: str) -> bool:
|
||||
return bool(_CYRILLIC_RE.search(str(text or "")))
|
||||
|
||||
|
||||
def source_lang_label(code: str | None) -> str:
|
||||
raw = str(code or "").strip().lower().replace("_", "-")
|
||||
return _SOURCE_LANG_LABELS.get(raw, raw.upper() if raw else "Unknown")
|
||||
|
||||
|
||||
def polish_translation(text: str) -> str:
|
||||
polished = str(text or "")
|
||||
for pattern, replacement in _POST_TRANSLATION_GLOSSARY:
|
||||
polished = pattern.sub(replacement, polished)
|
||||
return polished.strip()
|
||||
|
||||
|
||||
def guess_source_lang(text: str) -> str:
|
||||
if _UKRAINIAN_MARKERS_RE.search(text):
|
||||
return "uk"
|
||||
if _CYRILLIC_RE.search(text):
|
||||
return "ru"
|
||||
if _ARABIC_RE.search(text):
|
||||
return "ar"
|
||||
if _HEBREW_RE.search(text):
|
||||
return "he"
|
||||
if _CJK_RE.search(text):
|
||||
return "zh-CN"
|
||||
if _looks_english(text):
|
||||
return "en"
|
||||
return "auto"
|
||||
|
||||
|
||||
def _cache_key(text: str, target_lang: str) -> str:
|
||||
digest = hashlib.sha1(f"{target_lang}|{text}".encode("utf-8")).hexdigest()
|
||||
return digest
|
||||
|
||||
|
||||
def _cache_get(text: str, target_lang: str) -> tuple[str, str] | None:
|
||||
key = _cache_key(text, target_lang)
|
||||
with _CACHE_LOCK:
|
||||
return _CACHE.get(key)
|
||||
|
||||
|
||||
def _cache_put(text: str, target_lang: str, translated: str, source_lang: str) -> None:
|
||||
key = _cache_key(text, target_lang)
|
||||
with _CACHE_LOCK:
|
||||
if len(_CACHE) >= _CACHE_MAX:
|
||||
_CACHE.pop(next(iter(_CACHE)))
|
||||
_CACHE[key] = (translated, source_lang)
|
||||
|
||||
|
||||
def _google_translate(clean: str, target: str, source: str | None = None) -> tuple[str, str]:
|
||||
params = {
|
||||
"client": "gtx",
|
||||
"sl": source or "auto",
|
||||
"tl": target,
|
||||
"dt": "t",
|
||||
"q": clean[:4500],
|
||||
}
|
||||
url = "https://translate.googleapis.com/translate_a/single?" + urllib.parse.urlencode(params)
|
||||
resp = requests.get(
|
||||
url,
|
||||
timeout=8,
|
||||
headers={"User-Agent": "Mozilla/5.0 (compatible; Shadowbroker-Telegram-Translate/1.0)"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
detected = str(data[2] or guess_source_lang(clean)).strip().lower()
|
||||
if detected in {"zh-cn", "zh-tw"}:
|
||||
detected = "zh-CN"
|
||||
parts: list[str] = []
|
||||
for chunk in data[0] or []:
|
||||
if chunk and chunk[0]:
|
||||
parts.append(str(chunk[0]))
|
||||
translated = polish_translation("".join(parts).strip() or clean)
|
||||
return translated, detected
|
||||
|
||||
|
||||
def translate_text(text: str, target_lang: str | None = None) -> tuple[str, str]:
|
||||
"""Translate text via Google Translate (unofficial client endpoint).
|
||||
|
||||
Returns ``(translated_text, detected_source_lang)``.
|
||||
"""
|
||||
clean = str(text or "").strip()
|
||||
if not clean:
|
||||
return "", "en"
|
||||
|
||||
target = normalize_translate_target(target_lang)
|
||||
if _looks_english(clean) and target == "en":
|
||||
return clean, "en"
|
||||
|
||||
cached = _cache_get(clean, target)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
try:
|
||||
translated, detected = _google_translate(clean, target)
|
||||
if detected == target or (detected == "en" and target == "en"):
|
||||
result = (clean, detected)
|
||||
_cache_put(clean, target, clean, detected)
|
||||
return result
|
||||
if contains_cyrillic(translated) and contains_cyrillic(clean):
|
||||
hinted = guess_source_lang(clean)
|
||||
if hinted not in {"auto", target}:
|
||||
retry_translated, retry_detected = _google_translate(clean, target, hinted)
|
||||
if not contains_cyrillic(retry_translated) or len(retry_translated) > len(translated):
|
||||
translated, detected = retry_translated, retry_detected
|
||||
result = (translated, detected)
|
||||
_cache_put(clean, target, translated, detected)
|
||||
return result
|
||||
except Exception as exc:
|
||||
logger.warning("Telegram translation failed: %s", exc)
|
||||
fallback_lang = guess_source_lang(clean)
|
||||
return clean, fallback_lang
|
||||
|
||||
|
||||
def apply_post_translation(post: dict[str, Any], target_lang: str | None = None) -> dict[str, Any]:
|
||||
"""Add translation fields to a Telegram OSINT post dict."""
|
||||
if not telegram_translate_enabled():
|
||||
return post
|
||||
|
||||
target = normalize_translate_target(target_lang)
|
||||
description = str(post.get("description") or "").strip()
|
||||
title = str(post.get("title") or "").strip()
|
||||
full_text = description or title
|
||||
if not full_text:
|
||||
return post
|
||||
|
||||
existing_translated = str(post.get("description_translated") or post.get("title_translated") or "").strip()
|
||||
if post.get("translate_to") == target and existing_translated:
|
||||
updated = dict(post)
|
||||
polished = polish_translation(existing_translated)
|
||||
if polished != existing_translated:
|
||||
lines = polished.split("\n", 1)
|
||||
updated["title_translated"] = lines[0][:160]
|
||||
updated["description_translated"] = polished[:1200]
|
||||
updated["source_lang_label"] = source_lang_label(str(post.get("source_lang") or ""))
|
||||
return updated
|
||||
|
||||
translated_full, source_lang = translate_text(full_text, target)
|
||||
updated = dict(post)
|
||||
updated["source_lang"] = source_lang
|
||||
updated["translate_to"] = target
|
||||
updated["source_lang_label"] = source_lang_label(source_lang)
|
||||
|
||||
if translated_full != full_text and source_lang != target:
|
||||
lines = translated_full.split("\n", 1)
|
||||
updated["title_translated"] = lines[0][:160]
|
||||
updated["description_translated"] = translated_full[:1200]
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
def apply_posts_translations(
|
||||
posts: list[dict[str, Any]],
|
||||
target_lang: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not telegram_translate_enabled():
|
||||
return posts
|
||||
return [apply_post_translation(post, target_lang) for post in posts]
|
||||
@@ -710,10 +710,10 @@ _UNIVERSAL_SEARCH_SPECS: dict[str, dict[str, Any]] = {
|
||||
"time_fields": ("updated_at", "timestamp"),
|
||||
},
|
||||
"telegram_osint": {
|
||||
"fields": ("title", "description", "source", "channel", "link"),
|
||||
"primary_fields": ("title", "description", "channel"),
|
||||
"label_fields": ("title", "channel"),
|
||||
"summary_fields": ("description", "source"),
|
||||
"fields": ("title", "description", "title_translated", "description_translated", "source", "channel", "link"),
|
||||
"primary_fields": ("title_translated", "title", "description_translated", "description", "channel"),
|
||||
"label_fields": ("title_translated", "title", "channel"),
|
||||
"summary_fields": ("description_translated", "description", "source"),
|
||||
"type_fields": ("channel", "source"),
|
||||
"id_fields": ("id", "link"),
|
||||
"time_fields": ("published", "timestamp"),
|
||||
@@ -1549,11 +1549,13 @@ def find_entity(
|
||||
owner: str = "",
|
||||
layers: list[str] | tuple[str, ...] | None = None,
|
||||
limit: int = 10,
|
||||
fallback_search: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Find a named entity across aircraft, maritime, and general telemetry.
|
||||
|
||||
This is an intent-level lookup for agents. It tries high-precision
|
||||
aircraft/ship fields first, then falls back to the universal search index.
|
||||
aircraft/ship fields first, then optionally falls back to the universal
|
||||
search index only when ``fallback_search`` is True (opt-in fuzzy scan).
|
||||
"""
|
||||
effective_query = str(query or name or owner or callsign or registration or icao24 or mmsi or imo or "").strip()
|
||||
if not effective_query:
|
||||
@@ -1628,16 +1630,18 @@ def find_entity(
|
||||
seen.add(key)
|
||||
results.append(normalized)
|
||||
|
||||
search_layers = requested_layers or _entity_layers_for_type(entity_type)
|
||||
search_result = search_telemetry(query=effective_query, layers=search_layers, limit=limit)
|
||||
if search_result.get("results"):
|
||||
strategies.append("universal_index")
|
||||
for item in search_result.get("results") or []:
|
||||
normalized = _normalize_entity_result(item)
|
||||
key = _entity_key(normalized)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
results.append(normalized)
|
||||
search_layers = list(requested_layers or _entity_layers_for_type(entity_type) or [])
|
||||
search_result: dict[str, Any] = {"results": [], "searched_layers": search_layers}
|
||||
if fallback_search:
|
||||
search_result = search_telemetry(query=effective_query, layers=search_layers, limit=limit)
|
||||
if search_result.get("results"):
|
||||
strategies.append("universal_index")
|
||||
for item in search_result.get("results") or []:
|
||||
normalized = _normalize_entity_result(item)
|
||||
key = _entity_key(normalized)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
results.append(normalized)
|
||||
|
||||
results.sort(
|
||||
key=lambda item: (
|
||||
@@ -2085,30 +2089,27 @@ def search_news(
|
||||
return {"results": out, "version": get_data_version(), "truncated": True}
|
||||
|
||||
if include_telegram:
|
||||
from services.telegram_osint_text import telegram_post_display_title, telegram_post_search_text
|
||||
|
||||
for post in _unwrap_layer_items(snap.get("telegram_osint"), "telegram_osint"):
|
||||
if not isinstance(post, dict):
|
||||
continue
|
||||
text = " ".join(
|
||||
(
|
||||
_norm_text(post.get("title")),
|
||||
_norm_text(post.get("description")),
|
||||
_norm_text(post.get("source")),
|
||||
_norm_text(post.get("channel")),
|
||||
)
|
||||
)
|
||||
text = telegram_post_search_text(post)
|
||||
if not _text_matches_query(query_norm, text):
|
||||
continue
|
||||
lat, lng = _extract_coords(post)
|
||||
out.append(
|
||||
{
|
||||
"source_layer": "telegram_osint",
|
||||
"title": post.get("title") or "",
|
||||
"summary": post.get("description") or "",
|
||||
"title": telegram_post_display_title(post),
|
||||
"summary": post.get("description_translated") or post.get("description") or "",
|
||||
"source": post.get("source") or post.get("channel") or "Telegram",
|
||||
"link": post.get("link") or "",
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"risk_score": post.get("risk_score"),
|
||||
"source_lang": post.get("source_lang"),
|
||||
"source_lang_label": post.get("source_lang_label"),
|
||||
}
|
||||
)
|
||||
if len(out) >= limit:
|
||||
|
||||
@@ -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,14 +450,9 @@ class TorHiddenService:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
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"
|
||||
"SocksPort 9050\n"
|
||||
"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(
|
||||
@@ -428,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
|
||||
@@ -65,20 +72,48 @@ _WORMHOLE_ENV_EXPLICIT = {
|
||||
"CORS_ORIGINS",
|
||||
"PUBLIC_API_KEY",
|
||||
"PRIVACY_CORE_ALLOWED_SHA256",
|
||||
"PRIVACY_CORE_DEV_OVERRIDE",
|
||||
"PRIVACY_CORE_LIB",
|
||||
"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":
|
||||
@@ -87,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 (
|
||||
@@ -109,18 +191,23 @@ 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) — proxy is not trusted as Tor",
|
||||
"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": 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:
|
||||
logger.warning("Arti Tor proof request failed on port %s: %s", socks_port, exc)
|
||||
_ARTI_PROOF_CACHE.update({"port": socks_port, "ok": False, "ts": now})
|
||||
return False
|
||||
logger.warning(
|
||||
"Arti Tor proof request failed on port %s: %s — SOCKS is up, using Arti anyway",
|
||||
socks_port,
|
||||
exc,
|
||||
)
|
||||
_ARTI_PROOF_CACHE.update({"port": socks_port, "ok": True, "ts": now})
|
||||
return True
|
||||
|
||||
|
||||
def get_transport_tier() -> str:
|
||||
@@ -285,6 +372,23 @@ def _terminate_pid(pid: int, *, timeout_s: float = 5.0) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _trust_wormhole_file_ready(status: dict[str, Any] | None = None) -> bool:
|
||||
try:
|
||||
from services.config import get_settings
|
||||
|
||||
if not bool(getattr(get_settings(), "MESH_WORMHOLE_TRUST_FILE_READY", False)):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
snapshot = status if status is not None else read_wormhole_status()
|
||||
if not bool(snapshot.get("ready")):
|
||||
return False
|
||||
started_at = int(snapshot.get("started_at", 0) or 0)
|
||||
if started_at <= 0:
|
||||
return False
|
||||
return (time.time() - started_at) < 3600
|
||||
|
||||
|
||||
def _probe_ready(timeout_s: float = 1.5) -> bool:
|
||||
try:
|
||||
with urlopen(f"http://{WORMHOLE_HOST}:{WORMHOLE_PORT}/api/health", timeout=timeout_s) as resp:
|
||||
@@ -333,7 +437,10 @@ def _current_runtime_state() -> dict[str, Any]:
|
||||
if not running and _probe_ready(timeout_s=0.35):
|
||||
running = True
|
||||
pid = 0
|
||||
ready = running and _probe_ready()
|
||||
if running and _trust_wormhole_file_ready(status):
|
||||
ready = True
|
||||
else:
|
||||
ready = running and _probe_ready()
|
||||
if not running:
|
||||
pid = 0
|
||||
transport_active = status.get("transport_active", "") if ready else ""
|
||||
@@ -514,7 +621,8 @@ def connect_wormhole(*, reason: str = "connect") -> dict[str, Any]:
|
||||
proxy=str(settings.get("socks_proxy", "")),
|
||||
)
|
||||
|
||||
deadline = time.monotonic() + 20.0
|
||||
startup_deadline_s = float(os.environ.get("WORMHOLE_STARTUP_DEADLINE_S", "60") or 60)
|
||||
deadline = time.monotonic() + max(20.0, startup_deadline_s)
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
err = f"Wormhole exited with code {process.returncode}."
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from services.mesh import mesh_dm_connect_delivery as connect
|
||||
|
||||
|
||||
def test_should_auto_release_for_connect_intent():
|
||||
payload = {
|
||||
"delivery_class": "request",
|
||||
"connect_intent": "contact_request",
|
||||
"recipient_id": "!sb_peer",
|
||||
}
|
||||
assert connect.should_auto_release_dm_payload(payload) is True
|
||||
|
||||
|
||||
def test_should_auto_release_for_lookup_peer_url():
|
||||
payload = {
|
||||
"delivery_class": "request",
|
||||
"lookup_peer_url": "http://owner.onion:8000",
|
||||
"recipient_id": "!sb_peer",
|
||||
}
|
||||
assert connect.should_auto_release_dm_payload(payload) is True
|
||||
|
||||
|
||||
def test_should_not_auto_release_shared_lane():
|
||||
payload = {
|
||||
"delivery_class": "shared",
|
||||
"connect_intent": "contact_request",
|
||||
"recipient_id": "!sb_peer",
|
||||
}
|
||||
assert connect.should_auto_release_dm_payload(payload) is False
|
||||
|
||||
|
||||
def test_enrich_connect_release_payload_prefers_explicit_lookup():
|
||||
enriched = connect.enrich_connect_release_payload(
|
||||
{
|
||||
"recipient_id": "!sb_peer",
|
||||
"lookup_peer_url": "http://owner.onion:8000/",
|
||||
}
|
||||
)
|
||||
assert enriched["lookup_peer_url"] == "http://owner.onion:8000"
|
||||
assert enriched["relay_push_peer_urls"] == ["http://owner.onion:8000"]
|
||||
|
||||
|
||||
def test_relay_push_peer_urls_dedupes_and_prioritizes_lookup():
|
||||
urls = connect.relay_push_peer_urls_for_payload(
|
||||
{
|
||||
"lookup_peer_url": "http://owner.onion:8000",
|
||||
"relay_push_peer_urls": ["http://relay.onion:8000", "http://owner.onion:8000"],
|
||||
}
|
||||
)
|
||||
assert urls[0] == "http://owner.onion:8000"
|
||||
assert "http://relay.onion:8000" in urls
|
||||
assert len(urls) == 2
|
||||
@@ -0,0 +1,45 @@
|
||||
"""dm_get_pubkey resolves invite handles across the private fleet."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_get_pubkey_falls_back_to_fleet_prekey_lookup():
|
||||
import main
|
||||
|
||||
request = main.Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/api/mesh/dm/pubkey",
|
||||
"headers": [],
|
||||
"client": ("127.0.0.1", 12345),
|
||||
}
|
||||
)
|
||||
|
||||
remote_bundle = {
|
||||
"ok": True,
|
||||
"agent_id": "!sb_peer_test",
|
||||
"identity_dh_pub_key": "Uo/wk78hu+ISyT9iCjNhcWgiANaHSXLMyNLn2q8YCkc=",
|
||||
"dh_algo": "X25519",
|
||||
"public_key": "v0pVNDQAz8wzvpMfIURjjVyCHhKZlAmrDPGaqzoJ7Rk=",
|
||||
"public_key_algo": "Ed25519",
|
||||
"signature": "sig",
|
||||
"sequence": 1,
|
||||
"bundle": {"identity_dh_pub_key": "Uo/wk78hu+ISyT9iCjNhcWgiANaHSXLMyNLn2q8YCkc="},
|
||||
}
|
||||
|
||||
with patch("services.mesh.mesh_dm_relay.dm_relay") as relay, patch(
|
||||
"services.mesh.mesh_wormhole_prekey.fetch_dm_prekey_bundle",
|
||||
return_value=remote_bundle,
|
||||
):
|
||||
relay.get_dh_key_by_lookup.return_value = (None, "")
|
||||
result = await main.dm_get_pubkey(request, lookup_token="fleet-handle-token")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["agent_id"] == "!sb_peer_test"
|
||||
assert result["dh_pub_key"] == "Uo/wk78hu+ISyT9iCjNhcWgiANaHSXLMyNLn2q8YCkc="
|
||||
@@ -0,0 +1,126 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from services.mesh import mesh_infonet_relay_bootstrap as relay_bootstrap
|
||||
|
||||
|
||||
def test_relay_auto_wormhole_skipped_by_default(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
relay_bootstrap,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE=False,
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED=False,
|
||||
MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY="",
|
||||
),
|
||||
)
|
||||
assert relay_bootstrap.infonet_relay_auto_wormhole_requested() is False
|
||||
|
||||
|
||||
def test_relay_auto_wormhole_enabled_by_flag(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
relay_bootstrap,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE=True,
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED=False,
|
||||
MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY="",
|
||||
),
|
||||
)
|
||||
assert relay_bootstrap.infonet_relay_auto_wormhole_requested() is True
|
||||
|
||||
|
||||
def test_relay_auto_wormhole_enabled_by_seed_signer_key(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
relay_bootstrap,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE=False,
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED=False,
|
||||
MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY="seed-private-key",
|
||||
),
|
||||
)
|
||||
assert relay_bootstrap.infonet_relay_auto_wormhole_requested() is True
|
||||
|
||||
|
||||
def test_relay_auto_wormhole_disabled_override(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
relay_bootstrap,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE=True,
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED=True,
|
||||
MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY="seed-private-key",
|
||||
),
|
||||
)
|
||||
assert relay_bootstrap.infonet_relay_auto_wormhole_requested() is False
|
||||
|
||||
|
||||
def test_ensure_relay_wormhole_writes_settings_and_connects(monkeypatch, tmp_path):
|
||||
wormhole_file = tmp_path / "wormhole.json"
|
||||
monkeypatch.setattr(relay_bootstrap, "WORMHOLE_FILE", wormhole_file, raising=False)
|
||||
monkeypatch.setattr(
|
||||
"services.wormhole_settings.WORMHOLE_FILE",
|
||||
wormhole_file,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.wormhole_settings.DATA_DIR",
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
settings = SimpleNamespace(
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE=True,
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE_DISABLED=False,
|
||||
MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY="",
|
||||
MESH_ARTI_SOCKS_PORT=9050,
|
||||
)
|
||||
monkeypatch.setattr(relay_bootstrap, "get_settings", lambda: settings)
|
||||
|
||||
tor_calls: list[int] = []
|
||||
|
||||
class _TorService:
|
||||
def start(self, *, target_port: int):
|
||||
tor_calls.append(target_port)
|
||||
return {"ok": True, "hostname": "example.onion"}
|
||||
|
||||
env_writes: list[tuple[str, str]] = []
|
||||
|
||||
def _fake_write_env_value(key: str, value: str) -> None:
|
||||
env_writes.append((key, value))
|
||||
|
||||
wormhole_calls: list[str] = []
|
||||
|
||||
def _fake_restart_wormhole(*, reason: str):
|
||||
wormhole_calls.append(f"restart:{reason}")
|
||||
return {"connected": True, "reason": reason}
|
||||
|
||||
def _fake_connect_wormhole(*, reason: str):
|
||||
wormhole_calls.append(f"connect:{reason}")
|
||||
return {"connected": True, "reason": reason}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"services.tor_hidden_service.tor_service",
|
||||
_TorService(),
|
||||
)
|
||||
monkeypatch.setattr("routers.ai_intel._write_env_value", _fake_write_env_value)
|
||||
monkeypatch.setattr(
|
||||
"services.wormhole_supervisor.restart_wormhole",
|
||||
_fake_restart_wormhole,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.wormhole_supervisor.connect_wormhole",
|
||||
_fake_connect_wormhole,
|
||||
)
|
||||
|
||||
result = relay_bootstrap.ensure_infonet_relay_wormhole_ready(reason="test_relay")
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["skipped"] is False
|
||||
assert result["settings_updated"] is True
|
||||
assert tor_calls == [8000]
|
||||
assert env_writes == [("MESH_ARTI_ENABLED", "true")]
|
||||
assert wormhole_calls == ["restart:test_relay"]
|
||||
saved = relay_bootstrap.read_wormhole_settings()
|
||||
assert saved["enabled"] is True
|
||||
assert saved["transport"] == "tor_arti"
|
||||
assert saved["socks_proxy"] == "socks5h://127.0.0.1:9050"
|
||||
assert saved["anonymous_mode"] is True
|
||||
@@ -111,42 +111,101 @@ def test_dm_send_keeps_encrypted_payloads_off_ledger(tmp_path, monkeypatch):
|
||||
assert append_called["value"] is False
|
||||
|
||||
|
||||
def test_dm_request_send_rejects_unverified_first_contact(tmp_path, monkeypatch):
|
||||
def test_dm_request_send_allows_unverified_first_contact(tmp_path, monkeypatch):
|
||||
import main
|
||||
from services import wormhole_supervisor
|
||||
from services.mesh import mesh_dm_relay, mesh_wormhole_contacts
|
||||
|
||||
monkeypatch.setattr(mesh_wormhole_contacts, "DATA_DIR", tmp_path)
|
||||
monkeypatch.setattr(mesh_wormhole_contacts, "CONTACTS_FILE", tmp_path / "wormhole_dm_contacts.json")
|
||||
from services.mesh import mesh_hashchain
|
||||
|
||||
append_called = {"value": False}
|
||||
|
||||
monkeypatch.setattr(main, "_verify_signed_write", lambda **kwargs: (True, ""))
|
||||
monkeypatch.setattr(main, "_secure_dm_enabled", lambda: False)
|
||||
monkeypatch.setattr(wormhole_supervisor, "get_transport_tier", lambda: "private_transitional")
|
||||
monkeypatch.setattr(mesh_dm_relay.dm_relay, "consume_nonce", lambda *_args, **_kwargs: (True, "ok"))
|
||||
monkeypatch.setattr(mesh_hashchain.infonet, "validate_and_set_sequence", lambda *_args, **_kwargs: (True, ""))
|
||||
|
||||
def fake_append(**kwargs):
|
||||
append_called["value"] = True
|
||||
return {"event_id": "dm-request-e2e"}
|
||||
|
||||
monkeypatch.setattr(mesh_hashchain.infonet, "append_private_dm_message", fake_append)
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"consume_wormhole_dm_sender_token",
|
||||
lambda **kwargs: {
|
||||
"ok": True,
|
||||
"sender_token_hash": "reqtok-first-contact",
|
||||
"sender_id": "alice",
|
||||
"public_key": "cHVi",
|
||||
"public_key_algo": "Ed25519",
|
||||
"protocol_version": "infonet/2",
|
||||
"recipient_id": kwargs.get("recipient_id", "") or "bob",
|
||||
"delivery_class": kwargs.get("delivery_class", "") or "request",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mesh_dm_relay.dm_relay,
|
||||
"deposit",
|
||||
lambda **kwargs: {
|
||||
"ok": True,
|
||||
"msg_id": kwargs.get("msg_id", ""),
|
||||
"detail": "stored",
|
||||
},
|
||||
)
|
||||
|
||||
from services.mesh.mesh_protocol import build_signed_context
|
||||
|
||||
timestamp = int(time.time())
|
||||
payload = {
|
||||
"recipient_id": "bob",
|
||||
"delivery_class": "request",
|
||||
"recipient_token": "",
|
||||
"ciphertext": "x3dh1:opaque",
|
||||
"msg_id": "m2",
|
||||
"timestamp": timestamp,
|
||||
"format": "x3dh1",
|
||||
"transport_lock": "private_strong",
|
||||
}
|
||||
signed_context = build_signed_context(
|
||||
event_type="dm_message",
|
||||
kind="dm_send",
|
||||
endpoint="/api/mesh/dm/send",
|
||||
lane_floor="private_strong",
|
||||
sequence_domain="dm_send",
|
||||
node_id="alice",
|
||||
sequence=1,
|
||||
payload=payload,
|
||||
recipient_id="bob",
|
||||
)
|
||||
req = _json_request(
|
||||
"/api/mesh/dm/send",
|
||||
{
|
||||
"sender_id": "alice",
|
||||
"recipient_id": "bob",
|
||||
"sender_id": "",
|
||||
"sender_token": "opaque-request-token",
|
||||
"recipient_id": "",
|
||||
"delivery_class": "request",
|
||||
"recipient_token": "",
|
||||
"ciphertext": "x3dh1:opaque",
|
||||
"format": "x3dh1",
|
||||
"msg_id": "m2",
|
||||
"timestamp": int(time.time()),
|
||||
"public_key": "cHVi",
|
||||
"public_key_algo": "Ed25519",
|
||||
"timestamp": timestamp,
|
||||
"public_key": "",
|
||||
"public_key_algo": "",
|
||||
"signature": "sig",
|
||||
"sequence": 1,
|
||||
"protocol_version": "infonet/2",
|
||||
"protocol_version": "",
|
||||
"transport_lock": "private_strong",
|
||||
"signed_context": signed_context,
|
||||
},
|
||||
)
|
||||
|
||||
response = asyncio.run(main.dm_send(req))
|
||||
|
||||
assert response["ok"] is False
|
||||
assert response["detail"] == "signed invite or SAS verification required before secure first contact"
|
||||
assert response["trust_level"] == "unpinned"
|
||||
assert response["ok"] is True
|
||||
|
||||
|
||||
def test_dm_key_registration_keeps_key_material_off_ledger(monkeypatch):
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from services.mesh.mesh_fleet_defaults import (
|
||||
FLEET_PEER_PUSH_SECRET,
|
||||
effective_bootstrap_signer_public_key_b64,
|
||||
effective_peer_push_secret,
|
||||
infonet_fleet_join_enabled,
|
||||
)
|
||||
|
||||
|
||||
def test_fleet_defaults_apply_when_join_enabled(monkeypatch):
|
||||
from services.config import get_settings
|
||||
|
||||
monkeypatch.delenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", raising=False)
|
||||
monkeypatch.delenv("MESH_PEER_PUSH_SECRET", raising=False)
|
||||
monkeypatch.setenv("MESH_INFONET_FLEET_JOIN", "true")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
assert infonet_fleet_join_enabled() is True
|
||||
assert effective_bootstrap_signer_public_key_b64()
|
||||
assert effective_peer_push_secret() == FLEET_PEER_PUSH_SECRET
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_fleet_defaults_disabled(monkeypatch):
|
||||
from services.config import get_settings
|
||||
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", "")
|
||||
monkeypatch.setenv("MESH_PEER_PUSH_SECRET", "")
|
||||
monkeypatch.setenv("MESH_INFONET_FLEET_JOIN_DISABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
assert infonet_fleet_join_enabled() is False
|
||||
assert effective_peer_push_secret() == ""
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
@@ -102,6 +102,7 @@ def test_refresh_node_peer_store_promotes_manifest_peers_to_sync_only(tmp_path,
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SEED_PEERS", "")
|
||||
monkeypatch.setenv("MESH_DEFAULT_SYNC_PEERS", "")
|
||||
monkeypatch.setenv("MESH_INFONET_ALLOW_CLEARNET_SYNC", "true")
|
||||
monkeypatch.setenv("MESH_INFONET_FLEET_JOIN_DISABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
|
||||
try:
|
||||
@@ -135,6 +136,7 @@ def test_refresh_node_peer_store_adds_bootstrap_seed_as_pull_only_peer(tmp_path,
|
||||
monkeypatch.setenv("MESH_DEFAULT_SYNC_PEERS", "")
|
||||
monkeypatch.setenv("MESH_INFONET_ALLOW_CLEARNET_SYNC", "true")
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", "")
|
||||
monkeypatch.setenv("MESH_INFONET_FLEET_JOIN_DISABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
|
||||
try:
|
||||
@@ -171,6 +173,7 @@ def test_refresh_node_peer_store_suppresses_clearnet_seed_by_default(tmp_path, m
|
||||
monkeypatch.setenv("MESH_DEFAULT_SYNC_PEERS", "")
|
||||
monkeypatch.delenv("MESH_INFONET_ALLOW_CLEARNET_SYNC", raising=False)
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", "")
|
||||
monkeypatch.setenv("MESH_INFONET_FLEET_JOIN_DISABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
|
||||
try:
|
||||
@@ -184,7 +187,7 @@ def test_refresh_node_peer_store_suppresses_clearnet_seed_by_default(tmp_path, m
|
||||
assert snapshot["skipped_clearnet_peer_count"] == 1
|
||||
assert snapshot["bootstrap_peer_count"] == 0
|
||||
assert snapshot["sync_peer_count"] == 0
|
||||
assert "no clearnet sync fallback" in snapshot["last_bootstrap_error"]
|
||||
assert snapshot["last_bootstrap_error"]
|
||||
assert store.records_for_bucket("bootstrap") == []
|
||||
assert store.records_for_bucket("sync") == []
|
||||
|
||||
@@ -402,6 +405,57 @@ def test_public_sync_cycle_allows_first_node_without_peers(tmp_path, monkeypatch
|
||||
assert result.consecutive_failures == 0
|
||||
|
||||
|
||||
def test_sync_from_peer_explains_stale_genesis_chain(monkeypatch):
|
||||
import main
|
||||
from services.mesh import mesh_hashchain
|
||||
|
||||
class FakeInfonet:
|
||||
events = []
|
||||
head_hash = mesh_hashchain.GENESIS_HASH
|
||||
|
||||
def get_locator(self):
|
||||
return [mesh_hashchain.GENESIS_HASH]
|
||||
|
||||
def ingest_events(self, events):
|
||||
return {
|
||||
"accepted": 0,
|
||||
"duplicates": 0,
|
||||
"rejected": [
|
||||
{"index": 0, "reason": "Event timestamp outside freshness window"},
|
||||
{"index": 1, "reason": "prev_hash does not match head"},
|
||||
],
|
||||
}
|
||||
|
||||
stale_events = [
|
||||
{
|
||||
"event_id": "old-1",
|
||||
"prev_hash": mesh_hashchain.GENESIS_HASH,
|
||||
"event_type": "message",
|
||||
"timestamp": 1,
|
||||
},
|
||||
{
|
||||
"event_id": "old-2",
|
||||
"prev_hash": "old-1",
|
||||
"event_type": "message",
|
||||
"timestamp": 2,
|
||||
},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(mesh_hashchain, "infonet", FakeInfonet())
|
||||
monkeypatch.setattr(main, "_peer_sync_response", lambda *_args, **_kwargs: {"events": stale_events})
|
||||
monkeypatch.setattr(main, "_hydrate_gate_store_from_chain", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(main, "_hydrate_dm_relay_from_chain", lambda *_args, **_kwargs: None)
|
||||
|
||||
ok, error, forked, retry_after_s = main._sync_from_peer("https://node.shadowbroker.info")
|
||||
|
||||
assert ok is False
|
||||
assert forked is False
|
||||
assert retry_after_s == 0
|
||||
assert "Event timestamp outside freshness window" in error
|
||||
assert "expired genesis chain" in error
|
||||
assert "MESH_INGEST_EVENT_MAX_AGE_S=0" in error
|
||||
|
||||
|
||||
def test_headless_mesh_node_runtime_is_explicit(monkeypatch):
|
||||
import main
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from services.mesh.mesh_bootstrap_manifest import (
|
||||
BootstrapManifestError,
|
||||
generate_bootstrap_signer,
|
||||
parse_bootstrap_manifest_dict,
|
||||
write_signed_bootstrap_manifest,
|
||||
)
|
||||
from services.mesh.mesh_peer_registry import PeerRegistry
|
||||
from services.mesh.mesh_peer_store import DEFAULT_PEER_STORE_PATH, PeerStore
|
||||
from services.mesh.mesh_swarm_runtime import (
|
||||
merge_manifest_into_peer_store,
|
||||
peer_registry_enabled,
|
||||
publish_registry_manifest,
|
||||
record_peer_announcement,
|
||||
)
|
||||
|
||||
|
||||
def test_peer_registry_upsert_and_prune(tmp_path, monkeypatch):
|
||||
registry_path = tmp_path / "peer_registry.json"
|
||||
monkeypatch.setattr(
|
||||
"services.mesh.mesh_peer_registry.DEFAULT_PEER_REGISTRY_PATH",
|
||||
registry_path,
|
||||
)
|
||||
registry = PeerRegistry(registry_path)
|
||||
peer = registry.upsert_announcement(
|
||||
peer_url="http://abc123.onion:8000",
|
||||
transport="onion",
|
||||
role="participant",
|
||||
node_id="!sb_test",
|
||||
now=1_750_000_000,
|
||||
)
|
||||
registry.save()
|
||||
assert peer.peer_url == "http://abc123.onion:8000"
|
||||
assert registry.prune_stale(max_age_s=3600, now=1_750_000_500) == 0
|
||||
assert registry.prune_stale(max_age_s=60, now=1_750_010_000) == 1
|
||||
|
||||
|
||||
def test_publish_registry_manifest_round_trip(tmp_path, monkeypatch):
|
||||
signer = generate_bootstrap_signer()
|
||||
manifest_path = tmp_path / "bootstrap_peers.json"
|
||||
registry_path = tmp_path / "peer_registry.json"
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", signer["public_key_b64"])
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY", signer["private_key_b64"])
|
||||
monkeypatch.setenv("MESH_PEER_REGISTRY_ENABLED", "true")
|
||||
monkeypatch.setenv(
|
||||
"MESH_BOOTSTRAP_SEED_PEERS",
|
||||
"http://seedpeer.onion:8000",
|
||||
)
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_MANIFEST_PATH", str(manifest_path))
|
||||
monkeypatch.setattr(
|
||||
"services.mesh.mesh_peer_registry.DEFAULT_PEER_REGISTRY_PATH",
|
||||
registry_path,
|
||||
)
|
||||
from services.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
assert peer_registry_enabled() is True
|
||||
manifest = publish_registry_manifest(now=1_750_000_000, persist=True)
|
||||
assert manifest_path.exists()
|
||||
parsed = parse_bootstrap_manifest_dict(
|
||||
json.loads(manifest_path.read_text(encoding="utf-8")),
|
||||
signer_public_key_b64=signer["public_key_b64"],
|
||||
now=1_750_000_000,
|
||||
)
|
||||
assert parsed.signer_id == manifest.signer_id
|
||||
assert any(peer.role == "seed" for peer in parsed.peers)
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_record_peer_announcement_updates_store(tmp_path, monkeypatch):
|
||||
signer = generate_bootstrap_signer()
|
||||
registry_path = tmp_path / "peer_registry.json"
|
||||
peer_store_path = tmp_path / "peer_store.json"
|
||||
manifest_path = tmp_path / "bootstrap_peers.json"
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", signer["public_key_b64"])
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY", signer["private_key_b64"])
|
||||
monkeypatch.setenv("MESH_PEER_REGISTRY_ENABLED", "true")
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_MANIFEST_PATH", str(manifest_path))
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SEED_PEERS", "http://seedpeer.onion:8000")
|
||||
monkeypatch.setattr(
|
||||
"services.mesh.mesh_peer_registry.DEFAULT_PEER_REGISTRY_PATH",
|
||||
registry_path,
|
||||
)
|
||||
monkeypatch.setattr("services.mesh.mesh_peer_store.DEFAULT_PEER_STORE_PATH", peer_store_path)
|
||||
monkeypatch.setattr("services.mesh.mesh_swarm_runtime.DEFAULT_PEER_STORE_PATH", peer_store_path)
|
||||
from services.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
peer = record_peer_announcement(
|
||||
{
|
||||
"peer_url": "http://participant.onion:8000",
|
||||
"transport": "onion",
|
||||
"role": "participant",
|
||||
},
|
||||
now=1_750_000_000,
|
||||
)
|
||||
assert peer.peer_url == "http://participant.onion:8000"
|
||||
store = PeerStore(peer_store_path)
|
||||
store.load()
|
||||
buckets = {record.bucket for record in store.records()}
|
||||
assert buckets == {"push", "sync"}
|
||||
assert any(record.source == "swarm" for record in store.records())
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_merge_manifest_into_peer_store(tmp_path, monkeypatch):
|
||||
signer = generate_bootstrap_signer()
|
||||
peer_store_path = tmp_path / "peer_store.json"
|
||||
manifest_path = tmp_path / "bootstrap_peers.json"
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", signer["public_key_b64"])
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY", signer["private_key_b64"])
|
||||
monkeypatch.setattr("services.mesh.mesh_peer_store.DEFAULT_PEER_STORE_PATH", peer_store_path)
|
||||
monkeypatch.setattr("services.mesh.mesh_swarm_runtime.DEFAULT_PEER_STORE_PATH", peer_store_path)
|
||||
manifest = write_signed_bootstrap_manifest(
|
||||
manifest_path,
|
||||
signer_id="test-signer",
|
||||
signer_private_key_b64=signer["private_key_b64"],
|
||||
peers=[
|
||||
{
|
||||
"peer_url": "http://relay.onion:8000",
|
||||
"transport": "onion",
|
||||
"role": "relay",
|
||||
"label": "relay-a",
|
||||
}
|
||||
],
|
||||
issued_at=1_750_000_000,
|
||||
valid_until=1_750_360_000,
|
||||
)
|
||||
merged = merge_manifest_into_peer_store(manifest, now=1_750_000_000)
|
||||
assert merged == 1
|
||||
store = PeerStore(peer_store_path)
|
||||
store.load()
|
||||
assert len(store.records()) == 2
|
||||
|
||||
|
||||
def test_parse_bootstrap_manifest_dict_rejects_expired():
|
||||
signer = generate_bootstrap_signer()
|
||||
manifest_path = None
|
||||
payload = {
|
||||
"version": 1,
|
||||
"issued_at": 1,
|
||||
"valid_until": 2,
|
||||
"signer_id": "test",
|
||||
"peers": [
|
||||
{
|
||||
"peer_url": "http://seedpeer.onion:8000",
|
||||
"transport": "onion",
|
||||
"role": "seed",
|
||||
}
|
||||
],
|
||||
}
|
||||
from services.mesh.mesh_bootstrap_manifest import build_bootstrap_manifest_payload, sign_bootstrap_manifest_payload
|
||||
|
||||
signed_payload = build_bootstrap_manifest_payload(
|
||||
signer_id="test",
|
||||
peers=payload["peers"],
|
||||
issued_at=1,
|
||||
valid_until=2,
|
||||
)
|
||||
signature = sign_bootstrap_manifest_payload(
|
||||
signed_payload,
|
||||
signer_private_key_b64=signer["private_key_b64"],
|
||||
)
|
||||
raw = dict(signed_payload)
|
||||
raw["signature"] = signature
|
||||
with pytest.raises(BootstrapManifestError, match="expired"):
|
||||
parse_bootstrap_manifest_dict(
|
||||
raw,
|
||||
signer_public_key_b64=signer["public_key_b64"],
|
||||
now=time.time(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bootstrap_manifest_endpoint_serves_live_registry(tmp_path, monkeypatch):
|
||||
import main
|
||||
|
||||
signer = generate_bootstrap_signer()
|
||||
registry_path = tmp_path / "peer_registry.json"
|
||||
manifest_path = tmp_path / "bootstrap_peers.json"
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY", signer["public_key_b64"])
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SIGNER_PRIVATE_KEY", signer["private_key_b64"])
|
||||
monkeypatch.setenv("MESH_PEER_REGISTRY_ENABLED", "true")
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_MANIFEST_PATH", str(manifest_path))
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SEED_PEERS", "http://seedpeer.onion:8000")
|
||||
monkeypatch.setattr("services.mesh.mesh_peer_registry.DEFAULT_PEER_REGISTRY_PATH", registry_path)
|
||||
from services.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
now = int(time.time())
|
||||
record_peer_announcement(
|
||||
{
|
||||
"peer_url": "http://participant.onion:8000",
|
||||
"transport": "onion",
|
||||
"role": "participant",
|
||||
},
|
||||
now=now,
|
||||
)
|
||||
async with AsyncClient(transport=ASGITransport(app=main.app), base_url="http://test") as ac:
|
||||
response = await ac.get("/api/mesh/infonet/bootstrap-manifest")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["ok"] is True
|
||||
manifest = body["manifest"]
|
||||
peer_urls = [peer["peer_url"] for peer in manifest["peers"]]
|
||||
assert "http://participant.onion:8000" in peer_urls
|
||||
assert "http://seedpeer.onion:8000" in peer_urls
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -618,38 +618,32 @@ class TestFetchPrekeyBundleByLookup:
|
||||
record = _valid_bundle_record("test-agent")
|
||||
requested_urls: list[str] = []
|
||||
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SEED_PEERS", "https://seed.example")
|
||||
monkeypatch.setenv("MESH_DEFAULT_SYNC_PEERS", "")
|
||||
monkeypatch.setenv("MESH_RELAY_PEERS", "")
|
||||
get_settings.cache_clear()
|
||||
def _public_lookup(lookup_token: str, **_kwargs):
|
||||
requested_urls.append(
|
||||
f"http://seed.onion:8000/api/mesh/dm/prekey-bundle?lookup_token={lookup_token}"
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"agent_id": record["agent_id"],
|
||||
"lookup_mode": "invite_lookup_handle",
|
||||
"public_lookup": True,
|
||||
"identity_dh_pub_key": record["dh_pub_key"],
|
||||
"dh_algo": record["dh_algo"],
|
||||
"public_key": record["public_key"],
|
||||
"public_key_algo": record["public_key_algo"],
|
||||
"protocol_version": record["protocol_version"],
|
||||
"sequence": 1,
|
||||
"bundle": record["bundle"],
|
||||
}
|
||||
|
||||
class _Response:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def read(self, _limit: int = -1):
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"identity_dh_pub_key": record["dh_pub_key"],
|
||||
"dh_algo": record["dh_algo"],
|
||||
"public_key": record["public_key"],
|
||||
"public_key_algo": record["public_key_algo"],
|
||||
"protocol_version": record["protocol_version"],
|
||||
"sequence": 1,
|
||||
"signed_at": int(record["bundle"].get("signed_at", 0) or 0),
|
||||
"bundle": record["bundle"],
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
def _urlopen(request, timeout=0):
|
||||
requested_urls.append(str(getattr(request, "full_url", "")))
|
||||
return _Response()
|
||||
|
||||
monkeypatch.setattr("services.mesh.mesh_wormhole_prekey.urllib.request.urlopen", _urlopen)
|
||||
monkeypatch.setattr(
|
||||
"services.mesh.mesh_wormhole_prekey._fetch_dm_prekey_bundle_from_peer_lookup",
|
||||
lambda *_args, **_kwargs: {"ok": False, "detail": "peer prekey lookup unavailable"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.mesh.mesh_wormhole_prekey._fetch_dm_prekey_bundle_from_public_lookup",
|
||||
_public_lookup,
|
||||
)
|
||||
|
||||
from services.mesh.mesh_wormhole_prekey import fetch_dm_prekey_bundle
|
||||
|
||||
@@ -668,33 +662,20 @@ class TestFetchPrekeyBundleByLookup:
|
||||
_isolated_relay(tmp_path, monkeypatch)
|
||||
requested_urls: list[str] = []
|
||||
|
||||
monkeypatch.setenv("MESH_BOOTSTRAP_SEED_PEERS", "https://seed.example")
|
||||
monkeypatch.setenv("MESH_DEFAULT_SYNC_PEERS", "")
|
||||
monkeypatch.setenv("MESH_RELAY_PEERS", "")
|
||||
get_settings.cache_clear()
|
||||
def _public_lookup(lookup_token: str, **_kwargs):
|
||||
requested_urls.append(
|
||||
f"http://seed.onion:8000/api/mesh/dm/prekey-bundle?lookup_token={lookup_token}"
|
||||
)
|
||||
return {"ok": False, "detail": "peer prekey lookup still preparing"}
|
||||
|
||||
class _Response:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def read(self, _limit: int = -1):
|
||||
return json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"pending": True,
|
||||
"status": "preparing_private_lane",
|
||||
"detail": "transport tier insufficient",
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
def _urlopen(request, timeout=0):
|
||||
requested_urls.append(str(getattr(request, "full_url", "")))
|
||||
return _Response()
|
||||
|
||||
monkeypatch.setattr("services.mesh.mesh_wormhole_prekey.urllib.request.urlopen", _urlopen)
|
||||
monkeypatch.setattr(
|
||||
"services.mesh.mesh_wormhole_prekey._fetch_dm_prekey_bundle_from_peer_lookup",
|
||||
lambda *_args, **_kwargs: {"ok": False, "detail": "peer prekey lookup unavailable"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.mesh.mesh_wormhole_prekey._fetch_dm_prekey_bundle_from_public_lookup",
|
||||
_public_lookup,
|
||||
)
|
||||
|
||||
from services.mesh.mesh_wormhole_prekey import fetch_dm_prekey_bundle
|
||||
|
||||
@@ -807,6 +788,16 @@ class TestFetchPrekeyBundleByLookup:
|
||||
monkeypatch.setenv("MESH_DEV_ALLOW_LEGACY_COMPAT", "true")
|
||||
monkeypatch.setenv("MESH_ALLOW_LEGACY_AGENT_ID_LOOKUP_UNTIL", "2026-06-01")
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.setattr(
|
||||
mesh_wormhole_prekey,
|
||||
"_validate_bundle_record",
|
||||
lambda *_args, **_kwargs: (True, ""),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mesh_wormhole_prekey,
|
||||
"legacy_agent_id_lookup_blocked",
|
||||
lambda: False,
|
||||
)
|
||||
mesh_wormhole_prekey._WARNED_LEGACY_PREKEY_LOOKUPS.clear()
|
||||
caplog.clear()
|
||||
caplog.set_level("WARNING")
|
||||
@@ -874,3 +865,55 @@ class TestFetchPrekeyBundleByLookup:
|
||||
)
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_invite_lookup_peer_order_prefers_active_over_bootstrap(monkeypatch):
|
||||
from services.mesh import mesh_wormhole_prekey as prekey_mod
|
||||
|
||||
monkeypatch.setenv(
|
||||
"MESH_BOOTSTRAP_SEED_PEERS",
|
||||
"http://seed-a.onion:8000,http://seed-b.onion:8000,http://seed-c.onion:8000,http://seed-d.onion:8000",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.mesh.mesh_router.active_sync_peer_urls",
|
||||
lambda: [
|
||||
"http://active-peer.onion:8000",
|
||||
"http://another-active.onion:8000",
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
prekey_mod,
|
||||
"_discovered_push_peer_urls",
|
||||
lambda **kwargs: [],
|
||||
)
|
||||
get_settings.cache_clear()
|
||||
|
||||
ordered = prekey_mod._prioritized_invite_lookup_peer_urls(
|
||||
preferred=["http://pinned-peer.onion:8000"],
|
||||
)
|
||||
|
||||
assert ordered[0] == "http://pinned-peer.onion:8000"
|
||||
assert ordered[1:3] == [
|
||||
"http://active-peer.onion:8000",
|
||||
"http://another-active.onion:8000",
|
||||
]
|
||||
assert ordered[-prekey_mod._INVITE_LOOKUP_MAX_BOOTSTRAP_PEERS:] == [
|
||||
"http://seed-a.onion:8000",
|
||||
"http://seed-b.onion:8000",
|
||||
"http://seed-c.onion:8000",
|
||||
]
|
||||
assert "http://seed-d.onion:8000" not in ordered
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_invite_export_includes_lookup_peer_url(tmp_path, monkeypatch):
|
||||
_isolated_invite_state(tmp_path, monkeypatch)
|
||||
monkeypatch.setenv("MESH_PUBLIC_PEER_URL", "http://owner-node.onion:8000")
|
||||
|
||||
from services.mesh.mesh_wormhole_identity import export_wormhole_dm_invite
|
||||
|
||||
exported = export_wormhole_dm_invite(label="routing-test")
|
||||
payload = dict(exported.get("invite", {}).get("payload") or {})
|
||||
|
||||
assert payload.get("prekey_lookup_handle")
|
||||
assert payload.get("lookup_peer_url") == "http://owner-node.onion:8000"
|
||||
|
||||
@@ -71,7 +71,11 @@ def test_dispatcher_chooses_dm_relay_when_direct_path_unavailable_but_lane_floor
|
||||
assert len(deposit_calls) == 1
|
||||
|
||||
|
||||
def test_dispatcher_does_not_release_dm_below_private_strong():
|
||||
def test_dispatcher_does_not_release_dm_below_private_transitional_when_rns_disabled(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"services.wormhole_supervisor.get_wormhole_state",
|
||||
lambda: {"rns_enabled": False},
|
||||
)
|
||||
result = attempt_private_release(
|
||||
lane="dm",
|
||||
current_tier="private_control_only",
|
||||
@@ -80,7 +84,22 @@ def test_dispatcher_does_not_release_dm_below_private_strong():
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["no_acceptable_path"] is True
|
||||
assert result["policy_reason_code"] == "dm_release_waiting_for_private_strong"
|
||||
assert result["policy_reason_code"] == "dm_release_waiting_for_private_transitional"
|
||||
assert result["required_tier"] == "private_transitional"
|
||||
|
||||
|
||||
def test_dispatcher_still_requires_private_strong_when_rns_enabled(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"services.wormhole_supervisor.get_wormhole_state",
|
||||
lambda: {"rns_enabled": True},
|
||||
)
|
||||
result = attempt_private_release(
|
||||
lane="dm",
|
||||
current_tier="private_transitional",
|
||||
payload={"msg_id": "dm-transitional"},
|
||||
)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["required_tier"] == "private_strong"
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
@@ -180,6 +181,31 @@ def test_private_dm_hashchain_rejects_non_sealed_ciphertext_shape(tmp_path, monk
|
||||
raise AssertionError("private DM append accepted non-base64 ciphertext")
|
||||
|
||||
|
||||
def test_private_dm_hashchain_accepts_x3dh1_prefixed_ciphertext(tmp_path, monkeypatch):
|
||||
inf = _fresh_infonet(tmp_path, monkeypatch)
|
||||
private_key, public_key, node_id = _keypair()
|
||||
envelope = {
|
||||
"h": {"ik_pub": "aGVsbG8=", "ek_pub": "d29ybGQ=", "spk_id": 1, "otk_id": 0},
|
||||
"ct": base64.b64encode(b"\x00" * 32).decode("ascii"),
|
||||
}
|
||||
payload = _payload(msg_id="dm-x3dh-1")
|
||||
payload["ciphertext"] = "x3dh1:" + base64.b64encode(
|
||||
json.dumps(envelope, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).decode("ascii")
|
||||
event = inf.append_private_dm_message(
|
||||
node_id=node_id,
|
||||
payload=payload,
|
||||
signature=_signature(private_key, node_id, 1, payload),
|
||||
sequence=1,
|
||||
public_key=public_key,
|
||||
public_key_algo="Ed25519",
|
||||
protocol_version=mesh_protocol.PROTOCOL_VERSION,
|
||||
timestamp=float(payload["timestamp"]),
|
||||
)
|
||||
assert event["event_type"] == "dm_message"
|
||||
assert str(event["payload"]["ciphertext"]).startswith("x3dh1:")
|
||||
|
||||
|
||||
def test_hydrate_dm_relay_from_chain_delivers_to_poll_claim(tmp_path, monkeypatch):
|
||||
inf = _fresh_infonet(tmp_path / "chain", monkeypatch)
|
||||
relay = _fresh_relay(tmp_path / "relay", monkeypatch)
|
||||
|
||||
@@ -216,19 +216,19 @@ def test_authenticated_wormhole_status_can_request_diagnostic_private_delivery_s
|
||||
assert item["meta"]["peer_id"] == "bob"
|
||||
|
||||
|
||||
def test_dm_pubkey_lookup_token_ordinary_response_omits_resolved_agent_id(monkeypatch):
|
||||
def test_dm_pubkey_lookup_token_ordinary_response_includes_resolved_agent_id(monkeypatch):
|
||||
monkeypatch.setattr(main, "_check_scoped_auth", lambda *_args, **_kwargs: (False, "no"))
|
||||
monkeypatch.setattr(main, "_is_debug_test_request", lambda *_args, **_kwargs: False)
|
||||
monkeypatch.setattr(
|
||||
"services.mesh.mesh_dm_relay.dm_relay.get_dh_key_by_lookup",
|
||||
lambda _lookup_token: ({"dh_pub": "pub", "dh_algo": "X25519"}, "peer-123"),
|
||||
lambda _lookup_token: ({"dh_pub_key": "pub", "dh_algo": "X25519"}, "peer-123"),
|
||||
)
|
||||
|
||||
result = asyncio.run(main.dm_get_pubkey(_request("/api/mesh/dm/pubkey"), lookup_token="invite-handle"))
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["lookup_mode"] == "invite_lookup_handle"
|
||||
assert "agent_id" not in result
|
||||
assert result["agent_id"] == "peer-123"
|
||||
|
||||
|
||||
def test_dm_pubkey_lookup_token_diagnostic_response_exposes_resolved_agent_id(monkeypatch):
|
||||
@@ -249,7 +249,7 @@ def test_dm_pubkey_lookup_token_diagnostic_response_exposes_resolved_agent_id(mo
|
||||
assert result["agent_id"] == "peer-123"
|
||||
|
||||
|
||||
def test_prekey_bundle_lookup_token_ordinary_response_omits_resolved_agent_id(monkeypatch):
|
||||
def test_prekey_bundle_lookup_token_ordinary_response_includes_resolved_agent_id(monkeypatch):
|
||||
monkeypatch.setattr(main, "_check_scoped_auth", lambda *_args, **_kwargs: (False, "no"))
|
||||
monkeypatch.setattr(main, "_is_debug_test_request", lambda *_args, **_kwargs: False)
|
||||
monkeypatch.setattr(
|
||||
@@ -273,7 +273,7 @@ def test_prekey_bundle_lookup_token_ordinary_response_omits_resolved_agent_id(mo
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["lookup_mode"] == "invite_lookup_handle"
|
||||
assert "agent_id" not in result
|
||||
assert result["agent_id"] == "peer-456"
|
||||
assert result["trust_fingerprint"] == "aa" * 16
|
||||
|
||||
|
||||
|
||||
@@ -465,6 +465,45 @@ def test_user_facing_status_mapping_remains_plain_language_and_stable():
|
||||
assert evaluate_network_release("dm", "private_strong").status_label == "Delivered privately"
|
||||
|
||||
|
||||
def test_queued_dm_releases_at_private_transitional_when_rns_disabled(monkeypatch):
|
||||
deposit_calls = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"services.wormhole_supervisor.get_wormhole_state",
|
||||
lambda: {"rns_enabled": False},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.wormhole_supervisor.get_transport_tier",
|
||||
lambda: "private_transitional",
|
||||
)
|
||||
monkeypatch.setattr(mesh_private_release_worker, "_secure_dm_enabled", lambda: False)
|
||||
monkeypatch.setattr(mesh_private_release_worker, "_rns_private_dm_ready", lambda: False)
|
||||
monkeypatch.setattr(mesh_private_release_worker, "_maybe_apply_dm_relay_jitter", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
"services.mesh.mesh_dm_relay.dm_relay.deposit",
|
||||
lambda **kwargs: deposit_calls.append(kwargs) or {"ok": True, "msg_id": kwargs["msg_id"]},
|
||||
)
|
||||
|
||||
queued = main._queue_dm_release(
|
||||
current_tier="private_transitional",
|
||||
payload={
|
||||
"msg_id": "dm-tor-only-1",
|
||||
"sender_id": "alice",
|
||||
"recipient_id": "bob",
|
||||
"delivery_class": "request",
|
||||
"sender_token_hash": "abc123",
|
||||
"ciphertext": "x3dh1:ciphertext",
|
||||
"timestamp": 1,
|
||||
},
|
||||
)
|
||||
|
||||
mesh_private_release_worker.private_release_worker.run_once()
|
||||
|
||||
item = _outbox_item(queued["outbox_id"], exposure="diagnostic")
|
||||
assert len(deposit_calls) == 1
|
||||
assert item["release_state"] == "delivered"
|
||||
|
||||
|
||||
def test_outbox_exposes_publishing_state_without_claiming_delivery():
|
||||
item = mesh_private_outbox.private_delivery_outbox.enqueue(
|
||||
lane="dm",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from services.mesh import mesh_swarm_runtime as swarm
|
||||
|
||||
|
||||
def test_join_swarm_with_retries_succeeds_on_second_attempt(monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_announce(*, force=True):
|
||||
calls["n"] += 1
|
||||
if calls["n"] < 2:
|
||||
return {"ok": False, "results": [{"ok": False, "status_code": 503}]}
|
||||
return {"ok": True, "results": [{"ok": True, "status_code": 200}]}
|
||||
|
||||
def fake_manifest(*, force=True, now=None):
|
||||
if calls["n"] < 2:
|
||||
return {"ok": False, "detail": "manifest fetch failed"}
|
||||
return {"ok": True, "peer_count": 3, "merged_peer_count": 3}
|
||||
|
||||
monkeypatch.setattr(swarm, "announce_local_peer_to_seeds", fake_announce)
|
||||
monkeypatch.setattr(swarm, "refresh_swarm_manifest_from_seeds", fake_manifest)
|
||||
monkeypatch.setattr(swarm.time, "sleep", lambda _s: None)
|
||||
|
||||
joined = swarm.join_swarm_with_retries(attempts=3, delay_s=1.0)
|
||||
|
||||
assert joined["ok"] is True
|
||||
assert joined["attempts"] == 2
|
||||
@@ -0,0 +1,14 @@
|
||||
def test_agent_shell_settings_roundtrip(tmp_path, monkeypatch):
|
||||
from services import agent_shell_settings
|
||||
|
||||
settings_path = tmp_path / "agent_shell_settings.json"
|
||||
workdir = tmp_path / "workspace"
|
||||
workdir.mkdir()
|
||||
|
||||
monkeypatch.setattr(agent_shell_settings, "_SETTINGS_FILE", settings_path)
|
||||
|
||||
assert agent_shell_settings.get_agent_shell_settings()["working_directory"]
|
||||
|
||||
saved = agent_shell_settings.set_agent_shell_working_directory(str(workdir))
|
||||
assert saved["working_directory"] == str(workdir.resolve())
|
||||
assert agent_shell_settings.get_agent_shell_settings()["working_directory"] == str(workdir.resolve())
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Regression tests for the GDELT background title enrichment.
|
||||
|
||||
The background enrichment thread used to mutate the nested ``properties`` dicts
|
||||
of GDELT features *after* they were already published into
|
||||
``latest_data["gdelt"]``. HTTP readers serialize those dicts outside the data
|
||||
lock, so the in-place mutation raced the serializer and raised
|
||||
``RuntimeError: dictionary changed size during iteration``.
|
||||
|
||||
These tests pin the contract: the enrichment must NOT touch the
|
||||
already-published feature objects, and must instead publish enriched copies via
|
||||
an atomic swap (with an identity guard so a newer fetch is not clobbered).
|
||||
"""
|
||||
|
||||
from services.fetchers import _store
|
||||
from services import geopolitics
|
||||
|
||||
|
||||
def _make_feature():
|
||||
return {
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": [0.0, 0.0]},
|
||||
"properties": {"name": "loc", "_urls_list": ["http://example.test/article-1"]},
|
||||
}
|
||||
|
||||
|
||||
def test_enrichment_does_not_mutate_published_features(monkeypatch):
|
||||
feature = _make_feature()
|
||||
features = [feature]
|
||||
with _store._data_lock:
|
||||
_store.latest_data["gdelt"] = features
|
||||
|
||||
monkeypatch.setattr(
|
||||
geopolitics,
|
||||
"_batch_fetch_titles",
|
||||
lambda urls: {"http://example.test/article-1": "Real Headline"},
|
||||
)
|
||||
|
||||
geopolitics._enrich_gdelt_titles_background(features, {"http://example.test/article-1"})
|
||||
|
||||
# The originally-published feature object must be untouched (no in-place
|
||||
# mutation of its properties dict — that was the source of the crash).
|
||||
assert "_headlines_list" not in feature["properties"]
|
||||
assert "_snippets_list" not in feature["properties"]
|
||||
|
||||
# The layer must have been atomically replaced with an enriched COPY.
|
||||
published = _store.latest_data["gdelt"]
|
||||
assert published is not features
|
||||
assert published[0] is not feature
|
||||
assert published[0]["properties"]["_headlines_list"] == ["Real Headline"]
|
||||
|
||||
|
||||
def test_enrichment_skips_swap_when_layer_replaced(monkeypatch):
|
||||
feature = _make_feature()
|
||||
features = [feature]
|
||||
|
||||
# Simulate a newer fetch_gdelt() having already replaced the layer while the
|
||||
# background thread was still resolving titles.
|
||||
sentinel = [{"properties": {"name": "newer"}}]
|
||||
with _store._data_lock:
|
||||
_store.latest_data["gdelt"] = sentinel
|
||||
|
||||
monkeypatch.setattr(
|
||||
geopolitics,
|
||||
"_batch_fetch_titles",
|
||||
lambda urls: {"http://example.test/article-1": "Real Headline"},
|
||||
)
|
||||
|
||||
geopolitics._enrich_gdelt_titles_background(features, {"http://example.test/article-1"})
|
||||
|
||||
# The identity guard must prevent clobbering the newer layer.
|
||||
assert _store.latest_data["gdelt"] is sentinel
|
||||
@@ -0,0 +1,47 @@
|
||||
"""The full-store snapshot must survive a transient concurrent-mutation race.
|
||||
|
||||
``get_latest_data_deepcopy_snapshot`` deep-copies each top-level layer outside
|
||||
the data lock. If a misbehaving writer mutates a nested object in place during
|
||||
the copy, ``copy.deepcopy`` raises ``RuntimeError: dictionary changed size
|
||||
during iteration``. The snapshot retries a few times (the mutation window is
|
||||
tiny) so /api/health and /api/live-data do not 500 on a transient race.
|
||||
"""
|
||||
|
||||
import copy
|
||||
|
||||
from services.fetchers import _store
|
||||
|
||||
|
||||
def test_snapshot_retries_then_succeeds(monkeypatch):
|
||||
real_deepcopy = copy.deepcopy
|
||||
calls = {"n": 0}
|
||||
|
||||
def flaky_deepcopy(value, *args, **kwargs):
|
||||
calls["n"] += 1
|
||||
# Fail only on the very first deepcopy call, then behave normally.
|
||||
if calls["n"] == 1:
|
||||
raise RuntimeError("dictionary changed size during iteration")
|
||||
return real_deepcopy(value, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(_store.copy, "deepcopy", flaky_deepcopy)
|
||||
|
||||
snapshot = _store.get_latest_data_deepcopy_snapshot()
|
||||
|
||||
assert isinstance(snapshot, dict)
|
||||
assert calls["n"] >= 2 # it retried after the simulated race
|
||||
|
||||
|
||||
def test_snapshot_reraises_if_race_never_clears(monkeypatch):
|
||||
def always_racing(value, *args, **kwargs):
|
||||
raise RuntimeError("dictionary changed size during iteration")
|
||||
|
||||
monkeypatch.setattr(_store.copy, "deepcopy", always_racing)
|
||||
|
||||
# A persistent (non-transient) violation is a real bug — surface it rather
|
||||
# than hang or return corrupt data.
|
||||
raised = False
|
||||
try:
|
||||
_store.get_latest_data_deepcopy_snapshot()
|
||||
except RuntimeError:
|
||||
raised = True
|
||||
assert raised
|
||||
@@ -0,0 +1,81 @@
|
||||
"""OpenClaw Infonet delegation — command allowlist and dispatch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from services.openclaw_channel import (
|
||||
READ_COMMANDS,
|
||||
WRITE_COMMANDS,
|
||||
_dispatch_command,
|
||||
allowed_commands,
|
||||
)
|
||||
from services.openclaw_channel import CommandChannel
|
||||
|
||||
|
||||
INFONET_READS = frozenset({
|
||||
"infonet_status",
|
||||
"list_gates",
|
||||
"read_gate_messages",
|
||||
"poll_dms",
|
||||
})
|
||||
|
||||
INFONET_WRITES = frozenset({
|
||||
"ensure_infonet_ready",
|
||||
"join_infonet_swarm",
|
||||
"post_gate_message",
|
||||
"cast_vote",
|
||||
"send_dm",
|
||||
})
|
||||
|
||||
|
||||
def test_infonet_commands_in_allowlists():
|
||||
assert INFONET_READS <= READ_COMMANDS
|
||||
assert INFONET_WRITES <= WRITE_COMMANDS
|
||||
|
||||
|
||||
def test_restricted_tier_allows_infonet_reads_only():
|
||||
allowed = allowed_commands("restricted")
|
||||
assert INFONET_READS <= allowed
|
||||
assert not (INFONET_WRITES & allowed)
|
||||
|
||||
|
||||
def test_full_tier_allows_infonet_writes():
|
||||
allowed = allowed_commands("full")
|
||||
assert INFONET_WRITES <= allowed
|
||||
|
||||
|
||||
def test_restricted_tier_blocks_post_gate_message():
|
||||
channel = CommandChannel()
|
||||
result = channel.submit_command("post_gate_message", {"gate_id": "infonet", "plaintext": "hi"})
|
||||
assert result["ok"] is False
|
||||
assert "full access tier" in str(result.get("detail", ""))
|
||||
|
||||
|
||||
def test_dispatch_infonet_status_mocked():
|
||||
fake = {"ok": True, "chain": {"length": 3}, "valid": True}
|
||||
with patch("services.openclaw_infonet.get_infonet_status", return_value=fake):
|
||||
result = _dispatch_command("infonet_status", {})
|
||||
assert result == fake
|
||||
|
||||
|
||||
def test_dispatch_list_gates_mocked():
|
||||
fake = {"ok": True, "gates": [{"id": "infonet"}]}
|
||||
with patch("services.openclaw_infonet.list_gates", return_value=fake):
|
||||
result = _dispatch_command("list_gates", {})
|
||||
assert result["gates"][0]["id"] == "infonet"
|
||||
|
||||
|
||||
def test_dispatch_post_gate_message_mocked():
|
||||
fake = {"ok": True, "event_id": "evt-test"}
|
||||
with patch("services.openclaw_infonet.post_gate_message", return_value=fake):
|
||||
result = _dispatch_command(
|
||||
"post_gate_message",
|
||||
{"gate_id": "infonet", "plaintext": "agent bulletin"},
|
||||
)
|
||||
assert result["event_id"] == "evt-test"
|
||||
|
||||
|
||||
def test_cast_vote_rejects_invalid_vote():
|
||||
result = _dispatch_command("cast_vote", {"target_id": "!sb_test", "vote": 2})
|
||||
assert result["ok"] is False
|
||||
@@ -466,15 +466,55 @@ def test_find_entity_prioritizes_aircraft_operator_and_callsign(sample_store, mo
|
||||
|
||||
monkeypatch.setattr(telemetry, "get_data_version", lambda: 130)
|
||||
|
||||
by_operator = telemetry.find_entity(query="patriots jet", limit=5)
|
||||
by_operator = telemetry.find_entity(owner="Patriots", limit=5)
|
||||
assert by_operator["best_match"]["group"] == "aircraft"
|
||||
assert by_operator["best_match"]["label"] == "OXE2116"
|
||||
|
||||
fuzzy = telemetry.find_entity(query="patriots jet", limit=5, fallback_search=True)
|
||||
assert fuzzy["best_match"]["group"] == "aircraft"
|
||||
|
||||
by_callsign = telemetry.find_entity(callsign="AF1", entity_type="aircraft", limit=5)
|
||||
assert by_callsign["best_match"]["callsign"] == "AF1"
|
||||
assert by_callsign["best_match"]["alert_operator"] == "POTUS"
|
||||
|
||||
|
||||
def test_find_entity_skips_fuzzy_when_exact_match(sample_store, monkeypatch):
|
||||
import services.telemetry as telemetry
|
||||
|
||||
monkeypatch.setattr(telemetry, "get_data_version", lambda: 200)
|
||||
calls: list[str] = []
|
||||
|
||||
def _fake_search(*_args, **_kwargs):
|
||||
calls.append("search")
|
||||
return {"results": [], "searched_layers": []}
|
||||
|
||||
monkeypatch.setattr(telemetry, "search_telemetry", _fake_search)
|
||||
|
||||
result = telemetry.find_entity(callsign="AF1", entity_type="aircraft", fallback_search=False)
|
||||
assert result["best_match"]["callsign"] == "AF1"
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_find_entity_fuzzy_only_when_fallback_or_empty(sample_store, monkeypatch):
|
||||
import services.telemetry as telemetry
|
||||
|
||||
monkeypatch.setattr(telemetry, "get_data_version", lambda: 201)
|
||||
calls: list[str] = []
|
||||
|
||||
def _fake_search(*_args, **_kwargs):
|
||||
calls.append("search")
|
||||
return {"results": [], "searched_layers": []}
|
||||
|
||||
monkeypatch.setattr(telemetry, "search_telemetry", _fake_search)
|
||||
|
||||
empty = telemetry.find_entity(query="zzzznonexistententity", fallback_search=False)
|
||||
assert empty["best_match"] is None
|
||||
assert calls == []
|
||||
|
||||
telemetry.find_entity(query="zzzznonexistententity", fallback_search=True)
|
||||
assert calls == ["search"]
|
||||
|
||||
|
||||
def test_find_entity_prioritizes_maritime_owner_and_identifiers(sample_store, monkeypatch):
|
||||
import services.telemetry as telemetry
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""OpenClaw routing, playbooks, and expensive-command gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from services.openclaw_channel import _dispatch_command
|
||||
from services.openclaw_routing import (
|
||||
EXPENSIVE_COMMANDS,
|
||||
plan_playbook,
|
||||
requires_expensive_confirm,
|
||||
route_query,
|
||||
routing_manifest,
|
||||
)
|
||||
|
||||
|
||||
def test_routing_manifest_has_agent_surface():
|
||||
manifest = routing_manifest()
|
||||
assert manifest["preferred_entry"] == "route_query"
|
||||
assert manifest["client_wrapper"] == "ShadowBrokerClient.ask"
|
||||
assert "search_telemetry" in manifest["expensive_commands"]
|
||||
assert "hot_snapshot" in manifest["playbooks"]
|
||||
|
||||
|
||||
def test_route_query_tail_number():
|
||||
plan = route_query("track N628TS position")
|
||||
assert plan["recommended"]["cmd"] == "find_flights"
|
||||
assert plan["recommended"]["args"]["registration"] == "N628TS"
|
||||
assert "search_telemetry" in plan["avoid"]
|
||||
|
||||
|
||||
def test_route_query_callsign():
|
||||
plan = route_query("where is AF1 right now")
|
||||
assert plan["recommended"]["cmd"] == "find_flights"
|
||||
assert plan["recommended"]["args"]["callsign"] == "AF1"
|
||||
|
||||
|
||||
def test_route_query_news():
|
||||
plan = route_query("telegram news about Iran tanker")
|
||||
assert plan["recommended"]["cmd"] == "search_news"
|
||||
|
||||
|
||||
def test_route_query_cve():
|
||||
plan = route_query("details for CVE-2024-1234")
|
||||
assert plan["recommended"]["cmd"] == "osint_lookup"
|
||||
assert plan["recommended"]["args"]["tool"] == "cve"
|
||||
|
||||
|
||||
def test_route_query_default_entity():
|
||||
plan = route_query("where is the patriots jet")
|
||||
assert plan["recommended"]["cmd"] == "find_entity"
|
||||
assert plan["recommended"]["args"]["query"]
|
||||
|
||||
|
||||
def test_expensive_gate_blocks_search_telemetry():
|
||||
assert requires_expensive_confirm("search_telemetry", {"query": "test"})
|
||||
assert not requires_expensive_confirm(
|
||||
"search_telemetry",
|
||||
{"query": "test", "confirm_expensive": True},
|
||||
)
|
||||
result = _dispatch_command("search_telemetry", {"query": "test"})
|
||||
assert result["ok"] is False
|
||||
assert result.get("code") == "expensive_command_blocked"
|
||||
|
||||
|
||||
def test_expensive_gate_blocks_get_telemetry():
|
||||
result = _dispatch_command("get_telemetry", {})
|
||||
assert result["ok"] is False
|
||||
assert result.get("code") == "expensive_command_blocked"
|
||||
|
||||
|
||||
def test_dispatch_route_query():
|
||||
result = _dispatch_command("route_query", {"text": "news about carrier strike"})
|
||||
assert result["ok"] is True
|
||||
assert result["data"]["recommended"]["cmd"] == "search_news"
|
||||
|
||||
|
||||
def test_dispatch_run_playbook_hot_snapshot():
|
||||
result = _dispatch_command("run_playbook", {"name": "status_check"})
|
||||
assert result["ok"] is True
|
||||
cmds = [item["cmd"] for item in result["data"]["results"]]
|
||||
assert cmds == ["channel_status", "get_summary"]
|
||||
|
||||
|
||||
def test_plan_playbook_track_snapshot_requires_query():
|
||||
plan = plan_playbook("track_snapshot", {})
|
||||
assert plan["ok"] is False
|
||||
plan_ok = plan_playbook("track_snapshot", {"query": "patriots jet"})
|
||||
assert plan_ok["ok"] is True
|
||||
assert plan_ok["batch"][0]["cmd"] == "find_entity"
|
||||
|
||||
|
||||
def test_expensive_commands_set():
|
||||
assert "get_report" in EXPENSIVE_COMMANDS
|
||||
assert "route_query" not in EXPENSIVE_COMMANDS
|
||||
|
||||
|
||||
def test_routing_manifest_includes_infonet_hints():
|
||||
manifest = routing_manifest()
|
||||
recipes = " ".join(item.get("use", "") for item in manifest.get("recipes", []))
|
||||
assert "post_gate_message" in recipes
|
||||
writes = manifest.get("agent_surface", {}).get("writes", [])
|
||||
assert "post_gate_message" in writes
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Regression test for SIGINT snapshot dict aliasing.
|
||||
|
||||
``_merge_sigint_snapshot`` used to publish the *same* dict objects it received
|
||||
into ``latest_data["sigint"]``. Those inputs are owned and mutated in place by
|
||||
other threads (the SIGINT bridge updating live signals, and the
|
||||
``meshtastic_map_nodes`` layer), so a concurrent mutation could race the
|
||||
lock-free deepcopy in ``get_latest_data_deepcopy_snapshot`` (/api/health,
|
||||
/api/live-data) and raise ``dictionary changed size during iteration``.
|
||||
|
||||
The merged snapshot must own copies of every entry.
|
||||
"""
|
||||
|
||||
from services.fetchers.sigint import _merge_sigint_snapshot
|
||||
|
||||
|
||||
def test_merged_entries_are_copies_not_aliases():
|
||||
live = [{"callsign": "LIVE1", "source": "meshtastic", "timestamp": "2"}]
|
||||
api = [{"callsign": "MAP1", "source": "meshtastic", "from_api": True, "timestamp": "1"}]
|
||||
|
||||
merged = _merge_sigint_snapshot(live, api)
|
||||
|
||||
# No published entry may be the *same object* as an input the bridge or the
|
||||
# meshtastic_map_nodes layer keeps mutating.
|
||||
inputs = {id(live[0]), id(api[0])}
|
||||
assert all(id(entry) not in inputs for entry in merged)
|
||||
|
||||
|
||||
def test_mutating_inputs_after_merge_does_not_affect_snapshot():
|
||||
live = [{"callsign": "LIVE1", "source": "meshtastic", "timestamp": "2"}]
|
||||
api = [{"callsign": "MAP1", "source": "meshtastic", "from_api": True, "timestamp": "1"}]
|
||||
|
||||
merged = _merge_sigint_snapshot(live, api)
|
||||
|
||||
# Simulate the bridge adding a key to a live signal after publication — this
|
||||
# must not change the size of any dict reachable from the published list.
|
||||
live[0]["region"] = "added-later"
|
||||
api[0]["channel"] = "added-later"
|
||||
|
||||
assert all("region" not in entry for entry in merged)
|
||||
assert all("channel" not in entry for entry in merged)
|
||||
|
||||
|
||||
def test_merge_preserves_data_and_dedup():
|
||||
# Live meshtastic observation wins over the map node for the same callsign.
|
||||
live = [{"callsign": "DUP", "source": "meshtastic", "timestamp": "5"}]
|
||||
api = [
|
||||
{"callsign": "DUP", "source": "meshtastic", "from_api": True, "timestamp": "1"},
|
||||
{"callsign": "OTHER", "source": "meshtastic", "from_api": True, "timestamp": "1"},
|
||||
]
|
||||
|
||||
merged = _merge_sigint_snapshot(live, api)
|
||||
|
||||
callsigns = [m["callsign"] for m in merged]
|
||||
assert callsigns.count("DUP") == 1
|
||||
assert "OTHER" in callsigns
|
||||
# The surviving DUP is the live one (no from_api flag).
|
||||
dup = next(m for m in merged if m["callsign"] == "DUP")
|
||||
assert not dup.get("from_api")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Telegram OSINT auto-translation."""
|
||||
|
||||
from services import telegram_translate
|
||||
|
||||
|
||||
def test_guess_source_lang_detects_cyrillic():
|
||||
assert telegram_translate.guess_source_lang("В Крым поедем несмотря ни на что") == "ru"
|
||||
|
||||
|
||||
def test_apply_post_translation_skips_english(monkeypatch):
|
||||
monkeypatch.setattr(telegram_translate, "telegram_translate_enabled", lambda: True)
|
||||
post = {
|
||||
"title": "Missile strike reported near Kyiv overnight.",
|
||||
"description": "Missile strike reported near Kyiv overnight.",
|
||||
}
|
||||
enriched = telegram_translate.apply_post_translation(post, "en")
|
||||
assert enriched["source_lang"] == "en"
|
||||
assert "title_translated" not in enriched
|
||||
|
||||
|
||||
def test_apply_post_translation_adds_fields(monkeypatch):
|
||||
monkeypatch.setattr(telegram_translate, "telegram_translate_enabled", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
telegram_translate,
|
||||
"translate_text",
|
||||
lambda text, target_lang=None: (
|
||||
"We will go to Crimea no matter what. This is our homeland!",
|
||||
"ru",
|
||||
),
|
||||
)
|
||||
post = {
|
||||
"title": "«В Крым поедем несмотря ни на что. Это наша родина!»",
|
||||
"description": "«В Крым поедем несмотря ни на что. Это наша родина!»",
|
||||
}
|
||||
enriched = telegram_translate.apply_post_translation(post, "en")
|
||||
assert enriched["source_lang"] == "ru"
|
||||
assert enriched["translate_to"] == "en"
|
||||
assert "Crimea" in enriched["title_translated"]
|
||||
|
||||
|
||||
def test_normalize_translate_target_maps_ui_locales():
|
||||
assert telegram_translate.normalize_translate_target("zh-CN") == "zh-CN"
|
||||
assert telegram_translate.normalize_translate_target("fr") == "fr"
|
||||
|
||||
|
||||
def test_source_lang_label_avoids_uk_country_confusion():
|
||||
assert telegram_translate.source_lang_label("uk") == "Ukrainian"
|
||||
assert telegram_translate.source_lang_label("ru") == "Russian"
|
||||
|
||||
|
||||
def test_polish_translation_expands_bpla_shorthand():
|
||||
assert "UAV" in telegram_translate.polish_translation("Kyiv 1x BpLa on Rembazu.")
|
||||
|
||||
|
||||
def test_guess_source_lang_prefers_ukrainian_markers():
|
||||
assert telegram_translate.guess_source_lang("Київ 1х БпЛА") == "uk"
|
||||
@@ -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": {
|
||||
|
||||
@@ -132,7 +132,7 @@ try {
|
||||
) {
|
||||
$env:TAURI_SIGNING_PRIVATE_KEY = Get-Content -LiteralPath $localUpdaterKey -Raw
|
||||
if (($null -eq $env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD) -and (Test-Path $localUpdaterKeyPassword)) {
|
||||
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = Get-Content -LiteralPath $localUpdaterKeyPassword -Raw
|
||||
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = (Get-Content -LiteralPath $localUpdaterKeyPassword -Raw).Trim()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Lean local backend for live DM E2E — Infonet swarm + wormhole without OSINT fetchers.
|
||||
# Pair with docker-compose.override.yml (local build + fleet secrets).
|
||||
services:
|
||||
backend:
|
||||
environment:
|
||||
MESH_ONLY: "true"
|
||||
# DM E2E uses direct onion relay_push_peer_urls — skip fleet hashchain sync
|
||||
# (hundreds of dead manifest peers wedge /api/wormhole/status during Tor warmup).
|
||||
SHADOWBROKER_MESH_NODE_RUNTIME: "false"
|
||||
MESH_ARTI_ENABLED: "true"
|
||||
MESH_INFONET_FLEET_JOIN: "false"
|
||||
MESH_INFONET_FLEET_JOIN_DISABLED: "true"
|
||||
PRIVACY_CORE_DEV_OVERRIDE: "true"
|
||||
MESH_RELAY_PUSH_TIMEOUT_S: "300"
|
||||
MESH_RELAY_MAX_FAILURES: "12"
|
||||
MESH_DM_PENDING_PER_SENDER_LIMIT: "8"
|
||||
MESH_DM_PERSIST_SPOOL: "true"
|
||||
WORMHOLE_STARTUP_DEADLINE_S: "90"
|
||||
MESH_WORMHOLE_TRUST_FILE_READY: "true"
|
||||
@@ -0,0 +1,29 @@
|
||||
# Auto-loaded by `docker compose` — build from local source instead of pulling stale GHCR images.
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./backend/Dockerfile
|
||||
environment:
|
||||
# Private Infonet swarm: only the seed onion is required in config.
|
||||
# Other participants (e.g. Pete) are discovered via signed manifest pull.
|
||||
MESH_ARTI_ENABLED: "true"
|
||||
MESH_ARTI_SOCKS_PORT: "9050"
|
||||
MESH_SYNC_TIMEOUT_S: "45"
|
||||
MESH_RELAY_PUSH_TIMEOUT_S: "45"
|
||||
MESH_SYNC_MAX_PEERS_PER_CYCLE: "5"
|
||||
MESH_INFONET_ALLOW_CLEARNET_SYNC: "false"
|
||||
MESH_PUBLIC_PEER_URL: ""
|
||||
MESH_BOOTSTRAP_SEED_PEERS: "http://gqpbunqbgtkcqilvclm3xrkt3zowjyl3s62kkktvojgvxzizamvbrqid.onion:8000"
|
||||
MESH_RELAY_PEERS: ""
|
||||
MESH_DEFAULT_SYNC_PEERS: ""
|
||||
MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY: "ul1d0kj/ODPIp0OhHzX8eLAVXzJ3CVvzW1vn2IC6q3I="
|
||||
MESH_SWARM_MANIFEST_PULL_INTERVAL_S: "300"
|
||||
# Fleet testnet HMAC — overrides stale per-node .env so announce/push auth matches seed.
|
||||
MESH_PEER_PUSH_SECRET: "b7GoqsvoUD9MV7tyt0ZOzMptLA84QG6KCfaV9nDqz5Y"
|
||||
# Dev fleet: allow wormhole child agent to start without release attestation.
|
||||
PRIVACY_CORE_DEV_OVERRIDE: "true"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
@@ -0,0 +1,22 @@
|
||||
# Fleet participant — Infonet swarm + DM relay. OSINT fetchers stay on unless
|
||||
# MESH_ONLY=true in .env (lean mode for DM-only E2E / low-memory nodes).
|
||||
services:
|
||||
backend:
|
||||
environment:
|
||||
MESH_ONLY: "${MESH_ONLY:-false}"
|
||||
SHADOWBROKER_MESH_NODE_RUNTIME: "${SHADOWBROKER_MESH_NODE_RUNTIME:-true}"
|
||||
MESH_ARTI_ENABLED: "true"
|
||||
MESH_INFONET_FLEET_JOIN: "${MESH_INFONET_FLEET_JOIN:-true}"
|
||||
MESH_INFONET_FLEET_JOIN_DISABLED: "${MESH_INFONET_FLEET_JOIN_DISABLED:-false}"
|
||||
MESH_WORMHOLE_TRUST_FILE_READY: "true"
|
||||
PRIVACY_CORE_ALLOWED_SHA256: "5dd4b65a317277917842b12d7b430d49913789982ba906bd9a0ea6006d40e28a"
|
||||
MESH_RELAY_PUSH_TIMEOUT_S: "300"
|
||||
MESH_RELAY_MAX_FAILURES: "12"
|
||||
MESH_DM_PENDING_PER_SENDER_LIMIT: "8"
|
||||
MESH_DM_PERSIST_SPOOL: "true"
|
||||
WORMHOLE_STARTUP_DEADLINE_S: "90"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "1"
|
||||
memory: 4G
|
||||
@@ -1,13 +1,15 @@
|
||||
# Minimal relay-node compose — backend only, no frontend needed.
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./backend/Dockerfile
|
||||
image: ghcr.io/bigbodycobain/shadowbroker-backend:latest
|
||||
container_name: shadowbroker-relay
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
env_file: .env
|
||||
environment:
|
||||
# Keep Tor wormhole up across redeploys (no NODE UI on headless relay).
|
||||
MESH_INFONET_RELAY_AUTO_WORMHOLE: "true"
|
||||
MESH_ARTI_ENABLED: "true"
|
||||
volumes:
|
||||
- relay_data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
+10
-1
@@ -29,14 +29,21 @@ services:
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
||||
# Private Infonet bootstrap seeds. Seeds are discovery hints, not fixed roots.
|
||||
- MESH_BOOTSTRAP_SEED_PEERS=${MESH_BOOTSTRAP_SEED_PEERS:-http://gqpbunqbgtkcqilvclm3xrkt3zowjyl3s62kkktvojgvxzizamvbrqid.onion:8000}
|
||||
- MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY=${MESH_BOOTSTRAP_SIGNER_PUBLIC_KEY:-ul1d0kj/ODPIp0OhHzX8eLAVXzJ3CVvzW1vn2IC6q3I=}
|
||||
- MESH_INFONET_FLEET_JOIN=${MESH_INFONET_FLEET_JOIN:-true}
|
||||
- MESH_DEFAULT_SYNC_PEERS=${MESH_DEFAULT_SYNC_PEERS:-}
|
||||
- MESH_SYNC_TIMEOUT_S=${MESH_SYNC_TIMEOUT_S:-5}
|
||||
- MESH_SYNC_TIMEOUT_S=${MESH_SYNC_TIMEOUT_S:-45}
|
||||
- MESH_RELAY_PUSH_TIMEOUT_S=${MESH_RELAY_PUSH_TIMEOUT_S:-45}
|
||||
- MESH_SYNC_MAX_PEERS_PER_CYCLE=${MESH_SYNC_MAX_PEERS_PER_CYCLE:-5}
|
||||
- MESH_SWARM_MANIFEST_PULL_INTERVAL_S=${MESH_SWARM_MANIFEST_PULL_INTERVAL_S:-300}
|
||||
# Explicitly opt into HTTPS/IP-based peer sync. Default remains private transports only.
|
||||
- MESH_INFONET_ALLOW_CLEARNET_SYNC=${MESH_INFONET_ALLOW_CLEARNET_SYNC:-false}
|
||||
# Tor/Arti SOCKS transport for private .onion Infonet sync.
|
||||
- MESH_ARTI_ENABLED=${MESH_ARTI_ENABLED:-false}
|
||||
- MESH_ARTI_SOCKS_PORT=${MESH_ARTI_SOCKS_PORT:-9050}
|
||||
# Lean Infonet participant (meshnode.sh equivalent). Skips global OSINT fetchers.
|
||||
- MESH_ONLY=${MESH_ONLY:-}
|
||||
- SHADOWBROKER_MESH_NODE_RUNTIME=${SHADOWBROKER_MESH_NODE_RUNTIME:-}
|
||||
# Operator-trusted sync/push peers. Leave empty unless you control the peer secret on both sides.
|
||||
- MESH_RELAY_PEERS=${MESH_RELAY_PEERS:-}
|
||||
- MESH_PUBLIC_PEER_URL=${MESH_PUBLIC_PEER_URL:-}
|
||||
@@ -86,6 +93,8 @@ services:
|
||||
- TELEGRAM_OSINT_ENABLED=${TELEGRAM_OSINT_ENABLED:-true}
|
||||
- TELEGRAM_OSINT_CHANNELS=${TELEGRAM_OSINT_CHANNELS:-}
|
||||
- TELEGRAM_OSINT_INTERVAL_MINUTES=${TELEGRAM_OSINT_INTERVAL_MINUTES:-60}
|
||||
- TELEGRAM_OSINT_TRANSLATE=${TELEGRAM_OSINT_TRANSLATE:-true}
|
||||
- TELEGRAM_OSINT_TRANSLATE_TO=${TELEGRAM_OSINT_TRANSLATE_TO:-en}
|
||||
volumes:
|
||||
- backend_data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
Generated
+19
-3
@@ -1,16 +1,18 @@
|
||||
{
|
||||
"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",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"lucide-react": "^0.575.0",
|
||||
@@ -3251,6 +3253,21 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -7374,7 +7391,6 @@
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.9.82",
|
||||
"version": "0.9.83",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev-all.cjs",
|
||||
@@ -23,6 +23,8 @@
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"lucide-react": "^0.575.0",
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
isLikelyDmShortAddress,
|
||||
parseDmInviteImportBlob,
|
||||
inviteFromParsedBlob,
|
||||
} from '@/mesh/dmConnect';
|
||||
|
||||
describe('dmConnect', () => {
|
||||
it('detects short lookup handles', () => {
|
||||
expect(isLikelyDmShortAddress('5881eb8705c9abc1234567890abcd')).toBe(true);
|
||||
expect(isLikelyDmShortAddress('{"type":"invite"}')).toBe(false);
|
||||
});
|
||||
|
||||
it('parses short address without JSON', () => {
|
||||
const parsed = parseDmInviteImportBlob('abcd1234ef567890abcd1234ef567890');
|
||||
expect(parsed.short_address).toBe('abcd1234ef567890abcd1234ef567890');
|
||||
});
|
||||
|
||||
it('unwraps nested invite objects', () => {
|
||||
const invite = { event_type: 'dm_invite', payload: {} };
|
||||
const parsed = inviteFromParsedBlob({ invite, version: 1 });
|
||||
expect(parsed).toEqual(invite);
|
||||
});
|
||||
});
|
||||
@@ -539,15 +539,16 @@ describe('GateView compat-decrypt UX', () => {
|
||||
);
|
||||
|
||||
expect(await screen.findByText('sealed')).toBeInTheDocument();
|
||||
expect(mocks.subscribeGateSessionStreamEvents).toHaveBeenCalled();
|
||||
expect(mocks.fetchWormholeGateKeyStatus).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
expect.objectContaining({ mode: 'session_stream' }),
|
||||
await waitFor(() => expect(mocks.subscribeGateSessionStreamEvents).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(mocks.fetchWormholeGateKeyStatus).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
expect.objectContaining({ mode: 'session_stream' }),
|
||||
),
|
||||
);
|
||||
expect(mocks.controlPlaneJson).not.toHaveBeenCalled();
|
||||
waitSnapshotSpy.mockClear();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(waitSnapshotSpy).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(waitSnapshotSpy).not.toHaveBeenCalled(), { timeout: 2000 });
|
||||
|
||||
streamEventListeners.forEach((listener) =>
|
||||
listener({
|
||||
@@ -560,12 +561,14 @@ describe('GateView compat-decrypt UX', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchSnapshotSpy).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
40,
|
||||
expect.objectContaining({ force: true, proofMode: 'session_stream' }),
|
||||
),
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(fetchSnapshotSpy).toHaveBeenCalledWith(
|
||||
'infonet',
|
||||
40,
|
||||
expect.objectContaining({ force: true, proofMode: 'session_stream' }),
|
||||
),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
expect(
|
||||
fetchMock.mock.calls.some(([input]) =>
|
||||
|
||||
@@ -114,23 +114,24 @@ describe('MeshChat behavior - policy wiring', () => {
|
||||
expect(controller).toContain('timer = setTimeout(() => void poll(classification.refreshCount), classification.delay);');
|
||||
});
|
||||
|
||||
it('dead-drop UI distinguishes invite-pinned trust from TOFU-only', () => {
|
||||
const index = readSource('../../components/MeshChat/index.tsx');
|
||||
expect(index).toContain('getContactTrustSummary');
|
||||
expect(index).toContain('INVITE PINNED');
|
||||
expect(index).toContain('TOFU ONLY');
|
||||
expect(index).toContain('anchored by an imported signed invite');
|
||||
expect(index).toContain('rootWitnessContinuityLabel');
|
||||
expect(index).toContain('RECOVER ROOT');
|
||||
expect(index).toContain('!selectedContactTrustSummary?.rootMismatch');
|
||||
it('dead-drop trust copy lives in shared hints and Infonet Messages', () => {
|
||||
const hints = readSource('../../mesh/meshPrivacyHints.ts');
|
||||
expect(hints).toContain('INVITE PINNED');
|
||||
expect(hints).toContain('FIRST CONTACT (TOFU ONLY)');
|
||||
expect(hints).toContain('anchored by an imported signed invite');
|
||||
const messages = readSource('../../components/InfonetTerminal/MessagesView.tsx');
|
||||
expect(messages).toContain('getContactTrustSummary');
|
||||
expect(messages).toContain('rootWitnessContinuityLabel');
|
||||
});
|
||||
|
||||
it('request UI does not route ordinary request flow through legacy add-contact lookup', () => {
|
||||
it('MeshChat Agent Shell tab keeps legacy dm-add guidance in MeshTerminal', () => {
|
||||
const index = readSource('../../components/MeshChat/index.tsx');
|
||||
expect(index).toContain('handleRequestComposerAction');
|
||||
expect(index).toContain('AgentShellPanel');
|
||||
expect(index).toContain('SHELL');
|
||||
expect(index).not.toContain('handleAddContact().catch(() =>');
|
||||
expect(index).toContain('dm add');
|
||||
expect(index).toContain('legacy migration');
|
||||
const terminal = readSource('../../components/MeshTerminal.tsx');
|
||||
expect(terminal).toContain('dm add');
|
||||
expect(terminal).toContain('legacy migration');
|
||||
});
|
||||
|
||||
it('controller blocks trust-new-key when the stable root changed', () => {
|
||||
|
||||
@@ -221,9 +221,17 @@ describe('MeshChat decomposition — export stability', () => {
|
||||
expect(index).toMatch(/export\s+default\s+MeshChat/);
|
||||
});
|
||||
|
||||
it('presentational shell exposes the gate resync affordance', () => {
|
||||
const index = readFile('index.tsx');
|
||||
expect(index).toContain('RESYNC GATE STATE');
|
||||
expect(index).toContain('handleResyncGateState(selectedGate)');
|
||||
it('gate resync is controller-owned and exposed via Infonet / Mesh Terminal surfaces', () => {
|
||||
const controller = readFile('useMeshChatController.ts');
|
||||
const panel = readFile('InfonetTerminalPanel.tsx');
|
||||
const terminal = fs.readFileSync(
|
||||
path.resolve(MESH_CHAT_DIR, '../MeshTerminal.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(controller).toContain('handleResyncGateState');
|
||||
expect(controller).toContain('resyncWormholeGateState');
|
||||
expect(panel).toContain('InfonetShell');
|
||||
expect(terminal).toContain('gate resync');
|
||||
expect(terminal).toContain('resyncWormholeGateState');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,12 @@ describe('fetchDmPublicKey lookup posture', () => {
|
||||
|
||||
it('uses invite lookup handles without enabling legacy agent-id lookup', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
json: async () => ({ ok: true, dh_pub_key: 'peer-dh', lookup_mode: 'invite_lookup_handle' }),
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
agent_id: '!sb_peer',
|
||||
dh_pub_key: 'peer-dh',
|
||||
lookup_mode: 'invite_lookup_handle',
|
||||
}),
|
||||
});
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
|
||||
@@ -39,6 +44,28 @@ describe('fetchDmPublicKey lookup posture', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to prekey-bundle when pubkey lookup lacks agent_id', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({ ok: true, dh_pub_key: 'peer-dh', lookup_mode: 'invite_lookup_handle' }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
agent_id: '!sb_peer',
|
||||
lookup_mode: 'invite_lookup_handle',
|
||||
bundle: { identity_dh_pub_key: 'peer-dh' },
|
||||
}),
|
||||
});
|
||||
const mod = await import('@/mesh/meshDmClient');
|
||||
|
||||
const result = await mod.fetchDmPublicKey('http://localhost:8000', '', 'invite-handle-123');
|
||||
|
||||
expect(result?.agent_id).toBe('!sb_peer');
|
||||
expect(result?.dh_pub_key).toBe('peer-dh');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('still supports explicit legacy agent-id lookup for migration-only paths', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
json: async () => ({ ok: true, dh_pub_key: 'peer-dh', lookup_mode: 'legacy_agent_id' }),
|
||||
|
||||
@@ -725,6 +725,7 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
'http://localhost:8000',
|
||||
'',
|
||||
'f0eee9e9ccf849bcb2d86c0d7a1e0669c75be4e05533b0f6c67',
|
||||
{ allowLegacyAgentId: false },
|
||||
);
|
||||
expect(mocks.sendOffLedgerConsentMessage).toHaveBeenCalled();
|
||||
expect(screen.queryByText(/Unexpected number in JSON/i)).not.toBeInTheDocument();
|
||||
|
||||
@@ -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>({
|
||||
@@ -615,7 +620,7 @@ export default function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 2. MESH CHAT (Middle) */}
|
||||
{/* 2. MESHTASTIC CHAT (Middle) */}
|
||||
{secondaryBootReady && (
|
||||
<div className="contents" style={{ direction: 'ltr' }}>
|
||||
<MeshChat
|
||||
@@ -624,6 +629,8 @@ export default function Dashboard() {
|
||||
onExpandedChange={setLeftMeshExpanded}
|
||||
onSettingsClick={() => setSettingsOpen(true)}
|
||||
onTerminalToggle={openSecureTerminalLauncher}
|
||||
onOpenLiveGate={openLiveGateFromShell}
|
||||
onOpenDeadDrop={openDeadDropFromShell}
|
||||
launchRequest={meshChatLaunchRequest}
|
||||
/>
|
||||
</div>
|
||||
@@ -1022,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,150 +4,104 @@ 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.',
|
||||
];
|
||||
|
||||
const CONTRIBUTORS = [
|
||||
type ChangelogContributor = {
|
||||
name: string;
|
||||
desc: string;
|
||||
pr?: string;
|
||||
};
|
||||
|
||||
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: '@Alienmajik',
|
||||
desc: 'Raspberry Pi 5 support — ARM64 packaging, headless deployment notes, and runtime tuning for Pi-class hardware',
|
||||
},
|
||||
{
|
||||
name: '@wa1id',
|
||||
desc: 'CCTV ingestion fix — fresh SQLite connections per ingest, persistent DB path, startup hydration, cluster clickability',
|
||||
pr: '#92',
|
||||
},
|
||||
{
|
||||
name: '@AlborzNazari',
|
||||
desc: 'Spain DGT + Madrid CCTV sources and STIX 2.1 threat intelligence export endpoint',
|
||||
pr: '#91',
|
||||
},
|
||||
{
|
||||
name: '@adust09',
|
||||
desc: 'Power plants layer, East Asia intel coverage (JSDF bases, ICAO enrichment, Taiwan news sources, military classification)',
|
||||
pr: '#71, #72, #76, #77, #87',
|
||||
},
|
||||
{
|
||||
name: '@Xpirix',
|
||||
desc: 'LocateBar style and interaction improvements',
|
||||
pr: '#78',
|
||||
},
|
||||
{
|
||||
name: '@imqdcr',
|
||||
desc: 'Ship toggle split into 4 categories + stable MMSI/callsign entity IDs for map markers',
|
||||
pr: '#52',
|
||||
},
|
||||
{
|
||||
name: '@csysp',
|
||||
desc: 'Dismissible threat alerts + stable entity IDs for GDELT & News popups + UI declutter',
|
||||
pr: '#48, #61, #63',
|
||||
},
|
||||
{
|
||||
name: '@suranyami',
|
||||
desc: 'Parallel multi-arch Docker builds (11min → 3min) + runtime BACKEND_URL fix',
|
||||
pr: '#35, #44',
|
||||
},
|
||||
{
|
||||
name: '@chr0n1x',
|
||||
desc: 'Kubernetes / Helm chart architecture for high-availability deployments',
|
||||
name: 'privacy-core (MLS)',
|
||||
desc: 'Rust MLS gate crypto — WASM/FFI path for browser and Tauri sovereign shells',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -265,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>
|
||||
|
||||
@@ -60,6 +60,10 @@ export default function BootstrapView({ marketId, onBack }: BootstrapViewProps)
|
||||
nodeStatus?.bootstrap?.bootstrap_seed_peer_count ?? nodeStatus?.bootstrap?.default_sync_peer_count ?? 0,
|
||||
);
|
||||
const syncPeerCount = Number(nodeStatus?.bootstrap?.sync_peer_count || 0);
|
||||
const swarmSyncPeerCount = Number(nodeStatus?.bootstrap?.swarm_sync_peer_count || 0);
|
||||
const manifestLoaded = Boolean(nodeStatus?.bootstrap?.manifest_loaded);
|
||||
const swarmPull = nodeStatus?.bootstrap?.swarm_manifest_pull;
|
||||
const swarmPullOk = Boolean(swarmPull?.ok) && !swarmPull?.skipped;
|
||||
const lastPeerUrl = String(nodeStatus?.sync_runtime?.last_peer_url || '').trim();
|
||||
const privateTransportRequired = Boolean(nodeStatus?.private_transport_required);
|
||||
|
||||
@@ -146,7 +150,7 @@ export default function BootstrapView({ marketId, onBack }: BootstrapViewProps)
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 text-xs">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-2 text-xs">
|
||||
<div>
|
||||
<div className="text-gray-500">Transport</div>
|
||||
<div className="text-cyan-300 font-mono break-all">
|
||||
@@ -163,6 +167,19 @@ export default function BootstrapView({ marketId, onBack }: BootstrapViewProps)
|
||||
<div className="text-gray-500">Sync Path</div>
|
||||
<div className="text-white font-mono">
|
||||
{syncPeerCount} peers / {seedPeerCount} seeds
|
||||
{swarmSyncPeerCount > 0 ? ` (${swarmSyncPeerCount} swarm)` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Swarm Manifest</div>
|
||||
<div className={swarmPullOk || manifestLoaded ? 'text-green-400' : 'text-amber-400'}>
|
||||
{swarmPullOk
|
||||
? `LIVE (${Number(swarmPull?.merged_peer_count || swarmPull?.peer_count || 0)} peers)`
|
||||
: manifestLoaded
|
||||
? 'LOCAL FILE'
|
||||
: swarmPull?.skipped
|
||||
? 'WAITING'
|
||||
: 'NOT LOADED'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -137,6 +137,9 @@ interface CommandHistory {
|
||||
|
||||
interface InfonetShellProps {
|
||||
isOpen: boolean;
|
||||
embedded?: boolean;
|
||||
launchGate?: string;
|
||||
onLaunchGateConsumed?: () => void;
|
||||
onClose: () => void;
|
||||
onOpenLiveGate?: (gate: string) => void;
|
||||
onOpenDeadDrop?: (peerId: string, options?: { showSas?: boolean }) => void;
|
||||
@@ -144,6 +147,9 @@ interface InfonetShellProps {
|
||||
|
||||
export default function InfonetShell({
|
||||
isOpen,
|
||||
embedded = false,
|
||||
launchGate = '',
|
||||
onLaunchGateConsumed,
|
||||
onClose,
|
||||
onOpenLiveGate,
|
||||
onOpenDeadDrop,
|
||||
@@ -176,6 +182,7 @@ export default function InfonetShell({
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const gateLaunchAttemptRef = useRef(0);
|
||||
const launchGateConsumedRef = useRef('');
|
||||
|
||||
// Real mesh identity
|
||||
const nodeIdentity = useMemo(() => getNodeIdentity(), []);
|
||||
@@ -203,6 +210,7 @@ export default function InfonetShell({
|
||||
setPendingGate(null);
|
||||
setInput('');
|
||||
gateLaunchAttemptRef.current += 1;
|
||||
launchGateConsumedRef.current = '';
|
||||
setIsBooting(true);
|
||||
setBootText([]);
|
||||
|
||||
@@ -600,6 +608,14 @@ export default function InfonetShell({
|
||||
setHistory(prev => [...prev, { command: cmd, output }]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const gate = String(launchGate || '').trim().toLowerCase();
|
||||
if (!isOpen || isBooting || !gate || launchGateConsumedRef.current === gate) return;
|
||||
launchGateConsumedRef.current = gate;
|
||||
handleCommand(`join ${gate}`);
|
||||
onLaunchGateConsumed?.();
|
||||
}, [isOpen, isBooting, launchGate, onLaunchGateConsumed]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (inputMode === 'normal' && input.startsWith('g/') && searchMatch) {
|
||||
@@ -635,7 +651,12 @@ export default function InfonetShell({
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="h-full bg-[#0a0a0a] text-gray-300 p-4 md:p-8 font-mono relative flex flex-col overflow-hidden">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`h-full min-h-0 bg-[#0a0a0a] text-gray-300 font-mono relative flex flex-col overflow-hidden ${
|
||||
embedded ? 'p-2 md:p-3 text-[13px]' : 'p-4 md:p-8'
|
||||
}`}
|
||||
>
|
||||
{currentView === 'terminal' && (
|
||||
<>
|
||||
{/* Top Navigation / Quick Launch */}
|
||||
@@ -661,6 +682,20 @@ export default function InfonetShell({
|
||||
|
||||
{/* Main Terminal Area */}
|
||||
<div className="flex-1 overflow-y-auto pr-4 pb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleNavigate('messages')}
|
||||
className="w-full mb-6 text-left border border-emerald-500/30 bg-emerald-950/10 hover:bg-emerald-950/20 px-4 py-3 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-emerald-300 text-xs tracking-[0.2em] uppercase font-bold">
|
||||
<Mail size={14} />
|
||||
Secure Messages — Quick Connect
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-400 leading-relaxed">
|
||||
Message someone on the fleet in three steps: copy your short address (or ask for theirs),
|
||||
paste it in Secure Messages, tap Send Request — they tap Accept. No terminal commands.
|
||||
</p>
|
||||
</button>
|
||||
<div className="flex flex-col lg:flex-row justify-between items-start gap-6 mb-8">
|
||||
<TrendingPosts />
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
purgeBrowserContactGraph,
|
||||
purgeBrowserSigningMaterial,
|
||||
removeContact,
|
||||
severContact,
|
||||
unblockContact,
|
||||
unwrapSenderSealPayload,
|
||||
updateContact,
|
||||
@@ -74,6 +75,7 @@ import {
|
||||
type Contact,
|
||||
type NodeIdentity,
|
||||
} from '@/mesh/meshIdentity';
|
||||
import { connectDeliveryMeta, ensureDmOutboxReleased } from '@/mesh/dmConnectDelivery';
|
||||
import {
|
||||
getSenderRecoveryState,
|
||||
recoverSenderSealWithFallback,
|
||||
@@ -1516,6 +1518,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
API_BASE,
|
||||
senderId,
|
||||
existingContact?.invitePinnedPrekeyLookupHandle,
|
||||
{ lookupPeerUrl: existingContact?.invitePinnedLookupPeerUrl },
|
||||
);
|
||||
if (senderKey?.dh_pub_key) {
|
||||
const sharedKey = await deriveSharedKey(String(senderKey.dh_pub_key));
|
||||
@@ -1532,6 +1535,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
API_BASE,
|
||||
senderId,
|
||||
existingContact?.invitePinnedPrekeyLookupHandle,
|
||||
{ lookupPeerUrl: existingContact?.invitePinnedLookupPeerUrl },
|
||||
).catch(() => null);
|
||||
if (senderKey?.dh_pub_key) {
|
||||
addContact(senderId, String(senderKey.dh_pub_key), undefined, senderKey.dh_algo);
|
||||
@@ -2000,7 +2004,9 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
'This contact needs their full contact address once before messages can be sent. Paste it in Contacts and the app will handle the rest.',
|
||||
);
|
||||
}
|
||||
const targetKey = await fetchDmPublicKey(API_BASE, recipient, lookupHandle);
|
||||
const targetKey = await fetchDmPublicKey(API_BASE, recipient, lookupHandle, {
|
||||
lookupPeerUrl: recipientContact?.invitePinnedLookupPeerUrl,
|
||||
});
|
||||
if (!targetKey?.dh_pub_key) {
|
||||
queuePendingDeliveryMail({
|
||||
senderId: activeIdentity.nodeId,
|
||||
@@ -2037,15 +2043,23 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
}
|
||||
const msgId = `dm_${Date.now()}_${activeIdentity.nodeId.slice(-4)}`;
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const sent = await sendOffLedgerConsentMessage({
|
||||
apiBase: API_BASE,
|
||||
identity: activeIdentity,
|
||||
recipientId: recipient,
|
||||
recipientDhPub: String(targetKey.dh_pub_key),
|
||||
ciphertext,
|
||||
msgId,
|
||||
timestamp,
|
||||
const connectMeta = connectDeliveryMeta({
|
||||
intent: 'contact_request',
|
||||
contact: recipientContact,
|
||||
});
|
||||
const sent = await ensureDmOutboxReleased(
|
||||
await sendOffLedgerConsentMessage({
|
||||
apiBase: API_BASE,
|
||||
identity: activeIdentity,
|
||||
recipientId: recipient,
|
||||
recipientDhPub: String(targetKey.dh_pub_key),
|
||||
ciphertext,
|
||||
msgId,
|
||||
timestamp,
|
||||
connectIntent: connectMeta.connectIntent,
|
||||
lookupPeerUrl: connectMeta.lookupPeerUrl,
|
||||
}),
|
||||
);
|
||||
if (!sent.ok) {
|
||||
throw new Error(sent.detail || 'contact request failed');
|
||||
}
|
||||
@@ -2110,7 +2124,9 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
throw new Error('Secure mail is still preparing your private identity.');
|
||||
}
|
||||
const { registration, myDhPub } = await ensureLocalDmKey(activeIdentity);
|
||||
const targetKey = await fetchDmPublicKey(API_BASE, '', shortAddress);
|
||||
const targetKey = await fetchDmPublicKey(API_BASE, '', shortAddress, {
|
||||
allowLegacyAgentId: false,
|
||||
});
|
||||
if (!targetKey?.dh_pub_key || !targetKey.agent_id) {
|
||||
throw new Error('That address is not reachable yet. Ask them to copy their address again while their device is online.');
|
||||
}
|
||||
@@ -2136,15 +2152,18 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
}
|
||||
const msgId = `dm_${Date.now()}_${activeIdentity.nodeId.slice(-4)}`;
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const sent = await sendOffLedgerConsentMessage({
|
||||
apiBase: API_BASE,
|
||||
identity: activeIdentity,
|
||||
recipientId: recipient,
|
||||
recipientDhPub: String(targetKey.dh_pub_key),
|
||||
ciphertext,
|
||||
msgId,
|
||||
timestamp,
|
||||
});
|
||||
const sent = await ensureDmOutboxReleased(
|
||||
await sendOffLedgerConsentMessage({
|
||||
apiBase: API_BASE,
|
||||
identity: activeIdentity,
|
||||
recipientId: recipient,
|
||||
recipientDhPub: String(targetKey.dh_pub_key),
|
||||
ciphertext,
|
||||
msgId,
|
||||
timestamp,
|
||||
connectIntent: 'invite_short_address',
|
||||
}),
|
||||
);
|
||||
if (!sent.ok) {
|
||||
throw new Error(sent.detail || 'contact request failed');
|
||||
}
|
||||
@@ -2224,6 +2243,12 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
invitePayload.prekey_lookup_handle ||
|
||||
'',
|
||||
),
|
||||
invitePinnedLookupPeerUrl: String(
|
||||
resultContact.invitePinnedLookupPeerUrl ||
|
||||
(invite as Record<string, unknown>).lookup_peer_url ||
|
||||
invitePayload.lookup_peer_url ||
|
||||
'',
|
||||
),
|
||||
dhPubKey: String(resultContact.dhPubKey || resultContact.invitePinnedDhPubKey || ''),
|
||||
};
|
||||
const mergedContacts = importedPeerId
|
||||
@@ -2269,6 +2294,26 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
}
|
||||
}, [applyHydratedContacts, handleSendShortAddressRequest, inviteImportAlias, inviteImportBlob, loadBackendContacts, syncSecureMailRuntime]);
|
||||
|
||||
const handleSeverContact = useCallback(
|
||||
async (peerId: string) => {
|
||||
const name = displayNameForPeer(peerId, contacts);
|
||||
setComposeError('');
|
||||
setComposeStatus('');
|
||||
try {
|
||||
await severContact(peerId);
|
||||
setContacts(getContacts());
|
||||
setComposeStatus(
|
||||
`Secure contact ended with ${name}. You can message again only after a new request and approval.`,
|
||||
);
|
||||
} catch (error) {
|
||||
setComposeError(
|
||||
error instanceof Error ? error.message : 'Could not end secure contact right now.',
|
||||
);
|
||||
}
|
||||
},
|
||||
[contacts],
|
||||
);
|
||||
|
||||
const refreshDmAddressHandles = useCallback(async () => {
|
||||
try {
|
||||
const result = await listWormholeDmInviteHandles();
|
||||
@@ -2501,6 +2546,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
API_BASE,
|
||||
mail.senderId,
|
||||
existingContact?.invitePinnedPrekeyLookupHandle,
|
||||
{ lookupPeerUrl: existingContact?.invitePinnedLookupPeerUrl },
|
||||
).catch(() => null);
|
||||
const dhPubKey = String(registry?.dh_pub_key || mail.requestDhPubKey || '').trim();
|
||||
const dhAlgo = String(registry?.dh_algo || mail.requestDhAlgo || 'X25519').trim();
|
||||
@@ -2551,15 +2597,19 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
|
||||
const msgId = `dm_${Date.now()}_${activeIdentity.nodeId.slice(-4)}`;
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const sent = await sendOffLedgerConsentMessage({
|
||||
apiBase: API_BASE,
|
||||
identity: activeIdentity,
|
||||
recipientId: mail.senderId,
|
||||
recipientDhPub: dhPubKey,
|
||||
ciphertext,
|
||||
msgId,
|
||||
timestamp,
|
||||
});
|
||||
const sent = await ensureDmOutboxReleased(
|
||||
await sendOffLedgerConsentMessage({
|
||||
apiBase: API_BASE,
|
||||
identity: activeIdentity,
|
||||
recipientId: mail.senderId,
|
||||
recipientDhPub: dhPubKey,
|
||||
ciphertext,
|
||||
msgId,
|
||||
timestamp,
|
||||
connectIntent: 'contact_accept',
|
||||
lookupPeerUrl: existingContact?.invitePinnedLookupPeerUrl,
|
||||
}),
|
||||
);
|
||||
if (!sent.ok) {
|
||||
throw new Error(sent.detail || 'contact accept failed');
|
||||
}
|
||||
@@ -2715,7 +2765,9 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
<Mail size={24} className="mr-3" />
|
||||
SECURE MESSAGES
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">End-to-end encrypted peer-to-peer comms.</p>
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
Copy your short address and send it to someone. They paste it here and tap Send Request — you tap Accept. No terminal required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-cyan-900/30 bg-cyan-950/10 px-4 py-3 text-[11px] tracking-[0.16em] uppercase text-cyan-300 mb-4 shrink-0">
|
||||
@@ -2755,7 +2807,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
'Your contact address is being prepared automatically. Share it with someone so they can message you.'
|
||||
'Your contact address is being prepared. Copy the short address above and send it to anyone you want to message you.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -3428,7 +3480,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
)}
|
||||
{contact.sharedAlias && (
|
||||
<div className="text-[11px] text-emerald-300 mt-2">
|
||||
Shared alias: {contact.sharedAlias}
|
||||
Shared lane open — you can exchange secure mail.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3466,6 +3518,18 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
{nextStep.label}
|
||||
</button>
|
||||
)}
|
||||
{contact.sharedAlias && (
|
||||
<button
|
||||
onClick={() => void handleSeverContact(peerId)}
|
||||
className="px-3 py-2 border border-violet-500/30 text-violet-200 text-sm tracking-[0.18em] uppercase"
|
||||
title="Close the shared lane. A fresh contact request and approval will be required to message again."
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ShieldOff size={14} />
|
||||
End Contact
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
blockContact(peerId);
|
||||
|
||||
@@ -13,13 +13,27 @@ interface Stats {
|
||||
seedPeers: number;
|
||||
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',
|
||||
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);
|
||||
|
||||
@@ -27,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);
|
||||
@@ -41,6 +56,8 @@ export default function NetworkStats() {
|
||||
?? infonet?.bootstrap?.default_sync_peer_count
|
||||
?? 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),
|
||||
@@ -49,26 +66,36 @@ export default function NetworkStats() {
|
||||
syncPeers: syncPeerCount,
|
||||
seedPeers: seedPeerCount,
|
||||
nodeEnabled: Boolean(infonet?.node_enabled),
|
||||
syncOutcome: String(infonet?.sync_runtime?.last_outcome || 'offline').toLowerCase(),
|
||||
syncOutcome,
|
||||
syncError: String(infonet?.sync_runtime?.last_error || '').trim(),
|
||||
artiReady,
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
poll();
|
||||
const interval = setInterval(poll, 15000);
|
||||
const interval = setInterval(poll, 8000);
|
||||
return () => { alive = false; clearInterval(interval); };
|
||||
}, []);
|
||||
|
||||
const nodeColor = stats.syncOutcome === 'ok' ? 'text-green-400'
|
||||
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';
|
||||
const nodeLabel = stats.syncOutcome === 'ok' ? 'SEED SYNCED'
|
||||
: stats.syncOutcome === 'solo' ? 'SOLO'
|
||||
: stats.syncOutcome === 'running' ? 'SYNCING'
|
||||
: stats.syncOutcome === 'error' || stats.syncOutcome === 'fork' ? 'RETRYING'
|
||||
: stats.syncOutcome === 'error' || stats.syncOutcome === 'fork'
|
||||
? (artiBlocked ? 'ARTI WARMING' : 'SYNC BACKOFF')
|
||||
: stats.nodeEnabled ? 'WAITING' : 'OFFLINE';
|
||||
const nodeTitle = stats.syncError
|
||||
? `Infonet seed sync: ${stats.syncError}`
|
||||
: stats.nodeEnabled
|
||||
? 'Participant node enabled; waiting for seed ledger sync.'
|
||||
: 'Participant node offline.';
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-5 gap-y-1 mt-5 text-sm font-mono text-gray-500">
|
||||
<span>NODE <span className={nodeColor}>{nodeLabel}</span></span>
|
||||
<span title={nodeTitle}>NODE <span className={nodeColor}>{nodeLabel}</span></span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span>MESH <span className={stats.meshtastic > 0 ? 'text-green-400' : 'text-gray-600'}>{stats.meshtastic.toLocaleString()}</span></span>
|
||||
<span className="text-gray-700">|</span>
|
||||
@@ -80,7 +107,7 @@ export default function NetworkStats() {
|
||||
<span className="text-gray-700">|</span>
|
||||
<span>EVENTS <span className="text-white">{stats.infonetEvents}</span></span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span title="Configured peers this node pulls from. Usually this is just the seed unless another device is added as a sync peer.">
|
||||
<span title="Peers this node syncs from (seed + swarm-discovered participants).">
|
||||
SYNC PEERS <span className="text-white">{stats.syncPeers}</span>
|
||||
</span>
|
||||
{stats.seedPeers > stats.syncPeers ? (
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { X } from 'lucide-react';
|
||||
import {
|
||||
fetchInfonetNodeStatusSnapshot,
|
||||
setInfonetNodeEnabled,
|
||||
startTorHiddenService,
|
||||
} from '@/mesh/controlPlaneStatusClient';
|
||||
import { beginInfonetTerminalSession } from '@/lib/infonetTerminalSession';
|
||||
import InfonetShell from './InfonetShell';
|
||||
|
||||
interface InfonetTerminalProps {
|
||||
@@ -39,16 +35,8 @@ export default function InfonetTerminal({
|
||||
|
||||
const connectParticipantNode = async () => {
|
||||
try {
|
||||
const nodeStatus = await fetchInfonetNodeStatusSnapshot(true).catch(() => null);
|
||||
if (cancelled || nodeStatus?.node_enabled) return;
|
||||
|
||||
const torStatus = await startTorHiddenService().catch(() => null);
|
||||
if (cancelled || !torStatus?.running || !torStatus?.onion_address) return;
|
||||
|
||||
await setInfonetNodeEnabled(true);
|
||||
if (!cancelled) {
|
||||
await fetchInfonetNodeStatusSnapshot(true).catch(() => null);
|
||||
}
|
||||
if (cancelled) return;
|
||||
await beginInfonetTerminalSession();
|
||||
} catch {
|
||||
// Remote/shared viewers may not have local-operator rights. Leave manual controls intact.
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Popup } from 'react-map-gl/maplibre';
|
||||
import { Radio } from 'lucide-react';
|
||||
import { useTranslation } from '@/i18n';
|
||||
@@ -69,11 +69,58 @@ function riskTheme(rs: number) {
|
||||
};
|
||||
}
|
||||
|
||||
function postHeadline(post: TelegramOsintPost): string {
|
||||
return String(post.title || post.description || 'Telegram intercept').trim();
|
||||
const CYRILLIC_RE = /[\u0400-\u04FF]/;
|
||||
|
||||
function containsCyrillic(text: string): boolean {
|
||||
return CYRILLIC_RE.test(text);
|
||||
}
|
||||
|
||||
function postDetail(post: TelegramOsintPost): string | null {
|
||||
function sourceLangLabel(post: TelegramOsintPost): string {
|
||||
if (post.source_lang_label) return post.source_lang_label;
|
||||
const code = String(post.source_lang || '').trim().toLowerCase();
|
||||
const labels: Record<string, string> = {
|
||||
uk: 'Ukrainian',
|
||||
ru: 'Russian',
|
||||
en: 'English',
|
||||
ar: 'Arabic',
|
||||
he: 'Hebrew',
|
||||
'zh-cn': 'Chinese',
|
||||
fr: 'French',
|
||||
de: 'German',
|
||||
pl: 'Polish',
|
||||
};
|
||||
return labels[code] || code.toUpperCase();
|
||||
}
|
||||
|
||||
function hasTranslation(post: TelegramOsintPost): boolean {
|
||||
const translated = String(post.title_translated || post.description_translated || '').trim();
|
||||
const original = String(post.title || post.description || '').trim();
|
||||
return Boolean(translated && translated !== original);
|
||||
}
|
||||
|
||||
function postHeadline(post: TelegramOsintPost, showOriginal: boolean): string {
|
||||
const original = String(post.title || post.description || 'Telegram intercept').trim();
|
||||
const translated = String(post.title_translated || post.description_translated || '').trim();
|
||||
if (!showOriginal && translated) {
|
||||
return translated.split('\n', 1)[0].trim();
|
||||
}
|
||||
if (!showOriginal && containsCyrillic(original) && translated) {
|
||||
return translated.split('\n', 1)[0].trim();
|
||||
}
|
||||
return original;
|
||||
}
|
||||
|
||||
function postDetail(post: TelegramOsintPost, showOriginal: boolean): string | null {
|
||||
if (!showOriginal && post.description_translated) {
|
||||
const translatedTitle = String(post.title_translated || '').trim();
|
||||
const translatedBody = String(post.description_translated || '').trim();
|
||||
if (!translatedBody || translatedBody === translatedTitle) return null;
|
||||
const extra = translatedBody.startsWith(translatedTitle)
|
||||
? translatedBody.slice(translatedTitle.length).trim()
|
||||
: translatedBody;
|
||||
return extra || null;
|
||||
}
|
||||
|
||||
const title = String(post.title || '').trim();
|
||||
const description = String(post.description || '').trim();
|
||||
if (!description || description === title || description.startsWith(title)) return null;
|
||||
@@ -126,10 +173,12 @@ function TelegramPostMedia({ post }: { post: TelegramOsintPost }) {
|
||||
|
||||
function TelegramPostCard({ post }: { post: TelegramOsintPost }) {
|
||||
const { t } = useTranslation();
|
||||
const [showOriginal, setShowOriginal] = useState(false);
|
||||
const rs = post.risk_score ?? 1;
|
||||
const theme = riskTheme(rs);
|
||||
const headline = postHeadline(post);
|
||||
const detail = postDetail(post);
|
||||
const translated = hasTranslation(post);
|
||||
const headline = postHeadline(post, showOriginal);
|
||||
const detail = postDetail(post, showOriginal);
|
||||
const isHigh = rs >= 8;
|
||||
|
||||
return (
|
||||
@@ -150,12 +199,29 @@ function TelegramPostCard({ post }: { post: TelegramOsintPost }) {
|
||||
<p className="text-[11px] text-[var(--text-muted)] leading-relaxed whitespace-pre-wrap">{detail}</p>
|
||||
) : null}
|
||||
|
||||
{translated && !showOriginal && post.source_lang ? (
|
||||
<p className="text-[10px] text-cyan-700/80 uppercase tracking-wider">
|
||||
{t('telegram.translatedFrom').replace('{lang}', sourceLangLabel(post))}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<TelegramPostMedia post={post} />
|
||||
|
||||
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
||||
<span className={`text-[11px] font-bold font-mono px-1.5 py-0.5 rounded-sm border ${theme.badgeClass}`}>
|
||||
{isHigh ? 'BREAKING' : `LVL: ${rs}/10`}
|
||||
</span>
|
||||
{translated ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOriginal((prev) => !prev)}
|
||||
className="text-[11px] font-mono text-cyan-600 hover:text-cyan-300 transition-colors"
|
||||
>
|
||||
{showOriginal
|
||||
? t('telegram.showTranslation')
|
||||
: t('telegram.showOriginal').replace('{lang}', sourceLangLabel(post))}
|
||||
</button>
|
||||
) : null}
|
||||
{post.link ? (
|
||||
<a
|
||||
href={post.link}
|
||||
@@ -172,15 +238,49 @@ function TelegramPostCard({ post }: { post: TelegramOsintPost }) {
|
||||
}
|
||||
|
||||
export function TelegramOsintPopup({ posts, lat, lng, onClose }: TelegramOsintPopupProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, locale } = useTranslation();
|
||||
const [localizedPosts, setLocalizedPosts] = useState(posts);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalizedPosts(posts);
|
||||
}, [posts]);
|
||||
|
||||
useEffect(() => {
|
||||
const needsLocalizedFeed = posts.some((post) => !hasTranslation(post));
|
||||
if (!needsLocalizedFeed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
fetch(`/api/telegram-feed?lang=${encodeURIComponent(locale)}`, { signal: controller.signal })
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((payload) => {
|
||||
if (cancelled || !payload?.posts) return;
|
||||
const byId = new Map(
|
||||
(payload.posts as TelegramOsintPost[]).map((post) => [post.id, post]),
|
||||
);
|
||||
setLocalizedPosts(posts.map((post) => byId.get(post.id) || post));
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep feed posts when locale translation fetch fails */
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [locale, posts]);
|
||||
|
||||
const sortedPosts = useMemo(
|
||||
() =>
|
||||
[...posts].sort(
|
||||
[...localizedPosts].sort(
|
||||
(a, b) =>
|
||||
(b.risk_score ?? 0) - (a.risk_score ?? 0) ||
|
||||
String(b.published || '').localeCompare(String(a.published || '')),
|
||||
),
|
||||
[posts],
|
||||
[localizedPosts],
|
||||
);
|
||||
|
||||
const maxRisk = sortedPosts[0]?.risk_score ?? 1;
|
||||
@@ -252,4 +352,4 @@ export function TelegramOsintPopup({ posts, lat, lng, onClose }: TelegramOsintPo
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user