From 6ffd54931c56d5b52ac28a6a45bc821c4c673f03 Mon Sep 17 00:00:00 2001 From: BigBodyCobain <43977454+BigBodyCobain@users.noreply.github.com> Date: Wed, 6 May 2026 01:15:54 -0600 Subject: [PATCH] Release v0.9.75 runtime and onboarding update Ship the 0.9.75 source update with improved startup/runtime hardening, operator API key onboarding, Meshtastic MQTT controls, Infonet/MeshChat separation, desktop package versioning, and aircraft telemetry refinements. Also updates focused backend/frontend tests for node settings, Meshtastic MQTT settings, and desktop runtime behavior. --- backend/.env.example | 5 +- backend/Dockerfile | 4 +- backend/main.py | 35 +- backend/pyproject.toml | 4 +- backend/routers/admin.py | 77 +++- backend/routers/ai_intel.py | 4 +- backend/routers/data.py | 18 +- backend/routers/health.py | 2 +- backend/routers/mesh_public.py | 106 +++++ backend/services/ais_stream.py | 51 ++- .../services/fetchers/aircraft_database.py | 2 +- backend/services/fetchers/flights.py | 44 ++- backend/services/fetchers/meshtastic_map.py | 2 +- backend/services/fetchers/route_database.py | 2 +- backend/services/mesh/mesh_router.py | 27 +- backend/services/mesh/meshtastic_topics.py | 9 +- backend/services/meshtastic_mqtt_settings.py | 172 +++++++++ backend/services/network_utils.py | 2 +- backend/services/node_settings.py | 17 +- backend/services/shodan_connector.py | 2 +- backend/services/sigint_bridge.py | 93 +++-- backend/services/tor_hidden_service.py | 163 ++++---- backend/services/unusual_whales_connector.py | 2 +- .../mesh/test_meshtastic_mqtt_settings.py | 54 +++ backend/tests/mesh/test_meshtastic_topics.py | 21 + backend/tests/mesh/test_node_settings.py | 37 +- desktop-shell/package-lock.json | 4 +- desktop-shell/package.json | 2 +- .../tauri-skeleton/src-tauri/Cargo.lock | 2 +- .../tauri-skeleton/src-tauri/Cargo.toml | 2 +- .../tauri-skeleton/src-tauri/tauri.conf.json | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- .../__tests__/desktop/updateRuntime.test.ts | 10 +- frontend/src/app/api/[...path]/route.ts | 1 + frontend/src/components/ChangelogModal.tsx | 40 +- frontend/src/components/MaplibreViewer.tsx | 158 +++++++- frontend/src/components/MeshChat/index.tsx | 183 ++++++++- .../MeshChat/useMeshChatController.ts | 364 ++++++++++++++---- frontend/src/components/NewsFeed.tsx | 245 ++++++++++-- frontend/src/components/OnboardingModal.tsx | 277 ++++++++++++- frontend/src/components/SettingsPanel.tsx | 136 ++++--- .../src/components/StartupWarmupModal.tsx | 2 +- frontend/src/mesh/controlPlaneStatusClient.ts | 2 +- frontend/src/types/dashboard.ts | 1 + helm/chart/Chart.yaml | 3 +- pyproject.toml | 2 +- uv.lock | 2 +- 48 files changed, 2024 insertions(+), 375 deletions(-) create mode 100644 backend/services/meshtastic_mqtt_settings.py create mode 100644 backend/tests/mesh/test_meshtastic_mqtt_settings.py create mode 100644 backend/tests/mesh/test_meshtastic_topics.py diff --git a/backend/.env.example b/backend/.env.example index 41c3ee2..92899af 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -54,8 +54,9 @@ AIS_API_KEY= # https://aisstream.io/ — free tier WebSocket key # MESH_MQTT_INCLUDE_DEFAULT_ROOTS=true # MESH_MQTT_BROKER=mqtt.meshtastic.org # MESH_MQTT_PORT=1883 -# MESH_MQTT_USER=meshdev -# MESH_MQTT_PASS=large4cats +# Leave user/pass blank for the public Meshtastic broker default. +# MESH_MQTT_USER= +# MESH_MQTT_PASS= # Optional Meshtastic node ID (e.g. "!abcd1234"). When set, included in the # User-Agent sent to meshtastic.liamcottle.net so the upstream service operator diff --git a/backend/Dockerfile b/backend/Dockerfile index fdc0735..aeeb9ab 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -22,10 +22,12 @@ FROM python:3.11-slim-bookworm WORKDIR /app -# Install Node.js (for AIS WebSocket proxy) and curl (for network fallback) +# Install Node.js (for AIS WebSocket proxy), curl (for network fallback), and +# Tor (for Wormhole/remote-agent .onion transport). RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ + tor \ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && rm -rf /var/lib/apt/lists/* diff --git a/backend/main.py b/backend/main.py index 3df0aef..97f4e3a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,7 +14,7 @@ from dataclasses import dataclass, field from typing import Any from json import JSONDecodeError -APP_VERSION = "0.9.7" +APP_VERSION = "0.9.75" logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -1489,6 +1489,33 @@ def _run_public_sync_cycle() -> SyncWorkerState: ) +_NODE_SYNC_KICK_LOCK = threading.Lock() + + +def _kick_public_sync_background(reason: str = "") -> None: + """Start one immediate Infonet sync attempt without waiting for the poll loop.""" + if not _node_runtime_supported() or not _participant_node_enabled(): + return + + def _runner() -> None: + if not _NODE_SYNC_KICK_LOCK.acquire(blocking=False): + return + try: + label = f" ({reason})" if reason else "" + logger.info("Infonet sync kick starting%s", label) + _run_public_sync_cycle() + except Exception: + logger.exception("Infonet sync kick failed") + finally: + _NODE_SYNC_KICK_LOCK.release() + + threading.Thread( + target=_runner, + daemon=True, + name="infonet-sync-kick", + ).start() + + def _public_infonet_sync_loop() -> None: from services.mesh.mesh_hashchain import infonet @@ -2205,6 +2232,7 @@ async def lifespan(app: FastAPI): set_sync_state(_set_node_sync_disabled_state()) _NODE_SYNC_STOP.clear() threading.Thread(target=_public_infonet_sync_loop, daemon=True).start() + _kick_public_sync_background("startup") threading.Thread(target=_http_peer_push_loop, daemon=True).start() threading.Thread(target=_http_gate_push_loop, daemon=True).start() threading.Thread(target=_http_gate_pull_loop, daemon=True).start() @@ -8784,7 +8812,10 @@ 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() - return _set_participant_node_enabled(bool(body.enabled)) + result = _set_participant_node_enabled(bool(body.enabled)) + if bool(body.enabled): + _kick_public_sync_background("operator_enable") + return result @app.get("/api/settings/wormhole") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index afd0c0e..ca8d87c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -7,7 +7,7 @@ py-modules = [] [project] name = "backend" -version = "0.9.7" +version = "0.9.75" requires-python = ">=3.10" dependencies = [ "apscheduler==3.10.3", @@ -42,7 +42,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.7 release. +# Keep CI focused on actionable correctness checks for the v0.9.75 release. ignore = ["E401", "E402", "E701", "E731", "E741", "F401", "F402", "F541", "F811", "F841"] [tool.black] diff --git a/backend/routers/admin.py b/backend/routers/admin.py index e8595fb..536122a 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -28,6 +28,18 @@ class TimeMachineToggle(BaseModel): enabled: bool +class MeshtasticMqttUpdate(BaseModel): + enabled: bool | None = None + broker: str | None = None + port: int | None = None + username: str | None = None + password: str | None = None + psk: str | None = None + include_default_roots: bool | None = None + extra_roots: str | None = None + extra_topics: str | None = None + + @router.get("/api/settings/api-keys", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def api_get_keys(request: Request): @@ -120,7 +132,70 @@ 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() - return _set_participant_node_enabled(bool(body.enabled)) + result = _set_participant_node_enabled(bool(body.enabled)) + if bool(body.enabled): + try: + import main as _main + + _main._kick_public_sync_background("operator_enable") + except Exception: + logger.debug("Unable to kick Infonet sync after node enable", exc_info=True) + return result + + +def _meshtastic_runtime_snapshot() -> dict[str, Any]: + from services.meshtastic_mqtt_settings import redacted_meshtastic_mqtt_settings + from services.sigint_bridge import sigint_grid + + return { + **redacted_meshtastic_mqtt_settings(), + "runtime": sigint_grid.mesh.status(), + } + + +@router.get("/api/settings/meshtastic-mqtt", dependencies=[Depends(require_local_operator)]) +@limiter.limit("30/minute") +async def api_get_meshtastic_mqtt_settings(request: Request): + return _meshtastic_runtime_snapshot() + + +@router.put("/api/settings/meshtastic-mqtt", dependencies=[Depends(require_local_operator)]) +@limiter.limit("10/minute") +async def api_set_meshtastic_mqtt_settings(request: Request, body: MeshtasticMqttUpdate): + from services.meshtastic_mqtt_settings import write_meshtastic_mqtt_settings + from services.sigint_bridge import sigint_grid + + updates = body.model_dump(exclude_unset=True) + # Empty secret fields mean "keep existing"; explicit non-empty values replace. + if updates.get("password") == "": + updates.pop("password", None) + if updates.get("psk") == "": + updates.pop("psk", None) + + enabled_requested = updates.get("enabled") + settings = write_meshtastic_mqtt_settings(**updates) + + if enabled_requested is True: + # Public MQTT and Wormhole are intentionally mutually exclusive lanes. + try: + 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") + except Exception as exc: + logger.warning("Failed to disable Wormhole while enabling public mesh: %s", exc) + + if bool(settings.get("enabled")): + if sigint_grid.mesh.is_running(): + sigint_grid.mesh.stop() + threading.Timer(1.0, sigint_grid.mesh.start).start() + else: + sigint_grid.mesh.start() + else: + sigint_grid.mesh.stop() + + return _meshtastic_runtime_snapshot() @router.get("/api/settings/timemachine") diff --git a/backend/routers/ai_intel.py b/backend/routers/ai_intel.py index 2f5d8f7..8e292e5 100644 --- a/backend/routers/ai_intel.py +++ b/backend/routers/ai_intel.py @@ -1585,7 +1585,7 @@ async def agent_tool_manifest(request: Request): return { "ok": True, - "version": "0.9.7", + "version": "0.9.75", "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.7", + "version": "0.9.75", "auth": { "method": "HMAC-SHA256", "headers": ["X-SB-Timestamp", "X-SB-Nonce", "X-SB-Signature"], diff --git a/backend/routers/data.py b/backend/routers/data.py index 2884fc4..6e5cc33 100644 --- a/backend/routers/data.py +++ b/backend/routers/data.py @@ -282,6 +282,20 @@ async def ais_feed(request: Request): return {"status": "ok", "ingested": count} +@router.get("/api/trail/flight/{icao24}") +@limiter.limit("120/minute") +async def get_selected_flight_trail(icao24: str, request: Request): # noqa: ARG001 + from services.fetchers.flights import get_flight_trail + return {"id": icao24, "trail": get_flight_trail(icao24)} + + +@router.get("/api/trail/ship/{mmsi}") +@limiter.limit("120/minute") +async def get_selected_ship_trail(mmsi: int, request: Request): # noqa: ARG001 + from services.ais_stream import get_vessel_trail + return {"id": mmsi, "trail": get_vessel_trail(mmsi)} + + @router.post("/api/viewport") @limiter.limit("60/minute") async def update_viewport(vp: ViewportUpdate, request: Request): # noqa: ARG001 @@ -325,8 +339,8 @@ async def update_layers(update: LayerUpdate, request: Request): logger.info("Meshtastic MQTT bridge stopped (layer disabled)") elif not old_mesh and new_mesh: try: - from services.config import get_settings - mqtt_enabled = bool(getattr(get_settings(), "MESH_MQTT_ENABLED", False)) + from services.meshtastic_mqtt_settings import mqtt_bridge_enabled + mqtt_enabled = mqtt_bridge_enabled() except Exception: mqtt_enabled = False if mqtt_enabled: diff --git a/backend/routers/health.py b/backend/routers/health.py index 34487b8..f47c067 100644 --- a/backend/routers/health.py +++ b/backend/routers/health.py @@ -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.7") +APP_VERSION = os.environ.get("_HEALTH_APP_VERSION", "0.9.75") router = APIRouter() diff --git a/backend/routers/mesh_public.py b/backend/routers/mesh_public.py index cb7f696..374212c 100644 --- a/backend/routers/mesh_public.py +++ b/backend/routers/mesh_public.py @@ -754,6 +754,112 @@ async def mesh_send(request: Request): } +@router.post("/api/mesh/meshtastic/send", dependencies=[Depends(require_local_operator)]) +@limiter.limit("10/minute") +@mesh_write_exempt(MeshWriteExemption.LOCAL_OPERATOR_ONLY) +async def meshtastic_public_send(request: Request): + """Local public-MQTT send path for standalone Meshtastic-style identities.""" + body = await request.json() + destination = str(body.get("destination", "") or "").strip() or "broadcast" + message = str(body.get("message", "") or "") + sender_id = str(body.get("sender_id", "") or "").strip().lower() + if not message: + return {"ok": False, "detail": "Missing required field: message"} + + from services.mesh.mesh_router import ( + MeshEnvelope, + MeshtasticTransport, + Priority, + TransportResult, + mesh_router, + ) + from services.meshtastic_mqtt_settings import mqtt_bridge_enabled + + if MeshtasticTransport._parse_node_id(sender_id) is None: + return {"ok": False, "detail": "Missing or invalid public Meshtastic address"} + if not mqtt_bridge_enabled(): + return {"ok": False, "detail": "Meshtastic MQTT bridge is disabled"} + + payload_bytes = len(message.encode("utf-8")) + payload_type = str(body.get("payload_type", "text") or "text") + max_bytes = _BYTE_LIMITS.get(payload_type, 200) + if payload_bytes > max_bytes: + return { + "ok": False, + "detail": f"Message too long ({payload_bytes} bytes). Maximum: {max_bytes} bytes for {payload_type} messages.", + } + + priority_str = str(body.get("priority", "normal") or "normal").lower() + throttle_ok, throttle_reason = _check_throttle(sender_id, priority_str, "meshtastic") + if not throttle_ok: + return {"ok": False, "detail": throttle_reason} + + priority_map = { + "emergency": Priority.EMERGENCY, + "high": Priority.HIGH, + "normal": Priority.NORMAL, + "low": Priority.LOW, + } + priority = priority_map.get(priority_str, Priority.NORMAL) + envelope = MeshEnvelope( + sender_id=sender_id, + destination=destination, + channel=str(body.get("channel", "LongFast") or "LongFast"), + priority=priority, + payload=message, + ephemeral=bool(body.get("ephemeral", False)), + trust_tier="public_degraded", + ) + + if not mesh_router.meshtastic.can_reach(envelope): + results = [TransportResult(False, "meshtastic", "Message exceeds Meshtastic payload limit")] + else: + cb_ok, cb_reason = mesh_router.breakers["meshtastic"].check_and_record(envelope.priority) + if not cb_ok: + results = [TransportResult(False, "meshtastic", cb_reason)] + else: + envelope.route_reason = ( + "Local public Meshtastic MQTT path" + if MeshtasticTransport._parse_node_id(destination) is None + else "Local public Meshtastic direct node path" + ) + credentials = {"mesh_region": str(body.get("mesh_region", "US") or "US")} + result = mesh_router.meshtastic.send(envelope, credentials) + if result.ok: + envelope.routed_via = mesh_router.meshtastic.NAME + results = [result] + + any_ok = any(r.ok for r in results) + if any_ok and envelope.routed_via == "meshtastic": + 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", + } + ) + except Exception: + pass + + return { + "ok": any_ok, + "message_id": envelope.message_id, + "event_id": "", + "routed_via": envelope.routed_via, + "route_reason": envelope.route_reason, + "results": [r.to_dict() for r in results], + } + + @router.get("/api/mesh/log") @limiter.limit("30/minute") async def mesh_log(request: Request): diff --git a/backend/services/ais_stream.py b/backend/services/ais_stream.py index 442fd42..5e1ce1b 100644 --- a/backend/services/ais_stream.py +++ b/backend/services/ais_stream.py @@ -339,16 +339,61 @@ def get_country_from_mmsi(mmsi: int) -> str: # Global vessel store: MMSI → vessel dict _vessels: dict[int, dict] = {} +_vessel_trails: dict[int, dict] = {} _vessels_lock = threading.Lock() _ws_thread: threading.Thread | None = None _ws_running = False _proxy_process = None +_VESSEL_TRAIL_INTERVAL_S = 120 +_VESSEL_TRAIL_MAX_POINTS = 240 import os CACHE_FILE = os.path.join(os.path.dirname(__file__), "ais_cache.json") +def _record_vessel_trail_locked(mmsi: int, lat, lng, sog=0, now_ts: float | None = None) -> None: + """Append a sampled AIS trail point. Caller must hold _vessels_lock.""" + if lat is None or lng is None: + return + try: + lat_f = float(lat) + lng_f = float(lng) + except (TypeError, ValueError): + return + if abs(lat_f) > 90 or abs(lng_f) > 180 or (lat_f == 0 and lng_f == 0): + return + now = now_ts or time.time() + trail_data = _vessel_trails.setdefault(int(mmsi), {"points": [], "last_seen": now}) + point = [round(lat_f, 5), round(lng_f, 5), round(float(sog or 0), 1), round(now)] + last_point_ts = trail_data["points"][-1][3] if trail_data["points"] else 0 + if now - last_point_ts < _VESSEL_TRAIL_INTERVAL_S: + trail_data["last_seen"] = now + return + if ( + trail_data["points"] + and trail_data["points"][-1][0] == point[0] + and trail_data["points"][-1][1] == point[1] + ): + trail_data["last_seen"] = now + return + trail_data["points"].append(point) + trail_data["last_seen"] = now + if len(trail_data["points"]) > _VESSEL_TRAIL_MAX_POINTS: + trail_data["points"] = trail_data["points"][-_VESSEL_TRAIL_MAX_POINTS:] + + +def get_vessel_trail(mmsi: int) -> list: + """Return the accumulated trail for a single vessel without expanding live payloads.""" + try: + key = int(mmsi) + except (TypeError, ValueError): + return [] + with _vessels_lock: + points = _vessel_trails.get(key, {}).get("points", []) + return [list(point) for point in points] + + def _save_cache(): """Save vessel data to disk for persistence across restarts.""" try: @@ -391,6 +436,7 @@ def prune_stale_vessels(): stale_keys = [k for k, v in _vessels.items() if v.get("_updated", 0) < stale_cutoff] for k in stale_keys: del _vessels[k] + _vessel_trails.pop(k, None) if stale_keys: logger.info(f"AIS pruned {len(stale_keys)} stale vessels") @@ -459,6 +505,7 @@ def ingest_ais_catcher(msgs: list[dict]) -> int: heading = msg.get("heading", 511) vessel["heading"] = heading if heading != 511 else vessel.get("cog", 0) vessel["_updated"] = now + _record_vessel_trail_locked(mmsi, lat, lon, vessel["sog"], now) if msg.get("shipname"): vessel["name"] = msg["shipname"].strip() count += 1 @@ -595,7 +642,9 @@ def _ais_stream_loop(): vessel["cog"] = report.get("Cog", 0) heading = report.get("TrueHeading", 511) vessel["heading"] = heading if heading != 511 else report.get("Cog", 0) - vessel["_updated"] = time.time() + now_ts = time.time() + vessel["_updated"] = now_ts + _record_vessel_trail_locked(mmsi, lat, lng, vessel["sog"], now_ts) # Use metadata name if we don't have one yet if not vessel.get("name") or vessel["name"] == "UNKNOWN": vessel["name"] = ( diff --git a/backend/services/fetchers/aircraft_database.py b/backend/services/fetchers/aircraft_database.py index 8241239..2194d3b 100644 --- a/backend/services/fetchers/aircraft_database.py +++ b/backend/services/fetchers/aircraft_database.py @@ -32,7 +32,7 @@ _REFRESH_INTERVAL_S = 5 * 24 * 3600 _LIST_TIMEOUT_S = 30 _DOWNLOAD_TIMEOUT_S = 600 _USER_AGENT = ( - "ShadowBroker-OSINT/0.9.7 " + "ShadowBroker-OSINT/0.9.75 " "(+https://github.com/BigBodyCobain/Shadowbroker; " "contact: bigbodycobain@gmail.com)" ) diff --git a/backend/services/fetchers/flights.py b/backend/services/fetchers/flights.py index f0d18a6..be955ac 100644 --- a/backend/services/fetchers/flights.py +++ b/backend/services/fetchers/flights.py @@ -258,6 +258,16 @@ flight_trails = {} # {icao_hex: {points: [[lat, lng, alt, ts], ...], last_seen: _trails_lock = threading.Lock() _MAX_TRACKED_TRAILS = 2000 + +def get_flight_trail(icao24: str) -> list: + """Return the accumulated trail for a single aircraft without expanding live payloads.""" + hex_id = str(icao24 or "").strip().lower() + if not hex_id: + return [] + with _trails_lock: + points = flight_trails.get(hex_id, {}).get("points", []) + return [list(point) for point in points] + # Route enrichment is now served from services.fetchers.route_database, which # bulk-loads vrs-standing-data.adsb.lol/routes.csv.gz once per day and looks up # callsigns from an in-memory index. Replaces the legacy /api/0/routeset POST, @@ -612,13 +622,22 @@ def _classify_and_publish(all_adsb_flights): ) # --- Trail Accumulation --- - _TRAIL_INTERVAL_S = 600 # only record a new trail point every 10 minutes + _TRAIL_INTERVAL_S = 60 # selected trails need enough resolution to show where unknown-route traffic came from def _accumulate_trail(f, now_ts, check_route=True): hex_id = f.get("icao24", "").lower() if not hex_id: return 0, None - if check_route and f.get("origin_name", "UNKNOWN") != "UNKNOWN": + + def _known_route_name(value): + normalized = str(value or "").strip().upper() + return bool(normalized and normalized != "UNKNOWN") + + has_known_route = bool( + (f.get("origin_loc") and f.get("dest_loc")) + or (_known_route_name(f.get("origin_name")) and _known_route_name(f.get("dest_name"))) + ) + if check_route and has_known_route: f["trail"] = [] return 0, hex_id lat, lng, alt = f.get("lat"), f.get("lng"), f.get("alt", 0) @@ -629,7 +648,7 @@ def _classify_and_publish(all_adsb_flights): if hex_id not in flight_trails: flight_trails[hex_id] = {"points": [], "last_seen": now_ts} trail_data = flight_trails[hex_id] - # Only append a new point if 10 minutes have passed since the last one + # Only append a new point if enough time has passed since the last one last_point_ts = trail_data["points"][-1][3] if trail_data["points"] else 0 if now_ts - last_point_ts < _TRAIL_INTERVAL_S: trail_data["last_seen"] = now_ts @@ -649,14 +668,16 @@ def _classify_and_publish(all_adsb_flights): now_ts = datetime.utcnow().timestamp() with _data_lock: + commercial_snapshot = copy.deepcopy(latest_data.get("commercial_flights", [])) + private_jets_snapshot = copy.deepcopy(latest_data.get("private_jets", [])) + private_ga_snapshot = copy.deepcopy(latest_data.get("private_flights", [])) military_snapshot = copy.deepcopy(latest_data.get("military_flights", [])) tracked_snapshot = copy.deepcopy(latest_data.get("tracked_flights", [])) raw_flights_snapshot = list(latest_data.get("flights", [])) - # Commercial/private: skip trail if route is known (route line replaces trail) - route_check_lists = [commercial, private_jets, private_ga] - # Tracked + military: ALWAYS accumulate trails (high-interest flights) - always_trail_lists = [existing_tracked, military_snapshot] + # Skip trails for any flight with a known route; the route line replaces historical trail. + route_check_lists = [commercial_snapshot, private_jets_snapshot, private_ga_snapshot] + always_trail_lists = [tracked_snapshot, military_snapshot] seen_hexes = set() trail_count = 0 with _trails_lock: @@ -669,7 +690,7 @@ def _classify_and_publish(all_adsb_flights): for flist in always_trail_lists: for f in flist: - count, hex_id = _accumulate_trail(f, now_ts, check_route=False) + count, hex_id = _accumulate_trail(f, now_ts, check_route=True) trail_count += count if hex_id: seen_hexes.add(hex_id) @@ -693,6 +714,13 @@ def _classify_and_publish(all_adsb_flights): f"Trail accumulation: {trail_count} active trails, {len(stale_keys)} pruned, {len(flight_trails)} total" ) + with _data_lock: + latest_data["commercial_flights"] = commercial_snapshot + latest_data["private_jets"] = private_jets_snapshot + latest_data["private_flights"] = private_ga_snapshot + latest_data["tracked_flights"] = tracked_snapshot + latest_data["military_flights"] = military_snapshot + # --- GPS Jamming Detection --- # Uses NACp (Navigation Accuracy Category – Position) from ADS-B to infer # GPS interference zones, similar to GPSJam.org / Flightradar24. diff --git a/backend/services/fetchers/meshtastic_map.py b/backend/services/fetchers/meshtastic_map.py index 77dd320..7974e67 100644 --- a/backend/services/fetchers/meshtastic_map.py +++ b/backend/services/fetchers/meshtastic_map.py @@ -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.7 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com; 24h polling)" + ua_base = "ShadowBroker-OSINT/0.9.75 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com; 24h polling)" user_agent = f"{ua_base}; node={callsign}" if callsign else ua_base try: diff --git a/backend/services/fetchers/route_database.py b/backend/services/fetchers/route_database.py index 83b02da..433858b 100644 --- a/backend/services/fetchers/route_database.py +++ b/backend/services/fetchers/route_database.py @@ -25,7 +25,7 @@ _REFRESH_INTERVAL_S = 5 * 24 * 3600 _HTTP_TIMEOUT_S = 60 _USER_AGENT = ( - "ShadowBroker-OSINT/0.9.7 " + "ShadowBroker-OSINT/0.9.75 " "(+https://github.com/BigBodyCobain/Shadowbroker; " "contact: bigbodycobain@gmail.com)" ) diff --git a/backend/services/mesh/mesh_router.py b/backend/services/mesh/mesh_router.py index 6731fbc..06c65ef 100644 --- a/backend/services/mesh/mesh_router.py +++ b/backend/services/mesh/mesh_router.py @@ -390,15 +390,9 @@ class MeshtasticTransport: def _mqtt_config() -> tuple[str, int, str, str]: """Return (broker, port, user, password) from settings.""" try: - from services.config import get_settings + from services.meshtastic_mqtt_settings import mqtt_connection_config - s = get_settings() - return ( - str(s.MESH_MQTT_BROKER or "mqtt.meshtastic.org"), - int(s.MESH_MQTT_PORT or 1883), - str(s.MESH_MQTT_USER or "meshdev"), - str(s.MESH_MQTT_PASS or "large4cats"), - ) + return mqtt_connection_config() except Exception: return ("mqtt.meshtastic.org", 1883, "meshdev", "large4cats") @@ -433,8 +427,9 @@ class MeshtasticTransport: def _resolve_psk(cls) -> bytes: """Return the PSK from config, or the default LongFast key if empty.""" try: - from services.config import get_settings - raw = str(getattr(get_settings(), "MESH_MQTT_PSK", "") or "").strip() + from services.meshtastic_mqtt_settings import mqtt_psk_hex + + raw = mqtt_psk_hex() except Exception: raw = "" if not raw: @@ -449,7 +444,10 @@ class MeshtasticTransport: @staticmethod def mesh_address_for_sender(sender_id: str) -> str: - """Return the synthetic public mesh address used for MQTT-originated sends.""" + """Return the public mesh address used for MQTT-originated sends.""" + parsed = MeshtasticTransport._parse_node_id(sender_id) + if parsed is not None: + return f"!{parsed:08x}" return f"!{MeshtasticTransport._stable_node_id(sender_id):08x}" @staticmethod @@ -489,7 +487,8 @@ class MeshtasticTransport: # Generate IDs packet_id = random.randint(1, 0xFFFFFFFF) - from_node = self._stable_node_id(envelope.sender_id) + parsed_sender = self._parse_node_id(envelope.sender_id) + from_node = parsed_sender if parsed_sender is not None else self._stable_node_id(envelope.sender_id) direct_node = self._parse_node_id(envelope.destination) to_node = direct_node if direct_node is not None else 0xFFFFFFFF @@ -529,9 +528,7 @@ class MeshtasticTransport: error_msg[0] = f"MQTT connect refused: rc={rc}" client.disconnect() - client = mqtt.Client( - client_id=f"shadowbroker-tx-{envelope.message_id[:8]}", protocol=mqtt.MQTTv311 - ) + client = mqtt.Client(client_id=f"meshchat-tx-{envelope.message_id[:8]}", protocol=mqtt.MQTTv311) broker, port, user, pw = self._mqtt_config() client.username_pw_set(user, pw) client.on_connect = _on_connect diff --git a/backend/services/mesh/meshtastic_topics.py b/backend/services/mesh/meshtastic_topics.py index ac39b40..c28d8ca 100644 --- a/backend/services/mesh/meshtastic_topics.py +++ b/backend/services/mesh/meshtastic_topics.py @@ -8,6 +8,7 @@ from typing import Iterable # Default subscription roots — US-only to avoid flooding the public broker. # Users can opt into additional regions via MESH_MQTT_EXTRA_ROOTS. DEFAULT_ROOTS: tuple[str, ...] = ("US",) +DEFAULT_CHANNEL = "LongFast" # Every known official region root (for UI dropdowns / manual opt-in). ALL_OFFICIAL_ROOTS: tuple[str, ...] = ( @@ -107,6 +108,10 @@ 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 build_subscription_topics( extra_roots: str = "", extra_topics: str = "", @@ -119,7 +124,7 @@ 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 = [f"msh/{root}/#" for root in _dedupe(roots)] + topics = [_default_topic_for_root(root) for root in _dedupe(roots)] topics.extend( topic for topic in ( @@ -137,7 +142,7 @@ def known_roots(extra_roots: str = "", include_defaults: bool = True) -> list[st for topic in topics: if not topic.startswith("msh/") or not topic.endswith("/#"): continue - root = normalize_root(topic[4:-2]) + root = normalize_root(parse_topic_metadata(topic)["root"]) if root: roots.append(root) return _dedupe(roots) diff --git a/backend/services/meshtastic_mqtt_settings.py b/backend/services/meshtastic_mqtt_settings.py new file mode 100644 index 0000000..a263f92 --- /dev/null +++ b/backend/services/meshtastic_mqtt_settings.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import json +import os +import time +from pathlib import Path +from typing import Any + +from services.config import get_settings + + +PUBLIC_DEFAULT_USER = "meshdev" +PUBLIC_DEFAULT_PASS = "large4cats" +DATA_DIR = Path(os.environ.get("SB_DATA_DIR", str(Path(__file__).parent.parent / "data"))) +if not DATA_DIR.is_absolute(): + DATA_DIR = Path(__file__).parent.parent / DATA_DIR + +SETTINGS_FILE = DATA_DIR / "meshtastic_mqtt.json" +_cache: dict[str, Any] | None = None +_cache_ts: float = 0.0 +_CACHE_TTL = 2.0 + + +def _settings_defaults() -> dict[str, Any]: + try: + s = get_settings() + return { + "enabled": bool(getattr(s, "MESH_MQTT_ENABLED", False)), + "broker": str(getattr(s, "MESH_MQTT_BROKER", "") or "mqtt.meshtastic.org"), + "port": int(getattr(s, "MESH_MQTT_PORT", 1883) or 1883), + "username": str(getattr(s, "MESH_MQTT_USER", "") or PUBLIC_DEFAULT_USER), + "password": str(getattr(s, "MESH_MQTT_PASS", "") or PUBLIC_DEFAULT_PASS), + "psk": str(getattr(s, "MESH_MQTT_PSK", "") or ""), + "include_default_roots": bool(getattr(s, "MESH_MQTT_INCLUDE_DEFAULT_ROOTS", True)), + "extra_roots": str(getattr(s, "MESH_MQTT_EXTRA_ROOTS", "") or ""), + "extra_topics": str(getattr(s, "MESH_MQTT_EXTRA_TOPICS", "") or ""), + } + except Exception: + return { + "enabled": False, + "broker": "mqtt.meshtastic.org", + "port": 1883, + "username": PUBLIC_DEFAULT_USER, + "password": PUBLIC_DEFAULT_PASS, + "psk": "", + "include_default_roots": True, + "extra_roots": "", + "extra_topics": "", + } + + +def _safe_int(value: Any, default: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + if parsed < 1 or parsed > 65535: + return default + return parsed + + +def _normalize(data: dict[str, Any]) -> dict[str, Any]: + defaults = _settings_defaults() + return { + "enabled": bool(data.get("enabled", defaults["enabled"])), + "broker": str(data.get("broker", defaults["broker"]) or defaults["broker"]).strip(), + "port": _safe_int(data.get("port", defaults["port"]), defaults["port"]), + "username": str(data.get("username", defaults["username"]) or "").strip(), + "password": str(data.get("password", defaults["password"]) or ""), + "psk": str(data.get("psk", defaults["psk"]) or "").strip(), + "include_default_roots": bool(data.get("include_default_roots", defaults["include_default_roots"])), + "extra_roots": str(data.get("extra_roots", defaults["extra_roots"]) or "").strip(), + "extra_topics": str(data.get("extra_topics", defaults["extra_topics"]) or "").strip(), + "updated_at": _safe_int(data.get("updated_at", 0), 0), + } + + +def read_meshtastic_mqtt_settings() -> dict[str, Any]: + global _cache, _cache_ts + now = time.monotonic() + if _cache is not None and (now - _cache_ts) < _CACHE_TTL: + return dict(_cache) + if not SETTINGS_FILE.exists(): + result = {**_settings_defaults(), "updated_at": 0} + else: + try: + loaded = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) + except Exception: + loaded = {} + result = _normalize(loaded if isinstance(loaded, dict) else {}) + _cache = result + _cache_ts = now + return dict(result) + + +def write_meshtastic_mqtt_settings(**updates: Any) -> dict[str, Any]: + DATA_DIR.mkdir(parents=True, exist_ok=True) + existing = read_meshtastic_mqtt_settings() + next_data = dict(existing) + for key in ( + "enabled", + "broker", + "port", + "username", + "password", + "psk", + "include_default_roots", + "extra_roots", + "extra_topics", + ): + if key in updates and updates[key] is not None: + next_data[key] = updates[key] + if "username" in updates and not str(updates.get("username") or "").strip() and "password" not in updates: + next_data["password"] = PUBLIC_DEFAULT_PASS + next_data["updated_at"] = int(time.time()) + normalized = _normalize(next_data) + SETTINGS_FILE.write_text(json.dumps(normalized, indent=2), encoding="utf-8") + if os.name != "nt": + os.chmod(SETTINGS_FILE, 0o600) + global _cache, _cache_ts + _cache = normalized + _cache_ts = time.monotonic() + return dict(normalized) + + +def redacted_meshtastic_mqtt_settings(data: dict[str, Any] | None = None) -> dict[str, Any]: + source = read_meshtastic_mqtt_settings() if data is None else dict(data) + username = str(source.get("username", "") or "") + uses_default_credentials = username in ("", PUBLIC_DEFAULT_USER) and str(source.get("password", "") or "") in ( + "", + PUBLIC_DEFAULT_PASS, + ) + return { + "enabled": bool(source.get("enabled")), + "broker": str(source.get("broker", "")), + "port": int(source.get("port", 1883) or 1883), + "username": "" if uses_default_credentials else username, + "uses_default_credentials": uses_default_credentials, + "has_password": bool(str(source.get("password", "") or "")), + "has_psk": bool(str(source.get("psk", "") or "")), + "include_default_roots": bool(source.get("include_default_roots", True)), + "extra_roots": str(source.get("extra_roots", "") or ""), + "extra_topics": str(source.get("extra_topics", "") or ""), + "updated_at": int(source.get("updated_at", 0) or 0), + } + + +def mqtt_connection_config() -> tuple[str, int, str, str]: + data = read_meshtastic_mqtt_settings() + return ( + str(data.get("broker") or "mqtt.meshtastic.org"), + int(data.get("port") or 1883), + str(data.get("username") or PUBLIC_DEFAULT_USER), + str(data.get("password") or PUBLIC_DEFAULT_PASS), + ) + + +def mqtt_bridge_enabled() -> bool: + return bool(read_meshtastic_mqtt_settings().get("enabled")) + + +def mqtt_psk_hex() -> str: + return str(read_meshtastic_mqtt_settings().get("psk", "") or "").strip() + + +def mqtt_subscription_settings() -> tuple[str, str, bool]: + data = read_meshtastic_mqtt_settings() + return ( + str(data.get("extra_roots", "") or ""), + str(data.get("extra_topics", "") or ""), + bool(data.get("include_default_roots", True)), + ) diff --git a/backend/services/network_utils.py b/backend/services/network_utils.py index 405dd6a..107de93 100644 --- a/backend/services/network_utils.py +++ b/backend/services/network_utils.py @@ -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.7 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com)", + "User-Agent": "ShadowBroker-OSINT/0.9.75 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com)", } if headers: default_headers.update(headers) diff --git a/backend/services/node_settings.py b/backend/services/node_settings.py index 1af2431..1448adf 100644 --- a/backend/services/node_settings.py +++ b/backend/services/node_settings.py @@ -10,7 +10,8 @@ _cache: dict | None = None _cache_ts: float = 0.0 _CACHE_TTL = 5.0 _DEFAULTS = { - "enabled": False, + "enabled": True, + "operator_disabled": False, "timemachine_enabled": False, } @@ -35,8 +36,16 @@ def read_node_settings() -> dict: except Exception: result = {**_DEFAULTS, "updated_at": 0} else: + operator_disabled = bool(data.get("operator_disabled", False)) + raw_enabled = data.get("enabled", _DEFAULTS["enabled"]) + # v0.9.7 initially wrote enabled:false as a default/offline state, + # which accidentally blocked InfoNet participation. Treat legacy + # false-without-marker as auto-enabled; only an explicit operator + # disable should keep the participant sync loop off. + enabled = False if operator_disabled else bool(raw_enabled or "operator_disabled" not in data) result = { - "enabled": bool(data.get("enabled", _DEFAULTS["enabled"])), + "enabled": enabled, + "operator_disabled": operator_disabled, "timemachine_enabled": bool(data.get("timemachine_enabled", _DEFAULTS["timemachine_enabled"])), "updated_at": _safe_int(data.get("updated_at", 0) or 0), } @@ -48,8 +57,10 @@ def read_node_settings() -> dict: def write_node_settings(*, enabled: bool | None = None, timemachine_enabled: bool | None = None) -> dict: DATA_DIR.mkdir(parents=True, exist_ok=True) existing = read_node_settings() + next_enabled = bool(existing.get("enabled", _DEFAULTS["enabled"])) if enabled is None else bool(enabled) payload = { - "enabled": bool(existing.get("enabled", _DEFAULTS["enabled"])) if enabled is None else bool(enabled), + "enabled": next_enabled, + "operator_disabled": bool(existing.get("operator_disabled", _DEFAULTS["operator_disabled"])) if enabled is None else not next_enabled, "timemachine_enabled": bool(existing.get("timemachine_enabled", _DEFAULTS["timemachine_enabled"])) if timemachine_enabled is None else bool(timemachine_enabled), "updated_at": int(time.time()), } diff --git a/backend/services/shodan_connector.py b/backend/services/shodan_connector.py index 70d4e20..42dfcbd 100644 --- a/backend/services/shodan_connector.py +++ b/backend/services/shodan_connector.py @@ -20,7 +20,7 @@ from cachetools import TTLCache logger = logging.getLogger(__name__) _SHODAN_BASE = "https://api.shodan.io" -_USER_AGENT = "ShadowBroker/0.9.7 local Shodan connector" +_USER_AGENT = "ShadowBroker/0.9.75 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 diff --git a/backend/services/sigint_bridge.py b/backend/services/sigint_bridge.py index 7b354b1..f210856 100644 --- a/backend/services/sigint_bridge.py +++ b/backend/services/sigint_bridge.py @@ -22,6 +22,12 @@ from collections import deque from datetime import datetime, timezone from services.config import get_settings +from services.meshtastic_mqtt_settings import ( + mqtt_bridge_enabled, + mqtt_connection_config, + mqtt_psk_hex, + mqtt_subscription_settings, +) from services.mesh.meshtastic_topics import all_available_roots, build_subscription_topics, known_roots, parse_topic_metadata logger = logging.getLogger("services.sigint") @@ -477,22 +483,13 @@ class MeshtasticBridge: @staticmethod def _mqtt_config() -> tuple[str, int, str, str]: """Return (broker, port, user, password) from settings.""" - try: - s = get_settings() - return ( - str(s.MESH_MQTT_BROKER or "mqtt.meshtastic.org"), - int(s.MESH_MQTT_PORT or 1883), - str(s.MESH_MQTT_USER or "meshdev"), - str(s.MESH_MQTT_PASS or "large4cats"), - ) - except Exception: - return ("mqtt.meshtastic.org", 1883, "meshdev", "large4cats") + return mqtt_connection_config() @classmethod def _resolve_psk(cls) -> bytes: """Return the PSK from config, or the default LongFast key if empty.""" try: - raw = str(getattr(get_settings(), "MESH_MQTT_PSK", "") or "").strip() + raw = mqtt_psk_hex() except Exception: raw = "" if not raw: @@ -506,6 +503,11 @@ class MeshtasticBridge: self._thread: threading.Thread | None = None self._stop = threading.Event() self._client_id = self._build_client_id() + self._connected = False + self._last_error = "" + self._last_connected_at = 0.0 + self._last_disconnected_at = 0.0 + self._last_broker = "" # Rate-limiter: sliding window of receive timestamps self._rx_timestamps: deque[float] = deque() self._rx_dropped = 0 @@ -518,10 +520,11 @@ class MeshtasticBridge: second client connects with the same id. Using a fixed id made separate ShadowBroker instances kick each other off the broker. - Includes the app version so the Meshtastic team can track our footprint. + This is deliberately not tied to the user's public mesh address or + ShadowBroker node identity; it is only an MQTT session handle. """ suffix = uuid.uuid4().hex[:8] - return f"sb096-{suffix}" + return f"meshchat-{suffix}" def _dedupe_message( self, @@ -544,7 +547,12 @@ class MeshtasticBridge: def start(self): if self._thread and self._thread.is_alive(): - return + if not self._stop.is_set(): + return + self._thread.join(timeout=2.0) + if self._thread.is_alive(): + logger.warning("Meshtastic MQTT bridge is still stopping; start deferred") + return self._stop.clear() self._thread = threading.Thread(target=self._run, daemon=True, name="mesh-bridge") self._thread.start() @@ -552,13 +560,37 @@ class MeshtasticBridge: def stop(self): self._stop.set() + self._connected = False + + def is_running(self) -> bool: + return bool(self._thread and self._thread.is_alive() and not self._stop.is_set()) + + def status(self) -> dict: + broker, port, user, _pw = self._mqtt_config() + display_user = "" if user == "meshdev" else user + return { + "enabled": mqtt_bridge_enabled(), + "running": self.is_running(), + "connected": bool(self._connected), + "broker": broker, + "port": port, + "username": display_user, + "client_id": self._client_id, + "message_log_size": len(self.messages), + "signal_log_size": len(self.signals), + "last_error": self._last_error, + "last_broker": self._last_broker, + "last_connected_at": self._last_connected_at, + "last_disconnected_at": self._last_disconnected_at, + "rx_dropped": self._rx_dropped, + } def _subscription_topics(self) -> list[str]: - settings = get_settings() + extra_roots, extra_topics, include_defaults = mqtt_subscription_settings() return build_subscription_topics( - extra_roots=str(getattr(settings, "MESH_MQTT_EXTRA_ROOTS", "") or ""), - extra_topics=str(getattr(settings, "MESH_MQTT_EXTRA_TOPICS", "") or ""), - include_defaults=bool(getattr(settings, "MESH_MQTT_INCLUDE_DEFAULT_ROOTS", True)), + extra_roots=extra_roots, + extra_topics=extra_topics, + include_defaults=include_defaults, ) def _run(self): @@ -582,6 +614,9 @@ class MeshtasticBridge: def _on_connect(client, userdata, flags, rc): if rc == 0: + self._connected = True + self._last_error = "" + self._last_connected_at = time.time() logger.info( "Meshtastic MQTT connected (%s), subscribing to %s", self._client_id, @@ -590,6 +625,8 @@ class MeshtasticBridge: for topic in topics: client.subscribe(topic, qos=0) else: + self._connected = False + self._last_error = f"connect_refused:{rc}" logger.error( "Meshtastic MQTT connection refused (%s): rc=%s", self._client_id, @@ -597,7 +634,10 @@ class MeshtasticBridge: ) def _on_disconnect(client, userdata, rc): + self._connected = False + self._last_disconnected_at = time.time() if rc != 0: + self._last_error = f"disconnect:{rc}" logger.warning( "Meshtastic MQTT disconnected unexpectedly (%s, rc=%s), will auto-reconnect", self._client_id, @@ -607,6 +647,7 @@ class MeshtasticBridge: logger.info("Meshtastic MQTT disconnected cleanly (%s)", self._client_id) broker, port, user, pw = self._mqtt_config() + self._last_broker = f"{broker}:{port}" client = mqtt.Client(client_id=self._client_id, protocol=mqtt.MQTTv311) client.username_pw_set(user, pw) client.on_connect = _on_connect @@ -645,9 +686,6 @@ class MeshtasticBridge: def _on_message(self, client, userdata, msg): """Parse Meshtastic MQTT messages — protobuf + AES decryption.""" try: - if self._rate_limited(): - return - payload = msg.payload topic = msg.topic @@ -655,6 +693,8 @@ class MeshtasticBridge: if "/json/" in topic: try: data = json.loads(payload) + if self._rate_limited(): + return self._ingest_data(data, topic) return except (json.JSONDecodeError, UnicodeDecodeError): @@ -687,6 +727,8 @@ class MeshtasticBridge: } ) else: + if self._rate_limited(): + return self._ingest_data(data, topic) except Exception as e: @@ -1011,7 +1053,7 @@ class SIGINTGrid: self._started = True self.aprs.start() try: - mqtt_enabled = bool(getattr(get_settings(), "MESH_MQTT_ENABLED", False)) + mqtt_enabled = mqtt_bridge_enabled() except Exception: mqtt_enabled = False if mqtt_enabled: @@ -1123,13 +1165,12 @@ class SIGINTGrid: ch = msg.get("channel", "LongFast") channel_msgs[ch] = channel_msgs.get(ch, 0) + 1 + extra_roots, _extra_topics, include_defaults = mqtt_subscription_settings() + return { "regions": regions, "roots": roots, - "known_roots": known_roots( - str(getattr(get_settings(), "MESH_MQTT_EXTRA_ROOTS", "") or ""), - include_defaults=bool(getattr(get_settings(), "MESH_MQTT_INCLUDE_DEFAULT_ROOTS", True)), - ), + "known_roots": known_roots(extra_roots, include_defaults=include_defaults), "all_roots": all_available_roots(), "channel_messages": channel_msgs, "total_nodes": len(seen_callsigns), diff --git a/backend/services/tor_hidden_service.py b/backend/services/tor_hidden_service.py index 728cf37..784c22c 100644 --- a/backend/services/tor_hidden_service.py +++ b/backend/services/tor_hidden_service.py @@ -1,13 +1,9 @@ -"""Tor Hidden Service auto-provisioner. +"""Tor hidden-service auto-provisioner. Manages a Tor hidden service that points to the local ShadowBroker backend. -Tor is started as a subprocess with a generated torrc — no manual config needed. -Auto-installs the Tor Expert Bundle on Windows if not present. - -Usage: - from services.tor_hidden_service import tor_service - status = tor_service.start() # -> {"ok": True, "onion_address": "http://xxxx.onion:8000"} - tor_service.stop() +Tor is started as a subprocess with a generated torrc. Windows source installs +can download the Tor Expert Bundle into backend/data without admin rights. +Docker images should already include the `tor` package. """ from __future__ import annotations @@ -31,31 +27,33 @@ HOSTNAME_PATH = TOR_DIR / "hidden_service" / "hostname" TOR_DATA_DIR = TOR_DIR / "data" PIDFILE_PATH = TOR_DIR / "tor.pid" -# Bundled Tor install location (inside our data dir so no admin rights needed) +# Bundled Tor install location (inside data dir so no admin rights are needed). TOR_INSTALL_DIR = TOR_DIR / "tor_bin" -# How long to wait for Tor to generate the hostname file _STARTUP_TIMEOUT_S = 90 _POLL_INTERVAL_S = 1.0 -# Tor Expert Bundle download URL (Windows x86_64) -_TOR_EXPERT_BUNDLE_URL = "https://dist.torproject.org/torbrowser/15.0.8/tor-expert-bundle-windows-x86_64-15.0.8.tar.gz" +# Windows x86_64 Tor Expert Bundle URLs. Keep a fallback so first-run +# onboarding does not break when Tor rotates point releases. +_TOR_EXPERT_BUNDLE_URLS = [ + "https://dist.torproject.org/torbrowser/15.0.11/tor-expert-bundle-windows-x86_64-15.0.11.tar.gz", + "https://dist.torproject.org/torbrowser/15.0.8/tor-expert-bundle-windows-x86_64-15.0.8.tar.gz", +] def _find_tor_binary() -> str | None: """Locate the tor binary on the system, including our bundled install.""" - # Check our bundled install first bundled = TOR_INSTALL_DIR / "tor" / "tor.exe" if bundled.exists(): return str(bundled) - # Also check for extracted layout variants + for sub in TOR_INSTALL_DIR.rglob("tor.exe"): return str(sub) tor = shutil.which("tor") if tor: return tor - # Common locations on Windows + for candidate in [ r"C:\Program Files\Tor Browser\Browser\TorBrowser\Tor\tor.exe", r"C:\Program Files (x86)\Tor Browser\Browser\TorBrowser\Tor\tor.exe", @@ -67,77 +65,65 @@ def _find_tor_binary() -> str | None: def _auto_install_tor() -> str | None: - """Download and extract the Tor Expert Bundle. Returns path to tor binary or None.""" + """Install or download Tor when it is safe to do so.""" if os.name != "nt": - # On Linux/Mac, try package manager - try: - if shutil.which("apt-get"): - subprocess.run(["sudo", "apt-get", "install", "-y", "tor"], check=True, capture_output=True, timeout=120) - elif shutil.which("brew"): - subprocess.run(["brew", "install", "tor"], check=True, capture_output=True, timeout=120) - elif shutil.which("pacman"): - subprocess.run(["sudo", "pacman", "-S", "--noconfirm", "tor"], check=True, capture_output=True, timeout=120) - else: - logger.warning("No supported package manager found for auto-install") - return None - return shutil.which("tor") - except Exception as exc: - logger.error("Failed to auto-install Tor via package manager: %s", exc) - return None + # In Docker this should already be baked into the image. For source + # installs we avoid unattended sudo prompts from a web request path. + logger.warning("Tor is not installed. Install the tor package or use the Docker image with Tor baked in.") + return None - # Windows: download Tor Expert Bundle (no admin needed) TOR_INSTALL_DIR.mkdir(parents=True, exist_ok=True) - archive_path = TOR_INSTALL_DIR / "tor-expert-bundle.tar.gz" - - try: - logger.info("Downloading Tor Expert Bundle over HTTPS from dist.torproject.org...") - urlretrieve(_TOR_EXPERT_BUNDLE_URL, str(archive_path)) - - # Verify SHA-256 of the downloaded archive - sha256_url = _TOR_EXPERT_BUNDLE_URL + ".sha256sum" - sha256_file = TOR_INSTALL_DIR / "sha256sum.txt" + for bundle_url in _TOR_EXPERT_BUNDLE_URLS: + archive_path = TOR_INSTALL_DIR / "tor-expert-bundle.tar.gz" try: - urlretrieve(sha256_url, str(sha256_file)) - expected_hash = sha256_file.read_text().strip().split()[0].lower() - import hashlib - actual_hash = hashlib.sha256(archive_path.read_bytes()).hexdigest().lower() - sha256_file.unlink(missing_ok=True) - if actual_hash != expected_hash: - logger.error("SHA-256 MISMATCH — download may be compromised! Expected %s, got %s", expected_hash, actual_hash) - archive_path.unlink(missing_ok=True) - return None - logger.info("SHA-256 verified: %s", actual_hash[:16] + "...") - except Exception as hash_err: - # If we can't fetch the hash file, warn but proceed (HTTPS provides baseline integrity) - logger.warning("Could not verify SHA-256 (hash file unavailable): %s — proceeding with HTTPS-only verification", hash_err) + logger.info("Downloading Tor Expert Bundle over HTTPS from %s...", bundle_url) + urlretrieve(bundle_url, str(archive_path)) - logger.info("Download complete, extracting...") + sha256_url = bundle_url + ".sha256sum" + sha256_file = TOR_INSTALL_DIR / "sha256sum.txt" + try: + urlretrieve(sha256_url, str(sha256_file)) + expected_hash = sha256_file.read_text().strip().split()[0].lower() + import hashlib - # Extract .tar.gz with path traversal protection - import tarfile - with tarfile.open(str(archive_path), "r:gz") as tar: - for member in tar.getmembers(): - member_path = (TOR_INSTALL_DIR / member.name).resolve() - if not str(member_path).startswith(str(TOR_INSTALL_DIR.resolve())): - logger.error("Tar path traversal blocked: %s", member.name) + actual_hash = hashlib.sha256(archive_path.read_bytes()).hexdigest().lower() + sha256_file.unlink(missing_ok=True) + if actual_hash != expected_hash: + logger.error("SHA-256 mismatch for Tor download. Expected %s, got %s", expected_hash, actual_hash) archive_path.unlink(missing_ok=True) - return None - tar.extractall(path=str(TOR_INSTALL_DIR)) + continue + logger.info("SHA-256 verified: %s", actual_hash[:16] + "...") + except Exception as hash_err: + logger.warning( + "Could not verify SHA-256 (hash file unavailable): %s; proceeding with HTTPS-only verification", + hash_err, + ) - # Clean up archive - archive_path.unlink(missing_ok=True) + logger.info("Download complete, extracting...") + import tarfile - # Find the tor.exe in extracted files - for p in TOR_INSTALL_DIR.rglob("tor.exe"): - logger.info("Tor installed at: %s", p) - return str(p) + with tarfile.open(str(archive_path), "r:gz") as tar: + for member in tar.getmembers(): + member_path = (TOR_INSTALL_DIR / member.name).resolve() + if not str(member_path).startswith(str(TOR_INSTALL_DIR.resolve())): + logger.error("Tar path traversal blocked: %s", member.name) + archive_path.unlink(missing_ok=True) + return None + tar.extractall(path=str(TOR_INSTALL_DIR)) - logger.error("tor.exe not found after extraction") - return None - except Exception as exc: - logger.error("Failed to download/extract Tor: %s", exc) - archive_path.unlink(missing_ok=True) - return None + archive_path.unlink(missing_ok=True) + + for p in TOR_INSTALL_DIR.rglob("tor.exe"): + logger.info("Tor installed at: %s", p) + return str(p) + + logger.error("tor.exe not found after extracting %s", bundle_url) + except Exception as exc: + logger.error("Failed to download/extract Tor from %s: %s", bundle_url, exc) + finally: + archive_path.unlink(missing_ok=True) + + return None class TorHiddenService: @@ -150,7 +136,6 @@ class TorHiddenService: self._running = False self._error: str = "" - # Check if we already have a hostname from a previous run if HOSTNAME_PATH.exists(): try: hostname = HOSTNAME_PATH.read_text().strip() @@ -198,19 +183,20 @@ class TorHiddenService: self._error = "" tor_bin = _find_tor_binary() if not tor_bin: - logger.info("Tor not found, attempting auto-install...") + logger.info("Tor not found, attempting bootstrap...") tor_bin = _auto_install_tor() if not tor_bin: - self._error = "Failed to auto-install Tor. Please install it manually." + self._error = ( + "Could not prepare Tor automatically. Check network access to dist.torproject.org " + "or install Tor, then try again." + ) return {"ok": False, "detail": self._error} - # Create directories TOR_DIR.mkdir(parents=True, exist_ok=True) TOR_DATA_DIR.mkdir(parents=True, exist_ok=True) hidden_service_dir = TOR_DIR / "hidden_service" hidden_service_dir.mkdir(parents=True, exist_ok=True) - # On non-Windows, Tor requires strict permissions on HiddenServiceDir if os.name != "nt": try: os.chmod(str(hidden_service_dir), 0o700) @@ -218,19 +204,15 @@ class TorHiddenService: except OSError: pass - # Write torrc — enables both hidden service (inbound) and SOCKS proxy - # (outbound) so the mesh/wormhole system can route node-to-node - # traffic through Tor as well. torrc_content = ( f"DataDirectory {TOR_DATA_DIR.as_posix()}\n" f"HiddenServiceDir {hidden_service_dir.as_posix()}\n" f"HiddenServicePort {target_port} 127.0.0.1:{target_port}\n" - f"SocksPort 9050\n" - f"Log notice stderr\n" + "SocksPort 9050\n" + "Log notice stderr\n" ) TORRC_PATH.write_text(torrc_content, encoding="utf-8") - # Start Tor try: self._process = subprocess.Popen( [tor_bin, "-f", str(TORRC_PATH)], @@ -245,15 +227,12 @@ class TorHiddenService: logger.error(self._error) return {"ok": False, "detail": self._error} - # Wait for hostname file to appear deadline = time.monotonic() + _STARTUP_TIMEOUT_S while time.monotonic() < deadline: if self._process.poll() is not None: - # Tor exited prematurely stdout = self._process.stdout.read() if self._process.stdout else "" self._error = f"Tor exited with code {self._process.returncode}" if stdout: - # Get last few lines for error context lines = stdout.strip().split("\n") self._error += ": " + " | ".join(lines[-3:]) self._running = False @@ -273,7 +252,6 @@ class TorHiddenService: time.sleep(_POLL_INTERVAL_S) - # Timeout self._error = f"Tor did not generate hostname within {_STARTUP_TIMEOUT_S}s" self.stop() return {"ok": False, "detail": self._error} @@ -292,10 +270,7 @@ class TorHiddenService: pass self._process = None self._running = False - # Keep the onion_address — it persists across restarts - # since the key is stored in hidden_service_dir return {"ok": True, "detail": "stopped"} -# Singleton tor_service = TorHiddenService() diff --git a/backend/services/unusual_whales_connector.py b/backend/services/unusual_whales_connector.py index 9583f12..5e9429d 100644 --- a/backend/services/unusual_whales_connector.py +++ b/backend/services/unusual_whales_connector.py @@ -24,7 +24,7 @@ from cachetools import TTLCache logger = logging.getLogger(__name__) _FINNHUB_BASE = "https://finnhub.io/api/v1" -_USER_AGENT = "ShadowBroker/0.9.7 Finnhub connector" +_USER_AGENT = "ShadowBroker/0.9.75 Finnhub connector" _REQUEST_TIMEOUT = 12 _MIN_INTERVAL_SECONDS = 0.35 # Stay well under 60 calls/min diff --git a/backend/tests/mesh/test_meshtastic_mqtt_settings.py b/backend/tests/mesh/test_meshtastic_mqtt_settings.py new file mode 100644 index 0000000..fa669b2 --- /dev/null +++ b/backend/tests/mesh/test_meshtastic_mqtt_settings.py @@ -0,0 +1,54 @@ +import importlib + + +def test_meshtastic_mqtt_settings_redacts_secrets(tmp_path, monkeypatch): + monkeypatch.setenv("SB_DATA_DIR", str(tmp_path)) + + from services import meshtastic_mqtt_settings + + settings = importlib.reload(meshtastic_mqtt_settings) + saved = settings.write_meshtastic_mqtt_settings( + enabled=True, + broker="mqtt.example.test", + port=1884, + username="mesh-user", + password="mesh-pass", + psk="001122", + include_default_roots=False, + extra_roots="EU,US", + ) + redacted = settings.redacted_meshtastic_mqtt_settings(saved) + + assert saved["password"] == "mesh-pass" + assert saved["psk"] == "001122" + assert redacted["enabled"] is True + assert redacted["broker"] == "mqtt.example.test" + assert redacted["port"] == 1884 + assert redacted["username"] == "mesh-user" + assert redacted["has_password"] is True + assert redacted["has_psk"] is True + assert "password" not in redacted + assert "psk" not in redacted + assert settings.mqtt_connection_config() == ("mqtt.example.test", 1884, "mesh-user", "mesh-pass") + assert settings.mqtt_bridge_enabled() is True + assert settings.mqtt_psk_hex() == "001122" + assert settings.mqtt_subscription_settings() == ("EU,US", "", False) + + +def test_meshtastic_mqtt_settings_hide_public_defaults(tmp_path, monkeypatch): + monkeypatch.setenv("SB_DATA_DIR", str(tmp_path)) + + from services import meshtastic_mqtt_settings + + settings = importlib.reload(meshtastic_mqtt_settings) + saved = settings.write_meshtastic_mqtt_settings( + enabled=True, + broker="mqtt.meshtastic.org", + username="", + password="", + ) + redacted = settings.redacted_meshtastic_mqtt_settings(saved) + + assert redacted["username"] == "" + assert redacted["uses_default_credentials"] is True + assert settings.mqtt_connection_config() == ("mqtt.meshtastic.org", 1883, "meshdev", "large4cats") diff --git a/backend/tests/mesh/test_meshtastic_topics.py b/backend/tests/mesh/test_meshtastic_topics.py new file mode 100644 index 0000000..58ad849 --- /dev/null +++ b/backend/tests/mesh/test_meshtastic_topics.py @@ -0,0 +1,21 @@ +from services.mesh.meshtastic_topics import build_subscription_topics, known_roots, parse_topic_metadata + + +def test_default_subscription_is_longfast_only(): + assert build_subscription_topics() == ["msh/US/2/e/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/EU_868/2/e/LongFast/#", + "msh/ANZ/2/e/LongFast/#", + ] + + +def test_parse_longfast_topic_root(): + meta = parse_topic_metadata("msh/US/2/e/LongFast/!12345678") + assert meta["region"] == "US" + assert meta["root"] == "US" + assert meta["channel"] == "LongFast" diff --git a/backend/tests/mesh/test_node_settings.py b/backend/tests/mesh/test_node_settings.py index 13acfb5..959907e 100644 --- a/backend/tests/mesh/test_node_settings.py +++ b/backend/tests/mesh/test_node_settings.py @@ -7,9 +7,44 @@ def test_node_settings_roundtrip(tmp_path, monkeypatch): monkeypatch.setattr(node_settings, "_cache_ts", 0.0) initial = node_settings.read_node_settings() + disabled = node_settings.write_node_settings(enabled=False) updated = node_settings.write_node_settings(enabled=True) reread = node_settings.read_node_settings() - assert initial["enabled"] is False + assert initial["enabled"] is True + assert initial["operator_disabled"] is False + assert disabled["enabled"] is False + assert disabled["operator_disabled"] is True assert updated["enabled"] is True + assert updated["operator_disabled"] is False assert reread["enabled"] is True + + +def test_legacy_disabled_node_settings_auto_enable(tmp_path, monkeypatch): + from services import node_settings + + settings_path = tmp_path / "node.json" + settings_path.write_text('{"enabled": false, "updated_at": 123}', encoding="utf-8") + monkeypatch.setattr(node_settings, "NODE_FILE", settings_path) + monkeypatch.setattr(node_settings, "_cache", None) + monkeypatch.setattr(node_settings, "_cache_ts", 0.0) + + reread = node_settings.read_node_settings() + + assert reread["enabled"] is True + assert reread["operator_disabled"] is False + + +def test_explicit_operator_disabled_stays_disabled(tmp_path, monkeypatch): + from services import node_settings + + settings_path = tmp_path / "node.json" + settings_path.write_text('{"enabled": false, "operator_disabled": true, "updated_at": 123}', encoding="utf-8") + monkeypatch.setattr(node_settings, "NODE_FILE", settings_path) + monkeypatch.setattr(node_settings, "_cache", None) + monkeypatch.setattr(node_settings, "_cache_ts", 0.0) + + reread = node_settings.read_node_settings() + + assert reread["enabled"] is False + assert reread["operator_disabled"] is True diff --git a/desktop-shell/package-lock.json b/desktop-shell/package-lock.json index e4792e5..ee898a8 100644 --- a/desktop-shell/package-lock.json +++ b/desktop-shell/package-lock.json @@ -1,12 +1,12 @@ { "name": "@shadowbroker/desktop-shell", - "version": "0.9.7", + "version": "0.9.75", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@shadowbroker/desktop-shell", - "version": "0.9.7", + "version": "0.9.75", "devDependencies": { "typescript": "^5.6.0" } diff --git a/desktop-shell/package.json b/desktop-shell/package.json index 866a86a..51b7ccc 100644 --- a/desktop-shell/package.json +++ b/desktop-shell/package.json @@ -1,6 +1,6 @@ { "name": "@shadowbroker/desktop-shell", - "version": "0.9.7", + "version": "0.9.75", "private": true, "description": "ShadowBroker desktop shell packaging, runtime bridge, and release tooling", "scripts": { diff --git a/desktop-shell/tauri-skeleton/src-tauri/Cargo.lock b/desktop-shell/tauri-skeleton/src-tauri/Cargo.lock index 21d59fb..3214a04 100644 --- a/desktop-shell/tauri-skeleton/src-tauri/Cargo.lock +++ b/desktop-shell/tauri-skeleton/src-tauri/Cargo.lock @@ -4201,7 +4201,7 @@ dependencies = [ [[package]] name = "shadowbroker-tauri-shell" -version = "0.9.7" +version = "0.9.75" dependencies = [ "axum", "base64 0.22.1", diff --git a/desktop-shell/tauri-skeleton/src-tauri/Cargo.toml b/desktop-shell/tauri-skeleton/src-tauri/Cargo.toml index b5e6917..88d6e85 100644 --- a/desktop-shell/tauri-skeleton/src-tauri/Cargo.toml +++ b/desktop-shell/tauri-skeleton/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shadowbroker-tauri-shell" -version = "0.9.7" +version = "0.9.75" edition = "2021" [build-dependencies] diff --git a/desktop-shell/tauri-skeleton/src-tauri/tauri.conf.json b/desktop-shell/tauri-skeleton/src-tauri/tauri.conf.json index b0e5477..36a4b71 100644 --- a/desktop-shell/tauri-skeleton/src-tauri/tauri.conf.json +++ b/desktop-shell/tauri-skeleton/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ShadowBroker", - "version": "0.9.7", + "version": "0.9.75", "identifier": "com.shadowbroker.desktop", "build": { "frontendDist": "../../../frontend/out", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c2e8276..f1afd1b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.9.7", + "version": "0.9.75", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.9.7", + "version": "0.9.75", "dependencies": { "@mapbox/point-geometry": "^1.1.0", "@tauri-apps/plugin-process": "^2.3.1", diff --git a/frontend/package.json b/frontend/package.json index 402c9d5..5b27709 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.9.7", + "version": "0.9.75", "private": true, "scripts": { "dev": "node scripts/dev-all.cjs", diff --git a/frontend/src/__tests__/desktop/updateRuntime.test.ts b/frontend/src/__tests__/desktop/updateRuntime.test.ts index 48b822f..8d5dfbb 100644 --- a/frontend/src/__tests__/desktop/updateRuntime.test.ts +++ b/frontend/src/__tests__/desktop/updateRuntime.test.ts @@ -9,12 +9,12 @@ import { } from '@/lib/updateRuntime'; const RELEASE: GitHubLatestRelease = { - html_url: 'https://github.com/BigBodyCobain/Shadowbroker/releases/tag/v0.9.7', + html_url: 'https://github.com/BigBodyCobain/Shadowbroker/releases/tag/v0.9.75', assets: [ - { name: 'ShadowBroker_0.9.7_x64_en-US.msi', browser_download_url: 'https://example.test/windows.msi' }, - { name: 'ShadowBroker_0.9.7_x64-setup.exe', browser_download_url: 'https://example.test/windows-setup.exe' }, - { name: 'ShadowBroker_0.9.7_aarch64.dmg', browser_download_url: 'https://example.test/macos.dmg' }, - { name: 'ShadowBroker_0.9.7_amd64.AppImage', browser_download_url: 'https://example.test/linux.AppImage' }, + { name: 'ShadowBroker_0.9.75_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.75_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' }, ], }; diff --git a/frontend/src/app/api/[...path]/route.ts b/frontend/src/app/api/[...path]/route.ts index 4d84290..074f99a 100644 --- a/frontend/src/app/api/[...path]/route.ts +++ b/frontend/src/app/api/[...path]/route.ts @@ -66,6 +66,7 @@ function isSensitiveProxyPath(pathSegments: string[]): boolean { if (joined === 'system/update') return true; if (pathSegments[0] === 'settings') return true; if (joined === 'mesh/infonet/ingest') return true; + if (joined === 'mesh/meshtastic/send') return true; // mesh/peers and all tools/* use require_local_operator on the backend and // need X-Admin-Key injected on the server-side proxy leg. if (pathSegments[0] === 'mesh' && pathSegments[1] === 'peers') return true; diff --git a/frontend/src/components/ChangelogModal.tsx b/frontend/src/components/ChangelogModal.tsx index 2b9eb5f..bbbfba5 100644 --- a/frontend/src/components/ChangelogModal.tsx +++ b/frontend/src/components/ChangelogModal.tsx @@ -20,11 +20,23 @@ import { Heart, } from 'lucide-react'; -const CURRENT_VERSION = '0.9.7'; +const CURRENT_VERSION = '0.9.75'; const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`; -const RELEASE_TITLE = 'Agentic AI Channel + InfoNet Decentralized Intelligence'; +const RELEASE_TITLE = 'Onboarding, Live Feeds, Mesh, and Agent Hardening'; const HEADLINE_FEATURES = [ + { + icon: , + accent: 'purple' as const, + title: 'Agentic onboarding for OpenClaw-compatible agents', + subtitle: 'First-time setup now includes local/direct agent connection, access-tier selection, copyable HMAC setup, and optional Tor hidden-service prep.', + details: [ + 'The onboarding flow can generate the local agent connection bundle through the existing HMAC API, point agents at /api/ai/tools, and let operators choose restricted read-only or full write access before connecting an agent.', + 'Remote mode is labeled honestly: .onion exposes the signed HTTP agent API over Tor. Wormhole/MLS is not claimed as the current agent command transport.', + 'The setup copy works for OpenClaw, Hermes, or any custom agent that implements the documented HMAC request contract.', + ], + callToAction: 'OPEN FIRST-TIME SETUP -> AI AGENT', + }, { icon: , accent: 'purple' as const, @@ -53,6 +65,26 @@ const HEADLINE_FEATURES = [ ]; const NEW_FEATURES = [ + { + icon: , + title: 'Startup and Feed Responsiveness Pass', + desc: 'Map-critical feeds now lean on startup caches and priority preload behavior so the dashboard can paint before heavyweight synthesis jobs finish.', + }, + { + icon: , + title: 'MeshChat MQTT Settings', + desc: 'Public MeshChat stays opt-in and now has an in-panel settings lane for broker, port, username, password, and channel PSK while remaining separated from Wormhole/private mode.', + }, + { + icon: , + title: 'Selected Entity Trails', + desc: 'Flight and vessel trails are drawn only for selected assets, reducing global clutter while still exposing movement history for unknown-route entities.', + }, + { + icon: , + title: 'Aircraft Detail Cards', + desc: 'Commercial aircraft stay airline-first, while private and general aviation aircraft can show model-focused Wiki context and imagery when available.', + }, { icon: , title: 'AI Batch Command Channel', @@ -101,6 +133,10 @@ const NEW_FEATURES = [ ]; const BUG_FIXES = [ + 'Docker proxy and backend port handling hardened so changing the host backend port does not require changing the internal service contract.', + 'Global Threat Intercept and live-data startup paths no longer wait on slow-tier synthesis before cached data can paint the UI.', + 'MeshChat and Infonet statuses now separate public MQTT participation, private Wormhole mode, and local node bootstrap so the UI does not imply the wrong connection state.', + 'Commercial aircraft detail cards no longer show a confusing model image alongside the airline card.', 'Sovereign Shell adaptive polling — voting and challenge windows refresh every 8 seconds while active, every 30 to 60 seconds when idle. Voting feels live without a websocket layer.', 'Per-row write actions (petitions, upgrades, disputes) hold isolated submission state so concurrent forms no longer share a single in-flight slot.', 'Verbatim diagnostic surfacing on every write button. The backend reason text is always shown on rejection — no opaque "denied" toasts.', diff --git a/frontend/src/components/MaplibreViewer.tsx b/frontend/src/components/MaplibreViewer.tsx index 8aed9bc..be60545 100644 --- a/frontend/src/components/MaplibreViewer.tsx +++ b/frontend/src/components/MaplibreViewer.tsx @@ -159,7 +159,7 @@ import { EarthquakeLabels, ThreatMarkers, } from '@/components/map/MapMarkers'; -import type { DashboardData, KiwiSDR, MaplibreViewerProps, Scanner, SigintSignal } from '@/types/dashboard'; +import type { DashboardData, Flight, KiwiSDR, MaplibreViewerProps, Scanner, Ship, SigintSignal } from '@/types/dashboard'; import { useDataKeys } from '@/hooks/useDataStore'; import { useInterpolation } from '@/components/map/hooks/useInterpolation'; import { useClusterLabels } from '@/components/map/hooks/useClusterLabels'; @@ -225,6 +225,63 @@ type GeoExtras = { type KiwiProps = Partial & GeoExtras; type ScannerProps = Partial & GeoExtras; type SigintProps = Partial & GeoExtras; +type TrailPoint = { lng: number; lat: number; alt?: number; sog?: number; ts?: number }; +type TrailKind = 'flight' | 'ship'; + +const FLIGHT_SELECTION_TYPES = new Set([ + 'flight', + 'private_flight', + 'military_flight', + 'private_jet', + 'tracked_flight', +]); + +function parseTrailPoints(raw: unknown, kind: TrailKind): TrailPoint[] { + if (!Array.isArray(raw)) return []; + return raw + .map((p): TrailPoint | null => { + if (Array.isArray(p)) { + const lat = Number(p[0]); + const lng = Number(p[1]); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; + if (kind === 'ship') { + return { lat, lng, sog: Number(p[2]) || 0, ts: Number(p[3]) || 0 }; + } + return { lat, lng, alt: Number(p[2]) || 0, ts: Number(p[3]) || 0 }; + } + if (p && typeof p === 'object') { + const point = p as { lat?: number; lng?: number; alt?: number; sog?: number; ts?: number }; + const lat = Number(point.lat); + const lng = Number(point.lng); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; + return { + lat, + lng, + alt: Number(point.alt) || 0, + sog: Number(point.sog) || 0, + ts: Number(point.ts) || 0, + }; + } + return null; + }) + .filter((p): p is TrailPoint => Boolean(p && (p.lat !== 0 || p.lng !== 0))); +} + +function hasKnownRouteName(value?: string | null): boolean { + const normalized = String(value || '').trim().toUpperCase(); + return Boolean(normalized && normalized !== 'UNKNOWN'); +} + +function flightHasKnownRoute(entity: ReturnType, dynamicRoute: DynamicRoute | null): boolean { + if (!entity) return false; + if (dynamicRoute?.orig_loc || dynamicRoute?.dest_loc) return true; + if (!('origin_loc' in entity) && !('origin_name' in entity)) return false; + const flight = entity as Flight; + return Boolean( + (flight.origin_loc && flight.dest_loc) + || (hasKnownRouteName(flight.origin_name) && hasKnownRouteName(flight.dest_name)), + ); +} const MAP_EXTRA_DATA_KEYS = [ 'air_quality', @@ -479,6 +536,7 @@ const MaplibreViewer = ({ }, [activeLayers.viirs_nightlights, viirsProbeDayKey]); const [dynamicRoute, setDynamicRoute] = useState(null); + const [selectedTrailPoints, setSelectedTrailPoints] = useState([]); const prevCallsign = useRef(null); // Oracle region intel for map entity popups @@ -557,6 +615,7 @@ const MaplibreViewer = ({ if (callsign && callsign !== prevCallsign.current) { prevCallsign.current = callsign; + setDynamicRoute(null); fetch(`${API_BASE}/api/route/${callsign}?lat=${entityLat}&lng=${entityLng}`) .then((res) => res.json()) .then((routeData) => { @@ -575,6 +634,71 @@ const MaplibreViewer = ({ }; }, [selectedEntity, data]); + useEffect(() => { + let cancelled = false; + const entity = findSelectedEntity(selectedEntity, data); + if (!selectedEntity || !entity) { + setSelectedTrailPoints([]); + return () => { + cancelled = true; + }; + } + + const isFlight = FLIGHT_SELECTION_TYPES.has(selectedEntity.type); + const isShip = selectedEntity.type === 'ship'; + if (!isFlight && !isShip) { + setSelectedTrailPoints([]); + return () => { + cancelled = true; + }; + } + + if (isFlight && flightHasKnownRoute(entity, dynamicRoute)) { + setSelectedTrailPoints([]); + return () => { + cancelled = true; + }; + } + + const kind: TrailKind = isShip ? 'ship' : 'flight'; + const fallback = parseTrailPoints((entity as Flight | Ship).trail, kind); + if (fallback.length >= 2) { + setSelectedTrailPoints(fallback); + } else { + setSelectedTrailPoints([]); + } + + const trailId = String(selectedEntity.id || '').trim(); + if (!trailId) { + return () => { + cancelled = true; + }; + } + if (isShip && !/^\d+$/.test(trailId)) { + return () => { + cancelled = true; + }; + } + + const endpoint = isShip + ? `${API_BASE}/api/trail/ship/${encodeURIComponent(trailId)}` + : `${API_BASE}/api/trail/flight/${encodeURIComponent(trailId)}`; + fetch(endpoint) + .then((res) => (res.ok ? res.json() : null)) + .then((payload) => { + if (cancelled || !payload) return; + const points = parseTrailPoints(payload.trail, kind); + setSelectedTrailPoints(points.length >= 2 ? points : fallback); + }) + .catch(() => { + if (!cancelled) setSelectedTrailPoints(fallback); + }); + + return () => { + cancelled = true; + }; + }, [selectedEntity, data, dynamicRoute]); + // Fetch oracle region intel for entity popups useEffect(() => { if (!selectedEntity) { @@ -1349,27 +1473,29 @@ const MaplibreViewer = ({ return { type: 'FeatureCollection' as const, features }; }, [selectedEntity, data, dynamicRoute, getSelectedEntityLiveCoords, interpTick]); - // Trail history GeoJSON: shows where the SELECTED aircraft has been + // Trail history GeoJSON: shows where the selected unknown-route aircraft or vessel has been. const trailGeoJSON = useMemo(() => { void interpTick; const entity = findSelectedEntity(selectedEntity, data); - if (!entity || !('trail' in entity) || !entity.trail || entity.trail.length < 2) return null; + if (!entity || selectedTrailPoints.length < 2) return null; + if (selectedEntity && FLIGHT_SELECTION_TYPES.has(selectedEntity.type) && flightHasKnownRoute(entity, dynamicRoute)) { + return null; + } - // Parse trail points — backend sends [lat, lng, alt, ts] arrays - type TrailPt = { lng: number; lat: number; alt: number; ts: number }; - const points: TrailPt[] = ( - entity.trail as Array<{ lat?: number; lng?: number; alt?: number; ts?: number } | number[]> - ).map((p) => { - if (Array.isArray(p)) { - return { lat: p[0] as number, lng: p[1] as number, alt: (p[2] as number) || 0, ts: (p[3] as number) || 0 }; - } - return { lat: p.lat ?? 0, lng: p.lng ?? 0, alt: p.alt ?? 0, ts: p.ts ?? 0 }; - }).filter((p) => p.lat !== 0 || p.lng !== 0); + // Trails are loaded only for the selected asset to avoid open-map clutter. + const isShipTrail = selectedEntity?.type === 'ship'; + const points = [...selectedTrailPoints]; const currentLoc = getSelectedEntityLiveCoords(entity); if (currentLoc && points.length > 0) { const lastPt = points[points.length - 1]; - points.push({ lng: currentLoc[0], lat: currentLoc[1], alt: lastPt.alt, ts: Date.now() / 1000 }); + points.push({ + lng: currentLoc[0], + lat: currentLoc[1], + alt: lastPt.alt, + sog: lastPt.sog, + ts: Date.now() / 1000, + }); } if (points.length < 2) return null; @@ -1394,7 +1520,7 @@ const MaplibreViewer = ({ type: 'Feature' as const, properties: { type: 'trail', - color: altToColor((a.alt + b.alt) / 2), + color: isShipTrail ? '#22d3ee' : altToColor(((a.alt ?? 0) + (b.alt ?? 0)) / 2), opacity: 0.4 + progress * 0.5, // older segments more transparent segIndex: i, }, @@ -1406,7 +1532,7 @@ const MaplibreViewer = ({ } return { type: 'FeatureCollection' as const, features }; - }, [selectedEntity, data, getSelectedEntityLiveCoords, interpTick]); + }, [selectedEntity, data, selectedTrailPoints, dynamicRoute, getSelectedEntityLiveCoords, interpTick]); // Predictive vector GeoJSON: dotted line projecting ~5 min ahead based on heading + speed // Skip when entity has a known route (origin+dest) — the route line already shows where it's going diff --git a/frontend/src/components/MeshChat/index.tsx b/frontend/src/components/MeshChat/index.tsx index a689a7a..b3e70c2 100644 --- a/frontend/src/components/MeshChat/index.tsx +++ b/frontend/src/components/MeshChat/index.tsx @@ -117,6 +117,17 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) { setMeshView, meshDirectTarget, setMeshDirectTarget, + meshMqttSettings, + meshMqttForm, + setMeshMqttForm, + meshMqttBusy, + meshMqttStatusText, + meshMqttEnabled, + meshMqttRunning, + meshMqttConnected, + meshMqttConnectionLabel, + saveMeshMqttSettings, + refreshMeshMqttSettings, // Identity identity, publicIdentity, @@ -1155,17 +1166,179 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) { > INBOX + -
+
{meshSessionActive && publicMeshAddress - ? `ADDR ${publicMeshAddress.toUpperCase()}` + ? `${meshMqttConnectionLabel} / ADDR ${publicMeshAddress.toUpperCase()}` : publicMeshAddress - ? 'MESH OFF / KEY SAVED' - : 'NO PUBLIC MESH ADDRESS'} + ? `${meshMqttConnectionLabel} / KEY SAVED` + : `${meshMqttConnectionLabel} / NO ADDRESS`}
- {!meshSessionActive && ( + {meshView === 'settings' && ( +
+
+
+
+
MESHTASTIC MQTT
+
+ Public Mesh is separate from Wormhole. Turning MQTT on disables the private Wormhole lane for MeshChat. +
+
+ + {meshMqttConnectionLabel} + +
+ {meshMqttSettings?.runtime?.last_error && ( +
+ LAST ERROR: {meshMqttSettings.runtime.last_error} +
+ )} + {meshMqttRunning && !meshMqttConnected && !meshMqttSettings?.runtime?.last_error && ( +
+ MQTT bridge is starting. Live messages appear after broker connect. +
+ )} +
+ +
+ + +
+ + + + + + + + + + + +
+ + + +
+ {meshMqttStatusText && ( +
{meshMqttStatusText}
+ )} +
+ )} + {!meshSessionActive && meshView !== 'settings' && (
MeshChat is off. Turn it on to connect the public mesh lane.
diff --git a/frontend/src/components/MeshChat/useMeshChatController.ts b/frontend/src/components/MeshChat/useMeshChatController.ts index bac9b23..10f436b 100644 --- a/frontend/src/components/MeshChat/useMeshChatController.ts +++ b/frontend/src/components/MeshChat/useMeshChatController.ts @@ -15,9 +15,6 @@ import { import type { DesktopControlAuditReport } from '@/lib/desktopControlContract'; import { fetchPrivacyProfileSnapshot } from '@/mesh/controlPlaneStatusClient'; import { - clearBrowserIdentityState, - derivePublicMeshAddress, - generateNodeKeys, getNodeIdentity, getStoredNodeDescriptor, getWormholeIdentityDescriptor, @@ -31,9 +28,7 @@ import { updateContact, blockContact, getDMNotify, - getPublicKeyAlgo, nextSequence, - signEvent, verifyEventSignature, verifyRawSignature, purgeBrowserContactGraph, @@ -130,7 +125,6 @@ import { preferredDmPeerId, } from '@/mesh/meshDmConsent'; import { deriveSasPhrase } from '@/mesh/meshSas'; -import { PROTOCOL_VERSION } from '@/mesh/meshProtocol'; import { validateEventPayload } from '@/mesh/meshSchema'; import { buildDmTrustHint, @@ -223,6 +217,94 @@ interface GateCompatConsentPromptState { reason: string; } +interface MeshMqttRuntime { + enabled?: boolean; + running?: boolean; + connected?: boolean; + broker?: string; + port?: number; + username?: string; + client_id?: string; + message_log_size?: number; + signal_log_size?: number; + last_error?: string; + last_connected_at?: number; + last_disconnected_at?: number; +} + +interface MeshMqttSettings { + enabled: boolean; + broker: string; + port: number; + username: string; + uses_default_credentials?: boolean; + has_password: boolean; + has_psk: boolean; + include_default_roots: boolean; + extra_roots: string; + extra_topics: string; + runtime?: MeshMqttRuntime; +} + +interface MeshMqttForm { + broker: string; + port: string; + username: string; + password: string; + psk: string; + include_default_roots: boolean; + extra_roots: string; + extra_topics: string; +} + +const PUBLIC_MESH_ADDRESS_KEY = 'sb_public_meshtastic_address'; + +function normalizePublicMeshAddress(value: string): 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 ''; + } +} + +function writeStoredPublicMeshAddress(address: string): void { + if (typeof window === 'undefined') return; + const normalized = normalizePublicMeshAddress(address); + if (!normalized) return; + try { + window.localStorage.setItem(PUBLIC_MESH_ADDRESS_KEY, normalized); + } catch { + /* ignore */ + } +} + +function clearStoredPublicMeshAddress(): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.removeItem(PUBLIC_MESH_ADDRESS_KEY); + } catch { + /* ignore */ + } +} + +function createPublicMeshAddress(): string { + if (typeof window !== 'undefined' && window.crypto?.getRandomValues) { + const value = new Uint32Array(1); + window.crypto.getRandomValues(value); + if (value[0]) return `!${value[0].toString(16).padStart(8, '0')}`; + } + const fallback = Math.floor((Date.now() ^ Math.floor(Math.random() * 0xffffffff)) >>> 0); + return `!${fallback.toString(16).padStart(8, '0')}`; +} + function describeGateCompatConsentRequired(): string { return 'Local gate runtime is unavailable for this room.'; } @@ -315,8 +397,21 @@ export function useMeshChatController({ const [meshQuickStatus, setMeshQuickStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); const [meshSessionActive, setMeshSessionActive] = useState(false); const [publicMeshAddress, setPublicMeshAddress] = useState(''); - const [meshView, setMeshView] = useState<'channel' | 'inbox'>('channel'); + const [meshView, setMeshView] = useState<'channel' | 'inbox' | 'settings'>('channel'); const [meshDirectTarget, setMeshDirectTarget] = useState(''); + const [meshMqttSettings, setMeshMqttSettings] = useState(null); + const [meshMqttForm, setMeshMqttForm] = useState({ + broker: 'mqtt.meshtastic.org', + port: '1883', + username: '', + password: '', + psk: '', + include_default_roots: true, + extra_roots: '', + extra_topics: '', + }); + const [meshMqttBusy, setMeshMqttBusy] = useState(false); + const [meshMqttStatusText, setMeshMqttStatusText] = useState(''); // Identity const [identity, setIdentity] = useState(null); @@ -329,15 +424,123 @@ export function useMeshChatController({ const [recentPrivateFallbackReason, setRecentPrivateFallbackReason] = useState(''); const [unresolvedSenderSealCount, setUnresolvedSenderSealCount] = useState(0); const [privacyProfile, setPrivacyProfile] = useState<'default' | 'high'>('default'); - const storedPublicIdentity = clientHydrated ? getNodeIdentity() : null; - const hasStoredPublicLaneIdentity = clientHydrated && Boolean(storedPublicIdentity) && hasSovereignty(); - const publicIdentity = meshSessionActive ? storedPublicIdentity : null; - const hasPublicLaneIdentity = meshSessionActive && hasStoredPublicLaneIdentity; + const storedPublicMeshAddress = clientHydrated ? readStoredPublicMeshAddress() : ''; + const hasStoredPublicLaneIdentity = clientHydrated && Boolean(storedPublicMeshAddress); + const publicIdentity = null; + const hasPublicLaneIdentity = meshSessionActive && Boolean(publicMeshAddress); const hasId = Boolean(identity) && (hasSovereignty() || wormholeEnabled); const shouldShowIdentityWarning = activeTab !== 'meshtastic' && !hasId; const privateInfonetReady = wormholeEnabled && wormholeReadyState; const publicMeshBlockedByWormhole = wormholeEnabled || wormholeReadyState; const dmSendQueue = useRef<(() => Promise)[]>([]); + const meshMqttRuntime = meshMqttSettings?.runtime; + const meshMqttEnabled = Boolean(meshMqttSettings?.enabled || meshMqttRuntime?.enabled); + const meshMqttRunning = Boolean(meshMqttRuntime?.running); + const meshMqttConnected = Boolean(meshMqttRuntime?.connected); + const meshMqttConnectionLabel = !meshMqttEnabled + ? 'MQTT OFF' + : meshMqttConnected + ? 'MQTT LIVE' + : meshMqttRunning + ? 'MQTT CONNECTING' + : 'MQTT STARTING'; + + const applyMeshMqttSettings = useCallback((data: MeshMqttSettings) => { + setMeshMqttSettings(data); + setMeshMqttForm((prev) => ({ + broker: data.broker || prev.broker || 'mqtt.meshtastic.org', + port: String(data.port || prev.port || '1883'), + username: data.uses_default_credentials ? '' : data.username || prev.username || '', + password: '', + psk: '', + include_default_roots: Boolean(data.include_default_roots), + extra_roots: data.extra_roots || '', + extra_topics: data.extra_topics || '', + })); + }, []); + + const refreshMeshMqttSettings = useCallback(async () => { + try { + const res = await fetch(`${API_BASE}/api/settings/meshtastic-mqtt`, { cache: 'no-store' }); + if (!res.ok) return null; + const data = (await res.json()) as MeshMqttSettings; + applyMeshMqttSettings(data); + return data; + } catch { + return null; + } + }, [applyMeshMqttSettings]); + + const saveMeshMqttSettings = useCallback( + async (updates: Partial & { enabled?: boolean } = {}) => { + setMeshMqttBusy(true); + setMeshMqttStatusText(''); + try { + const nextForm = { ...meshMqttForm, ...updates }; + const body: Record = { + broker: nextForm.broker.trim() || 'mqtt.meshtastic.org', + port: Number.parseInt(nextForm.port, 10) || 1883, + username: nextForm.username.trim(), + include_default_roots: Boolean(nextForm.include_default_roots), + extra_roots: nextForm.extra_roots.trim(), + extra_topics: nextForm.extra_topics.trim(), + }; + if (!nextForm.username.trim() && !nextForm.password.trim()) { + body.password = ''; + } + if (typeof updates.enabled === 'boolean') { + body.enabled = updates.enabled; + } + if (nextForm.password.trim()) { + body.password = nextForm.password; + } + if (nextForm.psk.trim()) { + body.psk = nextForm.psk.trim(); + } + const res = await fetch(`${API_BASE}/api/settings/meshtastic-mqtt`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const detail = await res.text().catch(() => ''); + throw new Error(detail || `HTTP ${res.status}`); + } + const data = (await res.json()) as MeshMqttSettings; + applyMeshMqttSettings(data); + if (data.enabled) { + setWormholeEnabled(false); + setWormholeReadyState(false); + setWormholeRnsReady(false); + setWormholeRnsDirectReady(false); + setWormholeRnsPeers({ active: 0, configured: 0 }); + setSecureModeCached(false); + } + const status = data.runtime?.connected + ? 'MQTT bridge connected.' + : data.enabled + ? 'MQTT bridge enabled. Connection may take a few seconds.' + : 'MQTT bridge disabled.'; + setMeshMqttStatusText(status); + return { ok: true as const, text: status, data }; + } catch (err) { + const text = err instanceof Error ? err.message : 'MQTT settings update failed'; + setMeshMqttStatusText(text); + return { ok: false as const, text }; + } finally { + setMeshMqttBusy(false); + } + }, + [applyMeshMqttSettings, meshMqttForm], + ); + + const enableMeshMqttBridge = useCallback(async () => { + const result = await saveMeshMqttSettings({ enabled: true }); + if (!result.ok) { + throw new Error(result.text); + } + return result; + }, [saveMeshMqttSettings]); const dmSendTimer = useRef | null>(null); const streamEnabledForSelectedGateRef = useRef(false); const displayPublicMeshSender = useCallback( @@ -345,15 +548,14 @@ export function useMeshChatController({ if (!sender) return '???'; if ( hasPublicLaneIdentity && - publicIdentity?.nodeId && publicMeshAddress && - sender.toLowerCase() === publicIdentity.nodeId.toLowerCase() + sender.toLowerCase() === publicMeshAddress.toLowerCase() ) { return publicMeshAddress.toUpperCase(); } return sender; }, - [hasPublicLaneIdentity, publicIdentity?.nodeId, publicMeshAddress], + [hasPublicLaneIdentity, publicMeshAddress], ); const openIdentityWizard = useCallback( @@ -370,6 +572,7 @@ export function useMeshChatController({ useEffect(() => { if (!clientHydrated) return; + setPublicMeshAddress(readStoredPublicMeshAddress()); setMeshSessionActive(false); setMeshMessages([]); setMeshQuickStatus(null); @@ -525,25 +728,6 @@ export function useMeshChatController({ }; }, []); - useEffect(() => { - let alive = true; - const senderId = storedPublicIdentity?.nodeId || ''; - if (!senderId || !globalThis.crypto?.subtle) { - setPublicMeshAddress(''); - return; - } - derivePublicMeshAddress(senderId) - .then((addr) => { - if (alive) setPublicMeshAddress(addr); - }) - .catch(() => { - if (alive) setPublicMeshAddress(''); - }); - return () => { - alive = false; - }; - }, [storedPublicIdentity?.nodeId]); - const flushDmQueue = useCallback(async () => { const queue = dmSendQueue.current.splice(0); if (dmSendTimer.current) { @@ -1155,6 +1339,36 @@ export function useMeshChatController({ return filteredMeshMessages.filter((m) => String(m.to || '').toLowerCase() === target); }, [filteredMeshMessages, meshSessionActive, publicMeshAddress]); + useEffect(() => { + if (!expanded || activeTab !== 'meshtastic') return; + let alive = true; + const tick = async () => { + const data = await refreshMeshMqttSettings(); + if (!alive || !data) return; + if (!data.enabled && meshSessionActive) { + setMeshQuickStatus({ + type: 'err', + text: 'Public Mesh key is ready, but MQTT is off. Enable MQTT in Settings to join the live public lane.', + }); + } + }; + void tick(); + const timer = window.setInterval(() => { + void tick(); + }, meshMqttEnabled && !meshMqttConnected ? 5_000 : 15_000); + return () => { + alive = false; + window.clearInterval(timer); + }; + }, [ + activeTab, + expanded, + meshMqttConnected, + meshMqttEnabled, + meshSessionActive, + refreshMeshMqttSettings, + ]); + // ─── InfoNet Polling ───────────────────────────────────────────────────── useEffect(() => { @@ -2411,7 +2625,7 @@ export function useMeshChatController({ ]); setGateReplyContext(null); } else if (activeTab === 'meshtastic') { - if (!meshSessionActive || !publicIdentity || !hasSovereignty()) { + if (!meshSessionActive || !publicMeshAddress) { setInputValue(msg); setLastSendTime(0); setSendError(meshSessionActive ? 'public mesh identity needed' : 'meshchat is off'); @@ -2425,8 +2639,20 @@ export function useMeshChatController({ setBusy(false); return; } + if (!meshMqttEnabled) { + setInputValue(msg); + setLastSendTime(0); + setSendError('mqtt is off'); + setMeshQuickStatus({ + type: 'err', + text: 'Public Mesh key is ready, but MQTT is off. Open Settings and enable the public broker.', + }); + setMeshView('settings'); + setTimeout(() => setSendError(''), 4000); + setBusy(false); + return; + } const meshDestination = meshDirectTarget.trim() || 'broadcast'; - const sequence = nextSequence(); const payload = { message: msg, destination: meshDestination, @@ -2444,8 +2670,7 @@ export function useMeshChatController({ setBusy(false); return; } - const signature = await signEvent('message', publicIdentity.nodeId, sequence, payload); - const sendRes = await fetch(`${API_BASE}/api/mesh/send`, { + const sendRes = await fetch(`${API_BASE}/api/mesh/meshtastic/send`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -2455,14 +2680,8 @@ export function useMeshChatController({ priority: 'normal', ephemeral: false, transport_lock: 'meshtastic', - sender_id: publicIdentity.nodeId, - node_id: publicIdentity.nodeId, - public_key: publicIdentity.publicKey, - public_key_algo: getPublicKeyAlgo(), - signature, - sequence, - protocol_version: PROTOCOL_VERSION, - credentials: { mesh_region: meshRegion }, + sender_id: publicMeshAddress, + mesh_region: meshRegion, }), }); if (!sendRes.ok) { @@ -2476,15 +2695,7 @@ export function useMeshChatController({ if (!sendData.ok) { setInputValue(msg); setLastSendTime(0); - if (sendData.detail === 'Invalid signature') { - setSendError('public mesh signature failed'); - openIdentityWizard({ - type: 'err', - text: 'This public mesh identity did not verify. Reset it, recreate it, then retry.', - }); - } else { - setSendError(sendData.detail || 'send failed'); - } + setSendError(sendData.detail || 'send failed'); setTimeout(() => setSendError(''), 4000); return; } @@ -3937,6 +4148,7 @@ export function useMeshChatController({ wormholeReadyState && !selectedGateAccessReady) || (activeTab === 'infonet' && anonymousPublicBlocked) || + (activeTab === 'meshtastic' && (!hasPublicLaneIdentity || !meshMqttEnabled)) || (activeTab === 'dms' && (dmView !== 'chat' || !selectedContact || @@ -4003,11 +4215,11 @@ export function useMeshChatController({ setIdentityWizardStatus(null); try { await disableWormholeForPublicMesh(); - const nextIdentity = await generateNodeKeys(); - const nextAddress = await derivePublicMeshAddress(nextIdentity.nodeId).catch(() => ''); - const readyAddress = (nextAddress || nextIdentity.nodeId).toUpperCase(); - setIdentity(nextIdentity); - setPublicMeshAddress(nextAddress || nextIdentity.nodeId); + const nextAddress = createPublicMeshAddress(); + await enableMeshMqttBridge(); + writeStoredPublicMeshAddress(nextAddress); + const readyAddress = nextAddress.toUpperCase(); + setPublicMeshAddress(nextAddress); setMeshSessionActive(true); setMeshMessages([]); setSendError(''); @@ -4038,7 +4250,7 @@ export function useMeshChatController({ setIdentityWizardBusy(false); } }, - [disableWormholeForPublicMesh], + [disableWormholeForPublicMesh, enableMeshMqttBridge], ); const handleCreatePublicIdentity = useCallback(async () => { @@ -4059,8 +4271,8 @@ export function useMeshChatController({ setIdentityWizardStatus(null); setMeshQuickStatus(null); try { - const savedIdentity = getNodeIdentity(); - if (!savedIdentity || !hasSovereignty()) { + const savedAddress = readStoredPublicMeshAddress(); + if (!savedAddress) { const text = 'No saved public mesh key is available. Create a mesh key first.'; setMeshSessionActive(false); setIdentityWizardStatus({ type: 'err', text }); @@ -4068,10 +4280,9 @@ export function useMeshChatController({ return { ok: false as const, text }; } await disableWormholeForPublicMesh(); - const nextAddress = await derivePublicMeshAddress(savedIdentity.nodeId).catch(() => ''); - const readyAddress = (nextAddress || savedIdentity.nodeId).toUpperCase(); - setIdentity(savedIdentity); - setPublicMeshAddress(nextAddress || savedIdentity.nodeId); + await enableMeshMqttBridge(); + const readyAddress = savedAddress.toUpperCase(); + setPublicMeshAddress(savedAddress); setMeshSessionActive(true); setMeshMessages([]); setSendError(''); @@ -4091,7 +4302,7 @@ export function useMeshChatController({ } finally { setIdentityWizardBusy(false); } - }, [disableWormholeForPublicMesh]); + }, [disableWormholeForPublicMesh, enableMeshMqttBridge]); const handleReplyToMeshAddress = useCallback((address: string) => { const target = String(address || '').trim(); @@ -4127,14 +4338,8 @@ export function useMeshChatController({ try { setMeshSessionActive(false); setMeshMessages([]); - await clearBrowserIdentityState(); - setIdentity(null); + clearStoredPublicMeshAddress(); setPublicMeshAddress(''); - setContacts({}); - setSelectedContact(''); - setDmMessages([]); - setAccessRequestsState([]); - setPendingSentState([]); setIdentityWizardStatus({ type: 'ok', text: 'Public mesh identity cleared. Start a fresh one when you are ready.', @@ -4246,6 +4451,17 @@ export function useMeshChatController({ setMeshView, meshDirectTarget, setMeshDirectTarget, + meshMqttSettings, + meshMqttForm, + setMeshMqttForm, + meshMqttBusy, + meshMqttStatusText, + meshMqttEnabled, + meshMqttRunning, + meshMqttConnected, + meshMqttConnectionLabel, + saveMeshMqttSettings, + refreshMeshMqttSettings, // Identity identity, publicIdentity, diff --git a/frontend/src/components/NewsFeed.tsx b/frontend/src/components/NewsFeed.tsx index 298fb41..495fcc3 100644 --- a/frontend/src/components/NewsFeed.tsx +++ b/frontend/src/components/NewsFeed.tsx @@ -100,6 +100,7 @@ const AIRCRAFT_WIKI: Record = { PA46: 'Piper PA-46 Malibu', BE36: 'Beechcraft Bonanza', BE9L: 'Beechcraft King Air', BE20: 'Beechcraft Super King Air', B350: 'Beechcraft King Air 350', PC12: 'Pilatus PC-12', PC24: 'Pilatus PC-24', TBM7: 'Daher TBM', TBM8: 'Daher TBM', TBM9: 'Daher TBM', + PIVI: 'Pipistrel Virus', // Helicopters R44: 'Robinson R44', R22: 'Robinson R22', R66: 'Robinson R66', B06: 'Bell 206', B407: 'Bell 407', B412: 'Bell 412', @@ -196,12 +197,17 @@ function resolveAcTypeWiki(acType: string): string | null { return null; } +function resolveAircraftWikiTitle(model: string | undefined): string | null { + if (!model) return null; + return AIRCRAFT_WIKI[model] || resolveAcTypeWiki(model); +} + // Module-level cache for Wikipedia thumbnails (persists across re-renders) const _wikiThumbCache: Record = {}; function useAircraftImage(model: string | undefined): { imgUrl: string | null; wikiUrl: string | null; loading: boolean } { const [, forceUpdate] = useState(0); - const wikiTitle = model ? AIRCRAFT_WIKI[model] : undefined; + const wikiTitle = resolveAircraftWikiTitle(model) || undefined; const wikiUrl = wikiTitle ? `https://en.wikipedia.org/wiki/${wikiTitle.replace(/ /g, '_')}` : null; useEffect(() => { @@ -236,6 +242,162 @@ const VESSEL_TYPE_WIKI: Record = { 'military_vessel': 'https://en.wikipedia.org/wiki/Warship', }; +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[]) : []; + 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 >= 5 / 60) { + 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 >= 5 / 60 && 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 }) { + const observed = estimateObservedEmissions(flight); + const emissions = flight?.emissions; + const context = observed + ? `${observed.durationLabel} ${observed.basisLabel}${observed.distanceLabel ? ` / ${observed.distanceLabel}` : ''}` + : emissions + ? 'Rate only until enough trail history accumulates' + : null; + + return ( +
+ EMISSIONS ESTIMATE +
+
+
+ {observed ? 'FUEL BURNED' : 'FUEL RATE'} +
+
+ {observed ? ( + <>{observed.fuelGallons.toLocaleString()} GAL + ) : emissions ? ( + <>{emissions.fuel_gph} GPH + ) : 'UNKNOWN'} +
+
+
+
+ {observed ? 'CO2 PRODUCED' : 'CO2 RATE'} +
+
+ {observed ? ( + <>{observed.co2Kg.toLocaleString()} KG + ) : emissions ? ( + <>{emissions.co2_kg_per_hour.toLocaleString()} KG/HR + ) : 'UNKNOWN'} +
+
+
+ {context && ( +
+ {context} + {observed && emissions ? ` - estimated from ${emissions.fuel_gph} GPH model rate.` : ''} +
+ )} +
+ ); +} + function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, onArticleClick }: { selectedEntity?: SelectedEntity | null, regionDossier?: RegionDossier | null, regionDossierLoading?: boolean, onArticleClick?: (idx: number, lat?: number, lng?: number, title?: string) => void }) { const data = useDataKeys([ 'news', 'fimi', 'commercial_flights', 'private_flights', 'private_jets', @@ -277,12 +439,17 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on const selectedFlightModel = (() => { if (!selectedEntity) return undefined; const { type, id } = selectedEntity; - let flight: any = null; - if (type === 'flight') flight = data?.commercial_flights?.[id as number]; - else if (type === 'private_flight') flight = data?.private_flights?.[id as number]; - else if (type === 'private_jet') flight = data?.private_jets?.[id as number]; - else if (type === 'military_flight') flight = data?.military_flights?.[id as number]; - else if (type === 'tracked_flight') flight = data?.tracked_flights?.[id as number]; + const findByIdOrIndex = (flights?: Array<{ icao24?: string; model?: string }>) => { + if (!flights) return null; + if (typeof id === 'number') return flights[id] || null; + return flights.find((flight) => flight.icao24 === id) || null; + }; + let flight: { model?: string } | null = null; + if (type === 'flight') flight = findByIdOrIndex(data?.commercial_flights); + else if (type === 'private_flight') flight = findByIdOrIndex(data?.private_flights); + else if (type === 'private_jet') flight = findByIdOrIndex(data?.private_jets); + else if (type === 'military_flight') flight = findByIdOrIndex(data?.military_flights); + else if (type === 'tracked_flight') flight = findByIdOrIndex(data?.tracked_flights); return flight?.model; })(); const { imgUrl: aircraftImgUrl, wikiUrl: aircraftWikiUrl, loading: aircraftImgLoading } = useAircraftImage(selectedFlightModel); @@ -684,19 +851,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on {flight.squawk}{flight.squawk === '7700' ? ' ⚠ EMERGENCY' : flight.squawk === '7600' ? ' COMMS LOST' : ''}
)} -
- EMISSIONS ESTIMATE -
-
-
FUEL BURN
-
{flight.emissions ? <>{flight.emissions.fuel_gph} GPH : 'UNKNOWN'}
-
-
-
CO2 OUTPUT
-
{flight.emissions ? <>{flight.emissions.co2_kg_per_hour.toLocaleString()} KG/HR : 'UNKNOWN'}
-
-
-
+ {flight.alert_link && (
REFERENCE @@ -750,6 +905,12 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on if (flight) { const callsign = flight.callsign || "UNKNOWN"; let airline = "UNKNOWN"; + const isPrivateFlight = selectedEntity.type === 'private_flight' || selectedEntity.type === 'private_jet'; + const aircraftWikiTitle = resolveAircraftWikiTitle(flight.model); + const aircraftModelWikiUrl = aircraftWikiTitle + ? `https://en.wikipedia.org/wiki/${aircraftWikiTitle.replace(/ /g, '_')}` + : null; + const showModelWiki = isPrivateFlight || selectedEntity.type === 'military_flight'; if (selectedEntity.type === 'military_flight') { const mil = flight as import('@/types/dashboard').MilitaryFlight; @@ -798,7 +959,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
OPERATOR - {selectedEntity.type !== 'military_flight' && airline && airline !== 'COMMERCIAL FLIGHT' && airline !== 'UNKNOWN' ? ( + {!isPrivateFlight && selectedEntity.type !== 'military_flight' && airline && airline !== 'COMMERCIAL FLIGHT' && airline !== 'UNKNOWN' ? ( {/* Commercial: Airline company Wikipedia image */} - {selectedEntity.type !== 'military_flight' && airline && airline !== 'COMMERCIAL FLIGHT' && airline !== 'UNKNOWN' && ( + {!isPrivateFlight && selectedEntity.type !== 'military_flight' && airline && airline !== 'COMMERCIAL FLIGHT' && airline !== 'UNKNOWN' && (
{/* Military: Aircraft model Wikipedia image (gold accent) */} {selectedEntity.type === 'military_flight' && (() => { @@ -878,8 +1050,19 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on } return null; })()} + {/* Private/GA: aircraft model Wikipedia image as the primary visual */} + {isPrivateFlight && aircraftModelWikiUrl && ( +
+ +
+ )} {/* Non-military: Aircraft model photo (secondary, below airline image) */} - {selectedEntity.type !== 'military_flight' && (aircraftImgUrl || aircraftImgLoading || aircraftWikiUrl) && ( + {!isPrivateFlight && selectedEntity.type !== 'military_flight' && selectedEntity.type !== 'flight' && (aircraftImgUrl || aircraftImgLoading || aircraftWikiUrl) && (
{aircraftImgLoading && (
@@ -924,19 +1107,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on ROUTE {flight.origin_name !== "UNKNOWN" ? `[${flight.origin_name}] → [${flight.dest_name}]` : "UNKNOWN"}
-
- EMISSIONS ESTIMATE -
-
-
FUEL BURN
-
{flight.emissions ? <>{flight.emissions.fuel_gph} GPH : 'UNKNOWN'}
-
-
-
CO2 OUTPUT
-
{flight.emissions ? <>{flight.emissions.co2_kg_per_hour.toLocaleString()} KG/HR : 'UNKNOWN'}
-
-
-
+ {flight.icao24 && (
FLIGHT RECORD diff --git a/frontend/src/components/OnboardingModal.tsx b/frontend/src/components/OnboardingModal.tsx index 176f0a4..c34472d 100644 --- a/frontend/src/components/OnboardingModal.tsx +++ b/frontend/src/components/OnboardingModal.tsx @@ -2,9 +2,9 @@ import React, { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { X, ExternalLink, Key, Shield, Radar, Globe, Satellite, Ship, Radio } 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.7-docker-keys-1'; +const CURRENT_ONBOARDING_VERSION = '0.9.75-agentic-onboarding-1'; const STORAGE_KEY = `shadowbroker_onboarding_complete_v${CURRENT_ONBOARDING_VERSION}`; const LEGACY_STORAGE_KEY = 'shadowbroker_onboarding_complete'; @@ -68,6 +68,14 @@ const OnboardingModal = React.memo(function OnboardingModal({ }); const [setupSaving, setSetupSaving] = useState(false); const [setupMsg, setSetupMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); + const [agentSecret, setAgentSecret] = useState(''); + const [agentTier, setAgentTier] = useState<'restricted' | 'full'>('restricted'); + const [agentMode, setAgentMode] = useState<'local' | 'remote'>('local'); + const [agentLoading, setAgentLoading] = useState(false); + const [agentMsg, setAgentMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); + const [agentCopied, setAgentCopied] = useState(false); + const [torStarting, setTorStarting] = useState(false); + const [torAddress, setTorAddress] = useState(''); const handleDismiss = () => { localStorage.setItem(STORAGE_KEY, 'true'); @@ -114,6 +122,110 @@ const OnboardingModal = React.memo(function OnboardingModal({ } }; + const agentEndpoint = + agentMode === 'local' + ? 'http://localhost:8000' + : torAddress || ''; + + const agentSnippet = [ + `SHADOWBROKER_URL=${agentEndpoint}`, + agentSecret ? `SHADOWBROKER_KEY=${agentSecret}` : 'SHADOWBROKER_KEY=', + `SHADOWBROKER_ACCESS=${agentTier}`, + '', + '# FIRST: load available tools', + `GET ${agentEndpoint}/api/ai/tools`, + '', + '# Auth: HMAC-SHA256 signed requests.', + '# Restricted = read-only telemetry. Full = can write when asked.', + ].join('\n'); + const remoteAgentNeedsTor = agentMode === 'remote' && !torAddress; + + const fetchAgentConnectInfo = async (reveal = true) => { + setAgentLoading(true); + setAgentMsg(null); + try { + const res = await fetch(`/api/ai/connect-info?reveal=${reveal ? 'true' : 'false'}`); + const data = await res.json().catch(() => ({})); + if (!res.ok || data?.ok === false) { + throw new Error(data?.detail || 'Could not prepare agent credentials.'); + } + setAgentSecret(data.hmac_secret || ''); + setAgentTier(data.access_tier === 'full' ? 'full' : 'restricted'); + setAgentMsg({ type: 'ok', text: 'Agent key is ready. Copy it into your local or remote agent runtime.' }); + } catch (error) { + setAgentMsg({ + type: 'err', + text: error instanceof Error ? error.message : 'Could not prepare agent credentials.', + }); + } finally { + setAgentLoading(false); + } + }; + + const saveAgentTier = async (tier: 'restricted' | 'full') => { + setAgentTier(tier); + setAgentMsg(null); + try { + const res = await fetch('/api/ai/connect-info/access-tier', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tier }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data?.ok === false) { + throw new Error(data?.detail || 'Could not update agent access tier.'); + } + setAgentMsg({ + type: 'ok', + text: tier === 'full' + ? 'Full access saved. The agent can write to the dashboard when authenticated.' + : 'Restricted access saved. The agent can read telemetry but cannot write.', + }); + } catch (error) { + setAgentMsg({ + type: 'err', + text: error instanceof Error ? error.message : 'Could not update agent access tier.', + }); + } + }; + + const prepareTorAgentAddress = async () => { + setTorStarting(true); + setAgentMsg(null); + try { + const res = await fetch('/api/settings/tor/start', { method: 'POST' }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data?.ok === false || !data?.onion_address) { + throw new Error(data?.detail || 'Could not start Tor hidden service.'); + } + setTorAddress(data.onion_address); + setAgentMsg({ + type: 'ok', + text: 'Tor is ready. The remote agent link is private to your local ShadowBroker node.', + }); + } catch (error) { + setAgentMsg({ + type: 'err', + text: + error instanceof Error + ? error.message + : 'ShadowBroker could not install or start Tor automatically. Check network access and try again.', + }); + } finally { + setTorStarting(false); + } + }; + + const copyAgentSnippet = async () => { + if (remoteAgentNeedsTor) { + setAgentMsg({ type: 'err', text: 'Install Tor and create the remote link first, then copy the agent config.' }); + return; + } + await navigator.clipboard.writeText(agentSnippet); + setAgentCopied(true); + setTimeout(() => setAgentCopied(false), 1600); + }; + return ( {/* Backdrop */} @@ -166,7 +278,7 @@ const OnboardingModal = React.memo(function OnboardingModal({ {/* Step Indicators */}
- {['API Keys', 'Trust Modes', 'Free Sources'].map((label, i) => ( + {['API Keys', 'AI Agent', 'Trust Modes', 'Free Sources'].map((label, i) => ( + +
+
+ +
+

+ STEP 2 - WHAT CAN IT DO? +

+
+ + +
+
+ +
+
+
+

+ STEP 3 - COPY THIS INTO YOUR AGENT +

+

+ Generate a local key, then copy these variables into OpenClaw, Hermes, or another HMAC agent. +

+
+ +
+ + {remoteAgentNeedsTor && ( +
+
+
+

+ TOR REQUIRED FOR REMOTE AGENTS +

+

+ ShadowBroker will install or use Tor locally, then create a private .onion link for this backend. +

+
+ +
+
+ )} + +
+
+                      {agentSnippet}
+                    
+ +
+ + {agentMsg && ( +

+ {agentMsg.text} +

+ )} +
+ +

+ Remote agent access uses the signed HTTP API over Tor. Wormhole uses the same Tor/Arti transport lane when it is available; MLS-native agent transport is still planned. +

+
+ )} + {step === 0 && (
@@ -359,7 +622,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
)} - {step === 2 && ( + {step === 3 && (

These data sources are completely free and require no API keys. They activate @@ -401,7 +664,7 @@ const OnboardingModal = React.memo(function OnboardingModal({

- {[0, 1, 2].map((i) => ( + {[0, 1, 2, 3].map((i) => (
- {step < 2 ? ( + {step < 3 ? (
+
+ Configured keys stay hidden for shared dashboards. Unlock operator tools, then + use ROTATE only when you intentionally want to replace a working credential. +
{envMeta && (
@@ -2344,17 +2350,53 @@ const SettingsPanel = React.memo(function SettingsPanel({ {api.has_key && (
{api.is_set ? ( -
- - CONFIGURED - - - edit{' '} - - {api.env_key} - {' '} - Enter a replacement below if you need to rotate it. - +
+
+
+ + CONFIGURED + + + Secret hidden. Stored write-only on this backend as{' '} + + {api.env_key} + + . + +
+ {api.env_key && ( + + )} +
+ {!(nativeProtected || adminSessionReady) && ( +
+ Operator tools are locked. Viewers can see source status + but cannot replace saved credentials. +
+ )}
) : (
@@ -2366,40 +2408,42 @@ const SettingsPanel = React.memo(function SettingsPanel({
)} -
- { - if (!api.env_key) return; - setApiKeyInputs((prev) => ({ - ...prev, - [api.env_key as string]: event.target.value, - })); - }} - placeholder={ - api.is_set - ? 'Enter replacement key...' - : `Enter ${api.env_key}...` - } - className="min-w-0 flex-1 bg-[var(--bg-primary)] border border-[var(--border-primary)] px-2 py-1.5 text-sm text-[var(--text-primary)] outline-none focus:border-cyan-500/70 placeholder:text-[var(--text-muted)]/50" - autoComplete="off" - /> - -
+ {(!api.is_set || (api.env_key && apiKeyEditing[api.env_key])) && ( +
+ { + if (!api.env_key) return; + setApiKeyInputs((prev) => ({ + ...prev, + [api.env_key as string]: event.target.value, + })); + }} + placeholder={ + api.is_set + ? 'Enter replacement key...' + : `Enter ${api.env_key}...` + } + className="min-w-0 flex-1 bg-[var(--bg-primary)] border border-[var(--border-primary)] px-2 py-1.5 text-sm text-[var(--text-primary)] outline-none focus:border-cyan-500/70 placeholder:text-[var(--text-muted)]/50" + autoComplete="off" + /> + +
+ )}
)}
@@ -2416,7 +2460,7 @@ const SettingsPanel = React.memo(function SettingsPanel({
{apis.length} REGISTERED APIs - {apis.filter((a) => a.has_key).length} KEYS CONFIGURED + {apis.filter((a) => a.has_key && a.is_set).length} KEYS CONFIGURED
diff --git a/frontend/src/components/StartupWarmupModal.tsx b/frontend/src/components/StartupWarmupModal.tsx index aa39d3b..38561b8 100644 --- a/frontend/src/components/StartupWarmupModal.tsx +++ b/frontend/src/components/StartupWarmupModal.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Database, Clock, X } from 'lucide-react'; -const CURRENT_VERSION = '0.9.7'; +const CURRENT_VERSION = '0.9.75'; const STORAGE_KEY = `shadowbroker_startup_warmup_notice_v${CURRENT_VERSION}`; interface StartupWarmupModalProps { diff --git a/frontend/src/mesh/controlPlaneStatusClient.ts b/frontend/src/mesh/controlPlaneStatusClient.ts index c1b7bec..fc66fa8 100644 --- a/frontend/src/mesh/controlPlaneStatusClient.ts +++ b/frontend/src/mesh/controlPlaneStatusClient.ts @@ -81,7 +81,7 @@ export interface NodeSettingsSnapshot { export const DEFAULT_INFONET_SEED_URL = 'https://node.shadowbroker.info'; -const CACHE_TTL_MS = 15000; +const CACHE_TTL_MS = 5000; type CacheEntry = { value: T; diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts index 7e8b2e5..b7afa68 100644 --- a/frontend/src/types/dashboard.ts +++ b/frontend/src/types/dashboard.ts @@ -114,6 +114,7 @@ export interface Ship { source_url?: string; last_osint_update?: string; desc?: string; + trail?: Array<{ lat: number; lng: number; sog?: number; ts?: number } | number[]>; // Tracked yacht enrichment yacht_alert?: boolean; yacht_owner?: string; diff --git a/helm/chart/Chart.yaml b/helm/chart/Chart.yaml index 657d004..7b11207 100644 --- a/helm/chart/Chart.yaml +++ b/helm/chart/Chart.yaml @@ -1,7 +1,8 @@ --- apiVersion: v2 name: shadowbroker -version: 0.0.1 +version: 0.9.75 +appVersion: "0.9.75" description: simple shadowbroker installation type: application diff --git a/pyproject.toml b/pyproject.toml index 47a051e..b746826 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shadowbroker" -version = "0.9.7" +version = "0.9.75" readme = "README.md" requires-python = ">=3.10" dependencies = [] diff --git a/uv.lock b/uv.lock index 01bef01..05eabb4 100644 --- a/uv.lock +++ b/uv.lock @@ -74,7 +74,7 @@ wheels = [ [[package]] name = "backend" -version = "0.9.7" +version = "0.9.75" source = { virtual = "backend" } dependencies = [ { name = "apscheduler" },