mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-15 04:40:26 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25a98a9869 | |||
| 2ce0e43ee5 | |||
| b86a258535 |
@@ -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=
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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)"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 == ""
|
||||||
|
|||||||
@@ -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/#",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -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,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
@@ -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",
|
||||||
|
|||||||
Generated
+2
-2
@@ -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,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({
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' &&
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 = []
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user