3 Commits

Author SHA1 Message Date
BigBodyCobain 25a98a9869 Harden Infonet DM address flow and seed sync
Allow local-operator DM invite import without requiring a full admin session.

Prioritize bundled/bootstrap seed peers and shorten stale seed cooldowns for faster Infonet recovery.

Replace raw DM invite dumps with copyable signed-address controls, contact request handling, and safer sealed-send behavior while the private delivery route connects.
2026-05-12 21:23:38 -06:00
BigBodyCobain 2ce0e43ee5 Fix secure messaging test expectations 2026-05-12 12:46:56 -06:00
BigBodyCobain b86a258535 Release v0.9.79 runtime and messaging update
Ship the v0.9.79 runtime refresh with transport lane isolation, Infonet secure-message address management, MeshChat MQTT controls, selected asset trail behavior, telemetry panel refinements, onboarding updates, and desktop/package metadata alignment.

Also ignore local graphify work products so analysis folders do not leak into future commits.
2026-05-12 11:49:46 -06:00
58 changed files with 2499 additions and 846 deletions
+6
View File
@@ -67,6 +67,12 @@ ADMIN_KEY=
# SHADOWBROKER_SLOW_FETCH_CONCURRENCY=4 # SHADOWBROKER_SLOW_FETCH_CONCURRENCY=4
# SHADOWBROKER_STARTUP_HEAVY_CONCURRENCY=2 # SHADOWBROKER_STARTUP_HEAVY_CONCURRENCY=2
# Infonet bootstrap/sync responsiveness. Defaults favor fast seed failure
# detection so stale onion peers do not make the terminal look hung.
# MESH_SYNC_TIMEOUT_S=5
# MESH_SYNC_MAX_PEERS_PER_CYCLE=3
# MESH_BOOTSTRAP_SEED_FAILURE_COOLDOWN_S=15
# Google Earth Engine for VIIRS night lights change detection (optional). # Google Earth Engine for VIIRS night lights change detection (optional).
# pip install earthengine-api # pip install earthengine-api
# GEE_SERVICE_ACCOUNT_KEY= # GEE_SERVICE_ACCOUNT_KEY=
+2
View File
@@ -173,6 +173,8 @@ backend/services/test_*.py
# Local analysis & dev tools # Local analysis & dev tools
backend/analyze_xlsx.py backend/analyze_xlsx.py
backend/services/ais_cache.json backend/services/ais_cache.json
graphify/
graphify-out/
# ======================== # ========================
# Internal docs & brainstorming (never commit) # Internal docs & brainstorming (never commit)
+2
View File
@@ -361,6 +361,8 @@ async def _verify_openclaw_hmac(request: Request) -> bool:
# Bind request body: digest the raw bytes so any body tampering # Bind request body: digest the raw bytes so any body tampering
# invalidates the signature. Empty/absent bodies hash as sha256(b""). # invalidates the signature. Empty/absent bodies hash as sha256(b"").
body_bytes = await request.body() body_bytes = await request.body()
# Keep the cached body available for downstream handlers that call request.json().
request._body = body_bytes
body_digest = _hashlib_mod.sha256(body_bytes).hexdigest() body_digest = _hashlib_mod.sha256(body_bytes).hexdigest()
# Compute expected signature: HMAC-SHA256(secret, METHOD|path|ts|nonce|body_digest) # Compute expected signature: HMAC-SHA256(secret, METHOD|path|ts|nonce|body_digest)
+5
View File
@@ -14,4 +14,9 @@ if [ -d /app/image-data ]; then
done done
fi fi
if [ -z "${PRIVACY_CORE_ALLOWED_SHA256:-}" ] && [ -f /app/libprivacy_core.so ]; then
PRIVACY_CORE_ALLOWED_SHA256="$(sha256sum /app/libprivacy_core.so | awk '{print $1}')"
export PRIVACY_CORE_ALLOWED_SHA256
fi
exec "$@" exec "$@"
+132 -34
View File
@@ -14,7 +14,7 @@ from dataclasses import dataclass, field
from typing import Any from typing import Any
from json import JSONDecodeError from json import JSONDecodeError
APP_VERSION = "0.9.75" APP_VERSION = "0.9.79"
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1386,7 +1386,12 @@ def _peer_sync_response(peer_url: str, body: dict[str, Any]) -> dict[str, Any]:
if _infonet_private_transport_required() and not _is_private_infonet_transport(transport): if _infonet_private_transport_required() and not _is_private_infonet_transport(transport):
raise RuntimeError(_infonet_private_transport_error()) raise RuntimeError(_infonet_private_transport_error())
timeout = int(get_settings().MESH_RELAY_PUSH_TIMEOUT_S or 10) settings = get_settings()
timeout = int(
getattr(settings, "MESH_SYNC_TIMEOUT_S", 0)
or getattr(settings, "MESH_RELAY_PUSH_TIMEOUT_S", 0)
or 10
)
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {
"json": body, "json": body,
"timeout": timeout, "timeout": timeout,
@@ -1509,6 +1514,8 @@ def _run_public_sync_cycle() -> SyncWorkerState:
records = _filter_infonet_sync_records(store.records()) records = _filter_infonet_sync_records(store.records())
peers = eligible_sync_peers(records, now=time.time()) peers = eligible_sync_peers(records, now=time.time())
max_peers = max(1, int(getattr(get_settings(), "MESH_SYNC_MAX_PEERS_PER_CYCLE", 0) or 3))
peers = peers[:max_peers]
with _NODE_RUNTIME_LOCK: with _NODE_RUNTIME_LOCK:
current_state = get_sync_state() current_state = get_sync_state()
if not peers: if not peers:
@@ -1571,14 +1578,25 @@ def _run_public_sync_cycle() -> SyncWorkerState:
return updated return updated
last_error = error last_error = error
settings = get_settings()
is_seed_peer = str(getattr(record, "role", "") or "").strip().lower() == "seed"
cooldown_s = int(getattr(settings, "MESH_RELAY_FAILURE_COOLDOWN_S", 120) or 120)
if is_seed_peer:
cooldown_s = int(
getattr(settings, "MESH_BOOTSTRAP_SEED_FAILURE_COOLDOWN_S", cooldown_s)
or cooldown_s
)
store.mark_failure( store.mark_failure(
record.peer_url, record.peer_url,
"sync", "sync",
error=error, error=error,
cooldown_s=int(get_settings().MESH_RELAY_FAILURE_COOLDOWN_S or 120), cooldown_s=cooldown_s,
now=time.time(), now=time.time(),
) )
store.save() store.save()
failure_backoff_s = int(settings.MESH_SYNC_FAILURE_BACKOFF_S or 60)
if is_seed_peer:
failure_backoff_s = min(failure_backoff_s, max(1, cooldown_s))
updated = finish_sync( updated = finish_sync(
started, started,
ok=False, ok=False,
@@ -1588,7 +1606,7 @@ def _run_public_sync_cycle() -> SyncWorkerState:
fork_detected=forked, fork_detected=forked,
now=time.time(), now=time.time(),
interval_s=int(get_settings().MESH_SYNC_INTERVAL_S or 300), interval_s=int(get_settings().MESH_SYNC_INTERVAL_S or 300),
failure_backoff_s=int(get_settings().MESH_SYNC_FAILURE_BACKOFF_S or 60), failure_backoff_s=failure_backoff_s,
) )
with _NODE_RUNTIME_LOCK: with _NODE_RUNTIME_LOCK:
set_sync_state(updated) set_sync_state(updated)
@@ -3061,6 +3079,24 @@ def _resume_private_delivery_background_work(*, current_tier: str, reason: str)
) )
def _is_public_meshtastic_lane_path(path: str, method: str) -> bool:
"""Routes for the public Meshtastic MQTT lane.
These are intentionally outside the Wormhole/Infonet private transport
lifecycle. Polling public MeshChat must not wake or re-enable Wormhole.
"""
normalized_path = str(path or "").strip()
method_name = str(method or "").upper()
if method_name == "POST" and normalized_path == "/api/mesh/meshtastic/send":
return True
if method_name == "GET" and normalized_path in {
"/api/mesh/messages",
"/api/mesh/channels",
}:
return True
return False
def _upgrade_invite_scoped_contact_preferences_background() -> dict[str, Any]: def _upgrade_invite_scoped_contact_preferences_background() -> dict[str, Any]:
try: try:
from services.mesh.mesh_wormhole_contacts import upgrade_invite_scoped_contact_preferences from services.mesh.mesh_wormhole_contacts import upgrade_invite_scoped_contact_preferences
@@ -3092,7 +3128,11 @@ def _refresh_lookup_handle_rotation_background(*, reason: str) -> dict[str, Any]
@app.middleware("http") @app.middleware("http")
async def enforce_high_privacy_mesh(request: Request, call_next): async def enforce_high_privacy_mesh(request: Request, call_next):
path = request.url.path path = request.url.path
if path.startswith("/api/mesh") or path.startswith("/api/wormhole/gate/") or path.startswith("/api/wormhole/dm/"): private_mesh_path = path.startswith("/api/mesh") and not _is_public_meshtastic_lane_path(
path,
request.method,
)
if private_mesh_path or path.startswith("/api/wormhole/gate/") or path.startswith("/api/wormhole/dm/"):
request.state._private_lane_started_at = time.perf_counter() request.state._private_lane_started_at = time.perf_counter()
current_tier = "public_degraded" current_tier = "public_degraded"
try: try:
@@ -3193,7 +3233,7 @@ async def enforce_high_privacy_mesh(request: Request, call_next):
# Don't block the request on the upgrade — the transport # Don't block the request on the upgrade — the transport
# manager will converge in the background. # manager will converge in the background.
if ( if (
path.startswith("/api/mesh") private_mesh_path
and str(data.get("privacy_profile", "default")).lower() == "high" and str(data.get("privacy_profile", "default")).lower() == "high"
and not bool(data.get("enabled")) and not bool(data.get("enabled"))
): ):
@@ -3426,8 +3466,16 @@ async def update_layers(update: LayerUpdate, request: Request):
from services.sigint_bridge import sigint_grid from services.sigint_bridge import sigint_grid
if old_mesh and not new_mesh: if old_mesh and not new_mesh:
sigint_grid.mesh.stop() try:
logger.info("Meshtastic MQTT bridge stopped (layer disabled)") from services.meshtastic_mqtt_settings import mqtt_bridge_enabled
keep_chat_running = mqtt_bridge_enabled()
except Exception:
keep_chat_running = False
if keep_chat_running:
logger.info("Meshtastic map layer disabled; MQTT bridge kept running for MeshChat")
else:
sigint_grid.mesh.stop()
logger.info("Meshtastic MQTT bridge stopped (layer disabled)")
elif not old_mesh and new_mesh: elif not old_mesh and new_mesh:
# Respect the global MESH_MQTT_ENABLED gate even when the UI layer is # Respect the global MESH_MQTT_ENABLED gate even when the UI layer is
# toggled on. The layer toggle should not bypass the opt-in flag that # toggled on. The layer toggle should not bypass the opt-in flag that
@@ -4361,9 +4409,11 @@ async def mesh_send(request: Request):
any_ok = any(r.ok for r in results) any_ok = any(r.ok for r in results)
# ─── Mirror to Meshtastic bridge feed ──────────────────────── # ─── Mirror to Meshtastic bridge feed ────────────────────────
# The MQTT broker won't echo our own publishes back to our subscriber, # The MQTT broker won't echo our own publishes back to our subscriber, so
# so inject successfully-sent messages into the bridge's deque directly. # inject successfully-sent channel broadcasts into the bridge directly.
if any_ok and envelope.routed_via == "meshtastic": # Node-targeted packets must not appear in the public channel feed.
is_direct_destination = MeshtasticTransport._parse_node_id(destination) is not None
if any_ok and envelope.routed_via == "meshtastic" and not is_direct_destination:
try: try:
from services.sigint_bridge import sigint_grid from services.sigint_bridge import sigint_grid
@@ -4371,16 +4421,22 @@ async def mesh_send(request: Request):
if bridge: if bridge:
from datetime import datetime from datetime import datetime
bridge.messages.appendleft( append_text = getattr(bridge, "append_text_message", None)
message_record = (
{ {
"from": MeshtasticTransport.mesh_address_for_sender(node_id), "from": MeshtasticTransport.mesh_address_for_sender(node_id),
"to": destination if MeshtasticTransport._parse_node_id(destination) is not None else "broadcast", "to": "broadcast",
"text": message, "text": message,
"region": credentials.get("mesh_region", "US"), "region": credentials.get("mesh_region", "US"),
"root": credentials.get("mesh_region", "US"),
"channel": body.get("channel", "LongFast"), "channel": body.get("channel", "LongFast"),
"timestamp": datetime.utcnow().isoformat() + "Z", "timestamp": datetime.utcnow().isoformat() + "Z",
} }
) )
if callable(append_text):
append_text(message_record)
else:
bridge.messages.appendleft(message_record)
except Exception: except Exception:
pass # Non-critical pass # Non-critical
@@ -4390,6 +4446,8 @@ async def mesh_send(request: Request):
"event_id": "", "event_id": "",
"routed_via": envelope.routed_via, "routed_via": envelope.routed_via,
"route_reason": envelope.route_reason, "route_reason": envelope.route_reason,
"direct": is_direct_destination,
"channel_echo": not is_direct_destination,
"results": [r.to_dict() for r in results], "results": [r.to_dict() for r in results],
} }
@@ -4488,6 +4546,7 @@ async def mesh_messages(
root: str = "", root: str = "",
channel: str = "", channel: str = "",
limit: int = 30, limit: int = 30,
include_direct: bool = False,
): ):
"""Get recent Meshtastic text messages from the MQTT bridge.""" """Get recent Meshtastic text messages from the MQTT bridge."""
from services.sigint_bridge import sigint_grid from services.sigint_bridge import sigint_grid
@@ -4509,6 +4568,12 @@ async def mesh_messages(
msgs = [m for m in msgs if m.get("root", "").upper() == root_filter] msgs = [m for m in msgs if m.get("root", "").upper() == root_filter]
if channel: if channel:
msgs = [m for m in msgs if m.get("channel", "").lower() == channel.lower()] msgs = [m for m in msgs if m.get("channel", "").lower() == channel.lower()]
if not include_direct:
msgs = [
m
for m in msgs
if str(m.get("to") or "broadcast").strip().lower() in {"", "broadcast", "^all"}
]
return msgs[: min(limit, 100)] return msgs[: min(limit, 100)]
@@ -8789,6 +8854,16 @@ export_wormhole_dm_invite = getattr(
"export_wormhole_dm_invite", "export_wormhole_dm_invite",
_wormhole_identity_unavailable, _wormhole_identity_unavailable,
) )
list_prekey_lookup_handle_records_for_ui = getattr(
_mesh_wormhole_identity,
"list_prekey_lookup_handle_records_for_ui",
_wormhole_identity_unavailable,
)
revoke_prekey_lookup_handle = getattr(
_mesh_wormhole_identity,
"revoke_prekey_lookup_handle",
_wormhole_identity_unavailable,
)
import_wormhole_dm_invite = getattr( import_wormhole_dm_invite = getattr(
_mesh_wormhole_identity, _mesh_wormhole_identity,
"import_wormhole_dm_invite", "import_wormhole_dm_invite",
@@ -8935,6 +9010,13 @@ async def api_get_node_settings(request: Request):
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def api_set_node_settings(request: Request, body: NodeSettingsUpdate): async def api_set_node_settings(request: Request, body: NodeSettingsUpdate):
_refresh_node_peer_store() _refresh_node_peer_store()
if bool(body.enabled):
try:
from services.transport_lane_isolation import disable_public_mesh_lane
disable_public_mesh_lane(reason="private_node_enabled")
except Exception as exc:
logger.warning("Failed to disable public Mesh while enabling private node: %s", exc)
result = _set_participant_node_enabled(bool(body.enabled)) result = _set_participant_node_enabled(bool(body.enabled))
if bool(body.enabled): if bool(body.enabled):
_kick_public_sync_background("operator_enable") _kick_public_sync_background("operator_enable")
@@ -9659,7 +9741,7 @@ async def api_get_wormhole_status(request: Request):
) )
@app.post("/api/wormhole/join", dependencies=[Depends(require_local_operator)]) @app.post("/api/wormhole/join")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def api_wormhole_join(request: Request): async def api_wormhole_join(request: Request):
existing = read_wormhole_settings() existing = read_wormhole_settings()
@@ -9713,7 +9795,7 @@ async def api_wormhole_join(request: Request):
} }
@app.post("/api/wormhole/leave", dependencies=[Depends(require_local_operator)]) @app.post("/api/wormhole/leave")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def api_wormhole_leave(request: Request): async def api_wormhole_leave(request: Request):
updated = write_wormhole_settings(enabled=False) updated = write_wormhole_settings(enabled=False)
@@ -9730,7 +9812,7 @@ async def api_wormhole_leave(request: Request):
} }
@app.get("/api/wormhole/identity", dependencies=[Depends(require_local_operator)]) @app.get("/api/wormhole/identity")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def api_wormhole_identity(request: Request): async def api_wormhole_identity(request: Request):
try: try:
@@ -9743,7 +9825,7 @@ async def api_wormhole_identity(request: Request):
raise HTTPException(status_code=500, detail="wormhole_identity_failed") from exc raise HTTPException(status_code=500, detail="wormhole_identity_failed") from exc
@app.post("/api/wormhole/identity/bootstrap", dependencies=[Depends(require_local_operator)]) @app.post("/api/wormhole/identity/bootstrap")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def api_wormhole_identity_bootstrap(request: Request): async def api_wormhole_identity_bootstrap(request: Request):
bootstrap_wormhole_identity() bootstrap_wormhole_identity()
@@ -9776,11 +9858,27 @@ async def api_wormhole_dm_identity(request: Request):
@app.get("/api/wormhole/dm/invite", dependencies=[Depends(require_local_operator)]) @app.get("/api/wormhole/dm/invite", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def api_wormhole_dm_invite(request: Request): async def api_wormhole_dm_invite(
return export_wormhole_dm_invite() request: Request,
label: str = Query("", max_length=96),
expires_in_s: int = Query(0, ge=0, le=2_592_000),
):
return export_wormhole_dm_invite(label=label, expires_in_s=expires_in_s)
@app.post("/api/wormhole/dm/invite/import", dependencies=[Depends(require_admin)]) @app.get("/api/wormhole/dm/invite/handles", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute")
async def api_wormhole_dm_invite_handles(request: Request):
return list_prekey_lookup_handle_records_for_ui()
@app.delete("/api/wormhole/dm/invite/handles/{handle}", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute")
async def api_wormhole_dm_invite_handle_revoke(request: Request, handle: str):
return revoke_prekey_lookup_handle(handle)
@app.post("/api/wormhole/dm/invite/import", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def api_wormhole_dm_invite_import(request: Request, body: WormholeDmInviteImportRequest): async def api_wormhole_dm_invite_import(request: Request, body: WormholeDmInviteImportRequest):
return import_wormhole_dm_invite( return import_wormhole_dm_invite(
@@ -10507,7 +10605,7 @@ async def api_wormhole_sign(request: Request, body: WormholeSignRequest):
) )
@app.post("/api/wormhole/gate/enter", dependencies=[Depends(require_local_operator)]) @app.post("/api/wormhole/gate/enter")
@limiter.limit("20/minute") @limiter.limit("20/minute")
async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest): async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest):
gate_id = str(body.gate_id or "") gate_id = str(body.gate_id or "")
@@ -10521,25 +10619,25 @@ async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest):
return result return result
@app.post("/api/wormhole/gate/leave", dependencies=[Depends(require_local_operator)]) @app.post("/api/wormhole/gate/leave")
@limiter.limit("20/minute") @limiter.limit("20/minute")
async def api_wormhole_gate_leave(request: Request, body: WormholeGateRequest): async def api_wormhole_gate_leave(request: Request, body: WormholeGateRequest):
return leave_gate(str(body.gate_id or "")) return leave_gate(str(body.gate_id or ""))
@app.get("/api/wormhole/gate/{gate_id}/identity", dependencies=[Depends(require_local_operator)]) @app.get("/api/wormhole/gate/{gate_id}/identity")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def api_wormhole_gate_identity(request: Request, gate_id: str): async def api_wormhole_gate_identity(request: Request, gate_id: str):
return get_active_gate_identity(gate_id) return get_active_gate_identity(gate_id)
@app.get("/api/wormhole/gate/{gate_id}/personas", dependencies=[Depends(require_local_operator)]) @app.get("/api/wormhole/gate/{gate_id}/personas")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def api_wormhole_gate_personas(request: Request, gate_id: str): async def api_wormhole_gate_personas(request: Request, gate_id: str):
return list_gate_personas(gate_id) return list_gate_personas(gate_id)
@app.get("/api/wormhole/gate/{gate_id}/key", dependencies=[Depends(require_local_operator)]) @app.get("/api/wormhole/gate/{gate_id}/key")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def api_wormhole_gate_key_status(request: Request, gate_id: str): async def api_wormhole_gate_key_status(request: Request, gate_id: str):
exposure = metadata_exposure_for_request(request, authenticated=True) exposure = metadata_exposure_for_request(request, authenticated=True)
@@ -10563,7 +10661,7 @@ async def api_wormhole_gate_key_rotate(request: Request, body: WormholeGateRotat
return result return result
@app.post("/api/wormhole/gate/persona/create", dependencies=[Depends(require_local_operator)]) @app.post("/api/wormhole/gate/persona/create")
@limiter.limit("20/minute") @limiter.limit("20/minute")
async def api_wormhole_gate_persona_create( async def api_wormhole_gate_persona_create(
request: Request, body: WormholeGatePersonaCreateRequest request: Request, body: WormholeGatePersonaCreateRequest
@@ -10579,7 +10677,7 @@ async def api_wormhole_gate_persona_create(
return result return result
@app.post("/api/wormhole/gate/persona/activate", dependencies=[Depends(require_local_operator)]) @app.post("/api/wormhole/gate/persona/activate")
@limiter.limit("20/minute") @limiter.limit("20/minute")
async def api_wormhole_gate_persona_activate( async def api_wormhole_gate_persona_activate(
request: Request, body: WormholeGatePersonaActivateRequest request: Request, body: WormholeGatePersonaActivateRequest
@@ -10595,7 +10693,7 @@ async def api_wormhole_gate_persona_activate(
return result return result
@app.post("/api/wormhole/gate/persona/clear", dependencies=[Depends(require_local_operator)]) @app.post("/api/wormhole/gate/persona/clear")
@limiter.limit("20/minute") @limiter.limit("20/minute")
async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRequest): async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRequest):
gate_id = str(body.gate_id or "") gate_id = str(body.gate_id or "")
@@ -10609,7 +10707,7 @@ async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRe
return result return result
@app.post("/api/wormhole/gate/persona/retire", dependencies=[Depends(require_local_operator)]) @app.post("/api/wormhole/gate/persona/retire")
@limiter.limit("20/minute") @limiter.limit("20/minute")
async def api_wormhole_gate_persona_retire( async def api_wormhole_gate_persona_retire(
request: Request, body: WormholeGatePersonaActivateRequest request: Request, body: WormholeGatePersonaActivateRequest
@@ -10690,7 +10788,7 @@ async def api_wormhole_gate_message_compose(request: Request, body: WormholeGate
return composed return composed
@app.post("/api/wormhole/gate/message/sign-encrypted", dependencies=[Depends(require_local_operator)]) @app.post("/api/wormhole/gate/message/sign-encrypted")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def api_wormhole_gate_message_sign_encrypted( async def api_wormhole_gate_message_sign_encrypted(
request: Request, request: Request,
@@ -10722,7 +10820,7 @@ async def api_wormhole_gate_message_sign_encrypted(
return signed return signed
@app.post("/api/wormhole/gate/message/post-encrypted", dependencies=[Depends(require_local_operator)]) @app.post("/api/wormhole/gate/message/post-encrypted")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def api_wormhole_gate_message_post_encrypted( async def api_wormhole_gate_message_post_encrypted(
request: Request, request: Request,
@@ -10902,13 +11000,13 @@ async def api_wormhole_gate_messages_decrypt(request: Request, body: WormholeGat
return {"ok": True, "results": results} return {"ok": True, "results": results}
@app.post("/api/wormhole/gate/state/export", dependencies=[Depends(require_local_operator)]) @app.post("/api/wormhole/gate/state/export")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def api_wormhole_gate_state_export(request: Request, body: WormholeGateRequest): async def api_wormhole_gate_state_export(request: Request, body: WormholeGateRequest):
return export_gate_state_snapshot_with_repair(str(body.gate_id or "")) return export_gate_state_snapshot_with_repair(str(body.gate_id or ""))
@app.post("/api/wormhole/gate/proof", dependencies=[Depends(require_local_operator)]) @app.post("/api/wormhole/gate/proof")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def api_wormhole_gate_proof(request: Request, body: WormholeGateRequest): async def api_wormhole_gate_proof(request: Request, body: WormholeGateRequest):
proof = _sign_gate_access_proof(str(body.gate_id or "")) proof = _sign_gate_access_proof(str(body.gate_id or ""))
@@ -11455,7 +11553,7 @@ async def api_wormhole_health(request: Request):
return _redact_wormhole_status(full_state, authenticated=ok) return _redact_wormhole_status(full_state, authenticated=ok)
@app.post("/api/wormhole/connect", dependencies=[Depends(require_admin)]) @app.post("/api/wormhole/connect")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def api_wormhole_connect(request: Request): async def api_wormhole_connect(request: Request):
settings = read_wormhole_settings() settings = read_wormhole_settings()
+2 -2
View File
@@ -7,7 +7,7 @@ py-modules = []
[project] [project]
name = "backend" name = "backend"
version = "0.9.75" version = "0.9.79"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"apscheduler==3.10.3", "apscheduler==3.10.3",
@@ -43,7 +43,7 @@ dev = ["pytest>=8.3.4", "pytest-asyncio==0.25.0", "ruff>=0.9.0", "black>=24.0.0"
[tool.ruff.lint] [tool.ruff.lint]
# The current backend carries historical style debt in large legacy modules. # The current backend carries historical style debt in large legacy modules.
# Keep CI focused on actionable correctness checks for the v0.9.75 release. # Keep CI focused on actionable correctness checks for the v0.9.79 release.
ignore = ["E401", "E402", "E701", "E731", "E741", "F401", "F402", "F541", "F811", "F841"] ignore = ["E401", "E402", "E701", "E731", "E741", "F401", "F402", "F541", "F811", "F841"]
[tool.black] [tool.black]
+15 -3
View File
@@ -132,6 +132,13 @@ async def api_get_node_settings(request: Request):
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def api_set_node_settings(request: Request, body: NodeSettingsUpdate): async def api_set_node_settings(request: Request, body: NodeSettingsUpdate):
_refresh_node_peer_store() _refresh_node_peer_store()
if bool(body.enabled):
try:
from services.transport_lane_isolation import disable_public_mesh_lane
disable_public_mesh_lane(reason="private_node_enabled")
except Exception as exc:
logger.warning("Failed to disable public Mesh while enabling private node: %s", exc)
result = _set_participant_node_enabled(bool(body.enabled)) result = _set_participant_node_enabled(bool(body.enabled))
if bool(body.enabled): if bool(body.enabled):
try: try:
@@ -174,17 +181,22 @@ async def api_set_meshtastic_mqtt_settings(request: Request, body: MeshtasticMqt
enabled_requested = updates.get("enabled") enabled_requested = updates.get("enabled")
settings = write_meshtastic_mqtt_settings(**updates) settings = write_meshtastic_mqtt_settings(**updates)
if isinstance(enabled_requested, bool):
logger.info("Meshtastic MQTT settings update: enabled=%s", enabled_requested)
if enabled_requested is True: if enabled_requested is True:
# Public MQTT and Wormhole are intentionally mutually exclusive lanes. # Public MQTT and Wormhole are intentionally mutually exclusive lanes.
try: try:
from services.node_settings import write_node_settings
from services.wormhole_settings import write_wormhole_settings from services.wormhole_settings import write_wormhole_settings
from services.wormhole_supervisor import disconnect_wormhole from services.wormhole_supervisor import disconnect_wormhole
write_wormhole_settings(enabled=False) write_wormhole_settings(enabled=False)
disconnect_wormhole(reason="public_mesh_enabled") disconnect_wormhole(reason="public_mesh_enabled")
write_node_settings(enabled=False)
_set_participant_node_enabled(False)
except Exception as exc: except Exception as exc:
logger.warning("Failed to disable Wormhole while enabling public mesh: %s", exc) logger.warning("Failed to disable private mesh lane while enabling public mesh: %s", exc)
if bool(settings.get("enabled")): if bool(settings.get("enabled")):
if sigint_grid.mesh.is_running(): if sigint_grid.mesh.is_running():
@@ -357,8 +369,8 @@ async def api_reset_all_agent_credentials(request: Request):
return { return {
"ok": True, "ok": True,
"new_hmac_secret": new_secret, "hmac_regenerated": True,
"detail": "All agent credentials have been reset. Reconfigure your agent with the new credentials.", "detail": "All agent credentials have been reset. Use the agent connection screen to generate or reveal replacement credentials.",
**results, **results,
} }
+2 -2
View File
@@ -1585,7 +1585,7 @@ async def agent_tool_manifest(request: Request):
return { return {
"ok": True, "ok": True,
"version": "0.9.75", "version": "0.9.79",
"access_tier": access_tier, "access_tier": access_tier,
"available_commands": available_commands, "available_commands": available_commands,
"transport": { "transport": {
@@ -2221,7 +2221,7 @@ async def api_capabilities(request: Request):
access_tier = str(get_settings().OPENCLAW_ACCESS_TIER or "restricted").strip().lower() access_tier = str(get_settings().OPENCLAW_ACCESS_TIER or "restricted").strip().lower()
return { return {
"ok": True, "ok": True,
"version": "0.9.75", "version": "0.9.79",
"auth": { "auth": {
"method": "HMAC-SHA256", "method": "HMAC-SHA256",
"headers": ["X-SB-Timestamp", "X-SB-Nonce", "X-SB-Signature"], "headers": ["X-SB-Timestamp", "X-SB-Nonce", "X-SB-Signature"],
+10 -2
View File
@@ -335,8 +335,16 @@ async def update_layers(update: LayerUpdate, request: Request):
logger.info("AIS stream started (ship layer enabled)") logger.info("AIS stream started (ship layer enabled)")
from services.sigint_bridge import sigint_grid from services.sigint_bridge import sigint_grid
if old_mesh and not new_mesh: if old_mesh and not new_mesh:
sigint_grid.mesh.stop() try:
logger.info("Meshtastic MQTT bridge stopped (layer disabled)") from services.meshtastic_mqtt_settings import mqtt_bridge_enabled
keep_chat_running = mqtt_bridge_enabled()
except Exception:
keep_chat_running = False
if keep_chat_running:
logger.info("Meshtastic map layer disabled; MQTT bridge kept running for MeshChat")
else:
sigint_grid.mesh.stop()
logger.info("Meshtastic MQTT bridge stopped (layer disabled)")
elif not old_mesh and new_mesh: elif not old_mesh and new_mesh:
try: try:
from services.meshtastic_mqtt_settings import mqtt_bridge_enabled from services.meshtastic_mqtt_settings import mqtt_bridge_enabled
+1 -1
View File
@@ -8,7 +8,7 @@ from services.data_fetcher import get_latest_data
from services.schemas import HealthResponse from services.schemas import HealthResponse
import os import os
APP_VERSION = os.environ.get("_HEALTH_APP_VERSION", "0.9.75") APP_VERSION = os.environ.get("_HEALTH_APP_VERSION", "0.9.79")
router = APIRouter() router = APIRouter()
+35 -16
View File
@@ -721,9 +721,11 @@ async def mesh_send(request: Request):
any_ok = any(r.ok for r in results) any_ok = any(r.ok for r in results)
# ─── Mirror to Meshtastic bridge feed ──────────────────────── # ─── Mirror to Meshtastic bridge feed ────────────────────────
# The MQTT broker won't echo our own publishes back to our subscriber, # The MQTT broker won't echo our own publishes back to our subscriber, so
# so inject successfully-sent messages into the bridge's deque directly. # inject successfully-sent channel broadcasts into the bridge directly.
if any_ok and envelope.routed_via == "meshtastic": # Node-targeted packets must not appear in the public channel feed.
is_direct_destination = MeshtasticTransport._parse_node_id(destination) is not None
if any_ok and envelope.routed_via == "meshtastic" and not is_direct_destination:
try: try:
from services.sigint_bridge import sigint_grid from services.sigint_bridge import sigint_grid
@@ -734,7 +736,7 @@ async def mesh_send(request: Request):
bridge.messages.appendleft( bridge.messages.appendleft(
{ {
"from": MeshtasticTransport.mesh_address_for_sender(node_id), "from": MeshtasticTransport.mesh_address_for_sender(node_id),
"to": destination if MeshtasticTransport._parse_node_id(destination) is not None else "broadcast", "to": "broadcast",
"text": message, "text": message,
"region": credentials.get("mesh_region", "US"), "region": credentials.get("mesh_region", "US"),
"channel": body.get("channel", "LongFast"), "channel": body.get("channel", "LongFast"),
@@ -750,6 +752,8 @@ async def mesh_send(request: Request):
"event_id": "", "event_id": "",
"routed_via": envelope.routed_via, "routed_via": envelope.routed_via,
"route_reason": envelope.route_reason, "route_reason": envelope.route_reason,
"direct": is_direct_destination,
"channel_echo": not is_direct_destination,
"results": [r.to_dict() for r in results], "results": [r.to_dict() for r in results],
} }
@@ -818,9 +822,10 @@ async def meshtastic_public_send(request: Request):
if not cb_ok: if not cb_ok:
results = [TransportResult(False, "meshtastic", cb_reason)] results = [TransportResult(False, "meshtastic", cb_reason)]
else: else:
is_direct_destination = MeshtasticTransport._parse_node_id(destination) is not None
envelope.route_reason = ( envelope.route_reason = (
"Local public Meshtastic MQTT path" "Local public Meshtastic MQTT path"
if MeshtasticTransport._parse_node_id(destination) is None if not is_direct_destination
else "Local public Meshtastic direct node path" else "Local public Meshtastic direct node path"
) )
credentials = {"mesh_region": str(body.get("mesh_region", "US") or "US")} credentials = {"mesh_region": str(body.get("mesh_region", "US") or "US")}
@@ -830,23 +835,28 @@ async def meshtastic_public_send(request: Request):
results = [result] results = [result]
any_ok = any(r.ok for r in results) any_ok = any(r.ok for r in results)
if any_ok and envelope.routed_via == "meshtastic": is_direct_destination = MeshtasticTransport._parse_node_id(destination) is not None
if any_ok and envelope.routed_via == "meshtastic" and not is_direct_destination:
try: try:
from datetime import datetime from datetime import datetime
from services.sigint_bridge import sigint_grid from services.sigint_bridge import sigint_grid
bridge = sigint_grid.mesh bridge = sigint_grid.mesh
if bridge: if bridge:
bridge.messages.appendleft( record = {
{ "from": MeshtasticTransport.mesh_address_for_sender(sender_id),
"from": MeshtasticTransport.mesh_address_for_sender(sender_id), "to": "broadcast",
"to": destination if MeshtasticTransport._parse_node_id(destination) is not None else "broadcast", "text": message,
"text": message, "region": str(body.get("mesh_region", "US") or "US"),
"region": str(body.get("mesh_region", "US") or "US"), "root": str(body.get("mesh_region", "US") or "US"),
"channel": str(body.get("channel", "LongFast") or "LongFast"), "channel": str(body.get("channel", "LongFast") or "LongFast"),
"timestamp": datetime.utcnow().isoformat() + "Z", "timestamp": datetime.utcnow().isoformat() + "Z",
} }
) append_text = getattr(bridge, "append_text_message", None)
if callable(append_text):
append_text(record)
else:
bridge.messages.appendleft(record)
except Exception: except Exception:
pass pass
@@ -856,6 +866,8 @@ async def meshtastic_public_send(request: Request):
"event_id": "", "event_id": "",
"routed_via": envelope.routed_via, "routed_via": envelope.routed_via,
"route_reason": envelope.route_reason, "route_reason": envelope.route_reason,
"direct": is_direct_destination,
"channel_echo": not is_direct_destination,
"results": [r.to_dict() for r in results], "results": [r.to_dict() for r in results],
} }
@@ -954,6 +966,7 @@ async def mesh_messages(
root: str = "", root: str = "",
channel: str = "", channel: str = "",
limit: int = 30, limit: int = 30,
include_direct: bool = False,
): ):
"""Get recent Meshtastic text messages from the MQTT bridge.""" """Get recent Meshtastic text messages from the MQTT bridge."""
from services.sigint_bridge import sigint_grid from services.sigint_bridge import sigint_grid
@@ -975,6 +988,12 @@ async def mesh_messages(
msgs = [m for m in msgs if m.get("root", "").upper() == root_filter] msgs = [m for m in msgs if m.get("root", "").upper() == root_filter]
if channel: if channel:
msgs = [m for m in msgs if m.get("channel", "").lower() == channel.lower()] msgs = [m for m in msgs if m.get("channel", "").lower() == channel.lower()]
if not include_direct:
msgs = [
m
for m in msgs
if str(m.get("to") or "broadcast").strip().lower() in {"", "broadcast", "^all"}
]
return msgs[: min(limit, 100)] return msgs[: min(limit, 100)]
+75 -326
View File
@@ -78,6 +78,21 @@ export_wormhole_dm_invite = getattr(
"export_wormhole_dm_invite", "export_wormhole_dm_invite",
_wormhole_identity_unavailable, _wormhole_identity_unavailable,
) )
list_prekey_lookup_handle_records_for_ui = getattr(
_mesh_wormhole_identity,
"list_prekey_lookup_handle_records_for_ui",
_wormhole_identity_unavailable,
)
rename_prekey_lookup_handle = getattr(
_mesh_wormhole_identity,
"rename_prekey_lookup_handle",
_wormhole_identity_unavailable,
)
revoke_prekey_lookup_handle = getattr(
_mesh_wormhole_identity,
"revoke_prekey_lookup_handle",
_wormhole_identity_unavailable,
)
import_wormhole_dm_invite = getattr( import_wormhole_dm_invite = getattr(
_mesh_wormhole_identity, _mesh_wormhole_identity,
"import_wormhole_dm_invite", "import_wormhole_dm_invite",
@@ -311,6 +326,10 @@ class WormholeDmInviteImportRequest(BaseModel):
alias: str = "" alias: str = ""
class WormholeDmInviteHandleUpdateRequest(BaseModel):
label: str = ""
class WormholeDmSenderTokenRequest(BaseModel): class WormholeDmSenderTokenRequest(BaseModel):
recipient_id: str recipient_id: str
delivery_class: str delivery_class: str
@@ -477,6 +496,7 @@ def decrypt_wormhole_dm_envelope(
remote_alias: str | None = None, remote_alias: str | None = None,
session_welcome: str | None = None, session_welcome: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Delegate to main.py, which owns current MLS/alias/legacy gating behavior."""
import main as _m import main as _m
return _m.decrypt_wormhole_dm_envelope( return _m.decrypt_wormhole_dm_envelope(
@@ -489,71 +509,13 @@ def decrypt_wormhole_dm_envelope(
session_welcome=session_welcome, session_welcome=session_welcome,
) )
resolved_local, resolved_remote = _resolve_dm_aliases(
peer_id=peer_id,
local_alias=local_alias,
remote_alias=remote_alias,
)
normalized_format = str(payload_format or "dm1").strip().lower() or "dm1"
if normalized_format != "mls1" and is_dm_locked_to_mls(resolved_local, resolved_remote):
return {
"ok": False,
"detail": "DM session is locked to MLS format",
"required_format": "mls1",
"current_format": normalized_format,
}
if normalized_format == "mls1":
has_session = has_mls_dm_session(resolved_local, resolved_remote)
if not has_session.get("ok"):
return has_session
if not has_session.get("exists"):
ensured = ensure_mls_dm_session(resolved_local, resolved_remote, str(session_welcome or ""))
if not ensured.get("ok"):
return ensured
decrypted = decrypt_mls_dm(
resolved_local,
resolved_remote,
str(ciphertext or ""),
str(nonce or ""),
)
if not decrypted.get("ok"):
return decrypted
return {
"ok": True,
"peer_id": str(peer_id or "").strip(),
"local_alias": resolved_local,
"remote_alias": resolved_remote,
"plaintext": str(decrypted.get("plaintext", "") or ""),
"format": "mls1",
}
from services.wormhole_supervisor import get_transport_tier
current_tier = get_transport_tier()
if str(current_tier or "").startswith("private_"):
return {
"ok": False,
"detail": "MLS format required in private transport mode — legacy DM decrypt blocked",
}
logger.warning("legacy dm decrypt path used")
legacy = decrypt_wormhole_dm(peer_id=str(peer_id or ""), ciphertext=str(ciphertext or ""))
if not legacy.get("ok"):
return legacy
return {
"ok": True,
"peer_id": str(peer_id or "").strip(),
"local_alias": resolved_local,
"remote_alias": resolved_remote,
"plaintext": str(legacy.get("result", "") or ""),
"format": "dm1",
}
# --- Routes --- # --- Routes ---
@router.get("/api/settings/wormhole") @router.get("/api/settings/wormhole")
@limiter.limit("30/minute") @limiter.limit("240/minute")
async def api_get_wormhole_settings(request: Request): async def api_get_wormhole_settings(request: Request):
settings = await asyncio.to_thread(read_wormhole_settings) settings = await asyncio.to_thread(read_wormhole_settings)
return _redact_wormhole_settings(settings, authenticated=_scoped_view_authenticated(request, "wormhole")) return _redact_wormhole_settings(settings, authenticated=_scoped_view_authenticated(request, "wormhole"))
@@ -582,248 +544,9 @@ async def api_set_wormhole_settings(request: Request, body: WormholeUpdate):
return {**updated, "requires_restart": False, "runtime": state} return {**updated, "requires_restart": False, "runtime": state}
class PrivacyProfileUpdate(BaseModel):
profile: str
class WormholeSignRequest(BaseModel):
event_type: str
payload: dict
sequence: int | None = None
gate_id: str | None = None
class WormholeSignRawRequest(BaseModel):
message: str
class WormholeDmEncryptRequest(BaseModel):
peer_id: str
peer_dh_pub: str = ""
plaintext: str
local_alias: str | None = None
remote_alias: str | None = None
remote_prekey_bundle: dict[str, Any] | None = None
class WormholeDmComposeRequest(BaseModel):
peer_id: str
peer_dh_pub: str = ""
plaintext: str
local_alias: str | None = None
remote_alias: str | None = None
remote_prekey_bundle: dict[str, Any] | None = None
class WormholeDmDecryptRequest(BaseModel):
peer_id: str
ciphertext: str
format: str = "dm1"
nonce: str = ""
local_alias: str | None = None
remote_alias: str | None = None
session_welcome: str | None = None
class WormholeDmResetRequest(BaseModel):
peer_id: str | None = None
class WormholeDmBootstrapEncryptRequest(BaseModel):
peer_id: str
plaintext: str
class WormholeDmBootstrapDecryptRequest(BaseModel):
sender_id: str = ""
ciphertext: str
class WormholeDmSenderTokenRequest(BaseModel):
recipient_id: str
delivery_class: str
recipient_token: str = ""
count: int = 1
class WormholeOpenSealRequest(BaseModel):
sender_seal: str
candidate_dh_pub: str = ""
recipient_id: str
expected_msg_id: str
class WormholeBuildSealRequest(BaseModel):
recipient_id: str
recipient_dh_pub: str = ""
msg_id: str
timestamp: int
class WormholeDeadDropTokenRequest(BaseModel):
peer_id: str
peer_dh_pub: str = ""
peer_ref: str = ""
class WormholePairwiseAliasRequest(BaseModel):
peer_id: str
peer_dh_pub: str = ""
class WormholePairwiseAliasRotateRequest(BaseModel):
peer_id: str
peer_dh_pub: str = ""
grace_ms: int = 45_000
class WormholeDeadDropContactsRequest(BaseModel):
contacts: list[dict[str, Any]]
limit: int = 24
class WormholeSasRequest(BaseModel):
peer_id: str
peer_dh_pub: str = ""
words: int = 8
peer_ref: str = ""
class WormholeGateRequest(BaseModel):
gate_id: str
rotate: bool = False
class WormholeGatePersonaCreateRequest(BaseModel):
gate_id: str
label: str = ""
class WormholeGatePersonaActivateRequest(BaseModel):
gate_id: str
persona_id: str
class WormholeGateKeyGrantRequest(BaseModel):
gate_id: str
recipient_node_id: str
recipient_dh_pub: str
recipient_scope: str = "member"
class WormholeGateComposeRequest(BaseModel):
gate_id: str
plaintext: str
reply_to: str = ""
compat_plaintext: bool = False
class WormholeGateDecryptRequest(BaseModel):
gate_id: str
epoch: int = 0
ciphertext: str
nonce: str = ""
sender_ref: str = ""
format: str = "mls1"
gate_envelope: str = ""
envelope_hash: str = ""
recovery_envelope: bool = False
compat_decrypt: bool = False
event_id: str = ""
class WormholeGateDecryptBatchRequest(BaseModel):
messages: list[WormholeGateDecryptRequest]
class WormholeGateRotateRequest(BaseModel):
gate_id: str
reason: str = "manual_rotate"
def decrypt_wormhole_dm_envelope(
*,
peer_id: str,
ciphertext: str,
payload_format: str = "dm1",
nonce: str = "",
local_alias: str | None = None,
remote_alias: str | None = None,
session_welcome: str | None = None,
) -> dict[str, Any]:
import main as _m
return _m.decrypt_wormhole_dm_envelope(
peer_id=peer_id,
ciphertext=ciphertext,
payload_format=payload_format,
nonce=nonce,
local_alias=local_alias,
remote_alias=remote_alias,
session_welcome=session_welcome,
)
resolved_local, resolved_remote = _resolve_dm_aliases(
peer_id=peer_id,
local_alias=local_alias,
remote_alias=remote_alias,
)
normalized_format = str(payload_format or "dm1").strip().lower() or "dm1"
if normalized_format != "mls1" and is_dm_locked_to_mls(resolved_local, resolved_remote):
return {
"ok": False,
"detail": "DM session is locked to MLS format",
"required_format": "mls1",
"current_format": normalized_format,
}
if normalized_format == "mls1":
has_session = has_mls_dm_session(resolved_local, resolved_remote)
if not has_session.get("ok"):
return has_session
if not has_session.get("exists"):
ensured = ensure_mls_dm_session(resolved_local, resolved_remote, str(session_welcome or ""))
if not ensured.get("ok"):
return ensured
decrypted = decrypt_mls_dm(
resolved_local,
resolved_remote,
str(ciphertext or ""),
str(nonce or ""),
)
if not decrypted.get("ok"):
return decrypted
return {
"ok": True,
"peer_id": str(peer_id or "").strip(),
"local_alias": resolved_local,
"remote_alias": resolved_remote,
"plaintext": str(decrypted.get("plaintext", "") or ""),
"format": "mls1",
}
from services.wormhole_supervisor import get_transport_tier
current_tier = get_transport_tier()
if str(current_tier or "").startswith("private_"):
return {
"ok": False,
"detail": "MLS format required in private transport mode — legacy DM decrypt blocked",
}
logger.warning("legacy dm decrypt path used")
legacy = decrypt_wormhole_dm(peer_id=str(peer_id or ""), ciphertext=str(ciphertext or ""))
if not legacy.get("ok"):
return legacy
return {
"ok": True,
"peer_id": str(peer_id or "").strip(),
"local_alias": resolved_local,
"remote_alias": resolved_remote,
"plaintext": str(legacy.get("result", "") or ""),
"format": "dm1",
}
@router.get("/api/settings/privacy-profile") @router.get("/api/settings/privacy-profile")
@limiter.limit("30/minute") @limiter.limit("240/minute")
async def api_get_privacy_profile(request: Request): async def api_get_privacy_profile(request: Request):
data = await asyncio.to_thread(read_wormhole_settings) data = await asyncio.to_thread(read_wormhole_settings)
return _redact_privacy_profile_settings( return _redact_privacy_profile_settings(
@@ -833,7 +556,7 @@ async def api_get_privacy_profile(request: Request):
@router.get("/api/settings/wormhole-status") @router.get("/api/settings/wormhole-status")
@limiter.limit("30/minute") @limiter.limit("240/minute")
async def api_get_wormhole_status(request: Request): async def api_get_wormhole_status(request: Request):
state = await asyncio.to_thread(get_wormhole_state) state = await asyncio.to_thread(get_wormhole_state)
transport_tier = _current_private_lane_tier(state) transport_tier = _current_private_lane_tier(state)
@@ -866,7 +589,7 @@ async def api_get_wormhole_status(request: Request):
) )
@router.post("/api/wormhole/join", dependencies=[Depends(require_local_operator)]) @router.post("/api/wormhole/join")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def api_wormhole_join(request: Request): async def api_wormhole_join(request: Request):
from services.config import get_settings from services.config import get_settings
@@ -907,7 +630,7 @@ async def api_wormhole_join(request: Request):
) )
# Enable node participation so the sync/push workers connect to peers. # Enable node participation so the sync/push workers connect to peers.
# This is the voluntary opt-in the node only joins the network when # This is the voluntary opt-in — the node only joins the network when
# the user explicitly opens the Wormhole. # the user explicitly opens the Wormhole.
from services.node_settings import write_node_settings from services.node_settings import write_node_settings
@@ -923,7 +646,7 @@ async def api_wormhole_join(request: Request):
} }
@router.post("/api/wormhole/leave", dependencies=[Depends(require_local_operator)]) @router.post("/api/wormhole/leave")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def api_wormhole_leave(request: Request): async def api_wormhole_leave(request: Request):
updated = write_wormhole_settings(enabled=False) updated = write_wormhole_settings(enabled=False)
@@ -940,8 +663,8 @@ async def api_wormhole_leave(request: Request):
} }
@router.get("/api/wormhole/identity", dependencies=[Depends(require_local_operator)]) @router.get("/api/wormhole/identity")
@limiter.limit("30/minute") @limiter.limit("240/minute")
async def api_wormhole_identity(request: Request): async def api_wormhole_identity(request: Request):
try: try:
bootstrap_wormhole_persona_state() bootstrap_wormhole_persona_state()
@@ -951,7 +674,7 @@ async def api_wormhole_identity(request: Request):
raise HTTPException(status_code=500, detail="wormhole_identity_failed") from exc raise HTTPException(status_code=500, detail="wormhole_identity_failed") from exc
@router.post("/api/wormhole/identity/bootstrap", dependencies=[Depends(require_local_operator)]) @router.post("/api/wormhole/identity/bootstrap")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def api_wormhole_identity_bootstrap(request: Request): async def api_wormhole_identity_bootstrap(request: Request):
bootstrap_wormhole_identity() bootstrap_wormhole_identity()
@@ -970,7 +693,7 @@ async def api_wormhole_identity_bootstrap(request: Request):
@router.get("/api/wormhole/dm/identity", dependencies=[Depends(require_local_operator)]) @router.get("/api/wormhole/dm/identity", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute") @limiter.limit("240/minute")
async def api_wormhole_dm_identity(request: Request): async def api_wormhole_dm_identity(request: Request):
try: try:
bootstrap_wormhole_persona_state() bootstrap_wormhole_persona_state()
@@ -982,11 +705,37 @@ async def api_wormhole_dm_identity(request: Request):
@router.get("/api/wormhole/dm/invite", dependencies=[Depends(require_local_operator)]) @router.get("/api/wormhole/dm/invite", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def api_wormhole_dm_invite(request: Request): async def api_wormhole_dm_invite(
return export_wormhole_dm_invite() request: Request,
label: str = Query("", max_length=96),
expires_in_s: int = Query(0, ge=0, le=2_592_000),
):
return export_wormhole_dm_invite(label=label, expires_in_s=expires_in_s)
@router.post("/api/wormhole/dm/invite/import", dependencies=[Depends(require_admin)]) @router.get("/api/wormhole/dm/invite/handles", dependencies=[Depends(require_local_operator)])
@limiter.limit("240/minute")
async def api_wormhole_dm_invite_handles(request: Request):
return list_prekey_lookup_handle_records_for_ui()
@router.patch("/api/wormhole/dm/invite/handles/{handle}", dependencies=[Depends(require_local_operator)])
@limiter.limit("60/minute")
async def api_wormhole_dm_invite_handle_update(
request: Request,
handle: str,
body: WormholeDmInviteHandleUpdateRequest,
):
return rename_prekey_lookup_handle(handle, str(body.label or "").strip())
@router.delete("/api/wormhole/dm/invite/handles/{handle}", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute")
async def api_wormhole_dm_invite_handle_revoke(request: Request, handle: str):
return revoke_prekey_lookup_handle(handle)
@router.post("/api/wormhole/dm/invite/import", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def api_wormhole_dm_invite_import(request: Request, body: WormholeDmInviteImportRequest): async def api_wormhole_dm_invite_import(request: Request, body: WormholeDmInviteImportRequest):
return import_wormhole_dm_invite( return import_wormhole_dm_invite(
@@ -1024,7 +773,7 @@ async def api_wormhole_sign(request: Request, body: WormholeSignRequest):
) )
@router.post("/api/wormhole/gate/enter", dependencies=[Depends(require_local_operator)]) @router.post("/api/wormhole/gate/enter")
@limiter.limit("20/minute") @limiter.limit("20/minute")
async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest): async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest):
gate_id = str(body.gate_id or "") gate_id = str(body.gate_id or "")
@@ -1038,25 +787,25 @@ async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest):
return result return result
@router.post("/api/wormhole/gate/leave", dependencies=[Depends(require_local_operator)]) @router.post("/api/wormhole/gate/leave")
@limiter.limit("20/minute") @limiter.limit("20/minute")
async def api_wormhole_gate_leave(request: Request, body: WormholeGateRequest): async def api_wormhole_gate_leave(request: Request, body: WormholeGateRequest):
return leave_gate(str(body.gate_id or "")) return leave_gate(str(body.gate_id or ""))
@router.get("/api/wormhole/gate/{gate_id}/identity", dependencies=[Depends(require_local_operator)]) @router.get("/api/wormhole/gate/{gate_id}/identity")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def api_wormhole_gate_identity(request: Request, gate_id: str): async def api_wormhole_gate_identity(request: Request, gate_id: str):
return get_active_gate_identity(gate_id) return get_active_gate_identity(gate_id)
@router.get("/api/wormhole/gate/{gate_id}/personas", dependencies=[Depends(require_local_operator)]) @router.get("/api/wormhole/gate/{gate_id}/personas")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def api_wormhole_gate_personas(request: Request, gate_id: str): async def api_wormhole_gate_personas(request: Request, gate_id: str):
return list_gate_personas(gate_id) return list_gate_personas(gate_id)
@router.get("/api/wormhole/gate/{gate_id}/key", dependencies=[Depends(require_local_operator)]) @router.get("/api/wormhole/gate/{gate_id}/key")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def api_wormhole_gate_key_status(request: Request, gate_id: str): async def api_wormhole_gate_key_status(request: Request, gate_id: str):
import main as _m import main as _m
@@ -1080,7 +829,7 @@ async def api_wormhole_gate_key_rotate(request: Request, body: WormholeGateRotat
return result return result
@router.post("/api/wormhole/gate/persona/create", dependencies=[Depends(require_local_operator)]) @router.post("/api/wormhole/gate/persona/create")
@limiter.limit("20/minute") @limiter.limit("20/minute")
async def api_wormhole_gate_persona_create( async def api_wormhole_gate_persona_create(
request: Request, body: WormholeGatePersonaCreateRequest request: Request, body: WormholeGatePersonaCreateRequest
@@ -1096,7 +845,7 @@ async def api_wormhole_gate_persona_create(
return result return result
@router.post("/api/wormhole/gate/persona/activate", dependencies=[Depends(require_local_operator)]) @router.post("/api/wormhole/gate/persona/activate")
@limiter.limit("20/minute") @limiter.limit("20/minute")
async def api_wormhole_gate_persona_activate( async def api_wormhole_gate_persona_activate(
request: Request, body: WormholeGatePersonaActivateRequest request: Request, body: WormholeGatePersonaActivateRequest
@@ -1112,7 +861,7 @@ async def api_wormhole_gate_persona_activate(
return result return result
@router.post("/api/wormhole/gate/persona/clear", dependencies=[Depends(require_local_operator)]) @router.post("/api/wormhole/gate/persona/clear")
@limiter.limit("20/minute") @limiter.limit("20/minute")
async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRequest): async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRequest):
gate_id = str(body.gate_id or "") gate_id = str(body.gate_id or "")
@@ -1126,7 +875,7 @@ async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRe
return result return result
@router.post("/api/wormhole/gate/persona/retire", dependencies=[Depends(require_local_operator)]) @router.post("/api/wormhole/gate/persona/retire")
@limiter.limit("20/minute") @limiter.limit("20/minute")
async def api_wormhole_gate_persona_retire( async def api_wormhole_gate_persona_retire(
request: Request, body: WormholeGatePersonaActivateRequest request: Request, body: WormholeGatePersonaActivateRequest
@@ -1195,7 +944,7 @@ async def api_wormhole_gate_message_compose(request: Request, body: WormholeGate
return await _m.api_wormhole_gate_message_compose(request, body) return await _m.api_wormhole_gate_message_compose(request, body)
@router.post("/api/wormhole/gate/message/sign-encrypted", dependencies=[Depends(require_local_operator)]) @router.post("/api/wormhole/gate/message/sign-encrypted")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def api_wormhole_gate_message_sign_encrypted( async def api_wormhole_gate_message_sign_encrypted(
request: Request, request: Request,
@@ -1205,7 +954,7 @@ async def api_wormhole_gate_message_sign_encrypted(
return await _m.api_wormhole_gate_message_sign_encrypted(request, body) return await _m.api_wormhole_gate_message_sign_encrypted(request, body)
@router.post("/api/wormhole/gate/message/post-encrypted", dependencies=[Depends(require_local_operator)]) @router.post("/api/wormhole/gate/message/post-encrypted")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def api_wormhole_gate_message_post_encrypted( async def api_wormhole_gate_message_post_encrypted(
request: Request, request: Request,
@@ -1255,14 +1004,14 @@ async def api_wormhole_gate_messages_decrypt(request: Request, body: WormholeGat
return await _m.api_wormhole_gate_messages_decrypt(request, body) return await _m.api_wormhole_gate_messages_decrypt(request, body)
@router.post("/api/wormhole/gate/state/export", dependencies=[Depends(require_local_operator)]) @router.post("/api/wormhole/gate/state/export")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def api_wormhole_gate_state_export(request: Request, body: WormholeGateRequest): async def api_wormhole_gate_state_export(request: Request, body: WormholeGateRequest):
import main as _m import main as _m
return await _m.api_wormhole_gate_state_export(request, body) return await _m.api_wormhole_gate_state_export(request, body)
@router.post("/api/wormhole/gate/proof", dependencies=[Depends(require_local_operator)]) @router.post("/api/wormhole/gate/proof")
@limiter.limit("30/minute") @limiter.limit("30/minute")
async def api_wormhole_gate_proof(request: Request, body: WormholeGateRequest): async def api_wormhole_gate_proof(request: Request, body: WormholeGateRequest):
proof = _sign_gate_access_proof(str(body.gate_id or "")) proof = _sign_gate_access_proof(str(body.gate_id or ""))
@@ -1547,7 +1296,7 @@ class PrivateDeliveryActionRequest(BaseModel):
@router.get("/api/wormhole/status") @router.get("/api/wormhole/status")
@limiter.limit("30/minute") @limiter.limit("240/minute")
async def api_wormhole_status(request: Request): async def api_wormhole_status(request: Request):
import main as _m import main as _m
@@ -1590,7 +1339,7 @@ async def api_wormhole_private_delivery_action(
@router.get("/api/wormhole/health") @router.get("/api/wormhole/health")
@limiter.limit("30/minute") @limiter.limit("240/minute")
async def api_wormhole_health(request: Request): async def api_wormhole_health(request: Request):
state = get_wormhole_state() state = get_wormhole_state()
transport_tier = _current_private_lane_tier(state) transport_tier = _current_private_lane_tier(state)
@@ -1611,7 +1360,7 @@ async def api_wormhole_health(request: Request):
return _redact_wormhole_status(full_state, authenticated=ok) return _redact_wormhole_status(full_state, authenticated=ok)
@router.post("/api/wormhole/connect", dependencies=[Depends(require_admin)]) @router.post("/api/wormhole/connect")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def api_wormhole_connect(request: Request): async def api_wormhole_connect(request: Request):
settings = read_wormhole_settings() settings = read_wormhole_settings()
+3
View File
@@ -46,9 +46,12 @@ class Settings(BaseSettings):
MESH_NODE_MODE: str = "participant" MESH_NODE_MODE: str = "participant"
MESH_SYNC_INTERVAL_S: int = 300 MESH_SYNC_INTERVAL_S: int = 300
MESH_SYNC_FAILURE_BACKOFF_S: int = 60 MESH_SYNC_FAILURE_BACKOFF_S: int = 60
MESH_SYNC_TIMEOUT_S: int = 5
MESH_SYNC_MAX_PEERS_PER_CYCLE: int = 3
MESH_RELAY_PUSH_TIMEOUT_S: int = 10 MESH_RELAY_PUSH_TIMEOUT_S: int = 10
MESH_RELAY_MAX_FAILURES: int = 3 MESH_RELAY_MAX_FAILURES: int = 3
MESH_RELAY_FAILURE_COOLDOWN_S: int = 120 MESH_RELAY_FAILURE_COOLDOWN_S: int = 120
MESH_BOOTSTRAP_SEED_FAILURE_COOLDOWN_S: int = 15
MESH_PEER_PUSH_SECRET: str = "" MESH_PEER_PUSH_SECRET: str = ""
MESH_RNS_APP_NAME: str = "shadowbroker" MESH_RNS_APP_NAME: str = "shadowbroker"
MESH_RNS_ASPECT: str = "infonet" MESH_RNS_ASPECT: str = "infonet"
@@ -32,7 +32,7 @@ _REFRESH_INTERVAL_S = 5 * 24 * 3600
_LIST_TIMEOUT_S = 30 _LIST_TIMEOUT_S = 30
_DOWNLOAD_TIMEOUT_S = 600 _DOWNLOAD_TIMEOUT_S = 600
_USER_AGENT = ( _USER_AGENT = (
"ShadowBroker-OSINT/0.9.75 " "ShadowBroker-OSINT/0.9.79 "
"(+https://github.com/BigBodyCobain/Shadowbroker; " "(+https://github.com/BigBodyCobain/Shadowbroker; "
"contact: bigbodycobain@gmail.com)" "contact: bigbodycobain@gmail.com)"
) )
+1 -1
View File
@@ -182,7 +182,7 @@ def fetch_meshtastic_nodes():
callsign = str(getattr(get_settings(), "MESHTASTIC_OPERATOR_CALLSIGN", "") or "").strip() callsign = str(getattr(get_settings(), "MESHTASTIC_OPERATOR_CALLSIGN", "") or "").strip()
except Exception: except Exception:
callsign = "" callsign = ""
ua_base = "ShadowBroker-OSINT/0.9.75 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com; 24h polling)" ua_base = "ShadowBroker-OSINT/0.9.79 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com; 24h polling)"
user_agent = f"{ua_base}; node={callsign}" if callsign else ua_base user_agent = f"{ua_base}; node={callsign}" if callsign else ua_base
try: try:
+1 -1
View File
@@ -25,7 +25,7 @@ _REFRESH_INTERVAL_S = 5 * 24 * 3600
_HTTP_TIMEOUT_S = 60 _HTTP_TIMEOUT_S = 60
_USER_AGENT = ( _USER_AGENT = (
"ShadowBroker-OSINT/0.9.75 " "ShadowBroker-OSINT/0.9.79 "
"(+https://github.com/BigBodyCobain/Shadowbroker; " "(+https://github.com/BigBodyCobain/Shadowbroker; "
"contact: bigbodycobain@gmail.com)" "contact: bigbodycobain@gmail.com)"
) )
+15
View File
@@ -1264,6 +1264,21 @@ class DMRelay:
) )
self._save() self._save()
def unregister_prekey_lookup_alias(self, alias: str) -> bool:
"""Remove an invite-scoped lookup alias from the local relay."""
handle = str(alias or "").strip()
if not handle:
return False
removed = False
with self._lock:
self._refresh_from_shared_relay()
if handle in self._prekey_lookup_aliases:
del self._prekey_lookup_aliases[handle]
removed = True
if removed:
self._save()
return removed
def consume_one_time_prekey(self, agent_id: str) -> dict[str, Any] | None: def consume_one_time_prekey(self, agent_id: str) -> dict[str, Any] | None:
"""Atomically claim the next published one-time prekey for a peer bundle.""" """Atomically claim the next published one-time prekey for a peer bundle."""
claimed: dict[str, Any] | None = None claimed: dict[str, Any] | None = None
@@ -30,10 +30,19 @@ def eligible_sync_peers(records: list[PeerRecord], *, now: float | None = None)
for record in records for record in records
if record.bucket == "sync" and record.enabled and int(record.cooldown_until or 0) <= current_time if record.bucket == "sync" and record.enabled and int(record.cooldown_until or 0) <= current_time
] ]
def _seed_priority(record: PeerRecord) -> int:
role = str(record.role or "").strip().lower()
source = str(record.source or "").strip().lower()
if role == "seed" and source in {"bundle", "bootstrap_promoted"}:
return 0
return 1
return sorted( return sorted(
candidates, candidates,
key=lambda record: ( key=lambda record: (
-int(record.last_sync_ok_at or 0), -int(record.last_sync_ok_at or 0),
_seed_priority(record),
int(record.failure_count or 0), int(record.failure_count or 0),
int(record.added_at or 0), int(record.added_at or 0),
record.peer_url, record.peer_url,
+9 -3
View File
@@ -258,6 +258,12 @@ class PeerStore:
self._records[record.record_key()] = record self._records[record.record_key()] = record
return record return record
explicit_seed_refresh = (
record.bucket == "sync"
and record.role == "seed"
and record.source in {"bundle", "bootstrap_promoted"}
)
merged = PeerRecord( merged = PeerRecord(
bucket=record.bucket, bucket=record.bucket,
source=record.source, source=record.source,
@@ -272,9 +278,9 @@ class PeerStore:
last_seen_at=max(existing.last_seen_at, record.last_seen_at), last_seen_at=max(existing.last_seen_at, record.last_seen_at),
last_sync_ok_at=max(existing.last_sync_ok_at, record.last_sync_ok_at), last_sync_ok_at=max(existing.last_sync_ok_at, record.last_sync_ok_at),
last_push_ok_at=max(existing.last_push_ok_at, record.last_push_ok_at), last_push_ok_at=max(existing.last_push_ok_at, record.last_push_ok_at),
last_error=record.last_error or existing.last_error, last_error="" if explicit_seed_refresh else record.last_error or existing.last_error,
failure_count=max(existing.failure_count, record.failure_count), failure_count=0 if explicit_seed_refresh else max(existing.failure_count, record.failure_count),
cooldown_until=max(existing.cooldown_until, record.cooldown_until), cooldown_until=0 if explicit_seed_refresh else max(existing.cooldown_until, record.cooldown_until),
metadata={**existing.metadata, **record.metadata}, metadata={**existing.metadata, **record.metadata},
) )
self._records[record.record_key()] = merged self._records[record.record_key()] = merged
+3 -3
View File
@@ -520,7 +520,7 @@ class MeshtasticTransport:
def _on_connect(client, userdata, flags, rc): def _on_connect(client, userdata, flags, rc):
if rc == 0: if rc == 0:
info = client.publish(topic, payload, qos=0) info = client.publish(topic, payload, qos=1)
info.wait_for_publish(timeout=5) info.wait_for_publish(timeout=5)
published[0] = True published[0] = True
client.disconnect() client.disconnect()
@@ -550,9 +550,9 @@ class MeshtasticTransport:
True, True,
self.NAME, self.NAME,
( (
f"Published direct to !{to_node:08x} via {region}/{channel}" f"Broker accepted direct publish to !{to_node:08x} via {region}/{channel}"
if direct_node is not None if direct_node is not None
else f"Published to {region}/{channel} ({len(payload)}B protobuf)" else f"Broker accepted channel publish to {region}/{channel} ({len(payload)}B protobuf)"
), ),
) )
except Exception as e: except Exception as e:
+178 -4
View File
@@ -11,6 +11,7 @@ import base64
import hmac import hmac
import hashlib import hashlib
import json import json
import logging
import secrets import secrets
import time import time
from typing import Any from typing import Any
@@ -51,6 +52,8 @@ PREKEY_LOOKUP_ROTATE_BEFORE_REMAINING_USES = 8
PREKEY_LOOKUP_ROTATION_OVERLAP_S = 12 * 60 * 60 PREKEY_LOOKUP_ROTATION_OVERLAP_S = 12 * 60 * 60
PREKEY_LOOKUP_ROTATION_ACTIVE_CAP = 4 PREKEY_LOOKUP_ROTATION_ACTIVE_CAP = 4
logger = logging.getLogger(__name__)
def _safe_int(val, default=0) -> int: def _safe_int(val, default=0) -> int:
try: try:
@@ -107,6 +110,7 @@ def _default_identity() -> dict[str, Any]:
def _prekey_lookup_handle_record( def _prekey_lookup_handle_record(
handle: str, handle: str,
*, *,
label: str = "",
issued_at: int = 0, issued_at: int = 0,
expires_at: int = 0, expires_at: int = 0,
max_uses: int = 0, max_uses: int = 0,
@@ -125,6 +129,7 @@ def _prekey_lookup_handle_record(
bounded_max_uses = max(1, _safe_int(max_uses or PREKEY_LOOKUP_HANDLE_MAX_USES, PREKEY_LOOKUP_HANDLE_MAX_USES)) bounded_max_uses = max(1, _safe_int(max_uses or PREKEY_LOOKUP_HANDLE_MAX_USES, PREKEY_LOOKUP_HANDLE_MAX_USES))
return { return {
"handle": str(handle or "").strip(), "handle": str(handle or "").strip(),
"label": str(label or "").strip()[:96],
"issued_at": issued, "issued_at": issued,
"expires_at": bounded_expires_at, "expires_at": bounded_expires_at,
"max_uses": bounded_max_uses, "max_uses": bounded_max_uses,
@@ -152,8 +157,10 @@ def _coerce_prekey_lookup_handle_record(
max_uses = _safe_int(value.get("max_uses", PREKEY_LOOKUP_HANDLE_MAX_USES) or PREKEY_LOOKUP_HANDLE_MAX_USES) max_uses = _safe_int(value.get("max_uses", PREKEY_LOOKUP_HANDLE_MAX_USES) or PREKEY_LOOKUP_HANDLE_MAX_USES)
use_count = _safe_int(value.get("use_count", value.get("uses", 0)) or 0, 0) use_count = _safe_int(value.get("use_count", value.get("uses", 0)) or 0, 0)
last_used_at = _safe_int(value.get("last_used_at", value.get("last_used", 0)) or 0, 0) last_used_at = _safe_int(value.get("last_used_at", value.get("last_used", 0)) or 0, 0)
label = str(value.get("label", "") or "").strip()
return _prekey_lookup_handle_record( return _prekey_lookup_handle_record(
handle, handle,
label=label,
issued_at=issued_at, issued_at=issued_at,
expires_at=expires_at, expires_at=expires_at,
max_uses=max_uses, max_uses=max_uses,
@@ -228,6 +235,23 @@ def _fresh_prekey_lookup_handle_record(*, now: int | None = None) -> dict[str, A
) )
def _prekey_registration_failure_blocks_dm_invite(detail: str) -> bool:
"""Only trust-root failures block address export; transport warm-up can finish later."""
lowered = str(detail or "").lower()
critical_markers = (
"root transparency",
"external root witness",
"stable root",
"witness threshold",
"witness finality",
"root manifest",
"root witness",
"manifest_fingerprint",
"policy fingerprint",
)
return any(marker in lowered for marker in critical_markers)
def _bounded_lookup_handle_records( def _bounded_lookup_handle_records(
records: list[dict[str, Any]], records: list[dict[str, Any]],
*, *,
@@ -884,6 +908,7 @@ def export_wormhole_dm_invite(*, label: str = "", expires_in_s: int = 0) -> dict
existing_handles.append( existing_handles.append(
_prekey_lookup_handle_record( _prekey_lookup_handle_record(
lookup_handle, lookup_handle,
label=str(label or "").strip(),
issued_at=issued_at, issued_at=issued_at,
expires_at=expires_at, expires_at=expires_at,
) )
@@ -920,14 +945,25 @@ def export_wormhole_dm_invite(*, label: str = "", expires_in_s: int = 0) -> dict
except Exception: except Exception:
pass pass
prekey_registration: dict[str, Any] = {"ok": False, "detail": "prekey bundle publish not attempted"}
try: try:
from services.mesh.mesh_wormhole_prekey import register_wormhole_prekey_bundle from services.mesh.mesh_wormhole_prekey import register_wormhole_prekey_bundle
registered = register_wormhole_prekey_bundle() prekey_registration = register_wormhole_prekey_bundle()
if not registered.get("ok"): if not prekey_registration.get("ok"):
return {"ok": False, "detail": str(registered.get("detail", "") or "prekey bundle registration failed")} detail = str(prekey_registration.get("detail", "") or "prekey bundle registration failed")
if _prekey_registration_failure_blocks_dm_invite(detail):
return {"ok": False, "detail": detail}
logger.warning(
"DM invite prekey publish pending: %s",
detail,
)
except Exception as exc: except Exception as exc:
return {"ok": False, "detail": str(exc) or "prekey bundle registration failed"} prekey_registration = {"ok": False, "detail": str(exc) or "prekey bundle registration failed"}
detail = str(prekey_registration.get("detail", "") or "")
if _prekey_registration_failure_blocks_dm_invite(detail):
return {"ok": False, "detail": detail}
logger.warning("DM invite prekey publish pending: %s", prekey_registration["detail"])
invite_node_id, invite_public_key, invite_private_key = _generate_invite_signing_identity() invite_node_id, invite_public_key, invite_private_key = _generate_invite_signing_identity()
payload = _attach_dm_invite_root_distribution(payload) payload = _attach_dm_invite_root_distribution(payload)
@@ -958,6 +994,8 @@ def export_wormhole_dm_invite(*, label: str = "", expires_in_s: int = 0) -> dict
"peer_id": str(invite_node_id or ""), "peer_id": str(invite_node_id or ""),
"trust_fingerprint": str(payload.get("identity_commitment", "") or ""), "trust_fingerprint": str(payload.get("identity_commitment", "") or ""),
"invite": invite, "invite": invite,
"prekey_publish_pending": not bool(prekey_registration.get("ok")),
"prekey_registration": prekey_registration,
} }
@@ -980,6 +1018,140 @@ def get_prekey_lookup_handle_records() -> list[dict[str, Any]]:
] ]
def list_prekey_lookup_handle_records_for_ui(*, now: int | None = None) -> dict[str, Any]:
"""Return shareable DM address records without exposing local identity secrets."""
current_time = _safe_int(now or time.time(), int(time.time()))
addresses: list[dict[str, Any]] = []
for record in get_prekey_lookup_handle_records():
handle = str(record.get("handle", "") or "").strip()
if not handle:
continue
expires_at = _effective_prekey_lookup_handle_expires_at(record)
max_uses = max(
1,
_safe_int(
record.get("max_uses", PREKEY_LOOKUP_HANDLE_MAX_USES) or PREKEY_LOOKUP_HANDLE_MAX_USES,
PREKEY_LOOKUP_HANDLE_MAX_USES,
),
)
use_count = max(0, _safe_int(record.get("use_count", 0) or 0, 0))
addresses.append(
{
"handle": handle,
"label": str(record.get("label", "") or "").strip(),
"issued_at": _safe_int(record.get("issued_at", 0) or 0, 0),
"expires_at": expires_at,
"max_uses": max_uses,
"use_count": use_count,
"remaining_uses": max(0, max_uses - use_count),
"last_used_at": _safe_int(record.get("last_used_at", 0) or 0, 0),
"expired": bool(expires_at > 0 and current_time >= expires_at),
"exhausted": bool(use_count >= max_uses),
}
)
addresses.sort(key=lambda item: _safe_int(item.get("issued_at", 0) or 0, 0), reverse=True)
return {"ok": True, "addresses": addresses}
def rename_prekey_lookup_handle(handle: str, label: str) -> dict[str, Any]:
"""Rename an active invite-scoped DM lookup handle without changing the handle."""
lookup_handle = str(handle or "").strip()
next_label = str(label or "").strip()[:96]
if not lookup_handle:
return {"ok": False, "detail": "missing_lookup_handle"}
current_time = int(time.time())
data = read_wormhole_identity()
existing, _ = _normalize_prekey_lookup_handles(
data.get("prekey_lookup_handles", []),
fallback_issued_at=current_time,
now=current_time,
)
updated = False
next_records: list[dict[str, Any]] = []
for record in existing:
current = dict(record)
if str(current.get("handle", "") or "").strip() == lookup_handle:
current["label"] = next_label
updated = True
next_records.append(current)
if not updated:
return {
"ok": False,
"handle": lookup_handle,
"label": next_label,
"updated": False,
"detail": "lookup_handle_not_found",
}
normalized_records, _ = _normalize_prekey_lookup_handles(
next_records,
fallback_issued_at=current_time,
now=current_time,
)
_write_identity({"prekey_lookup_handles": normalized_records})
return {
"ok": True,
"handle": lookup_handle,
"label": next_label,
"updated": True,
}
def revoke_prekey_lookup_handle(handle: str) -> dict[str, Any]:
"""Revoke an invite-scoped DM lookup handle for future first-contact attempts."""
lookup_handle = str(handle or "").strip()
if not lookup_handle:
return {"ok": False, "detail": "missing_lookup_handle"}
current_time = int(time.time())
data = read_wormhole_identity()
existing, _ = _normalize_prekey_lookup_handles(
data.get("prekey_lookup_handles", []),
fallback_issued_at=current_time,
now=current_time,
)
next_records = [
dict(record)
for record in existing
if str(record.get("handle", "") or "").strip() != lookup_handle
]
identity_removed = len(next_records) != len(existing)
if identity_removed:
_write_identity({"prekey_lookup_handles": next_records})
relay_removed = False
try:
from services.mesh.mesh_dm_relay import dm_relay
relay_removed = bool(dm_relay.unregister_prekey_lookup_alias(lookup_handle))
except Exception:
relay_removed = False
republished = False
detail = ""
if identity_removed:
try:
from services.mesh.mesh_wormhole_prekey import register_wormhole_prekey_bundle
registered = register_wormhole_prekey_bundle()
republished = bool(registered.get("ok"))
if not republished:
detail = str(registered.get("detail", "") or "prekey bundle republish failed")
except Exception as exc:
detail = str(exc) or "prekey bundle republish failed"
return {
"ok": True,
"handle": lookup_handle,
"revoked": bool(identity_removed or relay_removed),
"identity_removed": identity_removed,
"relay_removed": relay_removed,
"republished": republished,
"detail": detail,
}
def record_prekey_lookup_handle_use(handle: str, *, now: int | None = None) -> dict[str, Any] | None: def record_prekey_lookup_handle_use(handle: str, *, now: int | None = None) -> dict[str, Any] | None:
lookup_handle = str(handle or "").strip() lookup_handle = str(handle or "").strip()
if not lookup_handle: if not lookup_handle:
@@ -999,6 +1171,7 @@ def record_prekey_lookup_handle_use(handle: str, *, now: int | None = None) -> d
if str(current.get("handle", "") or "").strip() == lookup_handle: if str(current.get("handle", "") or "").strip() == lookup_handle:
current = _prekey_lookup_handle_record( current = _prekey_lookup_handle_record(
lookup_handle, lookup_handle,
label=str(current.get("label", "") or "").strip(),
issued_at=_safe_int(current.get("issued_at", 0) or 0, current_time), issued_at=_safe_int(current.get("issued_at", 0) or 0, current_time),
expires_at=_safe_int(current.get("expires_at", 0) or 0, 0), expires_at=_safe_int(current.get("expires_at", 0) or 0, 0),
max_uses=_safe_int(current.get("max_uses", PREKEY_LOOKUP_HANDLE_MAX_USES) or PREKEY_LOOKUP_HANDLE_MAX_USES), max_uses=_safe_int(current.get("max_uses", PREKEY_LOOKUP_HANDLE_MAX_USES) or PREKEY_LOOKUP_HANDLE_MAX_USES),
@@ -1129,6 +1302,7 @@ def maybe_rotate_prekey_lookup_handles(*, now: int | None = None) -> dict[str, A
candidate_records.append( candidate_records.append(
_prekey_lookup_handle_record( _prekey_lookup_handle_record(
old_handle, old_handle,
label=str(record.get("label", "") or "").strip(),
issued_at=_safe_int(record.get("issued_at", 0) or 0, current_time), issued_at=_safe_int(record.get("issued_at", 0) or 0, current_time),
expires_at=overlap_expires_at, expires_at=overlap_expires_at,
max_uses=_safe_int(record.get("max_uses", PREKEY_LOOKUP_HANDLE_MAX_USES) or PREKEY_LOOKUP_HANDLE_MAX_USES), max_uses=_safe_int(record.get("max_uses", PREKEY_LOOKUP_HANDLE_MAX_USES) or PREKEY_LOOKUP_HANDLE_MAX_USES),
@@ -12,6 +12,7 @@ from __future__ import annotations
import base64 import base64
import hashlib import hashlib
import json import json
import logging
import time import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -23,7 +24,7 @@ from cryptography.hazmat.primitives.asymmetric import ed25519
from services.mesh.mesh_crypto import build_signature_payload, derive_node_id, verify_node_binding, verify_signature from services.mesh.mesh_crypto import build_signature_payload, derive_node_id, verify_node_binding, verify_signature
from services.mesh.mesh_protocol import PROTOCOL_VERSION from services.mesh.mesh_protocol import PROTOCOL_VERSION
from services.mesh.mesh_secure_storage import read_domain_json, write_domain_json from services.mesh.mesh_secure_storage import SecureStorageError, read_domain_json, write_domain_json
from services.mesh.mesh_wormhole_identity import root_identity_fingerprint_for_material from services.mesh.mesh_wormhole_identity import root_identity_fingerprint_for_material
from services.mesh.mesh_wormhole_persona import ( from services.mesh.mesh_wormhole_persona import (
bootstrap_wormhole_persona_state, bootstrap_wormhole_persona_state,
@@ -51,6 +52,7 @@ DEFAULT_ROOT_WITNESS_THRESHOLD = 2
DEFAULT_ROOT_WITNESS_MANAGEMENT_SCOPE = "local" DEFAULT_ROOT_WITNESS_MANAGEMENT_SCOPE = "local"
DEFAULT_ROOT_WITNESS_INDEPENDENCE_GROUP = "local_system" DEFAULT_ROOT_WITNESS_INDEPENDENCE_GROUP = "local_system"
DEFAULT_ROOT_EXTERNAL_WITNESS_MAX_AGE_S = 3600 DEFAULT_ROOT_EXTERNAL_WITNESS_MAX_AGE_S = 3600
logger = logging.getLogger(__name__)
def _safe_int(val: Any, default: int = 0) -> int: def _safe_int(val: Any, default: int = 0) -> int:
@@ -461,12 +463,22 @@ def witness_policy_fingerprint(policy: dict[str, Any]) -> str:
def read_root_distribution_state() -> dict[str, Any]: def read_root_distribution_state() -> dict[str, Any]:
raw = read_domain_json( try:
ROOT_DISTRIBUTION_DOMAIN, raw = read_domain_json(
ROOT_DISTRIBUTION_FILE, ROOT_DISTRIBUTION_DOMAIN,
_default_state, ROOT_DISTRIBUTION_FILE,
base_dir=DATA_DIR, _default_state,
) base_dir=DATA_DIR,
)
except SecureStorageError as exc:
detail = str(exc)
if "Failed to decrypt domain JSON" not in detail:
raise
logger.warning(
"Root distribution state could not decrypt; regenerating local witness distribution: %s",
detail,
)
raw = _default_state()
state = {**_default_state(), **dict(raw or {})} state = {**_default_state(), **dict(raw or {})}
state["witness_identity"] = {**_empty_witness_identity(), **dict(state.get("witness_identity") or {})} state["witness_identity"] = {**_empty_witness_identity(), **dict(state.get("witness_identity") or {})}
witness_identities, witness_changed = _normalize_witness_identities( witness_identities, witness_changed = _normalize_witness_identities(
+17 -3
View File
@@ -108,8 +108,18 @@ def normalize_topic_filter(value: str) -> str | None:
return "/".join(parts) return "/".join(parts)
def _default_topic_for_root(root: str) -> str: def _default_topics_for_root(root: str) -> list[str]:
return f"msh/{root}/2/e/{DEFAULT_CHANNEL}/#" """Return the default LongFast subscriptions for a region root.
The public broker carries protobuf/encrypted traffic under ``/e/`` and
companion decoded JSON traffic under ``/json/``. Positions often arrive on
the protobuf path, while public text is commonly easiest to observe on the
JSON path.
"""
return [
f"msh/{root}/2/e/{DEFAULT_CHANNEL}/#",
f"msh/{root}/2/json/{DEFAULT_CHANNEL}/#",
]
def build_subscription_topics( def build_subscription_topics(
@@ -124,7 +134,11 @@ def build_subscription_topics(
# via MESH_MQTT_EXTRA_ROOTS to avoid flooding the public broker. # via MESH_MQTT_EXTRA_ROOTS to avoid flooding the public broker.
roots.extend(root for root in (normalize_root(item) for item in _split_config_values(extra_roots)) if root) roots.extend(root for root in (normalize_root(item) for item in _split_config_values(extra_roots)) if root)
topics = [_default_topic_for_root(root) for root in _dedupe(roots)] topics = [
topic
for root in _dedupe(roots)
for topic in _default_topics_for_root(root)
]
topics.extend( topics.extend(
topic topic
for topic in ( for topic in (
+1 -1
View File
@@ -73,7 +73,7 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None,
both Python requests and the barebones Windows system curl. both Python requests and the barebones Windows system curl.
""" """
default_headers = { default_headers = {
"User-Agent": "ShadowBroker-OSINT/0.9.75 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com)", "User-Agent": "ShadowBroker-OSINT/0.9.79 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com)",
} }
if headers: if headers:
default_headers.update(headers) default_headers.update(headers)
+1 -1
View File
@@ -20,7 +20,7 @@ from cachetools import TTLCache
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_SHODAN_BASE = "https://api.shodan.io" _SHODAN_BASE = "https://api.shodan.io"
_USER_AGENT = "ShadowBroker/0.9.75 local Shodan connector" _USER_AGENT = "ShadowBroker/0.9.79 local Shodan connector"
_REQUEST_TIMEOUT = 15 _REQUEST_TIMEOUT = 15
_MIN_INTERVAL_SECONDS = 1.05 # Shodan docs say API plans are rate limited to ~1 req/sec. _MIN_INTERVAL_SECONDS = 1.05 # Shodan docs say API plans are rate limited to ~1 req/sec.
_DEFAULT_SEARCH_PAGES = 1 _DEFAULT_SEARCH_PAGES = 1
+196 -1
View File
@@ -545,6 +545,198 @@ class MeshtasticBridge:
self._message_dedupe[key] = now self._message_dedupe[key] = now
return False return False
@staticmethod
def _message_dedupe_key(message: dict) -> str:
sender = str(message.get("from") or "???").strip().lower()
recipient = str(message.get("to") or "broadcast").strip().lower()
text = str(message.get("text") or "").strip()
channel = str(message.get("channel") or "LongFast").strip().lower()
root = str(message.get("root") or message.get("region") or "").strip().lower()
if root == "us":
root = "us"
return f"{sender}:{recipient}:{root}:{channel}:{text}"
def append_text_message(self, message: dict, *, dedupe_window_s: float = 5.0) -> bool:
"""Append a Meshtastic text message unless it is a near-immediate echo."""
if not str(message.get("text") or "").strip():
return False
now = time.time()
cutoff = now - max(1.0, dedupe_window_s)
next_message = dict(message)
next_message.setdefault("to", "broadcast")
next_message.setdefault("channel", "LongFast")
next_message.setdefault("timestamp", datetime.utcnow().isoformat() + "Z")
key = self._message_dedupe_key(next_message)
for existing in list(self.messages)[:40]:
if self._message_dedupe_key(existing) != key:
continue
try:
existing_ts_raw = existing.get("timestamp")
existing_ts = (
datetime.fromisoformat(str(existing_ts_raw).replace("Z", "+00:00")).timestamp()
if existing_ts_raw
else now
)
except Exception:
existing_ts = now
if existing_ts >= cutoff:
if not existing.get("root") and next_message.get("root"):
existing["root"] = next_message.get("root")
if not existing.get("region") and next_message.get("region"):
existing["region"] = next_message.get("region")
return False
self.messages.appendleft(next_message)
return True
@staticmethod
def _coerce_node_ref(value) -> str:
"""Normalize Meshtastic node identifiers into the public !xxxxxxxx form."""
if value is None:
return ""
if isinstance(value, int):
return f"!{value & 0xFFFFFFFF:08x}"
raw = str(value).strip()
if not raw:
return ""
if raw.startswith("!"):
return raw
lowered = raw.lower()
if lowered.startswith("0x"):
try:
return f"!{int(lowered, 16) & 0xFFFFFFFF:08x}"
except ValueError:
return raw
if raw.isdigit():
try:
return f"!{int(raw) & 0xFFFFFFFF:08x}"
except ValueError:
return raw
if len(raw) == 8 and all(ch in "0123456789abcdefABCDEF" for ch in raw):
return f"!{raw.lower()}"
return raw
@staticmethod
def _first_text_value(*values) -> str:
for value in values:
if isinstance(value, bytes):
value = value.decode("utf-8", errors="replace")
if isinstance(value, str):
text = value.strip()
if text:
return MeshtasticBridge._repair_text_mojibake(text)
return ""
@staticmethod
def _repair_text_mojibake(text: str) -> str:
"""Repair common UTF-8-as-Latin-1 mojibake from MQTT JSON bridges."""
if not text or not any(marker in text for marker in ("Ã", "Ð", "Ñ")):
return text
try:
repaired = text.encode("latin-1").decode("utf-8").strip()
except UnicodeError:
return text
if repaired and repaired != text:
return repaired
return text
@staticmethod
def _first_present(*values):
for value in values:
if value is not None and value != "":
return value
return None
def _extract_json_text_message(self, data: dict, topic: str) -> dict | None:
"""Extract a public Meshtastic text event from decoded MQTT JSON.
Meshtastic JSON brokers are not perfectly uniform. Some packets expose
text at the top level, some under ``decoded`` or ``payload``. Keep this
permissive for receive, but only return messages with non-empty text.
"""
if not isinstance(data, dict):
return None
topic_meta = parse_topic_metadata(topic)
packet = data.get("packet") if isinstance(data.get("packet"), dict) else {}
decoded = data.get("decoded") if isinstance(data.get("decoded"), dict) else {}
payload_obj = data.get("payload")
payload = payload_obj if isinstance(payload_obj, dict) else {}
decoded_payload_obj = decoded.get("payload") if decoded else None
decoded_payload = decoded_payload_obj if isinstance(decoded_payload_obj, dict) else {}
text = self._first_text_value(
data.get("text"),
data.get("message"),
data.get("msg"),
payload_obj if isinstance(payload_obj, str) else "",
payload.get("text"),
payload.get("message"),
payload.get("msg"),
payload.get("payload") if isinstance(payload.get("payload"), str) else "",
decoded.get("text"),
decoded.get("message"),
decoded.get("payload") if isinstance(decoded.get("payload"), str) else "",
decoded_payload.get("text"),
decoded_payload.get("message"),
decoded_payload.get("msg"),
)
if not text:
return None
sender = self._coerce_node_ref(
self._first_present(
data.get("from"),
data.get("fromId"),
data.get("from_id"),
data.get("sender"),
data.get("senderId"),
data.get("sender_id"),
packet.get("from"),
packet.get("fromId"),
packet.get("from_id"),
decoded.get("from"),
)
)
recipient = self._coerce_node_ref(
self._first_present(
data.get("to"),
data.get("toId"),
data.get("to_id"),
data.get("recipient"),
data.get("recipientId"),
data.get("recipient_id"),
packet.get("to"),
packet.get("toId"),
packet.get("to_id"),
decoded.get("to"),
)
)
if not recipient or recipient in {"!ffffffff", "broadcast"}:
recipient = "broadcast"
timestamp = datetime.utcnow().isoformat() + "Z"
rx_time = self._first_present(
data.get("rxTime"),
data.get("rx_time"),
data.get("timestamp"),
packet.get("rxTime"),
packet.get("timestamp"),
)
if isinstance(rx_time, (int, float)) and rx_time > 0:
try:
timestamp = datetime.fromtimestamp(float(rx_time), tz=timezone.utc).isoformat()
except (OSError, ValueError):
pass
return {
"from": sender or topic.split("/")[-1],
"to": recipient,
"text": text[:500],
"region": topic_meta["region"],
"root": topic_meta["root"],
"channel": topic_meta["channel"],
"timestamp": timestamp,
}
def start(self): def start(self):
if self._thread and self._thread.is_alive(): if self._thread and self._thread.is_alive():
if not self._stop.is_set(): if not self._stop.is_set():
@@ -693,6 +885,9 @@ class MeshtasticBridge:
if "/json/" in topic: if "/json/" in topic:
try: try:
data = json.loads(payload) data = json.loads(payload)
text_message = self._extract_json_text_message(data, topic)
if text_message:
self.append_text_message(text_message, dedupe_window_s=30.0)
if self._rate_limited(): if self._rate_limited():
return return
self._ingest_data(data, topic) self._ingest_data(data, topic)
@@ -715,7 +910,7 @@ class MeshtasticBridge:
topic_meta["root"], topic_meta["root"],
): ):
return return
self.messages.appendleft( self.append_text_message(
{ {
"from": data.get("from", "???"), "from": data.get("from", "???"),
"to": recipient, "to": recipient,
@@ -0,0 +1,57 @@
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
def disable_public_mesh_lane(*, reason: str = "private_lane_enabled") -> dict[str, Any]:
"""Disable public Meshtastic MQTT before private Wormhole/Infonet starts."""
result: dict[str, Any] = {
"ok": True,
"reason": reason,
"settings_disabled": False,
"runtime_stopped": False,
}
# Scheduled Wormhole prewarm must not mutate the user's explicit public
# MeshChat session. Only a deliberate private-lane activation should sever
# the public MQTT lane.
normalized_reason = str(reason or "").strip().lower()
if normalized_reason == "wormhole_scheduled_prewarm" or normalized_reason.endswith(":scheduled_prewarm"):
try:
from services.meshtastic_mqtt_settings import mqtt_bridge_enabled
if mqtt_bridge_enabled():
logger.info("Keeping public Mesh lane active during Wormhole prewarm: %s", reason)
result["skipped"] = True
result["skip_reason"] = "public_mesh_user_enabled"
return result
except Exception as exc:
logger.debug("Could not inspect public Mesh state during %s: %s", reason, exc)
logger.info("Disabling public Mesh lane: %s", reason)
try:
from services.meshtastic_mqtt_settings import write_meshtastic_mqtt_settings
settings = write_meshtastic_mqtt_settings(enabled=False)
result["settings_disabled"] = not bool(settings.get("enabled"))
except Exception as exc:
logger.warning("Failed to disable public Mesh settings during %s: %s", reason, exc)
result["ok"] = False
result["settings_error"] = str(exc)
try:
from services.sigint_bridge import sigint_grid
if sigint_grid.mesh.is_running():
sigint_grid.mesh.stop()
result["runtime_stopped"] = not sigint_grid.mesh.is_running()
except Exception as exc:
logger.warning("Failed to stop public Mesh runtime during %s: %s", reason, exc)
result["ok"] = False
result["runtime_error"] = str(exc)
return result
+1 -1
View File
@@ -24,7 +24,7 @@ from cachetools import TTLCache
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_FINNHUB_BASE = "https://finnhub.io/api/v1" _FINNHUB_BASE = "https://finnhub.io/api/v1"
_USER_AGENT = "ShadowBroker/0.9.75 Finnhub connector" _USER_AGENT = "ShadowBroker/0.9.79 Finnhub connector"
_REQUEST_TIMEOUT = 12 _REQUEST_TIMEOUT = 12
_MIN_INTERVAL_SECONDS = 0.35 # Stay well under 60 calls/min _MIN_INTERVAL_SECONDS = 0.35 # Stay well under 60 calls/min
+80 -16
View File
@@ -243,6 +243,48 @@ def _pid_alive(pid: int) -> bool:
return True return True
def _find_wormhole_server_pid() -> int:
if os.name == "nt":
return 0
proc_dir = Path("/proc")
if not proc_dir.exists():
return 0
current_pid = os.getpid()
script_name = WORMHOLE_SCRIPT.name
script_path = str(WORMHOLE_SCRIPT)
for entry in proc_dir.iterdir():
if not entry.name.isdigit():
continue
pid = int(entry.name)
if pid == current_pid:
continue
try:
raw = (entry / "cmdline").read_bytes()
except OSError:
continue
cmdline = raw.replace(b"\x00", b" ").decode("utf-8", errors="replace")
if script_path in cmdline or script_name in cmdline:
return pid
return 0
def _terminate_pid(pid: int, *, timeout_s: float = 5.0) -> None:
if os.name == "nt" or pid <= 0:
return
try:
os.kill(pid, signal.SIGTERM)
except Exception:
return
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline and _pid_alive(pid):
time.sleep(0.1)
if _pid_alive(pid):
try:
os.kill(pid, signal.SIGKILL)
except Exception:
pass
def _probe_ready(timeout_s: float = 1.5) -> bool: def _probe_ready(timeout_s: float = 1.5) -> bool:
try: try:
with urlopen(f"http://{WORMHOLE_HOST}:{WORMHOLE_PORT}/api/health", timeout=timeout_s) as resp: with urlopen(f"http://{WORMHOLE_HOST}:{WORMHOLE_PORT}/api/health", timeout=timeout_s) as resp:
@@ -266,17 +308,32 @@ def _probe_json(path: str, timeout_s: float = 1.5) -> dict[str, Any] | None:
def _current_runtime_state() -> dict[str, Any]: def _current_runtime_state() -> dict[str, Any]:
settings = read_wormhole_settings() settings = read_wormhole_settings()
status = read_wormhole_status() status = read_wormhole_status()
configured = bool(settings.get("enabled"))
running = False running = False
ready = False
pid = int(status.get("pid", 0) or 0) pid = int(status.get("pid", 0) or 0)
if _PROCESS and _PROCESS.poll() is None: if not configured:
# Disabled private transport must stay disabled even if a stale local
# wormhole process is still answering on the health port. Public
# MeshChat relies on this state to keep the MQTT and Wormhole lanes
# mutually exclusive.
pid = 0
ready = False
elif _PROCESS and _PROCESS.poll() is None:
running = True running = True
pid = int(_PROCESS.pid or 0) pid = int(_PROCESS.pid or 0)
elif _pid_alive(pid): else:
running = True if _pid_alive(pid):
elif _probe_ready(timeout_s=0.35): running = True
running = True else:
pid = 0 discovered_pid = _find_wormhole_server_pid()
ready = running and _probe_ready() if discovered_pid > 0:
running = True
pid = discovered_pid
if not running and _probe_ready(timeout_s=0.35):
running = True
pid = 0
ready = running and _probe_ready()
if not running: if not running:
pid = 0 pid = 0
transport_active = status.get("transport_active", "") if ready else "" transport_active = status.get("transport_active", "") if ready else ""
@@ -319,13 +376,13 @@ def _current_runtime_state() -> dict[str, Any]:
anonymous_mode = bool(settings.get("anonymous_mode")) anonymous_mode = bool(settings.get("anonymous_mode"))
anonymous_mode_ready = bool( anonymous_mode_ready = bool(
anonymous_mode anonymous_mode
and settings.get("enabled") and configured
and ready and ready
and effective_transport in {"tor", "tor_arti", "i2p", "mixnet"} and effective_transport in {"tor", "tor_arti", "i2p", "mixnet"}
) )
snapshot = { snapshot = {
"installed": _installed(), "installed": _installed(),
"configured": bool(settings.get("enabled")), "configured": configured,
"running": running, "running": running,
"ready": ready, "ready": ready,
"transport_configured": str(settings.get("transport", "direct") or "direct"), "transport_configured": str(settings.get("transport", "direct") or "direct"),
@@ -395,6 +452,12 @@ def get_wormhole_state() -> dict[str, Any]:
def connect_wormhole(*, reason: str = "connect") -> dict[str, Any]: def connect_wormhole(*, reason: str = "connect") -> dict[str, Any]:
with _LOCK: with _LOCK:
_invalidate_state_cache() _invalidate_state_cache()
try:
from services.transport_lane_isolation import disable_public_mesh_lane
disable_public_mesh_lane(reason=f"wormhole_{reason}")
except Exception as exc:
logger.warning("Failed to enforce public/private lane isolation during %s: %s", reason, exc)
settings = read_wormhole_settings() settings = read_wormhole_settings()
if not settings.get("enabled"): if not settings.get("enabled"):
settings = settings.copy() settings = settings.copy()
@@ -487,8 +550,8 @@ def connect_wormhole(*, reason: str = "connect") -> dict[str, Any]:
def disconnect_wormhole(*, reason: str = "disconnect") -> dict[str, Any]: def disconnect_wormhole(*, reason: str = "disconnect") -> dict[str, Any]:
with _LOCK: with _LOCK:
_invalidate_state_cache() _invalidate_state_cache()
current = _current_runtime_state() status = read_wormhole_status()
pid = int(current.get("pid", 0) or 0) pid = int(status.get("pid", 0) or 0)
global _PROCESS global _PROCESS
if _PROCESS and _PROCESS.poll() is None: if _PROCESS and _PROCESS.poll() is None:
try: try:
@@ -499,14 +562,15 @@ def disconnect_wormhole(*, reason: str = "disconnect") -> dict[str, Any]:
_PROCESS.kill() _PROCESS.kill()
except Exception: except Exception:
pass pass
elif os.name != "nt" and _pid_alive(pid): if os.name != "nt":
try: _terminate_pid(pid)
os.kill(pid, signal.SIGTERM) discovered_pid = _find_wormhole_server_pid()
except Exception: if discovered_pid > 0 and discovered_pid != pid:
pass _terminate_pid(discovered_pid)
_PROCESS = None _PROCESS = None
write_wormhole_status( write_wormhole_status(
reason=reason, reason=reason,
configured=False,
running=False, running=False,
ready=False, ready=False,
pid=0, pid=0,
@@ -37,6 +37,30 @@ def test_eligible_sync_peers_filters_bucket_and_cooldown():
assert [record.peer_url for record in candidates] == ["https://active.example"] assert [record.peer_url for record in candidates] == ["https://active.example"]
def test_eligible_sync_peers_prioritizes_explicit_bootstrap_seed():
old_runtime = make_sync_peer_record(
peer_url="https://old-runtime.example",
transport="clearnet",
role="participant",
source="runtime",
now=100,
)
seed = make_sync_peer_record(
peer_url="https://node.shadowbroker.info",
transport="clearnet",
role="seed",
source="bundle",
now=200,
)
candidates = eligible_sync_peers([old_runtime, seed], now=300)
assert [record.peer_url for record in candidates] == [
"https://node.shadowbroker.info",
"https://old-runtime.example",
]
def test_finish_sync_success_updates_schedule(): def test_finish_sync_success_updates_schedule():
state = begin_sync(SyncWorkerState(), peer_url="https://seed.example", now=100) state = begin_sync(SyncWorkerState(), peer_url="https://seed.example", now=100)
finished = finish_sync( finished = finish_sync(
@@ -96,3 +96,38 @@ def test_peer_store_failure_and_success_lifecycle(tmp_path):
assert recovered.cooldown_until == 0 assert recovered.cooldown_until == 0
assert recovered.last_error == "" assert recovered.last_error == ""
assert recovered.last_sync_ok_at == 250 assert recovered.last_sync_ok_at == 250
def test_upsert_explicit_seed_clears_stale_cooldown(tmp_path):
store = PeerStore(tmp_path / "peer_store.json")
store.upsert(
make_sync_peer_record(
peer_url="https://node.shadowbroker.info",
transport="clearnet",
role="seed",
source="bundle",
now=100,
)
)
failed = store.mark_failure(
"https://node.shadowbroker.info",
"sync",
error="timed out",
cooldown_s=120,
now=110,
)
assert failed.cooldown_until == 230
refreshed = store.upsert(
make_sync_peer_record(
peer_url="https://node.shadowbroker.info",
transport="clearnet",
role="seed",
source="bundle",
now=120,
)
)
assert refreshed.failure_count == 0
assert refreshed.cooldown_until == 0
assert refreshed.last_error == ""
+7 -1
View File
@@ -2,15 +2,21 @@ from services.mesh.meshtastic_topics import build_subscription_topics, known_roo
def test_default_subscription_is_longfast_only(): def test_default_subscription_is_longfast_only():
assert build_subscription_topics() == ["msh/US/2/e/LongFast/#"] assert build_subscription_topics() == [
"msh/US/2/e/LongFast/#",
"msh/US/2/json/LongFast/#",
]
assert known_roots() == ["US"] assert known_roots() == ["US"]
def test_extra_roots_are_longfast_only(): def test_extra_roots_are_longfast_only():
assert build_subscription_topics(extra_roots="EU_868,ANZ") == [ assert build_subscription_topics(extra_roots="EU_868,ANZ") == [
"msh/US/2/e/LongFast/#", "msh/US/2/e/LongFast/#",
"msh/US/2/json/LongFast/#",
"msh/EU_868/2/e/LongFast/#", "msh/EU_868/2/e/LongFast/#",
"msh/EU_868/2/json/LongFast/#",
"msh/ANZ/2/e/LongFast/#", "msh/ANZ/2/e/LongFast/#",
"msh/ANZ/2/json/LongFast/#",
] ]
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@shadowbroker/desktop-shell", "name": "@shadowbroker/desktop-shell",
"version": "0.9.75", "version": "0.9.79",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@shadowbroker/desktop-shell", "name": "@shadowbroker/desktop-shell",
"version": "0.9.75", "version": "0.9.79",
"devDependencies": { "devDependencies": {
"typescript": "^5.6.0" "typescript": "^5.6.0"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@shadowbroker/desktop-shell", "name": "@shadowbroker/desktop-shell",
"version": "0.9.75", "version": "0.9.79",
"private": true, "private": true,
"description": "ShadowBroker desktop shell packaging, runtime bridge, and release tooling", "description": "ShadowBroker desktop shell packaging, runtime bridge, and release tooling",
"scripts": { "scripts": {
+1 -1
View File
@@ -4201,7 +4201,7 @@ dependencies = [
[[package]] [[package]]
name = "shadowbroker-tauri-shell" name = "shadowbroker-tauri-shell"
version = "0.9.75" version = "0.9.79"
dependencies = [ dependencies = [
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
@@ -1,6 +1,6 @@
[package] [package]
name = "shadowbroker-tauri-shell" name = "shadowbroker-tauri-shell"
version = "0.9.75" version = "0.9.79"
edition = "2021" edition = "2021"
[build-dependencies] [build-dependencies]
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ShadowBroker", "productName": "ShadowBroker",
"version": "0.9.75", "version": "0.9.79",
"identifier": "com.shadowbroker.desktop", "identifier": "com.shadowbroker.desktop",
"build": { "build": {
"frontendDist": "../../../frontend/out", "frontendDist": "../../../frontend/out",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "frontend", "name": "frontend",
"version": "0.9.75", "version": "0.9.79",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend", "name": "frontend",
"version": "0.9.75", "version": "0.9.79",
"dependencies": { "dependencies": {
"@mapbox/point-geometry": "^1.1.0", "@mapbox/point-geometry": "^1.1.0",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "frontend", "name": "frontend",
"version": "0.9.75", "version": "0.9.79",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "node scripts/dev-all.cjs", "dev": "node scripts/dev-all.cjs",
@@ -9,12 +9,12 @@ import {
} from '@/lib/updateRuntime'; } from '@/lib/updateRuntime';
const RELEASE: GitHubLatestRelease = { const RELEASE: GitHubLatestRelease = {
html_url: 'https://github.com/BigBodyCobain/Shadowbroker/releases/tag/v0.9.75', html_url: 'https://github.com/BigBodyCobain/Shadowbroker/releases/tag/v0.9.79',
assets: [ assets: [
{ name: 'ShadowBroker_0.9.75_x64_en-US.msi', browser_download_url: 'https://example.test/windows.msi' }, { name: 'ShadowBroker_0.9.79_x64_en-US.msi', browser_download_url: 'https://example.test/windows.msi' },
{ name: 'ShadowBroker_0.9.75_x64-setup.exe', browser_download_url: 'https://example.test/windows-setup.exe' }, { name: 'ShadowBroker_0.9.79_x64-setup.exe', browser_download_url: 'https://example.test/windows-setup.exe' },
{ name: 'ShadowBroker_0.9.75_aarch64.dmg', browser_download_url: 'https://example.test/macos.dmg' }, { name: 'ShadowBroker_0.9.79_aarch64.dmg', browser_download_url: 'https://example.test/macos.dmg' },
{ name: 'ShadowBroker_0.9.75_amd64.AppImage', browser_download_url: 'https://example.test/linux.AppImage' }, { name: 'ShadowBroker_0.9.79_amd64.AppImage', browser_download_url: 'https://example.test/linux.AppImage' },
], ],
}; };
@@ -61,8 +61,29 @@ const mocks = vi.hoisted(() => ({
bootstrapDecryptAccessRequest: vi.fn(async () => 'offer'), bootstrapDecryptAccessRequest: vi.fn(async () => 'offer'),
bootstrapEncryptAccessRequest: vi.fn(async () => 'x3dh1:bootstrap'), bootstrapEncryptAccessRequest: vi.fn(async () => 'x3dh1:bootstrap'),
canUseWormholeBootstrap: vi.fn(async () => false), canUseWormholeBootstrap: vi.fn(async () => false),
bootstrapWormholeIdentity: vi.fn(async () => ({
node_id: '!sb_local',
public_key: 'local-pub',
public_key_algo: 'Ed25519',
sequence: 1,
protocol_version: 'infonet/2',
})),
exportWormholeDmInvite: vi.fn(async () => ({
ok: true,
invite: {
event_type: 'dm_invite',
payload: {
prekey_lookup_handle: 'handle-123',
expires_at: 2_000_000_000,
},
},
peer_id: '!sb_local',
trust_fingerprint: 'trustfp123456',
prekey_publish_pending: false,
})),
fetchWormholeStatus: vi.fn(async () => ({ ready: true, transport_tier: 'private_strong' })), fetchWormholeStatus: vi.fn(async () => ({ ready: true, transport_tier: 'private_strong' })),
fetchWormholeIdentity: vi.fn(async () => ({ node_id: '!sb_local', public_key: 'local-pub' })), fetchWormholeIdentity: vi.fn(async () => ({ node_id: '!sb_local', public_key: 'local-pub' })),
listWormholeDmInviteHandles: vi.fn(async () => ({ ok: true, addresses: [] })),
prepareWormholeInteractiveLane: vi.fn(async () => ({ prepareWormholeInteractiveLane: vi.fn(async () => ({
ready: true, ready: true,
settingsEnabled: true, settingsEnabled: true,
@@ -75,10 +96,13 @@ const mocks = vi.hoisted(() => ({
trust_fingerprint: 'invitefp', trust_fingerprint: 'invitefp',
trust_level: 'invite_pinned', trust_level: 'invite_pinned',
})), })),
renameWormholeDmInviteHandle: vi.fn(async () => ({ ok: true })),
revokeWormholeDmInviteHandle: vi.fn(async () => ({ ok: true, revoked: true })),
isWormholeReady: vi.fn(async () => true), isWormholeReady: vi.fn(async () => true),
isWormholeSecureRequired: vi.fn(async () => false), isWormholeSecureRequired: vi.fn(async () => false),
issueWormholePairwiseAlias: vi.fn(async () => ({ ok: true, shared_alias: 'alias-123' })), issueWormholePairwiseAlias: vi.fn(async () => ({ ok: true, shared_alias: 'alias-123' })),
openWormholeSenderSeal: vi.fn(async () => ({ sender_id: '!sb_peer', seal_verified: true })), openWormholeSenderSeal: vi.fn(async () => ({ sender_id: '!sb_peer', seal_verified: true })),
writeClipboard: vi.fn(async () => undefined),
})); }));
vi.mock('@/lib/api', () => ({ vi.mock('@/lib/api', () => ({
@@ -152,8 +176,10 @@ vi.mock('@/mesh/wormholeDmBootstrapClient', () => ({
})); }));
vi.mock('@/mesh/wormholeIdentityClient', () => ({ vi.mock('@/mesh/wormholeIdentityClient', () => ({
bootstrapWormholeIdentity: mocks.bootstrapWormholeIdentity,
fetchWormholeStatus: mocks.fetchWormholeStatus, fetchWormholeStatus: mocks.fetchWormholeStatus,
fetchWormholeIdentity: mocks.fetchWormholeIdentity, fetchWormholeIdentity: mocks.fetchWormholeIdentity,
exportWormholeDmInvite: mocks.exportWormholeDmInvite,
prepareWormholeInteractiveLane: mocks.prepareWormholeInteractiveLane, prepareWormholeInteractiveLane: mocks.prepareWormholeInteractiveLane,
getWormholeDmInviteImportErrorResult: (error: unknown) => getWormholeDmInviteImportErrorResult: (error: unknown) =>
error && typeof error === 'object' && 'result' in (error as Record<string, unknown>) error && typeof error === 'object' && 'result' in (error as Record<string, unknown>)
@@ -162,8 +188,11 @@ vi.mock('@/mesh/wormholeIdentityClient', () => ({
importWormholeDmInvite: mocks.importWormholeDmInvite, importWormholeDmInvite: mocks.importWormholeDmInvite,
isWormholeReady: mocks.isWormholeReady, isWormholeReady: mocks.isWormholeReady,
isWormholeSecureRequired: mocks.isWormholeSecureRequired, isWormholeSecureRequired: mocks.isWormholeSecureRequired,
listWormholeDmInviteHandles: mocks.listWormholeDmInviteHandles,
issueWormholePairwiseAlias: mocks.issueWormholePairwiseAlias, issueWormholePairwiseAlias: mocks.issueWormholePairwiseAlias,
openWormholeSenderSeal: mocks.openWormholeSenderSeal, openWormholeSenderSeal: mocks.openWormholeSenderSeal,
renameWormholeDmInviteHandle: mocks.renameWormholeDmInviteHandle,
revokeWormholeDmInviteHandle: mocks.revokeWormholeDmInviteHandle,
})); }));
import MessagesView from '@/components/InfonetTerminal/MessagesView'; import MessagesView from '@/components/InfonetTerminal/MessagesView';
@@ -191,10 +220,21 @@ describe('MessagesView first-contact trust UX', () => {
localStorage.clear(); localStorage.clear();
contactsState = {}; contactsState = {};
vi.clearAllMocks(); vi.clearAllMocks();
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: mocks.writeClipboard },
configurable: true,
});
mocks.getContacts.mockImplementation(() => contactsState); mocks.getContacts.mockImplementation(() => contactsState);
mocks.hydrateWormholeContacts.mockImplementation(async () => contactsState); mocks.hydrateWormholeContacts.mockImplementation(async () => contactsState);
mocks.fetchWormholeStatus.mockResolvedValue({ ready: true, transport_tier: 'private_strong' }); mocks.fetchWormholeStatus.mockResolvedValue({ ready: true, transport_tier: 'private_strong' });
mocks.bootstrapWormholeIdentity.mockResolvedValue({
node_id: '!sb_local',
public_key: 'local-pub',
public_key_algo: 'Ed25519',
sequence: 1,
protocol_version: 'infonet/2',
});
mocks.prepareWormholeInteractiveLane.mockResolvedValue({ mocks.prepareWormholeInteractiveLane.mockResolvedValue({
ready: true, ready: true,
settingsEnabled: true, settingsEnabled: true,
@@ -215,6 +255,20 @@ describe('MessagesView first-contact trust UX', () => {
mocks.fetchDmPublicKey.mockResolvedValue({ dh_pub_key: 'peer-dh', dh_algo: 'X25519' }); mocks.fetchDmPublicKey.mockResolvedValue({ dh_pub_key: 'peer-dh', dh_algo: 'X25519' });
mocks.sendOffLedgerConsentMessage.mockResolvedValue({ ok: true, transport: 'relay' }); mocks.sendOffLedgerConsentMessage.mockResolvedValue({ ok: true, transport: 'relay' });
mocks.canUseWormholeBootstrap.mockResolvedValue(false); mocks.canUseWormholeBootstrap.mockResolvedValue(false);
mocks.exportWormholeDmInvite.mockResolvedValue({
ok: true,
invite: {
event_type: 'dm_invite',
payload: {
prekey_lookup_handle: 'handle-123',
expires_at: 2_000_000_000,
},
},
peer_id: '!sb_local',
trust_fingerprint: 'trustfp123456',
prekey_publish_pending: false,
});
mocks.listWormholeDmInviteHandles.mockResolvedValue({ ok: true, addresses: [] });
}); });
afterEach(() => { afterEach(() => {
@@ -238,7 +292,7 @@ describe('MessagesView first-contact trust UX', () => {
fireEvent.click(screen.getByRole('button', { name: 'Import Signed Invite' })); fireEvent.click(screen.getByRole('button', { name: 'Import Signed Invite' }));
expect(await screen.findByText('Import Verified Invite')).toBeInTheDocument(); expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
expect(screen.getByLabelText(/Local Alias/i)).toHaveValue('!sb_unknown'); expect(screen.getByLabelText(/Local Alias/i)).toHaveValue('!sb_unknown');
}); });
@@ -285,7 +339,7 @@ describe('MessagesView first-contact trust UX', () => {
expect(screen.getByRole('button', { name: 'Send Secure Mail' })).toBeEnabled(); expect(screen.getByRole('button', { name: 'Send Secure Mail' })).toBeEnabled();
}); });
it('warms the private lane in the background before sending secure mail', async () => { it('sends sealed mail without waiting for the private delivery route', async () => {
contactsState = { contactsState = {
'!sb_pinned': { '!sb_pinned': {
alias: 'Pinned Peer', alias: 'Pinned Peer',
@@ -296,6 +350,17 @@ describe('MessagesView first-contact trust UX', () => {
}, },
}; };
mocks.fetchWormholeStatus.mockResolvedValue({ ready: false, transport_tier: 'public_degraded' }); mocks.fetchWormholeStatus.mockResolvedValue({ ready: false, transport_tier: 'public_degraded' });
mocks.prepareWormholeInteractiveLane.mockImplementation(
() =>
new Promise(() => {
/* background route prep stays pending */
}),
);
mocks.sendDmMessage.mockResolvedValueOnce({
ok: true,
queued: true,
private_transport_pending: true,
});
renderMessagesView(); renderMessagesView();
await openComposeForRecipient('!sb_pinned', 'hello after warmup'); await openComposeForRecipient('!sb_pinned', 'hello after warmup');
@@ -306,7 +371,8 @@ describe('MessagesView first-contact trust UX', () => {
await waitFor(() => expect(mocks.prepareWormholeInteractiveLane).toHaveBeenCalled(), { timeout: 5000 }); await waitFor(() => expect(mocks.prepareWormholeInteractiveLane).toHaveBeenCalled(), { timeout: 5000 });
await waitFor(() => expect(mocks.sendDmMessage).toHaveBeenCalled(), { timeout: 5000 }); await waitFor(() => expect(mocks.sendDmMessage).toHaveBeenCalled(), { timeout: 5000 });
await screen.findByText(/Mail delivered to Pinned Peer/i, {}, { timeout: 5000 }); await screen.findByText(/Mail sealed locally for Pinned Peer/i, {}, { timeout: 5000 });
expect(screen.queryByText(/still warming up/i)).not.toBeInTheDocument();
}, 10000); }, 10000);
it('does not flatten witness policy not met into a generic witnessed root label', async () => { it('does not flatten witness policy not met into a generic witnessed root label', async () => {
@@ -360,6 +426,70 @@ describe('MessagesView first-contact trust UX', () => {
expect(screen.getByLabelText(/Local Alias/i)).toHaveValue('!sb_unpinned'); expect(screen.getByLabelText(/Local Alias/i)).toHaveValue('!sb_unpinned');
}); });
it('surfaces pending contact requests in the contact list with approve and deny actions', async () => {
localStorage.setItem(
'sb_infonet_mailbox_v1:!sb_local',
JSON.stringify({
version: 1,
items: [
{
id: 'request-1',
msgId: 'request-1',
folder: 'inbox',
kind: 'request',
direction: 'inbound',
senderId: '!sb_requester',
recipientId: '!sb_local',
subject: 'Contact request from !sb_requester',
body: '!sb_requester wants to open a secure mailbox.',
timestamp: 1_778_624_800,
read: false,
transport: 'relay',
deliveryClass: 'request',
requestStatus: 'pending',
requestDhPubKey: 'requester-dh',
requestDhAlgo: 'X25519',
},
],
}),
);
mocks.addContact.mockImplementation((peerId: string, dhPubKey: string, _alias?: string, dhAlgo?: string) => {
contactsState[peerId] = {
alias: 'Requester',
blocked: false,
dhPubKey,
dhAlgo,
trust_level: 'unpinned',
};
});
renderMessagesView();
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
expect(await screen.findByText('Contact Requests')).toBeInTheDocument();
expect(await screen.findByText('1 pending')).toBeInTheDocument();
expect(await screen.findAllByText('!sb_requester')).toHaveLength(2);
expect(screen.getByRole('button', { name: 'Deny' })).toBeEnabled();
fireEvent.click(screen.getByRole('button', { name: 'Approve' }));
await waitFor(() => expect(mocks.addContact).toHaveBeenCalledWith(
'!sb_requester',
'peer-dh',
undefined,
'X25519',
));
await waitFor(() =>
expect(mocks.sendOffLedgerConsentMessage).toHaveBeenCalledWith(
expect.objectContaining({
recipientId: '!sb_requester',
recipientDhPub: 'peer-dh',
}),
),
);
expect(await screen.findByText(/Contact accepted: Requester\./i)).toBeInTheDocument();
});
it('routes continuity reverify from Secure Messages into Dead Drop with SAS visible', async () => { it('routes continuity reverify from Secure Messages into Dead Drop with SAS visible', async () => {
contactsState = { contactsState = {
'!sb_reverify': { '!sb_reverify': {
@@ -463,18 +593,133 @@ describe('MessagesView first-contact trust UX', () => {
renderMessagesView(); renderMessagesView();
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
expect(await screen.findByText('Import Verified Invite')).toBeInTheDocument(); expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/Signed Invite JSON/i), { fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), {
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) }, target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
}); });
fireEvent.click(screen.getByRole('button', { name: 'Import Signed Invite' })); fireEvent.click(screen.getByRole('button', { name: 'Import Address' }));
expect( expect(
await screen.findByText(/INVITE PINNED for !sb_attested \(invitefp\.\.tested\)\./i), await screen.findByText(/INVITE PINNED for !sb_attested \(invitefp\.\.tested\)\./i),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it('generates and copies the full signed public address instead of the lookup handle', async () => {
renderMessagesView();
fireEvent.click(await screen.findByRole('button', { name: 'Generate Address' }));
await waitFor(() => expect(mocks.writeClipboard).toHaveBeenCalled());
const copied = String(mocks.writeClipboard.mock.calls[0][0] || '');
expect(copied).toContain('"type": "shadowbroker.infonet.dm.invite"');
expect(copied).toContain('"prekey_lookup_handle": "handle-123"');
expect(copied).not.toBe('handle-123');
expect(await screen.findByText(/Generated and copied/i)).toBeInTheDocument();
expect(screen.getByText(/Signed invite ready/i)).toBeInTheDocument();
expect(screen.queryByText(/shadowbroker\.infonet\.dm\.invite/i)).not.toBeInTheDocument();
});
it('does not advertise legacy handle-only addresses as copyable public addresses', async () => {
localStorage.setItem(
'sb_infonet_dm_addresses_v1:!sb_local',
JSON.stringify({
version: 1,
addresses: [
{
id: 'legacy-address',
label: 'Legacy handle',
handle: 'd8ce691f751817e137066f2a1858e21689b0118f8ec485c1',
peerId: '',
trustFingerprint: '',
inviteBlob: '',
createdAt: 1_700_000_000,
},
],
}),
);
renderMessagesView();
expect(await screen.findByText(/Generate an address, then send it to someone/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
expect(await screen.findByText('Legacy handle')).toBeInTheDocument();
expect(screen.getByText('Address unavailable locally.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Copy' })).toBeDisabled();
});
it('explains raw lookup handles instead of showing a JSON parser error', async () => {
renderMessagesView();
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), {
target: { value: 'f0eee9e9ccf849bcb2d86c0d7a1e0669c75be4e05533b0f6c67' },
});
expect(await screen.findByText(/only a short address ID/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Import Address' })).toBeDisabled();
expect(screen.queryByText(/Unexpected number in JSON/i)).not.toBeInTheDocument();
expect(mocks.importWormholeDmInvite).not.toHaveBeenCalled();
});
it('hides pasted signed address JSON until advanced details are opened', async () => {
const signedAddress = JSON.stringify({
type: 'shadowbroker.infonet.dm.invite',
version: 1,
invite: { event_type: 'dm_invite', payload: {} },
});
renderMessagesView();
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
const addressField = screen.getByPlaceholderText(/Paste the full text copied/i);
fireEvent.paste(addressField, {
clipboardData: {
getData: () => signedAddress,
},
});
expect(screen.getByDisplayValue(/Copied address received\. Ready to import\./i)).toBeInTheDocument();
expect(screen.queryByDisplayValue(/shadowbroker\.infonet\.dm\.invite/i)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Advanced Details' }));
expect(screen.getByLabelText('Raw copied public address')).toHaveValue(signedAddress);
});
it('imports a copied address without waiting for secure mail warm-up', async () => {
mocks.fetchWormholeStatus.mockResolvedValue({ ready: false, transport_tier: 'public_degraded' });
mocks.prepareWormholeInteractiveLane.mockImplementation(
() =>
new Promise(() => {
/* background warm-up stays pending */
}),
);
mocks.importWormholeDmInvite.mockResolvedValueOnce({
ok: true,
peer_id: '!sb_now',
trust_fingerprint: 'invitefp-now',
trust_level: 'invite_pinned',
contact: {},
});
renderMessagesView();
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), {
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
});
fireEvent.click(screen.getByRole('button', { name: 'Import Address' }));
expect(await screen.findByText(/INVITE PINNED for !sb_now \(invitefp-now\)\./i)).toBeInTheDocument();
expect(mocks.importWormholeDmInvite).toHaveBeenCalled();
expect(screen.queryByText(/Secure mail is still warming up/i)).not.toBeInTheDocument();
});
it('announces compat invite imports as TOFU PINNED with backend detail', async () => { it('announces compat invite imports as TOFU PINNED with backend detail', async () => {
mocks.importWormholeDmInvite.mockResolvedValueOnce({ mocks.importWormholeDmInvite.mockResolvedValueOnce({
ok: true, ok: true,
@@ -487,12 +732,12 @@ describe('MessagesView first-contact trust UX', () => {
renderMessagesView(); renderMessagesView();
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
expect(await screen.findByText('Import Verified Invite')).toBeInTheDocument(); expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/Signed Invite JSON/i), { fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), {
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) }, target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
}); });
fireEvent.click(screen.getByRole('button', { name: 'Import Signed Invite' })); fireEvent.click(screen.getByRole('button', { name: 'Import Address' }));
expect( expect(
await screen.findByText(/TOFU PINNED for !sb_compat \(invitefp\.\.compat\)\./i), await screen.findByText(/TOFU PINNED for !sb_compat \(invitefp\.\.compat\)\./i),
@@ -536,12 +781,12 @@ describe('MessagesView first-contact trust UX', () => {
renderMessagesView(); renderMessagesView();
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' })); fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
expect(await screen.findByText('Import Verified Invite')).toBeInTheDocument(); expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/Signed Invite JSON/i), { fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), {
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) }, target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
}); });
fireEvent.click(screen.getByRole('button', { name: 'Import Signed Invite' })); fireEvent.click(screen.getByRole('button', { name: 'Import Address' }));
expect( expect(
await screen.findByText(/CONTINUITY BROKEN for Pinned Peer\. Stable root continuity changed\./i), await screen.findByText(/CONTINUITY BROKEN for Pinned Peer\. Stable root continuity changed\./i),
@@ -552,7 +797,7 @@ describe('MessagesView first-contact trust UX', () => {
}); });
it('uses non-blocking secure-mail startup language while the DM lane warms', async () => { it('uses non-blocking secure-mail startup language while the DM lane warms', async () => {
mocks.fetchWormholeStatus.mockResolvedValueOnce({ ready: false, transport_tier: 'public_degraded' }); mocks.fetchWormholeStatus.mockResolvedValue({ ready: false, transport_tier: 'public_degraded' });
mocks.prepareWormholeInteractiveLane.mockImplementation( mocks.prepareWormholeInteractiveLane.mockImplementation(
() => () =>
new Promise(() => { new Promise(() => {
@@ -563,8 +808,9 @@ describe('MessagesView first-contact trust UX', () => {
renderMessagesView(); renderMessagesView();
expect( expect(
await screen.findByText(/Preparing secure mail in the background/i), await screen.findByText(/Private delivery route is connecting/i),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText(/Addresses, contacts, and sealed sends can proceed now/i)).toBeInTheDocument();
expect(screen.queryByText(/LOCKED/i)).not.toBeInTheDocument(); expect(screen.queryByText(/LOCKED/i)).not.toBeInTheDocument();
expect(screen.queryByText(/enter the Wormhole/i)).not.toBeInTheDocument(); expect(screen.queryByText(/enter the Wormhole/i)).not.toBeInTheDocument();
}); });
@@ -1327,6 +1327,7 @@ describe('wormholeIdentityClient strict profile hints', () => {
expect.objectContaining({ expect.objectContaining({
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
requireAdminSession: false,
body: JSON.stringify({ body: JSON.stringify({
invite: { event_type: 'dm_invite' }, invite: { event_type: 'dm_invite' },
alias: 'field contact', alias: 'field contact',
@@ -1378,6 +1379,7 @@ describe('wormholeIdentityClient strict profile hints', () => {
const prepared = await mod.prepareWormholeInteractiveLane({ bootstrapIdentity: true }); const prepared = await mod.prepareWormholeInteractiveLane({ bootstrapIdentity: true });
expect(connectWormhole).toHaveBeenCalledTimes(1); expect(connectWormhole).toHaveBeenCalledTimes(1);
expect(connectWormhole).toHaveBeenCalledWith({ requireAdminSession: false });
expect(joinWormhole).not.toHaveBeenCalled(); expect(joinWormhole).not.toHaveBeenCalled();
expect(prepared).toEqual( expect(prepared).toEqual(
expect.objectContaining({ expect.objectContaining({
+1 -1
View File
@@ -20,7 +20,7 @@ import {
Heart, Heart,
} from 'lucide-react'; } from 'lucide-react';
const CURRENT_VERSION = '0.9.75'; const CURRENT_VERSION = '0.9.79';
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`; const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
const RELEASE_TITLE = 'Onboarding, Live Feeds, Mesh, and Agent Hardening'; const RELEASE_TITLE = 'Onboarding, Live Feeds, Mesh, and Agent Hardening';
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -275,6 +275,11 @@ function hasKnownRouteName(value?: string | null): boolean {
function flightHasKnownRoute(entity: ReturnType<typeof findSelectedEntity>, dynamicRoute: DynamicRoute | null): boolean { function flightHasKnownRoute(entity: ReturnType<typeof findSelectedEntity>, dynamicRoute: DynamicRoute | null): boolean {
if (!entity) return false; if (!entity) return false;
if (dynamicRoute?.orig_loc && dynamicRoute?.dest_loc) return true; if (dynamicRoute?.orig_loc && dynamicRoute?.dest_loc) return true;
return flightPayloadHasKnownRoute(entity);
}
function flightPayloadHasKnownRoute(entity: ReturnType<typeof findSelectedEntity>): boolean {
if (!entity) return false;
if (!('origin_loc' in entity) && !('origin_name' in entity)) return false; if (!('origin_loc' in entity) && !('origin_name' in entity)) return false;
const flight = entity as Flight; const flight = entity as Flight;
return Boolean( return Boolean(
@@ -653,7 +658,7 @@ const MaplibreViewer = ({
}; };
} }
if (isFlight && flightHasKnownRoute(entity, dynamicRoute)) { if (isFlight && flightPayloadHasKnownRoute(entity)) {
setSelectedTrailPoints([]); setSelectedTrailPoints([]);
return () => { return () => {
cancelled = true; cancelled = true;
@@ -1483,7 +1488,7 @@ const MaplibreViewer = ({
void interpTick; void interpTick;
const entity = findSelectedEntity(selectedEntity, data); const entity = findSelectedEntity(selectedEntity, data);
if (!entity || selectedTrailPoints.length < 2) return null; if (!entity || selectedTrailPoints.length < 2) return null;
if (selectedEntity && FLIGHT_SELECTION_TYPES.has(selectedEntity.type) && flightHasKnownRoute(entity, dynamicRoute)) { if (selectedEntity && FLIGHT_SELECTION_TYPES.has(selectedEntity.type) && flightPayloadHasKnownRoute(entity)) {
return null; return null;
} }
+105 -35
View File
@@ -113,10 +113,13 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
meshQuickStatus, meshQuickStatus,
meshSessionActive, meshSessionActive,
publicMeshAddress, publicMeshAddress,
activePublicMeshAddress,
meshView, meshView,
setMeshView, setMeshView,
meshDirectTarget, meshDirectTarget,
setMeshDirectTarget, setMeshDirectTarget,
meshAddressDraft,
setMeshAddressDraft,
meshMqttSettings, meshMqttSettings,
meshMqttForm, meshMqttForm,
setMeshMqttForm, setMeshMqttForm,
@@ -133,6 +136,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
publicIdentity, publicIdentity,
hasStoredPublicLaneIdentity, hasStoredPublicLaneIdentity,
hasPublicLaneIdentity, hasPublicLaneIdentity,
canUsePublicMeshInput,
hasId, hasId,
shouldShowIdentityWarning, shouldShowIdentityWarning,
wormholeEnabled, wormholeEnabled,
@@ -339,14 +343,13 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
void handleRequestAccess(targetId); void handleRequestAccess(targetId);
}; };
const meshActivationText = const meshActivationText =
meshQuickStatus?.text || publicMeshBlockedByWormhole
(publicMeshBlockedByWormhole
? hasStoredPublicLaneIdentity ? hasStoredPublicLaneIdentity
? 'Wormhole is active. Turning MeshChat on will turn Wormhole off and use your saved public mesh key.' ? 'Wormhole is active. Turning MeshChat on will turn Wormhole off and use your saved public mesh key.'
: 'Wormhole is active. Turning MeshChat on will turn Wormhole off and mint a separate public mesh key.' : 'Wormhole is active. Turning MeshChat on will turn Wormhole off and mint a separate public mesh key.'
: hasStoredPublicLaneIdentity : hasStoredPublicLaneIdentity
? 'MeshChat is off. Turn it on to use your saved public mesh key.' ? 'MeshChat is off. Turn it on to use your saved public mesh key.'
: 'Public mesh posting needs a mesh key. One tap gets you a fresh address.'); : 'Public mesh posting needs a mesh key. One tap gets you a fresh address.';
const handleMeshActivationAction = () => { const handleMeshActivationAction = () => {
if (hasStoredPublicLaneIdentity) { if (hasStoredPublicLaneIdentity) {
void handleActivatePublicMeshSession(); void handleActivatePublicMeshSession();
@@ -358,6 +361,21 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
} }
void handleQuickCreatePublicIdentity(); void handleQuickCreatePublicIdentity();
}; };
const normalizeMeshDirectAddress = (value: string) => {
const compact = value.trim().replace(/^!/, '').toLowerCase();
return /^[0-9a-f]{8}$/.test(compact) ? `!${compact}` : '';
};
const handleMeshDirectTargetSubmit = () => {
const target = normalizeMeshDirectAddress(meshAddressDraft);
if (!target) {
setSendError('enter node address like !1ee21986');
window.setTimeout(() => setSendError(''), 4000);
return;
}
setMeshDirectTarget(target);
setMeshView('channel');
window.setTimeout(() => inputRef.current?.focus(), 0);
};
const meshActivationLabel = identityWizardBusy const meshActivationLabel = identityWizardBusy
? 'GETTING MESH KEY' ? 'GETTING MESH KEY'
: hasStoredPublicLaneIdentity : hasStoredPublicLaneIdentity
@@ -482,7 +500,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
</div> </div>
)} )}
{anonymousModeEnabled && !anonymousModeReady && ( {activeTab !== 'meshtastic' && anonymousModeEnabled && !anonymousModeReady && (
<div className="px-3 py-2 text-sm font-mono text-red-400/90 border-b border-red-900/30 bg-red-950/20 leading-[1.65] shrink-0"> <div className="px-3 py-2 text-sm font-mono text-red-400/90 border-b border-red-900/30 bg-red-950/20 leading-[1.65] shrink-0">
Anonymous mode is active, but hidden transport is not ready. Dead Drop is blocked Anonymous mode is active, but hidden transport is not ready. Dead Drop is blocked
until Wormhole is running over Tor, I2P, or Mixnet. until Wormhole is running over Tor, I2P, or Mixnet.
@@ -1144,8 +1162,8 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
))} ))}
</select> </select>
</div> </div>
<div className="flex items-center justify-between gap-2 px-3 py-1 border-b border-[var(--border-primary)]/20 shrink-0 bg-green-950/10"> <div className="flex items-center gap-1 px-3 py-1 border-b border-[var(--border-primary)]/20 shrink-0 bg-green-950/10">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1 min-w-0 flex-wrap">
<button <button
onClick={() => setMeshView('channel')} onClick={() => setMeshView('channel')}
className={`px-2 py-0.5 text-[11px] font-mono tracking-wider border transition-colors ${ className={`px-2 py-0.5 text-[11px] font-mono tracking-wider border transition-colors ${
@@ -1176,24 +1194,71 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
> >
SETTINGS SETTINGS
</button> </button>
</div> <button
<div onClick={() => {
className={`text-[10px] font-mono truncate ${ setMeshAddressDraft(meshDirectTarget || '');
meshMqttConnected setMeshView('message');
? 'text-green-300/80' }}
: meshMqttEnabled className={`px-2 py-0.5 text-[11px] font-mono tracking-wider border transition-colors ${
? 'text-amber-300/80' meshView === 'message'
: 'text-[var(--text-muted)]' ? 'border-green-500/40 text-green-200 bg-green-950/25'
}`} : 'border-[var(--border-primary)]/40 text-[var(--text-muted)] hover:text-green-300'
> }`}
{meshSessionActive && publicMeshAddress >
? `${meshMqttConnectionLabel} / ADDR ${publicMeshAddress.toUpperCase()}` MESSAGE
: publicMeshAddress </button>
? `${meshMqttConnectionLabel} / KEY SAVED`
: `${meshMqttConnectionLabel} / NO ADDRESS`}
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto styled-scrollbar px-3 py-1.5 border-l-2 border-cyan-800/25"> <div className="flex-1 overflow-y-auto styled-scrollbar px-3 py-1.5 border-l-2 border-cyan-800/25">
{meshView === 'message' && (
<div className="space-y-2 py-1 text-[11px] font-mono">
<div className="border border-green-700/35 bg-green-950/10 p-2">
<div className="text-green-300 tracking-[0.18em]">DIRECT MESHTASTIC MESSAGE</div>
<div className="mt-1 text-[10px] text-[var(--text-muted)] leading-[1.5]">
Enter a public Meshtastic node address. Direct MQTT publishes are public/degraded and depend on the target mesh hearing the broker bridge.
</div>
</div>
<label className="block space-y-1">
<span className="text-[var(--text-muted)]">NODE ADDRESS</span>
<input
value={meshAddressDraft}
onChange={(e) => setMeshAddressDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleMeshDirectTargetSubmit();
}
}}
placeholder="!1ee21986"
className="w-full border border-[var(--border-primary)] bg-black/30 px-2 py-1 text-green-200 outline-none placeholder:text-[var(--text-muted)] focus:border-green-500/50"
/>
</label>
<div className="grid grid-cols-2 gap-2">
<button
onClick={handleMeshDirectTargetSubmit}
className="border border-green-600/45 bg-green-950/20 px-2 py-1.5 text-green-300 hover:bg-green-950/35"
>
USE ADDRESS
</button>
<button
onClick={() => {
setMeshDirectTarget('');
setMeshAddressDraft('');
setMeshView('channel');
window.setTimeout(() => inputRef.current?.focus(), 0);
}}
className="border border-cyan-700/40 bg-cyan-950/15 px-2 py-1.5 text-cyan-300 hover:bg-cyan-950/25"
>
BROADCAST
</button>
</div>
{meshDirectTarget && (
<div className="border border-amber-600/30 bg-amber-950/10 p-2 text-amber-200/85 leading-[1.5]">
Active direct target: {meshDirectTarget.toUpperCase()}. Type in the input below and press send, or clear it to return to channel broadcast.
</div>
)}
</div>
)}
{meshView === 'settings' && ( {meshView === 'settings' && (
<div className="space-y-2 py-1 text-[11px] font-mono"> <div className="space-y-2 py-1 text-[11px] font-mono">
<div className="border border-cyan-800/35 bg-cyan-950/10 p-2"> <div className="border border-cyan-800/35 bg-cyan-950/10 p-2">
@@ -1338,26 +1403,26 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
)} )}
</div> </div>
)} )}
{!meshSessionActive && meshView !== 'settings' && ( {!canUsePublicMeshInput && meshView !== 'settings' && (
<div className="text-[12px] font-mono text-green-300/70 text-center py-4 leading-[1.65]"> <div className="text-[12px] font-mono text-green-300/70 text-center py-4 leading-[1.65]">
MeshChat is off. Turn it on to connect the public mesh lane. MeshChat is off. Turn it on to connect the public mesh lane.
</div> </div>
)} )}
{meshSessionActive && meshView === 'channel' && filteredMeshMessages.length === 0 && ( {canUsePublicMeshInput && meshView === 'channel' && filteredMeshMessages.length === 0 && (
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]"> <div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
No messages from {meshRegion} / {meshChannel} No messages from {meshRegion} / {meshChannel}
</div> </div>
)} )}
{meshSessionActive && meshView === 'inbox' && ( {canUsePublicMeshInput && meshView === 'inbox' && (
<> <>
{!publicMeshAddress && ( {!activePublicMeshAddress && (
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]"> <div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
Create or load a public mesh identity to see direct Meshtastic traffic. Create or load a public mesh identity to see direct Meshtastic traffic.
</div> </div>
)} )}
{publicMeshAddress && meshInboxMessages.length === 0 && ( {activePublicMeshAddress && meshInboxMessages.length === 0 && (
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]"> <div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
No public direct messages addressed to {publicMeshAddress.toUpperCase()} yet. No public direct messages addressed to {activePublicMeshAddress.toUpperCase()} yet.
</div> </div>
)} )}
{meshInboxMessages.map((m, i) => ( {meshInboxMessages.map((m, i) => (
@@ -1371,7 +1436,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
</button> </button>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-[10px] text-amber-200/70 mb-0.5"> <div className="text-[10px] text-amber-200/70 mb-0.5">
TO {publicMeshAddress.toUpperCase()} TO {activePublicMeshAddress.toUpperCase()}
</div> </div>
<div className="break-words whitespace-pre-wrap text-amber-100/90"> <div className="break-words whitespace-pre-wrap text-amber-100/90">
{m.text} {m.text}
@@ -2264,10 +2329,12 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
? `→ INFONET${selectedGate ? ` / ${selectedGate}` : ''}${privateInfonetTransportReady ? '' : ' / EXPERIMENTAL ENCRYPTION'}` ? `→ INFONET${selectedGate ? ` / ${selectedGate}` : ''}${privateInfonetTransportReady ? '' : ' / EXPERIMENTAL ENCRYPTION'}`
: '→ PRIVATE LANE LOCKED' : '→ PRIVATE LANE LOCKED'
: activeTab === 'meshtastic' : activeTab === 'meshtastic'
? hasPublicLaneIdentity ? canUsePublicMeshInput
? meshDirectTarget ? meshDirectTarget
? `→ MESH / TO ${meshDirectTarget.toUpperCase()}` ? `→ MESH / TO ${meshDirectTarget.toUpperCase()} / FROM ${activePublicMeshAddress.toUpperCase()}`
: `→ MESH / ${meshRegion} / ${meshChannel}` : `→ MESH / ${meshRegion} / ${meshChannel} / ${activePublicMeshAddress.toUpperCase()}`
: publicMeshBlockedByWormhole
? '→ MESH BLOCKED / WORMHOLE ACTIVE'
: hasStoredPublicLaneIdentity : hasStoredPublicLaneIdentity
? '→ MESH OFF' ? '→ MESH OFF'
: '→ MESH LOCKED' : '→ MESH LOCKED'
@@ -2279,7 +2346,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
</span> </span>
)} )}
</div> </div>
{activeTab === 'meshtastic' && !hasPublicLaneIdentity && !sendError && ( {activeTab === 'meshtastic' && !sendError && (!canUsePublicMeshInput || meshQuickStatus) && (
<div <div
className={`px-3 pt-1 text-[12px] font-mono leading-[1.5] ${ className={`px-3 pt-1 text-[12px] font-mono leading-[1.5] ${
meshQuickStatus?.type === 'err' meshQuickStatus?.type === 'err'
@@ -2289,7 +2356,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
: 'text-green-300/70' : 'text-green-300/70'
}`} }`}
> >
{meshActivationText} {meshQuickStatus?.text || meshActivationText}
</div> </div>
)} )}
<div className="flex items-center gap-2 px-3 pb-2 pt-1"> <div className="flex items-center gap-2 px-3 pb-2 pt-1">
@@ -2319,7 +2386,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
NEED WORMHOLE NEED WORMHOLE
</span> </span>
</button> </button>
) : activeTab === 'meshtastic' && !hasPublicLaneIdentity ? ( ) : activeTab === 'meshtastic' && !canUsePublicMeshInput ? (
<button <button
onClick={handleMeshActivationAction} onClick={handleMeshActivationAction}
disabled={identityWizardBusy} disabled={identityWizardBusy}
@@ -2335,7 +2402,10 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
</button> </button>
) : activeTab === 'meshtastic' && meshDirectTarget ? ( ) : activeTab === 'meshtastic' && meshDirectTarget ? (
<button <button
onClick={() => setMeshDirectTarget('')} onClick={() => {
setMeshDirectTarget('');
setMeshAddressDraft('');
}}
className="w-full flex items-center justify-between gap-2 px-3 py-2 border border-amber-700/40 bg-amber-950/10 text-amber-200 hover:bg-amber-950/20 hover:border-amber-500/50 transition-colors" className="w-full flex items-center justify-between gap-2 px-3 py-2 border border-amber-700/40 bg-amber-950/10 text-amber-200 hover:bg-amber-950/20 hover:border-amber-500/50 transition-colors"
> >
<span className="inline-flex items-center gap-2 text-sm font-mono tracking-[0.2em]"> <span className="inline-flex items-center gap-2 text-sm font-mono tracking-[0.2em]">
@@ -13,7 +13,7 @@ import {
extractNativeGateResyncTarget, extractNativeGateResyncTarget,
} from '@/lib/desktopControlContract'; } from '@/lib/desktopControlContract';
import type { DesktopControlAuditReport } from '@/lib/desktopControlContract'; import type { DesktopControlAuditReport } from '@/lib/desktopControlContract';
import { fetchPrivacyProfileSnapshot } from '@/mesh/controlPlaneStatusClient'; import { fetchPrivacyProfileSnapshot, setInfonetNodeEnabled } from '@/mesh/controlPlaneStatusClient';
import { import {
getNodeIdentity, getNodeIdentity,
getStoredNodeDescriptor, getStoredNodeDescriptor,
@@ -397,8 +397,9 @@ export function useMeshChatController({
const [meshQuickStatus, setMeshQuickStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); const [meshQuickStatus, setMeshQuickStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [meshSessionActive, setMeshSessionActive] = useState(false); const [meshSessionActive, setMeshSessionActive] = useState(false);
const [publicMeshAddress, setPublicMeshAddress] = useState(''); const [publicMeshAddress, setPublicMeshAddress] = useState('');
const [meshView, setMeshView] = useState<'channel' | 'inbox' | 'settings'>('channel'); const [meshView, setMeshView] = useState<'channel' | 'inbox' | 'settings' | 'message'>('channel');
const [meshDirectTarget, setMeshDirectTarget] = useState(''); const [meshDirectTarget, setMeshDirectTarget] = useState('');
const [meshAddressDraft, setMeshAddressDraft] = useState('');
const [meshMqttSettings, setMeshMqttSettings] = useState<MeshMqttSettings | null>(null); const [meshMqttSettings, setMeshMqttSettings] = useState<MeshMqttSettings | null>(null);
const [meshMqttForm, setMeshMqttForm] = useState<MeshMqttForm>({ const [meshMqttForm, setMeshMqttForm] = useState<MeshMqttForm>({
broker: 'mqtt.meshtastic.org', broker: 'mqtt.meshtastic.org',
@@ -427,14 +428,17 @@ export function useMeshChatController({
const storedPublicMeshAddress = clientHydrated ? readStoredPublicMeshAddress() : ''; const storedPublicMeshAddress = clientHydrated ? readStoredPublicMeshAddress() : '';
const hasStoredPublicLaneIdentity = clientHydrated && Boolean(storedPublicMeshAddress); const hasStoredPublicLaneIdentity = clientHydrated && Boolean(storedPublicMeshAddress);
const publicIdentity = null; const publicIdentity = null;
const hasPublicLaneIdentity = meshSessionActive && Boolean(publicMeshAddress); const activePublicMeshAddress = publicMeshAddress || storedPublicMeshAddress;
const hasPublicLaneIdentity = meshSessionActive && Boolean(activePublicMeshAddress);
const hasId = Boolean(identity) && (hasSovereignty() || wormholeEnabled); const hasId = Boolean(identity) && (hasSovereignty() || wormholeEnabled);
const shouldShowIdentityWarning = activeTab !== 'meshtastic' && !hasId; const shouldShowIdentityWarning = activeTab !== 'meshtastic' && !hasId;
const privateInfonetReady = wormholeEnabled && wormholeReadyState; const privateInfonetReady = wormholeEnabled && wormholeReadyState;
const publicMeshBlockedByWormhole = wormholeEnabled || wormholeReadyState; const publicMeshBlockedByWormhole = wormholeEnabled || wormholeReadyState;
const dmSendQueue = useRef<(() => Promise<void>)[]>([]); const dmSendQueue = useRef<(() => Promise<void>)[]>([]);
const infonetAutoBootstrapRef = useRef(false);
const meshMqttRuntime = meshMqttSettings?.runtime; const meshMqttRuntime = meshMqttSettings?.runtime;
const meshMqttEnabled = Boolean(meshMqttSettings?.enabled || meshMqttRuntime?.enabled); const meshMqttEnabled = Boolean(meshMqttSettings?.enabled || meshMqttRuntime?.enabled);
const canUsePublicMeshInput = Boolean(activePublicMeshAddress) && meshMqttEnabled && !publicMeshBlockedByWormhole;
const meshMqttRunning = Boolean(meshMqttRuntime?.running); const meshMqttRunning = Boolean(meshMqttRuntime?.running);
const meshMqttConnected = Boolean(meshMqttRuntime?.connected); const meshMqttConnected = Boolean(meshMqttRuntime?.connected);
const meshMqttConnectionLabel = !meshMqttEnabled const meshMqttConnectionLabel = !meshMqttEnabled
@@ -546,16 +550,12 @@ export function useMeshChatController({
const displayPublicMeshSender = useCallback( const displayPublicMeshSender = useCallback(
(sender: string) => { (sender: string) => {
if (!sender) return '???'; if (!sender) return '???';
if ( if (activePublicMeshAddress && sender.toLowerCase() === activePublicMeshAddress.toLowerCase()) {
hasPublicLaneIdentity && return activePublicMeshAddress.toUpperCase();
publicMeshAddress &&
sender.toLowerCase() === publicMeshAddress.toLowerCase()
) {
return publicMeshAddress.toUpperCase();
} }
return sender; return sender;
}, },
[hasPublicLaneIdentity, publicMeshAddress], [activePublicMeshAddress],
); );
const openIdentityWizard = useCallback( const openIdentityWizard = useCallback(
@@ -1221,6 +1221,7 @@ export function useMeshChatController({
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const cursorMirrorRef = useRef<HTMLDivElement>(null); const cursorMirrorRef = useRef<HTMLDivElement>(null);
const cursorMarkerRef = useRef<HTMLSpanElement>(null); const cursorMarkerRef = useRef<HTMLSpanElement>(null);
const publicMeshPrivacyEnforcedRef = useRef(false);
useEffect(() => { useEffect(() => {
const el = messagesEndRef.current; const el = messagesEndRef.current;
@@ -1329,15 +1330,21 @@ export function useMeshChatController({
() => infoMessages.filter((m) => !m.node_id || !mutedUsers.has(m.node_id)), () => infoMessages.filter((m) => !m.node_id || !mutedUsers.has(m.node_id)),
[infoMessages, mutedUsers], [infoMessages, mutedUsers],
); );
const isBroadcastMeshMessage = useCallback((m: MeshtasticMessage) => {
const target = String(m.to || 'broadcast').trim().toLowerCase();
return target === '' || target === 'broadcast' || target === '^all';
}, []);
const filteredMeshMessages = useMemo( const filteredMeshMessages = useMemo(
() => meshMessages.filter((m) => !mutedUsers.has(m.from)), () => meshMessages.filter((m) => isBroadcastMeshMessage(m) && !mutedUsers.has(m.from)),
[meshMessages, mutedUsers], [isBroadcastMeshMessage, meshMessages, mutedUsers],
); );
const meshInboxMessages = useMemo(() => { const meshInboxMessages = useMemo(() => {
if (!meshSessionActive || !publicMeshAddress) return []; if (!activePublicMeshAddress) return [];
const target = publicMeshAddress.toLowerCase(); const target = activePublicMeshAddress.toLowerCase();
return filteredMeshMessages.filter((m) => String(m.to || '').toLowerCase() === target); return meshMessages.filter(
}, [filteredMeshMessages, meshSessionActive, publicMeshAddress]); (m) => !mutedUsers.has(m.from) && String(m.to || '').toLowerCase() === target,
);
}, [activePublicMeshAddress, meshMessages, mutedUsers]);
useEffect(() => { useEffect(() => {
if (!expanded || activeTab !== 'meshtastic') return; if (!expanded || activeTab !== 'meshtastic') return;
@@ -1961,7 +1968,7 @@ export function useMeshChatController({
// ─── Meshtastic Channel Discovery ────────────────────────────────────── // ─── Meshtastic Channel Discovery ──────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!expanded || activeTab !== 'meshtastic' || !meshSessionActive) return; if (!expanded || activeTab !== 'meshtastic' || !canUsePublicMeshInput) return;
let cancelled = false; let cancelled = false;
const fetchChannels = async () => { const fetchChannels = async () => {
try { try {
@@ -2020,12 +2027,12 @@ export function useMeshChatController({
cancelled = true; cancelled = true;
clearInterval(iv); clearInterval(iv);
}; };
}, [expanded, activeTab, meshRegion, meshSessionActive]); }, [expanded, activeTab, meshRegion, canUsePublicMeshInput]);
// ─── Meshtastic Polling ────────────────────────────────────────────────── // ─── Meshtastic Polling ──────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!expanded || activeTab !== 'meshtastic' || !meshSessionActive) return; if (!expanded || activeTab !== 'meshtastic' || !canUsePublicMeshInput) return;
let cancelled = false; let cancelled = false;
const poll = async () => { const poll = async () => {
try { try {
@@ -2034,6 +2041,7 @@ export function useMeshChatController({
region: meshRegion, region: meshRegion,
channel: meshChannel, channel: meshChannel,
}); });
if (meshView === 'inbox') params.set('include_direct', '1');
const res = await fetch(`${API_BASE}/api/mesh/messages?${params}`); const res = await fetch(`${API_BASE}/api/mesh/messages?${params}`);
if (res.ok && !cancelled) { if (res.ok && !cancelled) {
const data = await res.json(); const data = await res.json();
@@ -2049,13 +2057,13 @@ export function useMeshChatController({
cancelled = true; cancelled = true;
clearInterval(iv); clearInterval(iv);
}; };
}, [expanded, activeTab, meshRegion, meshChannel, meshView, meshSessionActive]); }, [expanded, activeTab, meshRegion, meshChannel, meshView, canUsePublicMeshInput]);
useEffect(() => { useEffect(() => {
if (meshSessionActive) return; if (canUsePublicMeshInput) return;
setMeshMessages([]); setMeshMessages([]);
setMeshQuickStatus(null); setMeshQuickStatus(null);
}, [meshSessionActive]); }, [canUsePublicMeshInput]);
// ─── DM Polling ────────────────────────────────────────────────────────── // ─── DM Polling ──────────────────────────────────────────────────────────
@@ -2540,7 +2548,7 @@ export function useMeshChatController({
if (!msg || busy) return; if (!msg || busy) return;
if (activeTab !== 'meshtastic' && !hasId) return; if (activeTab !== 'meshtastic' && !hasId) return;
const cooldownMs = activeTab === 'dms' ? 0 : 30_000; const cooldownMs = activeTab === 'dms' ? 0 : activeTab === 'meshtastic' ? 6_000 : 30_000;
const now = Date.now(); const now = Date.now();
const elapsed = now - lastSendTime; const elapsed = now - lastSendTime;
if (cooldownMs > 0 && elapsed < cooldownMs) { if (cooldownMs > 0 && elapsed < cooldownMs) {
@@ -2550,8 +2558,8 @@ export function useMeshChatController({
return; return;
} }
if (anonymousPublicBlocked && (activeTab === 'infonet' || activeTab === 'meshtastic')) { if (anonymousPublicBlocked && activeTab === 'infonet') {
setSendError('hidden transport required for public posting'); setSendError('hidden transport required for infonet posting');
setTimeout(() => setSendError(''), 4000); setTimeout(() => setSendError(''), 4000);
return; return;
} }
@@ -2625,10 +2633,11 @@ export function useMeshChatController({
]); ]);
setGateReplyContext(null); setGateReplyContext(null);
} else if (activeTab === 'meshtastic') { } else if (activeTab === 'meshtastic') {
if (!meshSessionActive || !publicMeshAddress) { const meshSenderAddress = activePublicMeshAddress;
if (!meshSenderAddress) {
setInputValue(msg); setInputValue(msg);
setLastSendTime(0); setLastSendTime(0);
setSendError(meshSessionActive ? 'public mesh identity needed' : 'meshchat is off'); setSendError('public mesh identity needed');
openIdentityWizard({ openIdentityWizard({
type: 'err', type: 'err',
text: hasStoredPublicLaneIdentity text: hasStoredPublicLaneIdentity
@@ -2639,6 +2648,10 @@ export function useMeshChatController({
setBusy(false); setBusy(false);
return; return;
} }
if (!meshSessionActive) {
setPublicMeshAddress(meshSenderAddress);
setMeshSessionActive(true);
}
if (!meshMqttEnabled) { if (!meshMqttEnabled) {
setInputValue(msg); setInputValue(msg);
setLastSendTime(0); setLastSendTime(0);
@@ -2680,7 +2693,7 @@ export function useMeshChatController({
priority: 'normal', priority: 'normal',
ephemeral: false, ephemeral: false,
transport_lock: 'meshtastic', transport_lock: 'meshtastic',
sender_id: publicMeshAddress, sender_id: meshSenderAddress,
mesh_region: meshRegion, mesh_region: meshRegion,
}), }),
}); });
@@ -2700,12 +2713,28 @@ export function useMeshChatController({
return; return;
} }
// Re-fetch — backend injects our msg into the bridge feed after publish // Re-fetch — backend injects our msg into the bridge feed after publish
const directTarget = meshDestination !== 'broadcast'
? meshDestination.startsWith('!')
? meshDestination.toUpperCase()
: `!${meshDestination}`.toUpperCase()
: '';
const routeDetail = Array.isArray(sendData.results) && sendData.results[0]?.reason
? String(sendData.results[0].reason)
: String(sendData.route_reason || 'MQTT broker accepted publish');
setMeshQuickStatus({
type: 'ok',
text: directTarget
? `Direct message queued for ${directTarget}. ${routeDetail}`
: `Channel message published to ${meshRegion}/${meshChannel}. ${routeDetail}`,
});
window.setTimeout(() => setMeshQuickStatus(null), 6000);
await new Promise((r) => setTimeout(r, 500)); await new Promise((r) => setTimeout(r, 500));
const params = new URLSearchParams({ const params = new URLSearchParams({
limit: '30', limit: '30',
region: meshRegion, region: meshRegion,
channel: meshChannel, channel: meshChannel,
}); });
if (directTarget) params.set('include_direct', '1');
const mRes = await fetch(`${API_BASE}/api/mesh/messages?${params}`); const mRes = await fetch(`${API_BASE}/api/mesh/messages?${params}`);
if (mRes.ok) { if (mRes.ok) {
const data = await mRes.json(); const data = await mRes.json();
@@ -4138,7 +4167,7 @@ export function useMeshChatController({
privateInfonetTransportReady, privateInfonetTransportReady,
}); });
const inputDisabled = const inputDisabled =
!hasId || (activeTab !== 'meshtastic' && !hasId) ||
busy || busy ||
(activeTab === 'infonet' && !privateInfonetReady) || (activeTab === 'infonet' && !privateInfonetReady) ||
(activeTab === 'infonet' && !selectedGate) || (activeTab === 'infonet' && !selectedGate) ||
@@ -4148,7 +4177,7 @@ export function useMeshChatController({
wormholeReadyState && wormholeReadyState &&
!selectedGateAccessReady) || !selectedGateAccessReady) ||
(activeTab === 'infonet' && anonymousPublicBlocked) || (activeTab === 'infonet' && anonymousPublicBlocked) ||
(activeTab === 'meshtastic' && (!hasPublicLaneIdentity || !meshMqttEnabled)) || (activeTab === 'meshtastic' && !canUsePublicMeshInput) ||
(activeTab === 'dms' && (activeTab === 'dms' &&
(dmView !== 'chat' || (dmView !== 'chat' ||
!selectedContact || !selectedContact ||
@@ -4192,6 +4221,10 @@ export function useMeshChatController({
[inputDisabled], [inputDisabled],
); );
const disablePrivateNodeForPublicMesh = useCallback(async () => {
await setInfonetNodeEnabled(false);
}, []);
const disableWormholeForPublicMesh = useCallback(async () => { const disableWormholeForPublicMesh = useCallback(async () => {
const requireBackendLeave = wormholeEnabled || wormholeReadyState; const requireBackendLeave = wormholeEnabled || wormholeReadyState;
try { try {
@@ -4207,7 +4240,28 @@ export function useMeshChatController({
setWormholeRnsDirectReady(false); setWormholeRnsDirectReady(false);
setWormholeRnsPeers({ active: 0, configured: 0 }); setWormholeRnsPeers({ active: 0, configured: 0 });
setSecureModeCached(false); setSecureModeCached(false);
}, [wormholeEnabled, wormholeReadyState]); await disablePrivateNodeForPublicMesh();
}, [disablePrivateNodeForPublicMesh, wormholeEnabled, wormholeReadyState]);
useEffect(() => {
if (!meshSessionActive || !activePublicMeshAddress || !meshMqttEnabled) {
publicMeshPrivacyEnforcedRef.current = false;
return;
}
if (publicMeshPrivacyEnforcedRef.current) return;
publicMeshPrivacyEnforcedRef.current = true;
void disableWormholeForPublicMesh().catch((err) => {
publicMeshPrivacyEnforcedRef.current = false;
const message =
typeof err === 'object' && err !== null && 'message' in err
? String((err as { message?: string }).message)
: 'unknown error';
setMeshQuickStatus({
type: 'err',
text: `Could not isolate public Mesh lane: ${message}`,
});
});
}, [activePublicMeshAddress, disableWormholeForPublicMesh, meshMqttEnabled, meshSessionActive]);
const createPublicMeshIdentity = useCallback( const createPublicMeshIdentity = useCallback(
async ({ closeWizardOnSuccess }: { closeWizardOnSuccess: boolean }) => { async ({ closeWizardOnSuccess }: { closeWizardOnSuccess: boolean }) => {
@@ -4286,9 +4340,9 @@ export function useMeshChatController({
setMeshSessionActive(true); setMeshSessionActive(true);
setMeshMessages([]); setMeshMessages([]);
setSendError(''); setSendError('');
const text = `MeshChat is on with saved address ${readyAddress}.`; const text = `MeshChat is on. Address ${readyAddress}.`;
setIdentityWizardStatus({ type: 'ok', text }); setIdentityWizardStatus({ type: 'ok', text });
setMeshQuickStatus({ type: 'ok', text }); setMeshQuickStatus(null);
return { ok: true as const, text }; return { ok: true as const, text };
} catch (err) { } catch (err) {
const message = const message =
@@ -4308,7 +4362,8 @@ export function useMeshChatController({
const target = String(address || '').trim(); const target = String(address || '').trim();
if (!target) return; if (!target) return;
setMeshDirectTarget(target); setMeshDirectTarget(target);
setMeshView('inbox'); setMeshAddressDraft(target);
setMeshView('channel');
setSenderPopup(null); setSenderPopup(null);
setTimeout(() => inputRef.current?.focus(), 0); setTimeout(() => inputRef.current?.focus(), 0);
}, []); }, []);
@@ -4319,7 +4374,7 @@ export function useMeshChatController({
: await createPublicMeshIdentity({ closeWizardOnSuccess: false }); : await createPublicMeshIdentity({ closeWizardOnSuccess: false });
const status = { type: result.ok ? 'ok' as const : 'err' as const, text: result.text }; const status = { type: result.ok ? 'ok' as const : 'err' as const, text: result.text };
setIdentityWizardStatus(status); setIdentityWizardStatus(status);
setMeshQuickStatus(status); setMeshQuickStatus(result.ok ? null : status);
if (result.ok) { if (result.ok) {
window.setTimeout(() => setIdentityWizardOpen(false), 900); window.setTimeout(() => setIdentityWizardOpen(false), 900);
} }
@@ -4424,6 +4479,23 @@ export function useMeshChatController({
setIdentityWizardBusy(false); setIdentityWizardBusy(false);
} }
}, [wormholeDescriptor?.nodeId, wormholeEnabled, wormholeReadyState]); }, [wormholeDescriptor?.nodeId, wormholeEnabled, wormholeReadyState]);
useEffect(() => {
if (!expanded || activeTab !== 'infonet') {
infonetAutoBootstrapRef.current = false;
return;
}
if (privateInfonetReady) {
infonetAutoBootstrapRef.current = false;
return;
}
if (identityWizardBusy || infonetAutoBootstrapRef.current) return;
infonetAutoBootstrapRef.current = true;
void handleBootstrapPrivateIdentity().catch(() => {
infonetAutoBootstrapRef.current = false;
});
}, [activeTab, expanded, handleBootstrapPrivateIdentity, identityWizardBusy, privateInfonetReady]);
return { return {
// UI state // UI state
expanded, expanded,
@@ -4447,10 +4519,13 @@ export function useMeshChatController({
meshQuickStatus, meshQuickStatus,
meshSessionActive, meshSessionActive,
publicMeshAddress, publicMeshAddress,
activePublicMeshAddress,
meshView, meshView,
setMeshView, setMeshView,
meshDirectTarget, meshDirectTarget,
setMeshDirectTarget, setMeshDirectTarget,
meshAddressDraft,
setMeshAddressDraft,
meshMqttSettings, meshMqttSettings,
meshMqttForm, meshMqttForm,
setMeshMqttForm, setMeshMqttForm,
@@ -4467,6 +4542,7 @@ export function useMeshChatController({
publicIdentity, publicIdentity,
hasStoredPublicLaneIdentity, hasStoredPublicLaneIdentity,
hasPublicLaneIdentity, hasPublicLaneIdentity,
canUsePublicMeshInput,
hasId, hasId,
shouldShowIdentityWarning, shouldShowIdentityWarning,
wormholeEnabled, wormholeEnabled,
+5 -127
View File
@@ -245,147 +245,26 @@ const VESSEL_TYPE_WIKI: Record<string, string> = {
type FlightTrailPoint = { lat?: number; lng?: number; alt?: number; ts?: number } | number[]; type FlightTrailPoint = { lat?: number; lng?: number; alt?: number; ts?: number } | number[];
function readTrailTimestamp(point: FlightTrailPoint): number | null {
if (Array.isArray(point)) {
const ts = Number(point[3]);
return Number.isFinite(ts) && ts > 0 ? ts : null;
}
const ts = Number(point?.ts);
return Number.isFinite(ts) && ts > 0 ? ts : null;
}
function readTrailLatLng(point: FlightTrailPoint): { lat: number; lng: number } | null {
const lat = Number(Array.isArray(point) ? point[0] : point?.lat);
const lng = Number(Array.isArray(point) ? point[1] : point?.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
return { lat, lng };
}
function distanceNm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
const toRad = (deg: number) => (deg * Math.PI) / 180;
const earthRadiusNm = 3440.065;
const dLat = toRad(b.lat - a.lat);
const dLng = toRad(b.lng - a.lng);
const lat1 = toRad(a.lat);
const lat2 = toRad(b.lat);
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
return 2 * earthRadiusNm * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
}
function formatObservedDuration(hours: number): string {
const minutes = Math.max(1, Math.round(hours * 60));
if (minutes < 60) return `${minutes} min`;
const wholeHours = Math.floor(minutes / 60);
const remainder = minutes % 60;
return remainder ? `${wholeHours}h ${remainder}m` : `${wholeHours}h`;
}
function estimateObservedEmissions(flight: any): {
fuelGallons: number;
co2Kg: number;
durationLabel: string;
distanceLabel: string | null;
basisLabel: string;
} | null {
const fuelGph = Number(flight?.emissions?.fuel_gph);
const co2KgPerHour = Number(flight?.emissions?.co2_kg_per_hour);
const trail = Array.isArray(flight?.trail) ? (flight.trail as FlightTrailPoint[]) : [];
const isTrackedAircraft = flight?.type === 'tracked_flight' || Boolean(flight?.alert_category);
const minimumObservedHours = isTrackedAircraft ? 1 / 60 : 5 / 60;
if (!Number.isFinite(fuelGph) || !Number.isFinite(co2KgPerHour)) {
return null;
}
const timestamps = trail
.map(readTrailTimestamp)
.filter((ts): ts is number => ts !== null)
.sort((a, b) => a - b);
if (timestamps.length >= 2) {
const elapsedHours = (timestamps[timestamps.length - 1] - timestamps[0]) / 3600;
if (Number.isFinite(elapsedHours) && elapsedHours >= minimumObservedHours) {
let distance = 0;
let previous: { lat: number; lng: number } | null = null;
for (const point of trail) {
const current = readTrailLatLng(point);
if (previous && current) distance += distanceNm(previous, current);
if (current) previous = current;
}
return {
fuelGallons: Math.round(fuelGph * elapsedHours),
co2Kg: Math.round(co2KgPerHour * elapsedHours),
durationLabel: formatObservedDuration(elapsedHours),
distanceLabel: distance > 1 ? `${Math.round(distance).toLocaleString()} nm` : null,
basisLabel: 'trail history',
};
}
}
const origin = Array.isArray(flight?.origin_loc)
? { lng: Number(flight.origin_loc[0]), lat: Number(flight.origin_loc[1]) }
: null;
const current = { lat: Number(flight?.lat), lng: Number(flight?.lng) };
const speedKnots = Number(flight?.speed_knots);
if (
origin &&
Number.isFinite(origin.lat) &&
Number.isFinite(origin.lng) &&
Number.isFinite(current.lat) &&
Number.isFinite(current.lng) &&
Number.isFinite(speedKnots) &&
speedKnots > 50
) {
const flownNm = distanceNm(origin, current);
const elapsedHours = flownNm / speedKnots;
if (Number.isFinite(elapsedHours) && elapsedHours >= minimumObservedHours && elapsedHours <= 18) {
return {
fuelGallons: Math.round(fuelGph * elapsedHours),
co2Kg: Math.round(co2KgPerHour * elapsedHours),
durationLabel: formatObservedDuration(elapsedHours),
distanceLabel: `${Math.round(flownNm).toLocaleString()} nm`,
basisLabel: 'route progress',
};
}
}
return null;
}
function EmissionsEstimateBlock({ flight }: { flight: any }) { function EmissionsEstimateBlock({ flight }: { flight: any }) {
const observed = estimateObservedEmissions(flight);
const emissions = flight?.emissions; const emissions = flight?.emissions;
const context = observed const context = emissions ? 'Model-based cruise estimate' : null;
? `${observed.durationLabel} ${observed.basisLabel}${observed.distanceLabel ? ` / ${observed.distanceLabel}` : ''}`
: emissions
? 'Rate only until enough trail history accumulates'
: null;
return ( return (
<div className="border-b border-[var(--border-primary)] pb-2"> <div className="border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px] block mb-1.5">EMISSIONS ESTIMATE</span> <span className="text-[var(--text-muted)] text-[10px] block mb-1.5">EMISSIONS ESTIMATE</span>
<div className="flex gap-3"> <div className="flex gap-3">
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5"> <div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
<div className="text-[11px] text-[var(--text-muted)] tracking-widest"> <div className="text-[11px] text-[var(--text-muted)] tracking-widest">FUEL RATE</div>
{observed ? 'FUEL BURNED' : 'FUEL RATE'}
</div>
<div className="text-xs font-bold text-orange-400"> <div className="text-xs font-bold text-orange-400">
{observed ? ( {emissions ? (
<>{observed.fuelGallons.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">GAL</span></>
) : emissions ? (
<>{emissions.fuel_gph} <span className="text-[11px] text-[var(--text-muted)] font-normal">GPH</span></> <>{emissions.fuel_gph} <span className="text-[11px] text-[var(--text-muted)] font-normal">GPH</span></>
) : 'UNKNOWN'} ) : 'UNKNOWN'}
</div> </div>
</div> </div>
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5"> <div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
<div className="text-[11px] text-[var(--text-muted)] tracking-widest"> <div className="text-[11px] text-[var(--text-muted)] tracking-widest">CO2 RATE</div>
{observed ? 'CO2 PRODUCED' : 'CO2 RATE'}
</div>
<div className="text-xs font-bold text-red-400"> <div className="text-xs font-bold text-red-400">
{observed ? ( {emissions ? (
<>{observed.co2Kg.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">KG</span></>
) : emissions ? (
<>{emissions.co2_kg_per_hour.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">KG/HR</span></> <>{emissions.co2_kg_per_hour.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">KG/HR</span></>
) : 'UNKNOWN'} ) : 'UNKNOWN'}
</div> </div>
@@ -394,7 +273,6 @@ function EmissionsEstimateBlock({ flight }: { flight: any }) {
{context && ( {context && (
<div className="mt-1.5 text-[10px] text-[var(--text-muted)] leading-relaxed"> <div className="mt-1.5 text-[10px] text-[var(--text-muted)] leading-relaxed">
{context} {context}
{observed && emissions ? ` - estimated from ${emissions.fuel_gph} GPH model rate.` : ''}
</div> </div>
)} )}
</div> </div>
+1 -1
View File
@@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { X, ExternalLink, Key, Shield, Radar, Globe, Satellite, Ship, Radio, Bot, Copy, Check, Network } from 'lucide-react'; import { X, ExternalLink, Key, Shield, Radar, Globe, Satellite, Ship, Radio, Bot, Copy, Check, Network } from 'lucide-react';
const CURRENT_ONBOARDING_VERSION = '0.9.75-agentic-onboarding-1'; const CURRENT_ONBOARDING_VERSION = '0.9.79-agentic-onboarding-1';
const STORAGE_KEY = `shadowbroker_onboarding_complete_v${CURRENT_ONBOARDING_VERSION}`; const STORAGE_KEY = `shadowbroker_onboarding_complete_v${CURRENT_ONBOARDING_VERSION}`;
const LEGACY_STORAGE_KEY = 'shadowbroker_onboarding_complete'; const LEGACY_STORAGE_KEY = 'shadowbroker_onboarding_complete';
@@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Database, Clock, X } from 'lucide-react'; import { Database, Clock, X } from 'lucide-react';
const CURRENT_VERSION = '0.9.75'; const CURRENT_VERSION = '0.9.79';
const STORAGE_KEY = `shadowbroker_startup_warmup_notice_v${CURRENT_VERSION}`; const STORAGE_KEY = `shadowbroker_startup_warmup_notice_v${CURRENT_VERSION}`;
interface StartupWarmupModalProps { interface StartupWarmupModalProps {
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { Send } from 'lucide-react'; import { Send } from 'lucide-react';
import { API_BASE } from '@/lib/api'; import { API_BASE } from '@/lib/api';
import { import {
derivePublicMeshAddress,
getNodeIdentity, getNodeIdentity,
hasSovereignty, hasSovereignty,
signEvent, signEvent,
@@ -13,11 +12,28 @@ import { PROTOCOL_VERSION } from '@/mesh/meshProtocol';
import { validateEventPayload } from '@/mesh/meshSchema'; import { validateEventPayload } from '@/mesh/meshSchema';
const MESH_NODE_ID_RE = /^![0-9a-f]{8}$/i; const MESH_NODE_ID_RE = /^![0-9a-f]{8}$/i;
const PUBLIC_MESH_ADDRESS_KEY = 'sb_public_meshtastic_address';
function isMeshtasticNodeId(value: string | undefined | null): boolean { function isMeshtasticNodeId(value: string | undefined | null): boolean {
return !!value && MESH_NODE_ID_RE.test(value.trim()); return !!value && MESH_NODE_ID_RE.test(value.trim());
} }
function normalizePublicMeshAddress(value: string | undefined | null): string {
const raw = String(value || '').trim().toLowerCase();
const body = raw.startsWith('!') ? raw.slice(1) : raw;
if (!/^[0-9a-f]{8}$/.test(body)) return '';
return `!${body}`;
}
function readStoredPublicMeshAddress(): string {
if (typeof window === 'undefined') return '';
try {
return normalizePublicMeshAddress(window.localStorage.getItem(PUBLIC_MESH_ADDRESS_KEY));
} catch {
return '';
}
}
/** Inline send-message form for SIGINT popups — routes via MeshRouter */ /** Inline send-message form for SIGINT popups — routes via MeshRouter */
export function SigintSendForm({ export function SigintSendForm({
destination, destination,
@@ -40,26 +56,11 @@ export function SigintSendForm({
const isDirectMesh = isMesh && isMeshtasticNodeId(destination); const isDirectMesh = isMesh && isMeshtasticNodeId(destination);
useEffect(() => { useEffect(() => {
let cancelled = false;
if (!isMesh) { if (!isMesh) {
setPublicMeshAddress(''); setPublicMeshAddress('');
return; return;
} }
const identity = getNodeIdentity(); setPublicMeshAddress(readStoredPublicMeshAddress());
if (!identity?.nodeId || !globalThis.crypto?.subtle) {
setPublicMeshAddress('');
return;
}
derivePublicMeshAddress(identity.nodeId)
.then((addr) => {
if (!cancelled) setPublicMeshAddress(addr);
})
.catch(() => {
if (!cancelled) setPublicMeshAddress('');
});
return () => {
cancelled = true;
};
}, [isMesh]); }, [isMesh]);
const handleSend = async () => { const handleSend = async () => {
@@ -71,6 +72,56 @@ export function SigintSendForm({
} }
setStatus('sending'); setStatus('sending');
try { try {
if (isMesh) {
const meshSender = normalizePublicMeshAddress(publicMeshAddress || readStoredPublicMeshAddress());
if (!meshSender) {
setStatus('error');
setDetail('public mesh key required');
return;
}
const payload = {
message: msg.trim(),
destination: destination || 'broadcast',
channel: channel || 'LongFast',
priority: 'normal',
ephemeral: false,
transport_lock: 'meshtastic',
};
const v = validateEventPayload('message', payload);
if (!v.ok) {
setStatus('error');
setDetail(`invalid payload: ${v.reason}`);
return;
}
const res = await fetch(`${API_BASE}/api/mesh/meshtastic/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
destination: destination || 'broadcast',
message: msg.trim(),
sender_id: meshSender,
channel: channel || 'LongFast',
priority: 'normal',
ephemeral: false,
transport_lock: 'meshtastic',
mesh_region: region || 'US',
}),
});
const data = await res.json().catch(() => ({}));
if (res.ok && data.ok) {
setStatus('sent');
const routeDetail = Array.isArray(data.results) && data.results[0]?.reason
? String(data.results[0].reason)
: String(data.route_reason || 'MQTT broker accepted publish');
setDetail(routeDetail);
setMsg('');
} else {
setStatus('error');
setDetail(String(data.detail || data.route_reason || 'send failed'));
}
return;
}
const identity = getNodeIdentity(); const identity = getNodeIdentity();
if (!identity || !hasSovereignty()) { if (!identity || !hasSovereignty()) {
setStatus('error'); setStatus('error');
@@ -234,22 +285,7 @@ export function MeshtasticChannelFeed({ region, channel }: { region: string; cha
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; setPublicMeshAddress(readStoredPublicMeshAddress());
const identity = getNodeIdentity();
if (!identity?.nodeId || !globalThis.crypto?.subtle) {
setPublicMeshAddress('');
return;
}
derivePublicMeshAddress(identity.nodeId)
.then((addr) => {
if (!cancelled) setPublicMeshAddress(addr);
})
.catch(() => {
if (!cancelled) setPublicMeshAddress('');
});
return () => {
cancelled = true;
};
}, []); }, []);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
@@ -281,6 +317,10 @@ export function MeshtasticChannelFeed({ region, channel }: { region: string; cha
const regionData = channelStats?.roots?.[region] || channelStats?.regions?.[region]; const regionData = channelStats?.roots?.[region] || channelStats?.regions?.[region];
const regionChannels = regionData?.channels || {}; const regionChannels = regionData?.channels || {};
const sortedChannels = Object.entries(regionChannels).sort((a, b) => b[1] - a[1]); const sortedChannels = Object.entries(regionChannels).sort((a, b) => b[1] - a[1]);
const channelMessages = messages.filter((m) => {
const target = String(m.to || 'broadcast').trim().toLowerCase();
return target === '' || target === 'broadcast' || target === '^all';
});
if (loading) if (loading)
return <div className="text-[11px] text-cyan-400/50 animate-pulse mt-1">Loading...</div>; return <div className="text-[11px] text-cyan-400/50 animate-pulse mt-1">Loading...</div>;
@@ -317,13 +357,13 @@ export function MeshtasticChannelFeed({ region, channel }: { region: string; cha
)} )}
{/* Message feed */} {/* Message feed */}
{messages.length > 0 ? ( {channelMessages.length > 0 ? (
<> <>
<div className="text-[11px] text-green-400/60 tracking-widest mb-1"> <div className="text-[11px] text-green-400/60 tracking-widest mb-1">
MESSAGES {channel} ({region}) MESSAGES {channel} ({region})
</div> </div>
<div className="max-h-[140px] overflow-y-auto space-y-0.5 scrollbar-thin"> <div className="max-h-[140px] overflow-y-auto space-y-0.5 scrollbar-thin">
{messages.map((m: MeshtasticMessage, i: number) => { {channelMessages.map((m: MeshtasticMessage, i: number) => {
const directedToYou = const directedToYou =
!!publicMeshAddress && !!publicMeshAddress &&
typeof m.to === 'string' && typeof m.to === 'string' &&
+5 -1
View File
@@ -46,7 +46,11 @@ export async function controlPlaneJson<T>(
const res = await controlPlaneFetch(path, options); const res = await controlPlaneFetch(path, options);
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok || data?.ok === false) { if (!res.ok || data?.ok === false) {
throw new Error(data?.detail || data?.message || 'control_plane_request_failed'); const fallback =
res.status === 429
? 'control_plane_rate_limited'
: `control_plane_request_failed:${res.status || 'unknown'}`;
throw new Error(data?.detail || data?.message || fallback);
} }
return data as T; return data as T;
} }
+4 -1
View File
@@ -212,10 +212,13 @@ export async function fetchWormholeSettings(
return inflight; return inflight;
} }
export async function connectWormhole(): Promise<WormholeState> { export async function connectWormhole(
options: { requireAdminSession?: boolean } = {},
): Promise<WormholeState> {
resetWormholeCaches(); resetWormholeCaches();
const res = await controlPlaneFetch('/api/wormhole/connect', { const res = await controlPlaneFetch('/api/wormhole/connect', {
method: 'POST', method: 'POST',
requireAdminSession: options.requireAdminSession,
}); });
const state = await parseState(res); const state = await parseState(res);
wormholeStateCache = { wormholeStateCache = {
+103 -3
View File
@@ -91,6 +91,9 @@ export interface WormholeDmInviteExport {
peer_id: string; peer_id: string;
trust_fingerprint: string; trust_fingerprint: string;
invite: WormholeDmInviteEnvelope; invite: WormholeDmInviteEnvelope;
prekey_publish_pending?: boolean;
prekey_registration?: Record<string, unknown>;
detail?: string;
} }
export interface WormholeDmInviteImportResult { export interface WormholeDmInviteImportResult {
@@ -102,6 +105,44 @@ export interface WormholeDmInviteImportResult {
contact: Record<string, unknown>; contact: Record<string, unknown>;
} }
export interface WormholeDmAddressRecord {
handle: string;
label: string;
issued_at: number;
expires_at: number;
max_uses: number;
use_count: number;
remaining_uses: number;
last_used_at: number;
expired: boolean;
exhausted: boolean;
revoked?: boolean;
}
export interface WormholeDmInviteHandlesResponse {
ok: boolean;
addresses: WormholeDmAddressRecord[];
detail?: string;
}
export interface WormholeDmInviteHandleRevokeResult {
ok: boolean;
handle: string;
revoked: boolean;
identity_removed?: boolean;
relay_removed?: boolean;
republished?: boolean;
detail?: string;
}
export interface WormholeDmInviteHandleUpdateResult {
ok: boolean;
handle: string;
label: string;
updated: boolean;
detail?: string;
}
export type WormholeDmInviteImportFailure = Partial<WormholeDmInviteImportResult> & { export type WormholeDmInviteImportFailure = Partial<WormholeDmInviteImportResult> & {
ok?: false; ok?: false;
}; };
@@ -840,7 +881,7 @@ export async function prepareWormholeInteractiveLane(
let settings = await fetchWormholeSettings(true).catch(() => null); let settings = await fetchWormholeSettings(true).catch(() => null);
if (!runtime?.ready) { if (!runtime?.ready) {
if (settings?.enabled || runtime?.configured) { if (settings?.enabled || runtime?.configured) {
runtime = await connectWormhole().catch((error) => { runtime = await connectWormhole({ requireAdminSession: false }).catch((error) => {
throw new Error( throw new Error(
normalizeWormholeInteractivePrepError( normalizeWormholeInteractivePrepError(
error instanceof Error ? error.message : 'wormhole_connect_failed', error instanceof Error ? error.message : 'wormhole_connect_failed',
@@ -939,12 +980,70 @@ export async function fetchWormholeIdentity(): Promise<WormholeIdentity> {
return value; return value;
} }
export async function exportWormholeDmInvite(): Promise<WormholeDmInviteExport> { export async function exportWormholeDmInvite(options: {
return controlPlaneJson<WormholeDmInviteExport>('/api/wormhole/dm/invite', { label?: string;
expiresInSeconds?: number;
} = {}): Promise<WormholeDmInviteExport> {
const params = new URLSearchParams();
if (options.label?.trim()) {
params.set('label', options.label.trim());
}
if (options.expiresInSeconds && options.expiresInSeconds > 0) {
params.set('expires_in_s', String(Math.floor(options.expiresInSeconds)));
}
const suffix = params.toString() ? `?${params.toString()}` : '';
return controlPlaneJson<WormholeDmInviteExport>(`/api/wormhole/dm/invite${suffix}`, {
requireAdminSession: false, requireAdminSession: false,
}); });
} }
export async function listWormholeDmInviteHandles(): Promise<WormholeDmInviteHandlesResponse> {
return controlPlaneJson<WormholeDmInviteHandlesResponse>('/api/wormhole/dm/invite/handles', {
requireAdminSession: false,
});
}
export async function revokeWormholeDmInviteHandle(
handle: string,
): Promise<WormholeDmInviteHandleRevokeResult> {
const response = await controlPlaneFetch(
`/api/wormhole/dm/invite/handles/${encodeURIComponent(handle)}`,
{
method: 'DELETE',
requireAdminSession: false,
},
);
const data = (await response.json().catch(() => ({}))) as WormholeDmInviteHandleRevokeResult & {
message?: string;
};
if (!response.ok || data?.ok === false) {
throw new Error(String(data?.detail || data?.message || 'DM address revoke failed'));
}
return data;
}
export async function renameWormholeDmInviteHandle(
handle: string,
label: string,
): Promise<WormholeDmInviteHandleUpdateResult> {
const response = await controlPlaneFetch(
`/api/wormhole/dm/invite/handles/${encodeURIComponent(handle)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label }),
requireAdminSession: false,
},
);
const data = (await response.json().catch(() => ({}))) as WormholeDmInviteHandleUpdateResult & {
message?: string;
};
if (!response.ok || data?.ok === false) {
throw new Error(String(data?.detail || data?.message || 'DM address label update failed'));
}
return data;
}
export async function importWormholeDmInvite( export async function importWormholeDmInvite(
invite: Record<string, unknown>, invite: Record<string, unknown>,
alias: string = '', alias: string = '',
@@ -956,6 +1055,7 @@ export async function importWormholeDmInvite(
invite, invite,
alias, alias,
}), }),
requireAdminSession: false,
}); });
const data = (await response.json().catch(() => ({}))) as WormholeDmInviteImportResult & { const data = (await response.json().catch(() => ({}))) as WormholeDmInviteImportResult & {
message?: string; message?: string;
+14 -9
View File
@@ -8,13 +8,16 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
function buildCsp(_nonce: string): string { function buildCsp(nonce: string, strictScripts = false): string {
const isDev = process.env.NODE_ENV !== 'production'; const isDev = process.env.NODE_ENV !== 'production';
const scriptSrc = isDev
? "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:"
: strictScripts
? `script-src 'self' 'nonce-${nonce}' blob:`
: "script-src 'self' 'unsafe-inline' blob:";
const directives = [ const directives = [
"default-src 'self'", "default-src 'self'",
isDev scriptSrc,
? "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:"
: "script-src 'self' 'unsafe-inline' blob:",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"img-src 'self' data: blob: https:", "img-src 'self' data: blob: https:",
isDev isDev
@@ -35,10 +38,8 @@ function buildCsp(_nonce: string): string {
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64'); const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
// Forward a nonce for future fully-wired CSP support. Do not include it in // Forward a nonce for staged CSP support. Strict script-src is opt-in until
// script-src until every Next inline bootstrap script receives the nonce; // every Next inline bootstrap script is verified with the nonce in production.
// otherwise production hydration can fail and leave the app on the static
// "prioritizing map feeds" shell.
const requestHeaders = new Headers(request.headers); const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce); requestHeaders.set('x-nonce', nonce);
@@ -46,7 +47,11 @@ export function middleware(request: NextRequest) {
request: { headers: requestHeaders }, request: { headers: requestHeaders },
}); });
response.headers.set('Content-Security-Policy', buildCsp(nonce)); const strictCsp = process.env.SHADOWBROKER_STRICT_CSP === '1';
response.headers.set('Content-Security-Policy', buildCsp(nonce, strictCsp));
if (!strictCsp && process.env.NODE_ENV === 'production') {
response.headers.set('Content-Security-Policy-Report-Only', buildCsp(nonce, true));
}
return response; return response;
} }
+2 -2
View File
@@ -1,8 +1,8 @@
--- ---
apiVersion: v2 apiVersion: v2
name: shadowbroker name: shadowbroker
version: 0.9.75 version: 0.9.79
appVersion: "0.9.75" appVersion: "0.9.79"
description: simple shadowbroker installation description: simple shadowbroker installation
type: application type: application
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "shadowbroker" name = "shadowbroker"
version = "0.9.75" version = "0.9.79"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [] dependencies = []
Generated
+2 -2
View File
@@ -74,7 +74,7 @@ wheels = [
[[package]] [[package]]
name = "backend" name = "backend"
version = "0.9.75" version = "0.9.79"
source = { editable = "backend" } source = { editable = "backend" }
dependencies = [ dependencies = [
{ name = "apscheduler" }, { name = "apscheduler" },
@@ -2257,7 +2257,7 @@ wheels = [
[[package]] [[package]]
name = "shadowbroker" name = "shadowbroker"
version = "0.9.75" version = "0.9.79"
source = { virtual = "." } source = { virtual = "." }
[package.metadata] [package.metadata]