Release v0.9.79 runtime and messaging update

Ship the v0.9.79 runtime refresh with transport lane isolation, Infonet secure-message address management, MeshChat MQTT controls, selected asset trail behavior, telemetry panel refinements, onboarding updates, and desktop/package metadata alignment.

Also ignore local graphify work products so analysis folders do not leak into future commits.
This commit is contained in:
BigBodyCobain
2026-05-12 11:49:46 -06:00
parent 85636ce95c
commit b86a258535
49 changed files with 1779 additions and 772 deletions
+2
View File
@@ -361,6 +361,8 @@ async def _verify_openclaw_hmac(request: Request) -> bool:
# Bind request body: digest the raw bytes so any body tampering
# invalidates the signature. Empty/absent bodies hash as sha256(b"").
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()
# Compute expected signature: HMAC-SHA256(secret, METHOD|path|ts|nonce|body_digest)
+5
View File
@@ -14,4 +14,9 @@ if [ -d /app/image-data ]; then
done
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 "$@"
+110 -30
View File
@@ -14,7 +14,7 @@ from dataclasses import dataclass, field
from typing import Any
from json import JSONDecodeError
APP_VERSION = "0.9.75"
APP_VERSION = "0.9.79"
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@@ -3061,6 +3061,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]:
try:
from services.mesh.mesh_wormhole_contacts import upgrade_invite_scoped_contact_preferences
@@ -3092,7 +3110,11 @@ def _refresh_lookup_handle_rotation_background(*, reason: str) -> dict[str, Any]
@app.middleware("http")
async def enforce_high_privacy_mesh(request: Request, call_next):
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()
current_tier = "public_degraded"
try:
@@ -3193,7 +3215,7 @@ async def enforce_high_privacy_mesh(request: Request, call_next):
# Don't block the request on the upgrade — the transport
# manager will converge in the background.
if (
path.startswith("/api/mesh")
private_mesh_path
and str(data.get("privacy_profile", "default")).lower() == "high"
and not bool(data.get("enabled"))
):
@@ -3426,8 +3448,16 @@ async def update_layers(update: LayerUpdate, request: Request):
from services.sigint_bridge import sigint_grid
if old_mesh and not new_mesh:
sigint_grid.mesh.stop()
logger.info("Meshtastic MQTT bridge stopped (layer disabled)")
try:
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:
# 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
@@ -4361,9 +4391,11 @@ async def mesh_send(request: Request):
any_ok = any(r.ok for r in results)
# ─── Mirror to Meshtastic bridge feed ────────────────────────
# The MQTT broker won't echo our own publishes back to our subscriber,
# so inject successfully-sent messages into the bridge's deque directly.
if any_ok and envelope.routed_via == "meshtastic":
# The MQTT broker won't echo our own publishes back to our subscriber, so
# inject successfully-sent channel broadcasts into the bridge directly.
# 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:
from services.sigint_bridge import sigint_grid
@@ -4371,16 +4403,22 @@ async def mesh_send(request: Request):
if bridge:
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),
"to": destination if MeshtasticTransport._parse_node_id(destination) is not None else "broadcast",
"to": "broadcast",
"text": message,
"region": credentials.get("mesh_region", "US"),
"root": credentials.get("mesh_region", "US"),
"channel": body.get("channel", "LongFast"),
"timestamp": datetime.utcnow().isoformat() + "Z",
}
)
if callable(append_text):
append_text(message_record)
else:
bridge.messages.appendleft(message_record)
except Exception:
pass # Non-critical
@@ -4390,6 +4428,8 @@ async def mesh_send(request: Request):
"event_id": "",
"routed_via": envelope.routed_via,
"route_reason": envelope.route_reason,
"direct": is_direct_destination,
"channel_echo": not is_direct_destination,
"results": [r.to_dict() for r in results],
}
@@ -4488,6 +4528,7 @@ async def mesh_messages(
root: str = "",
channel: str = "",
limit: int = 30,
include_direct: bool = False,
):
"""Get recent Meshtastic text messages from the MQTT bridge."""
from services.sigint_bridge import sigint_grid
@@ -4509,6 +4550,12 @@ async def mesh_messages(
msgs = [m for m in msgs if m.get("root", "").upper() == root_filter]
if channel:
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)]
@@ -8789,6 +8836,16 @@ export_wormhole_dm_invite = getattr(
"export_wormhole_dm_invite",
_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(
_mesh_wormhole_identity,
"import_wormhole_dm_invite",
@@ -8935,6 +8992,13 @@ async def api_get_node_settings(request: Request):
@limiter.limit("10/minute")
async def api_set_node_settings(request: Request, body: NodeSettingsUpdate):
_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))
if bool(body.enabled):
_kick_public_sync_background("operator_enable")
@@ -9659,7 +9723,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")
async def api_wormhole_join(request: Request):
existing = read_wormhole_settings()
@@ -9713,7 +9777,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")
async def api_wormhole_leave(request: Request):
updated = write_wormhole_settings(enabled=False)
@@ -9730,7 +9794,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")
async def api_wormhole_identity(request: Request):
try:
@@ -9743,7 +9807,7 @@ async def api_wormhole_identity(request: Request):
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")
async def api_wormhole_identity_bootstrap(request: Request):
bootstrap_wormhole_identity()
@@ -9776,8 +9840,24 @@ async def api_wormhole_dm_identity(request: Request):
@app.get("/api/wormhole/dm/invite", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute")
async def api_wormhole_dm_invite(request: Request):
return export_wormhole_dm_invite()
async def api_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.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_admin)])
@@ -10507,7 +10587,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")
async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest):
gate_id = str(body.gate_id or "")
@@ -10521,25 +10601,25 @@ async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest):
return result
@app.post("/api/wormhole/gate/leave", dependencies=[Depends(require_local_operator)])
@app.post("/api/wormhole/gate/leave")
@limiter.limit("20/minute")
async def api_wormhole_gate_leave(request: Request, body: WormholeGateRequest):
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")
async def api_wormhole_gate_identity(request: Request, gate_id: str):
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")
async def api_wormhole_gate_personas(request: Request, gate_id: str):
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")
async def api_wormhole_gate_key_status(request: Request, gate_id: str):
exposure = metadata_exposure_for_request(request, authenticated=True)
@@ -10563,7 +10643,7 @@ async def api_wormhole_gate_key_rotate(request: Request, body: WormholeGateRotat
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")
async def api_wormhole_gate_persona_create(
request: Request, body: WormholeGatePersonaCreateRequest
@@ -10579,7 +10659,7 @@ async def api_wormhole_gate_persona_create(
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")
async def api_wormhole_gate_persona_activate(
request: Request, body: WormholeGatePersonaActivateRequest
@@ -10595,7 +10675,7 @@ async def api_wormhole_gate_persona_activate(
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")
async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRequest):
gate_id = str(body.gate_id or "")
@@ -10609,7 +10689,7 @@ async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRe
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")
async def api_wormhole_gate_persona_retire(
request: Request, body: WormholeGatePersonaActivateRequest
@@ -10690,7 +10770,7 @@ async def api_wormhole_gate_message_compose(request: Request, body: WormholeGate
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")
async def api_wormhole_gate_message_sign_encrypted(
request: Request,
@@ -10722,7 +10802,7 @@ async def api_wormhole_gate_message_sign_encrypted(
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")
async def api_wormhole_gate_message_post_encrypted(
request: Request,
@@ -10902,13 +10982,13 @@ async def api_wormhole_gate_messages_decrypt(request: Request, body: WormholeGat
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")
async def api_wormhole_gate_state_export(request: Request, body: WormholeGateRequest):
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")
async def api_wormhole_gate_proof(request: Request, body: WormholeGateRequest):
proof = _sign_gate_access_proof(str(body.gate_id or ""))
@@ -11455,7 +11535,7 @@ async def api_wormhole_health(request: Request):
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")
async def api_wormhole_connect(request: Request):
settings = read_wormhole_settings()
+2 -2
View File
@@ -7,7 +7,7 @@ py-modules = []
[project]
name = "backend"
version = "0.9.75"
version = "0.9.79"
requires-python = ">=3.10"
dependencies = [
"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]
# 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"]
[tool.black]
+15 -3
View File
@@ -132,6 +132,13 @@ async def api_get_node_settings(request: Request):
@limiter.limit("10/minute")
async def api_set_node_settings(request: Request, body: NodeSettingsUpdate):
_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))
if bool(body.enabled):
try:
@@ -174,17 +181,22 @@ async def api_set_meshtastic_mqtt_settings(request: Request, body: MeshtasticMqt
enabled_requested = updates.get("enabled")
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:
# Public MQTT and Wormhole are intentionally mutually exclusive lanes.
try:
from services.node_settings import write_node_settings
from services.wormhole_settings import write_wormhole_settings
from services.wormhole_supervisor import disconnect_wormhole
write_wormhole_settings(enabled=False)
disconnect_wormhole(reason="public_mesh_enabled")
write_node_settings(enabled=False)
_set_participant_node_enabled(False)
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 sigint_grid.mesh.is_running():
@@ -357,8 +369,8 @@ async def api_reset_all_agent_credentials(request: Request):
return {
"ok": True,
"new_hmac_secret": new_secret,
"detail": "All agent credentials have been reset. Reconfigure your agent with the new credentials.",
"hmac_regenerated": True,
"detail": "All agent credentials have been reset. Use the agent connection screen to generate or reveal replacement credentials.",
**results,
}
+2 -2
View File
@@ -1585,7 +1585,7 @@ async def agent_tool_manifest(request: Request):
return {
"ok": True,
"version": "0.9.75",
"version": "0.9.79",
"access_tier": access_tier,
"available_commands": available_commands,
"transport": {
@@ -2221,7 +2221,7 @@ async def api_capabilities(request: Request):
access_tier = str(get_settings().OPENCLAW_ACCESS_TIER or "restricted").strip().lower()
return {
"ok": True,
"version": "0.9.75",
"version": "0.9.79",
"auth": {
"method": "HMAC-SHA256",
"headers": ["X-SB-Timestamp", "X-SB-Nonce", "X-SB-Signature"],
+10 -2
View File
@@ -335,8 +335,16 @@ async def update_layers(update: LayerUpdate, request: Request):
logger.info("AIS stream started (ship layer enabled)")
from services.sigint_bridge import sigint_grid
if old_mesh and not new_mesh:
sigint_grid.mesh.stop()
logger.info("Meshtastic MQTT bridge stopped (layer disabled)")
try:
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:
try:
from services.meshtastic_mqtt_settings import mqtt_bridge_enabled
+1 -1
View File
@@ -8,7 +8,7 @@ from services.data_fetcher import get_latest_data
from services.schemas import HealthResponse
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()
+35 -16
View File
@@ -721,9 +721,11 @@ async def mesh_send(request: Request):
any_ok = any(r.ok for r in results)
# ─── Mirror to Meshtastic bridge feed ────────────────────────
# The MQTT broker won't echo our own publishes back to our subscriber,
# so inject successfully-sent messages into the bridge's deque directly.
if any_ok and envelope.routed_via == "meshtastic":
# The MQTT broker won't echo our own publishes back to our subscriber, so
# inject successfully-sent channel broadcasts into the bridge directly.
# 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:
from services.sigint_bridge import sigint_grid
@@ -734,7 +736,7 @@ async def mesh_send(request: Request):
bridge.messages.appendleft(
{
"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,
"region": credentials.get("mesh_region", "US"),
"channel": body.get("channel", "LongFast"),
@@ -750,6 +752,8 @@ async def mesh_send(request: Request):
"event_id": "",
"routed_via": envelope.routed_via,
"route_reason": envelope.route_reason,
"direct": is_direct_destination,
"channel_echo": not is_direct_destination,
"results": [r.to_dict() for r in results],
}
@@ -818,9 +822,10 @@ async def meshtastic_public_send(request: Request):
if not cb_ok:
results = [TransportResult(False, "meshtastic", cb_reason)]
else:
is_direct_destination = MeshtasticTransport._parse_node_id(destination) is not None
envelope.route_reason = (
"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"
)
credentials = {"mesh_region": str(body.get("mesh_region", "US") or "US")}
@@ -830,23 +835,28 @@ async def meshtastic_public_send(request: Request):
results = [result]
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:
from datetime import datetime
from services.sigint_bridge import sigint_grid
bridge = sigint_grid.mesh
if bridge:
bridge.messages.appendleft(
{
"from": MeshtasticTransport.mesh_address_for_sender(sender_id),
"to": destination if MeshtasticTransport._parse_node_id(destination) is not None else "broadcast",
"text": message,
"region": str(body.get("mesh_region", "US") or "US"),
"channel": str(body.get("channel", "LongFast") or "LongFast"),
"timestamp": datetime.utcnow().isoformat() + "Z",
}
)
record = {
"from": MeshtasticTransport.mesh_address_for_sender(sender_id),
"to": "broadcast",
"text": message,
"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"),
"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:
pass
@@ -856,6 +866,8 @@ async def meshtastic_public_send(request: Request):
"event_id": "",
"routed_via": envelope.routed_via,
"route_reason": envelope.route_reason,
"direct": is_direct_destination,
"channel_echo": not is_direct_destination,
"results": [r.to_dict() for r in results],
}
@@ -954,6 +966,7 @@ async def mesh_messages(
root: str = "",
channel: str = "",
limit: int = 30,
include_direct: bool = False,
):
"""Get recent Meshtastic text messages from the MQTT bridge."""
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]
if channel:
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)]
+74 -325
View File
@@ -78,6 +78,21 @@ export_wormhole_dm_invite = getattr(
"export_wormhole_dm_invite",
_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(
_mesh_wormhole_identity,
"import_wormhole_dm_invite",
@@ -311,6 +326,10 @@ class WormholeDmInviteImportRequest(BaseModel):
alias: str = ""
class WormholeDmInviteHandleUpdateRequest(BaseModel):
label: str = ""
class WormholeDmSenderTokenRequest(BaseModel):
recipient_id: str
delivery_class: str
@@ -477,6 +496,7 @@ def decrypt_wormhole_dm_envelope(
remote_alias: str | None = None,
session_welcome: str | None = None,
) -> dict[str, Any]:
"""Delegate to main.py, which owns current MLS/alias/legacy gating behavior."""
import main as _m
return _m.decrypt_wormhole_dm_envelope(
@@ -489,71 +509,13 @@ def decrypt_wormhole_dm_envelope(
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 ---
@router.get("/api/settings/wormhole")
@limiter.limit("30/minute")
@limiter.limit("240/minute")
async def api_get_wormhole_settings(request: Request):
settings = await asyncio.to_thread(read_wormhole_settings)
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}
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")
@limiter.limit("30/minute")
@limiter.limit("240/minute")
async def api_get_privacy_profile(request: Request):
data = await asyncio.to_thread(read_wormhole_settings)
return _redact_privacy_profile_settings(
@@ -833,7 +556,7 @@ async def api_get_privacy_profile(request: Request):
@router.get("/api/settings/wormhole-status")
@limiter.limit("30/minute")
@limiter.limit("240/minute")
async def api_get_wormhole_status(request: Request):
state = await asyncio.to_thread(get_wormhole_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")
async def api_wormhole_join(request: Request):
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.
# 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.
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")
async def api_wormhole_leave(request: Request):
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)])
@limiter.limit("30/minute")
@router.get("/api/wormhole/identity")
@limiter.limit("240/minute")
async def api_wormhole_identity(request: Request):
try:
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
@router.post("/api/wormhole/identity/bootstrap", dependencies=[Depends(require_local_operator)])
@router.post("/api/wormhole/identity/bootstrap")
@limiter.limit("10/minute")
async def api_wormhole_identity_bootstrap(request: Request):
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)])
@limiter.limit("30/minute")
@limiter.limit("240/minute")
async def api_wormhole_dm_identity(request: Request):
try:
bootstrap_wormhole_persona_state()
@@ -982,8 +705,34 @@ async def api_wormhole_dm_identity(request: Request):
@router.get("/api/wormhole/dm/invite", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute")
async def api_wormhole_dm_invite(request: Request):
return export_wormhole_dm_invite()
async def api_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.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_admin)])
@@ -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")
async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest):
gate_id = str(body.gate_id or "")
@@ -1038,25 +787,25 @@ async def api_wormhole_gate_enter(request: Request, body: WormholeGateRequest):
return result
@router.post("/api/wormhole/gate/leave", dependencies=[Depends(require_local_operator)])
@router.post("/api/wormhole/gate/leave")
@limiter.limit("20/minute")
async def api_wormhole_gate_leave(request: Request, body: WormholeGateRequest):
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")
async def api_wormhole_gate_identity(request: Request, gate_id: str):
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")
async def api_wormhole_gate_personas(request: Request, gate_id: str):
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")
async def api_wormhole_gate_key_status(request: Request, gate_id: str):
import main as _m
@@ -1080,7 +829,7 @@ async def api_wormhole_gate_key_rotate(request: Request, body: WormholeGateRotat
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")
async def api_wormhole_gate_persona_create(
request: Request, body: WormholeGatePersonaCreateRequest
@@ -1096,7 +845,7 @@ async def api_wormhole_gate_persona_create(
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")
async def api_wormhole_gate_persona_activate(
request: Request, body: WormholeGatePersonaActivateRequest
@@ -1112,7 +861,7 @@ async def api_wormhole_gate_persona_activate(
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")
async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRequest):
gate_id = str(body.gate_id or "")
@@ -1126,7 +875,7 @@ async def api_wormhole_gate_persona_clear(request: Request, body: WormholeGateRe
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")
async def api_wormhole_gate_persona_retire(
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)
@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")
async def api_wormhole_gate_message_sign_encrypted(
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)
@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")
async def api_wormhole_gate_message_post_encrypted(
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)
@router.post("/api/wormhole/gate/state/export", dependencies=[Depends(require_local_operator)])
@router.post("/api/wormhole/gate/state/export")
@limiter.limit("30/minute")
async def api_wormhole_gate_state_export(request: Request, body: WormholeGateRequest):
import main as _m
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")
async def api_wormhole_gate_proof(request: Request, body: WormholeGateRequest):
proof = _sign_gate_access_proof(str(body.gate_id or ""))
@@ -1547,7 +1296,7 @@ class PrivateDeliveryActionRequest(BaseModel):
@router.get("/api/wormhole/status")
@limiter.limit("30/minute")
@limiter.limit("240/minute")
async def api_wormhole_status(request: Request):
import main as _m
@@ -1590,7 +1339,7 @@ async def api_wormhole_private_delivery_action(
@router.get("/api/wormhole/health")
@limiter.limit("30/minute")
@limiter.limit("240/minute")
async def api_wormhole_health(request: Request):
state = get_wormhole_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)
@router.post("/api/wormhole/connect", dependencies=[Depends(require_admin)])
@router.post("/api/wormhole/connect")
@limiter.limit("10/minute")
async def api_wormhole_connect(request: Request):
settings = read_wormhole_settings()
@@ -32,7 +32,7 @@ _REFRESH_INTERVAL_S = 5 * 24 * 3600
_LIST_TIMEOUT_S = 30
_DOWNLOAD_TIMEOUT_S = 600
_USER_AGENT = (
"ShadowBroker-OSINT/0.9.75 "
"ShadowBroker-OSINT/0.9.79 "
"(+https://github.com/BigBodyCobain/Shadowbroker; "
"contact: bigbodycobain@gmail.com)"
)
+1 -1
View File
@@ -182,7 +182,7 @@ def fetch_meshtastic_nodes():
callsign = str(getattr(get_settings(), "MESHTASTIC_OPERATOR_CALLSIGN", "") or "").strip()
except Exception:
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
try:
+1 -1
View File
@@ -25,7 +25,7 @@ _REFRESH_INTERVAL_S = 5 * 24 * 3600
_HTTP_TIMEOUT_S = 60
_USER_AGENT = (
"ShadowBroker-OSINT/0.9.75 "
"ShadowBroker-OSINT/0.9.79 "
"(+https://github.com/BigBodyCobain/Shadowbroker; "
"contact: bigbodycobain@gmail.com)"
)
+15
View File
@@ -1264,6 +1264,21 @@ class DMRelay:
)
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:
"""Atomically claim the next published one-time prekey for a peer bundle."""
claimed: dict[str, Any] | None = None
+3 -3
View File
@@ -520,7 +520,7 @@ class MeshtasticTransport:
def _on_connect(client, userdata, flags, rc):
if rc == 0:
info = client.publish(topic, payload, qos=0)
info = client.publish(topic, payload, qos=1)
info.wait_for_publish(timeout=5)
published[0] = True
client.disconnect()
@@ -550,9 +550,9 @@ class MeshtasticTransport:
True,
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
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:
+178 -4
View File
@@ -11,6 +11,7 @@ import base64
import hmac
import hashlib
import json
import logging
import secrets
import time
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_ACTIVE_CAP = 4
logger = logging.getLogger(__name__)
def _safe_int(val, default=0) -> int:
try:
@@ -107,6 +110,7 @@ def _default_identity() -> dict[str, Any]:
def _prekey_lookup_handle_record(
handle: str,
*,
label: str = "",
issued_at: int = 0,
expires_at: 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))
return {
"handle": str(handle or "").strip(),
"label": str(label or "").strip()[:96],
"issued_at": issued,
"expires_at": bounded_expires_at,
"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)
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)
label = str(value.get("label", "") or "").strip()
return _prekey_lookup_handle_record(
handle,
label=label,
issued_at=issued_at,
expires_at=expires_at,
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(
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(
_prekey_lookup_handle_record(
lookup_handle,
label=str(label or "").strip(),
issued_at=issued_at,
expires_at=expires_at,
)
@@ -920,14 +945,25 @@ def export_wormhole_dm_invite(*, label: str = "", expires_in_s: int = 0) -> dict
except Exception:
pass
prekey_registration: dict[str, Any] = {"ok": False, "detail": "prekey bundle publish not attempted"}
try:
from services.mesh.mesh_wormhole_prekey import register_wormhole_prekey_bundle
registered = register_wormhole_prekey_bundle()
if not registered.get("ok"):
return {"ok": False, "detail": str(registered.get("detail", "") or "prekey bundle registration failed")}
prekey_registration = register_wormhole_prekey_bundle()
if not prekey_registration.get("ok"):
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:
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()
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 ""),
"trust_fingerprint": str(payload.get("identity_commitment", "") or ""),
"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:
lookup_handle = str(handle or "").strip()
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:
current = _prekey_lookup_handle_record(
lookup_handle,
label=str(current.get("label", "") or "").strip(),
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),
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(
_prekey_lookup_handle_record(
old_handle,
label=str(record.get("label", "") or "").strip(),
issued_at=_safe_int(record.get("issued_at", 0) or 0, current_time),
expires_at=overlap_expires_at,
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 hashlib
import json
import logging
import time
from pathlib import Path
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_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_persona import (
bootstrap_wormhole_persona_state,
@@ -51,6 +52,7 @@ DEFAULT_ROOT_WITNESS_THRESHOLD = 2
DEFAULT_ROOT_WITNESS_MANAGEMENT_SCOPE = "local"
DEFAULT_ROOT_WITNESS_INDEPENDENCE_GROUP = "local_system"
DEFAULT_ROOT_EXTERNAL_WITNESS_MAX_AGE_S = 3600
logger = logging.getLogger(__name__)
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]:
raw = read_domain_json(
ROOT_DISTRIBUTION_DOMAIN,
ROOT_DISTRIBUTION_FILE,
_default_state,
base_dir=DATA_DIR,
)
try:
raw = read_domain_json(
ROOT_DISTRIBUTION_DOMAIN,
ROOT_DISTRIBUTION_FILE,
_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["witness_identity"] = {**_empty_witness_identity(), **dict(state.get("witness_identity") or {})}
witness_identities, witness_changed = _normalize_witness_identities(
+17 -3
View File
@@ -108,8 +108,18 @@ def normalize_topic_filter(value: str) -> str | None:
return "/".join(parts)
def _default_topic_for_root(root: str) -> str:
return f"msh/{root}/2/e/{DEFAULT_CHANNEL}/#"
def _default_topics_for_root(root: str) -> list[str]:
"""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(
@@ -124,7 +134,11 @@ def build_subscription_topics(
# 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)
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(
topic
for topic in (
+1 -1
View File
@@ -73,7 +73,7 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None,
both Python requests and the barebones Windows system curl.
"""
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:
default_headers.update(headers)
+1 -1
View File
@@ -20,7 +20,7 @@ from cachetools import TTLCache
logger = logging.getLogger(__name__)
_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
_MIN_INTERVAL_SECONDS = 1.05 # Shodan docs say API plans are rate limited to ~1 req/sec.
_DEFAULT_SEARCH_PAGES = 1
+196 -1
View File
@@ -545,6 +545,198 @@ class MeshtasticBridge:
self._message_dedupe[key] = now
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):
if self._thread and self._thread.is_alive():
if not self._stop.is_set():
@@ -693,6 +885,9 @@ class MeshtasticBridge:
if "/json/" in topic:
try:
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():
return
self._ingest_data(data, topic)
@@ -715,7 +910,7 @@ class MeshtasticBridge:
topic_meta["root"],
):
return
self.messages.appendleft(
self.append_text_message(
{
"from": data.get("from", "???"),
"to": recipient,
@@ -0,0 +1,57 @@
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
def disable_public_mesh_lane(*, reason: str = "private_lane_enabled") -> dict[str, Any]:
"""Disable public Meshtastic MQTT before private Wormhole/Infonet starts."""
result: dict[str, Any] = {
"ok": True,
"reason": reason,
"settings_disabled": False,
"runtime_stopped": False,
}
# Scheduled Wormhole prewarm must not mutate the user's explicit public
# MeshChat session. Only a deliberate private-lane activation should sever
# the public MQTT lane.
normalized_reason = str(reason or "").strip().lower()
if normalized_reason == "wormhole_scheduled_prewarm" or normalized_reason.endswith(":scheduled_prewarm"):
try:
from services.meshtastic_mqtt_settings import mqtt_bridge_enabled
if mqtt_bridge_enabled():
logger.info("Keeping public Mesh lane active during Wormhole prewarm: %s", reason)
result["skipped"] = True
result["skip_reason"] = "public_mesh_user_enabled"
return result
except Exception as exc:
logger.debug("Could not inspect public Mesh state during %s: %s", reason, exc)
logger.info("Disabling public Mesh lane: %s", reason)
try:
from services.meshtastic_mqtt_settings import write_meshtastic_mqtt_settings
settings = write_meshtastic_mqtt_settings(enabled=False)
result["settings_disabled"] = not bool(settings.get("enabled"))
except Exception as exc:
logger.warning("Failed to disable public Mesh settings during %s: %s", reason, exc)
result["ok"] = False
result["settings_error"] = str(exc)
try:
from services.sigint_bridge import sigint_grid
if sigint_grid.mesh.is_running():
sigint_grid.mesh.stop()
result["runtime_stopped"] = not sigint_grid.mesh.is_running()
except Exception as exc:
logger.warning("Failed to stop public Mesh runtime during %s: %s", reason, exc)
result["ok"] = False
result["runtime_error"] = str(exc)
return result
+1 -1
View File
@@ -24,7 +24,7 @@ from cachetools import TTLCache
logger = logging.getLogger(__name__)
_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
_MIN_INTERVAL_SECONDS = 0.35 # Stay well under 60 calls/min
+80 -16
View File
@@ -243,6 +243,48 @@ def _pid_alive(pid: int) -> bool:
return True
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:
try:
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]:
settings = read_wormhole_settings()
status = read_wormhole_status()
configured = bool(settings.get("enabled"))
running = False
ready = False
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
pid = int(_PROCESS.pid or 0)
elif _pid_alive(pid):
running = True
elif _probe_ready(timeout_s=0.35):
running = True
pid = 0
ready = running and _probe_ready()
else:
if _pid_alive(pid):
running = True
else:
discovered_pid = _find_wormhole_server_pid()
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:
pid = 0
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_ready = bool(
anonymous_mode
and settings.get("enabled")
and configured
and ready
and effective_transport in {"tor", "tor_arti", "i2p", "mixnet"}
)
snapshot = {
"installed": _installed(),
"configured": bool(settings.get("enabled")),
"configured": configured,
"running": running,
"ready": ready,
"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]:
with _LOCK:
_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()
if not settings.get("enabled"):
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]:
with _LOCK:
_invalidate_state_cache()
current = _current_runtime_state()
pid = int(current.get("pid", 0) or 0)
status = read_wormhole_status()
pid = int(status.get("pid", 0) or 0)
global _PROCESS
if _PROCESS and _PROCESS.poll() is None:
try:
@@ -499,14 +562,15 @@ def disconnect_wormhole(*, reason: str = "disconnect") -> dict[str, Any]:
_PROCESS.kill()
except Exception:
pass
elif os.name != "nt" and _pid_alive(pid):
try:
os.kill(pid, signal.SIGTERM)
except Exception:
pass
if os.name != "nt":
_terminate_pid(pid)
discovered_pid = _find_wormhole_server_pid()
if discovered_pid > 0 and discovered_pid != pid:
_terminate_pid(discovered_pid)
_PROCESS = None
write_wormhole_status(
reason=reason,
configured=False,
running=False,
ready=False,
pid=0,
+7 -1
View File
@@ -2,15 +2,21 @@ from services.mesh.meshtastic_topics import build_subscription_topics, known_roo
def test_default_subscription_is_longfast_only():
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"]
def test_extra_roots_are_longfast_only():
assert build_subscription_topics(extra_roots="EU_868,ANZ") == [
"msh/US/2/e/LongFast/#",
"msh/US/2/json/LongFast/#",
"msh/EU_868/2/e/LongFast/#",
"msh/EU_868/2/json/LongFast/#",
"msh/ANZ/2/e/LongFast/#",
"msh/ANZ/2/json/LongFast/#",
]