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.
This commit is contained in:
BigBodyCobain
2026-05-06 01:15:54 -06:00
parent a017ba86d6
commit 6ffd54931c
48 changed files with 2024 additions and 375 deletions
+3 -2
View File
@@ -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
+3 -1
View File
@@ -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/*
+33 -2
View File
@@ -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")
+2 -2
View File
@@ -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]
+76 -1
View File
@@ -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")
+2 -2
View File
@@ -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"],
+16 -2
View File
@@ -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:
+1 -1
View File
@@ -8,7 +8,7 @@ from services.data_fetcher import get_latest_data
from services.schemas import HealthResponse
import os
APP_VERSION = os.environ.get("_HEALTH_APP_VERSION", "0.9.7")
APP_VERSION = os.environ.get("_HEALTH_APP_VERSION", "0.9.75")
router = APIRouter()
+106
View File
@@ -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):
+50 -1
View File
@@ -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"] = (
@@ -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)"
)
+36 -8
View File
@@ -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.
+1 -1
View File
@@ -182,7 +182,7 @@ def fetch_meshtastic_nodes():
callsign = str(getattr(get_settings(), "MESHTASTIC_OPERATOR_CALLSIGN", "") or "").strip()
except Exception:
callsign = ""
ua_base = "ShadowBroker-OSINT/0.9.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:
+1 -1
View File
@@ -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)"
)
+12 -15
View File
@@ -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
+7 -2
View File
@@ -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)
@@ -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)),
)
+1 -1
View File
@@ -73,7 +73,7 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None,
both Python requests and the barebones Windows system curl.
"""
default_headers = {
"User-Agent": "ShadowBroker-OSINT/0.9.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)
+14 -3
View File
@@ -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()),
}
+1 -1
View File
@@ -20,7 +20,7 @@ from cachetools import TTLCache
logger = logging.getLogger(__name__)
_SHODAN_BASE = "https://api.shodan.io"
_USER_AGENT = "ShadowBroker/0.9.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
+67 -26
View File
@@ -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),
+69 -94
View File
@@ -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()
+1 -1
View File
@@ -24,7 +24,7 @@ from cachetools import TTLCache
logger = logging.getLogger(__name__)
_FINNHUB_BASE = "https://finnhub.io/api/v1"
_USER_AGENT = "ShadowBroker/0.9.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
@@ -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")
@@ -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"
+36 -1
View File
@@ -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
+2 -2
View File
@@ -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"
}
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -4201,7 +4201,7 @@ dependencies = [
[[package]]
name = "shadowbroker-tauri-shell"
version = "0.9.7"
version = "0.9.75"
dependencies = [
"axum",
"base64 0.22.1",
@@ -1,6 +1,6 @@
[package]
name = "shadowbroker-tauri-shell"
version = "0.9.7"
version = "0.9.75"
edition = "2021"
[build-dependencies]
@@ -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",
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.9.7",
"version": "0.9.75",
"private": true,
"scripts": {
"dev": "node scripts/dev-all.cjs",
@@ -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' },
],
};
+1
View File
@@ -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;
+38 -2
View File
@@ -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: <Bot size={20} className="text-purple-400" />,
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: <Bot size={20} className="text-purple-400" />,
accent: 'purple' as const,
@@ -53,6 +65,26 @@ const HEADLINE_FEATURES = [
];
const NEW_FEATURES = [
{
icon: <Clock size={18} className="text-cyan-400" />,
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: <Network size={18} className="text-green-400" />,
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: <Plane size={18} className="text-cyan-400" />,
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: <Plane size={18} className="text-amber-400" />,
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: <Cpu size={18} className="text-purple-400" />,
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.',
+142 -16
View File
@@ -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<KiwiSDR> & GeoExtras;
type ScannerProps = Partial<Scanner> & GeoExtras;
type SigintProps = Partial<SigintSignal> & 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<typeof findSelectedEntity>, 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<DynamicRoute | null>(null);
const [selectedTrailPoints, setSelectedTrailPoints] = useState<TrailPoint[]>([]);
const prevCallsign = useRef<string | null>(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
+178 -5
View File
@@ -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
</button>
<button
onClick={() => setMeshView('settings')}
className={`px-2 py-0.5 text-[11px] font-mono tracking-wider border transition-colors ${
meshView === 'settings'
? 'border-cyan-500/40 text-cyan-300 bg-cyan-950/20'
: 'border-[var(--border-primary)]/40 text-[var(--text-muted)] hover:text-cyan-300'
}`}
>
SETTINGS
</button>
</div>
<div className="text-[10px] font-mono text-[var(--text-muted)] truncate">
<div
className={`text-[10px] font-mono truncate ${
meshMqttConnected
? 'text-green-300/80'
: meshMqttEnabled
? 'text-amber-300/80'
: 'text-[var(--text-muted)]'
}`}
>
{meshSessionActive && publicMeshAddress
? `ADDR ${publicMeshAddress.toUpperCase()}`
? `${meshMqttConnectionLabel} / ADDR ${publicMeshAddress.toUpperCase()}`
: publicMeshAddress
? 'MESH OFF / KEY SAVED'
: 'NO PUBLIC MESH ADDRESS'}
? `${meshMqttConnectionLabel} / KEY SAVED`
: `${meshMqttConnectionLabel} / NO ADDRESS`}
</div>
</div>
<div className="flex-1 overflow-y-auto styled-scrollbar px-3 py-1.5 border-l-2 border-cyan-800/25">
{!meshSessionActive && (
{meshView === 'settings' && (
<div className="space-y-2 py-1 text-[11px] font-mono">
<div className="border border-cyan-800/35 bg-cyan-950/10 p-2">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-cyan-300 tracking-[0.18em]">MESHTASTIC MQTT</div>
<div className="mt-1 text-[10px] text-[var(--text-muted)] leading-[1.5]">
Public Mesh is separate from Wormhole. Turning MQTT on disables the private Wormhole lane for MeshChat.
</div>
</div>
<span
className={`shrink-0 border px-2 py-1 text-[10px] tracking-[0.16em] ${
meshMqttConnected
? 'border-green-500/40 text-green-300'
: meshMqttEnabled
? 'border-amber-500/40 text-amber-300'
: 'border-red-500/35 text-red-300'
}`}
>
{meshMqttConnectionLabel}
</span>
</div>
{meshMqttSettings?.runtime?.last_error && (
<div className="mt-2 text-red-300/80">
LAST ERROR: {meshMqttSettings.runtime.last_error}
</div>
)}
{meshMqttRunning && !meshMqttConnected && !meshMqttSettings?.runtime?.last_error && (
<div className="mt-2 text-amber-300/80">
MQTT bridge is starting. Live messages appear after broker connect.
</div>
)}
</div>
<div className="grid grid-cols-[1fr_70px] gap-2">
<label className="space-y-1">
<span className="text-[var(--text-muted)]">BROKER</span>
<input
value={meshMqttForm.broker}
onChange={(e) => setMeshMqttForm((prev) => ({ ...prev, broker: e.target.value }))}
className="w-full border border-[var(--border-primary)] bg-black/30 px-2 py-1 text-cyan-200 outline-none focus:border-cyan-500/50"
/>
</label>
<label className="space-y-1">
<span className="text-[var(--text-muted)]">PORT</span>
<input
value={meshMqttForm.port}
onChange={(e) => setMeshMqttForm((prev) => ({ ...prev, port: e.target.value }))}
className="w-full border border-[var(--border-primary)] bg-black/30 px-2 py-1 text-cyan-200 outline-none focus:border-cyan-500/50"
/>
</label>
</div>
<label className="block space-y-1">
<span className="text-[var(--text-muted)]">BROKER LOGIN (optional)</span>
<input
value={meshMqttForm.username}
onChange={(e) => setMeshMqttForm((prev) => ({ ...prev, username: e.target.value }))}
placeholder="blank uses public Meshtastic default"
className="w-full border border-[var(--border-primary)] bg-black/30 px-2 py-1 text-cyan-200 outline-none focus:border-cyan-500/50"
/>
</label>
<label className="block space-y-1">
<span className="text-[var(--text-muted)]">
BROKER PASSWORD {meshMqttSettings?.uses_default_credentials ? '(public default)' : meshMqttSettings?.has_password ? '(saved)' : ''}
</span>
<input
type="password"
value={meshMqttForm.password}
onChange={(e) => setMeshMqttForm((prev) => ({ ...prev, password: e.target.value }))}
placeholder={
meshMqttSettings?.uses_default_credentials
? 'blank uses public Meshtastic default'
: meshMqttSettings?.has_password
? 'leave blank to keep saved password'
: 'blank uses public Meshtastic default'
}
className="w-full border border-[var(--border-primary)] bg-black/30 px-2 py-1 text-cyan-200 outline-none placeholder:text-[var(--text-muted)] focus:border-cyan-500/50"
/>
</label>
<label className="block space-y-1">
<span className="text-[var(--text-muted)]">
CHANNEL PSK HEX {meshMqttSettings?.has_psk ? '(saved)' : '(default LongFast if blank)'}
</span>
<input
type="password"
value={meshMqttForm.psk}
onChange={(e) => setMeshMqttForm((prev) => ({ ...prev, psk: e.target.value }))}
placeholder="blank uses default LongFast key"
className="w-full border border-[var(--border-primary)] bg-black/30 px-2 py-1 text-cyan-200 outline-none placeholder:text-[var(--text-muted)] focus:border-cyan-500/50"
/>
</label>
<label className="flex items-center gap-2 border border-[var(--border-primary)]/40 bg-black/20 px-2 py-1 text-cyan-200">
<input
type="checkbox"
checked={meshMqttForm.include_default_roots}
onChange={(e) =>
setMeshMqttForm((prev) => ({ ...prev, include_default_roots: e.target.checked }))
}
/>
DEFAULT PUBLIC ROOTS
</label>
<label className="block space-y-1">
<span className="text-[var(--text-muted)]">EXTRA ROOTS</span>
<input
value={meshMqttForm.extra_roots}
onChange={(e) => setMeshMqttForm((prev) => ({ ...prev, extra_roots: e.target.value }))}
placeholder="comma separated, optional"
className="w-full border border-[var(--border-primary)] bg-black/30 px-2 py-1 text-cyan-200 outline-none placeholder:text-[var(--text-muted)] focus:border-cyan-500/50"
/>
</label>
<div className="grid grid-cols-3 gap-2 pt-1">
<button
onClick={() => void saveMeshMqttSettings({ enabled: true })}
disabled={meshMqttBusy}
className="border border-green-600/40 bg-green-950/20 px-2 py-1.5 text-green-300 hover:bg-green-950/35 disabled:opacity-50"
>
ENABLE
</button>
<button
onClick={() => void saveMeshMqttSettings({ enabled: false })}
disabled={meshMqttBusy}
className="border border-red-600/35 bg-red-950/15 px-2 py-1.5 text-red-300 hover:bg-red-950/25 disabled:opacity-50"
>
DISABLE
</button>
<button
onClick={() => void refreshMeshMqttSettings()}
disabled={meshMqttBusy}
className="border border-cyan-700/40 bg-cyan-950/15 px-2 py-1.5 text-cyan-300 hover:bg-cyan-950/25 disabled:opacity-50"
>
REFRESH
</button>
</div>
{meshMqttStatusText && (
<div className="text-[10px] text-cyan-200/80 leading-[1.5]">{meshMqttStatusText}</div>
)}
</div>
)}
{!meshSessionActive && meshView !== 'settings' && (
<div className="text-[12px] font-mono text-green-300/70 text-center py-4 leading-[1.65]">
MeshChat is off. Turn it on to connect the public mesh lane.
</div>
@@ -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<MeshMqttSettings | null>(null);
const [meshMqttForm, setMeshMqttForm] = useState<MeshMqttForm>({
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<NodeIdentity | null>(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<void>)[]>([]);
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<MeshMqttForm> & { enabled?: boolean } = {}) => {
setMeshMqttBusy(true);
setMeshMqttStatusText('');
try {
const nextForm = { ...meshMqttForm, ...updates };
const body: Record<string, unknown> = {
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<ReturnType<typeof setTimeout> | 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,
+208 -37
View File
@@ -100,6 +100,7 @@ const AIRCRAFT_WIKI: Record<string, string> = {
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<string, { url: string | null; loading: boolean }> = {};
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<string, string> = {
'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 (
<div className="border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px] block mb-1.5">EMISSIONS ESTIMATE</span>
<div className="flex gap-3">
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">
{observed ? 'FUEL BURNED' : 'FUEL RATE'}
</div>
<div className="text-xs font-bold text-orange-400">
{observed ? (
<>{observed.fuelGallons.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">GAL</span></>
) : emissions ? (
<>{emissions.fuel_gph} <span className="text-[11px] text-[var(--text-muted)] font-normal">GPH</span></>
) : 'UNKNOWN'}
</div>
</div>
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">
{observed ? 'CO2 PRODUCED' : 'CO2 RATE'}
</div>
<div className="text-xs font-bold text-red-400">
{observed ? (
<>{observed.co2Kg.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">KG</span></>
) : emissions ? (
<>{emissions.co2_kg_per_hour.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">KG/HR</span></>
) : 'UNKNOWN'}
</div>
</div>
</div>
{context && (
<div className="mt-1.5 text-[10px] text-[var(--text-muted)] leading-relaxed">
{context}
{observed && emissions ? ` - estimated from ${emissions.fuel_gph} GPH model rate.` : ''}
</div>
)}
</div>
);
}
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
<span className={`text-xs font-bold ${flight.squawk === '7700' ? 'text-red-400 animate-pulse' : flight.squawk === '7600' ? 'text-yellow-400' : 'text-[var(--text-primary)]'}`}>{flight.squawk}{flight.squawk === '7700' ? ' ⚠ EMERGENCY' : flight.squawk === '7600' ? ' COMMS LOST' : ''}</span>
</div>
)}
<div className="border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px] block mb-1.5">EMISSIONS ESTIMATE</span>
<div className="flex gap-3">
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">FUEL BURN</div>
<div className="text-xs font-bold text-orange-400">{flight.emissions ? <>{flight.emissions.fuel_gph} <span className="text-[11px] text-[var(--text-muted)] font-normal">GPH</span></> : 'UNKNOWN'}</div>
</div>
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">CO2 OUTPUT</div>
<div className="text-xs font-bold text-red-400">{flight.emissions ? <>{flight.emissions.co2_kg_per_hour.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">KG/HR</span></> : 'UNKNOWN'}</div>
</div>
</div>
</div>
<EmissionsEstimateBlock flight={flight} />
{flight.alert_link && (
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">REFERENCE</span>
@@ -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
<div className="p-4 flex flex-col gap-3">
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">OPERATOR</span>
{selectedEntity.type !== 'military_flight' && airline && airline !== 'COMMERCIAL FLIGHT' && airline !== 'UNKNOWN' ? (
{!isPrivateFlight && selectedEntity.type !== 'military_flight' && airline && airline !== 'COMMERCIAL FLIGHT' && airline !== 'UNKNOWN' ? (
<a
href={`https://en.wikipedia.org/wiki/${encodeURIComponent(airline.replace(/ /g, '_'))}`}
target="_blank"
@@ -812,7 +973,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
)}
</div>
{/* 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' && (
<div className="border-b border-[var(--border-primary)] pb-2">
<WikiImage
wikiUrl={`https://en.wikipedia.org/wiki/${encodeURIComponent(airline.replace(/ /g, '_'))}`}
@@ -828,7 +989,18 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
</div>
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">AIRCRAFT MODEL</span>
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.model || "UNKNOWN"}</span>
{showModelWiki && aircraftModelWikiUrl ? (
<a
href={aircraftModelWikiUrl}
target="_blank"
rel="noreferrer"
className="text-xs font-bold text-cyan-400 hover:text-cyan-300 underline"
>
{aircraftWikiTitle || flight.model}
</a>
) : (
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.model || "UNKNOWN"}</span>
)}
</div>
{/* 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 && (
<div className="border-b border-[var(--border-primary)] pb-3">
<WikiImage
wikiUrl={aircraftModelWikiUrl}
label={aircraftWikiTitle || flight.model}
maxH="max-h-36"
accent="hover:border-purple-400/60"
/>
</div>
)}
{/* 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) && (
<div className="border-b border-[var(--border-primary)] pb-3">
{aircraftImgLoading && (
<div className="w-full h-24 bg-[var(--bg-tertiary)]/60" />
@@ -924,19 +1107,7 @@ function NewsFeedInner({ selectedEntity, regionDossier, regionDossierLoading, on
<span className="text-[var(--text-muted)] text-[10px]">ROUTE</span>
<span className="text-cyan-400 text-xs font-bold">{flight.origin_name !== "UNKNOWN" ? `[${flight.origin_name}] → [${flight.dest_name}]` : "UNKNOWN"}</span>
</div>
<div className="border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px] block mb-1.5">EMISSIONS ESTIMATE</span>
<div className="flex gap-3">
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">FUEL BURN</div>
<div className="text-xs font-bold text-orange-400">{flight.emissions ? <>{flight.emissions.fuel_gph} <span className="text-[11px] text-[var(--text-muted)] font-normal">GPH</span></> : 'UNKNOWN'}</div>
</div>
<div className="flex-1 bg-[var(--bg-primary)]/50 border border-[var(--border-primary)] px-2 py-1.5">
<div className="text-[11px] text-[var(--text-muted)] tracking-widest">CO2 OUTPUT</div>
<div className="text-xs font-bold text-red-400">{flight.emissions ? <>{flight.emissions.co2_kg_per_hour.toLocaleString()} <span className="text-[11px] text-[var(--text-muted)] font-normal">KG/HR</span></> : 'UNKNOWN'}</div>
</div>
</div>
</div>
<EmissionsEstimateBlock flight={flight} />
{flight.icao24 && (
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
<span className="text-[var(--text-muted)] text-[10px]">FLIGHT RECORD</span>
+270 -7
View File
@@ -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 || '<prepare remote .onion link>';
const agentSnippet = [
`SHADOWBROKER_URL=${agentEndpoint}`,
agentSecret ? `SHADOWBROKER_KEY=${agentSecret}` : 'SHADOWBROKER_KEY=<generate in ShadowBroker>',
`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 (
<AnimatePresence>
{/* Backdrop */}
@@ -166,7 +278,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
{/* Step Indicators */}
<div className="flex gap-2 px-6 pt-4">
{['API Keys', 'Trust Modes', 'Free Sources'].map((label, i) => (
{['API Keys', 'AI Agent', 'Trust Modes', 'Free Sources'].map((label, i) => (
<button
key={label}
onClick={() => setStep(i)}
@@ -183,7 +295,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
{/* Content */}
<div className="flex-1 overflow-y-auto styled-scrollbar p-6">
{step === 1 && (
{step === 2 && (
<div className="space-y-4">
<div className="text-center py-4">
<div className="text-lg font-bold tracking-[0.3em] text-[var(--text-primary)] font-mono mb-2">
@@ -246,6 +358,157 @@ const OnboardingModal = React.memo(function OnboardingModal({
</div>
)}
{step === 1 && (
<div className="space-y-5">
<div>
<p className="text-[11px] text-violet-300 font-mono font-bold tracking-widest mb-2">
STEP 1 - WHERE IS YOUR AGENT?
</p>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => setAgentMode('local')}
className={`border px-4 py-3 text-left transition-all ${
agentMode === 'local'
? 'border-cyan-500/50 bg-cyan-950/40'
: 'border-[var(--border-primary)] hover:border-cyan-500/30'
}`}
>
<p className={`text-sm font-mono font-bold ${agentMode === 'local' ? 'text-cyan-300' : 'text-[var(--text-secondary)]'}`}>
Local
</p>
<p className="text-[10px] text-[var(--text-muted)] font-mono mt-1">
Same machine as ShadowBroker
</p>
</button>
<button
onClick={() => setAgentMode('remote')}
className={`border px-4 py-3 text-left transition-all ${
agentMode === 'remote'
? 'border-violet-500/50 bg-violet-950/40'
: 'border-[var(--border-primary)] hover:border-violet-500/30'
}`}
>
<p className={`text-sm font-mono font-bold ${agentMode === 'remote' ? 'text-violet-300' : 'text-[var(--text-secondary)]'}`}>
Remote
</p>
<p className="text-[10px] text-[var(--text-muted)] font-mono mt-1">
Different machine over Tor
</p>
</button>
</div>
</div>
<div>
<p className="text-[11px] text-violet-300 font-mono font-bold tracking-widest mb-2">
STEP 2 - WHAT CAN IT DO?
</p>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => void saveAgentTier('restricted')}
className={`border px-4 py-3 text-left transition-all ${
agentTier === 'restricted'
? 'border-green-500/50 bg-green-950/30'
: 'border-[var(--border-primary)] hover:border-green-500/30'
}`}
>
<p className="text-sm text-green-300 font-mono font-bold flex items-center gap-2">
<Shield size={14} /> Read Only
</p>
<p className="text-[10px] text-[var(--text-muted)] font-mono mt-2">
Can see live telemetry but cannot change anything
</p>
</button>
<button
onClick={() => void saveAgentTier('full')}
className={`border px-4 py-3 text-left transition-all ${
agentTier === 'full'
? 'border-amber-500/50 bg-amber-950/30'
: 'border-[var(--border-primary)] hover:border-amber-500/30'
}`}
>
<p className="text-sm text-amber-300 font-mono font-bold flex items-center gap-2">
<Network size={14} /> Full Access
</p>
<p className="text-[10px] text-[var(--text-muted)] font-mono mt-2">
Can place pins, create layers, and trigger display actions
</p>
</button>
</div>
</div>
<div>
<div className="flex items-center justify-between gap-3 mb-2">
<div>
<p className="text-[11px] text-violet-300 font-mono font-bold tracking-widest">
STEP 3 - COPY THIS INTO YOUR AGENT
</p>
<p className="text-[10px] text-[var(--text-muted)] font-mono mt-1">
Generate a local key, then copy these variables into OpenClaw, Hermes, or another HMAC agent.
</p>
</div>
<button
onClick={() => void fetchAgentConnectInfo(true)}
disabled={agentLoading}
className="px-3 py-2 border border-violet-500/40 text-violet-300 hover:bg-violet-500/10 disabled:opacity-50 text-[11px] font-mono tracking-widest"
>
{agentLoading ? 'GENERATING...' : 'GENERATE'}
</button>
</div>
{remoteAgentNeedsTor && (
<div className="mb-2 border border-violet-500/30 bg-violet-950/20 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[11px] text-violet-200 font-mono font-bold tracking-widest">
TOR REQUIRED FOR REMOTE AGENTS
</p>
<p className="text-[10px] text-[var(--text-muted)] font-mono mt-1 leading-relaxed">
ShadowBroker will install or use Tor locally, then create a private .onion link for this backend.
</p>
</div>
<button
onClick={() => void prepareTorAgentAddress()}
disabled={torStarting}
className="shrink-0 px-3 py-2 border border-violet-500/40 text-violet-200 hover:bg-violet-500/10 disabled:opacity-50 text-[10px] font-mono tracking-widest flex items-center gap-2"
>
<Network size={13} />
{torStarting ? 'INSTALLING...' : 'INSTALL TOR'}
</button>
</div>
</div>
)}
<div className="relative">
<pre className="min-h-40 max-h-56 overflow-auto styled-scrollbar bg-[var(--bg-primary)] border border-violet-500/30 p-4 pr-24 text-[12px] text-violet-100 font-mono whitespace-pre-wrap leading-relaxed">
{agentSnippet}
</pre>
<button
onClick={() => void copyAgentSnippet()}
disabled={remoteAgentNeedsTor}
className="absolute top-3 right-3 px-3 py-2 border border-violet-500/50 bg-violet-950/50 text-violet-200 hover:bg-violet-800/30 disabled:opacity-45 disabled:hover:bg-violet-950/50 text-[11px] font-mono tracking-widest flex items-center gap-2"
>
{agentCopied ? <Check size={13} /> : <Copy size={13} />}
{agentCopied ? 'COPIED' : 'COPY'}
</button>
</div>
{agentMsg && (
<p
className={`mt-2 text-sm font-mono ${
agentMsg.type === 'ok' ? 'text-green-300' : 'text-red-300'
}`}
>
{agentMsg.text}
</p>
)}
</div>
<p className="text-[11px] text-orange-300/80 font-mono leading-relaxed">
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.
</p>
</div>
)}
{step === 0 && (
<div className="space-y-4">
<div className="bg-yellow-950/20 border border-yellow-500/20 p-4">
@@ -359,7 +622,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
</div>
)}
{step === 2 && (
{step === 3 && (
<div className="space-y-3">
<p className="text-sm text-[var(--text-secondary)] font-mono mb-3">
These data sources are completely free and require no API keys. They activate
@@ -401,7 +664,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
</button>
<div className="flex gap-1.5">
{[0, 1, 2].map((i) => (
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className={`w-1.5 h-1.5 rounded-full transition-colors ${step === i ? 'bg-cyan-400' : 'bg-[var(--border-primary)]'}`}
@@ -409,7 +672,7 @@ const OnboardingModal = React.memo(function OnboardingModal({
))}
</div>
{step < 2 ? (
{step < 3 ? (
<button
onClick={() => setStep(step + 1)}
className="px-4 py-2 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/10 text-sm font-mono tracking-widest transition-all"
+90 -46
View File
@@ -500,6 +500,7 @@ const SettingsPanel = React.memo(function SettingsPanel({
// stored server-side, and never returned to the browser.
const [apis, setApis] = useState<ApiEntry[]>([]);
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
const [apiKeyEditing, setApiKeyEditing] = useState<Record<string, boolean>>({});
const [apiKeySaving, setApiKeySaving] = useState<string | null>(null);
const [apiKeyMsg, setApiKeyMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
@@ -573,6 +574,7 @@ const SettingsPanel = React.memo(function SettingsPanel({
if (result.keys) setApis(result.keys);
if (result.env) setEnvMeta(result.env);
setApiKeyInputs((prev) => ({ ...prev, [envKey]: '' }));
setApiKeyEditing((prev) => ({ ...prev, [envKey]: false }));
setApiKeyMsg({ type: 'ok', text: `${envKey} saved locally. Restart or refresh feeds to use it.` });
} catch (e) {
const message = e instanceof Error ? e.message : 'Could not save API key';
@@ -2215,6 +2217,10 @@ const SettingsPanel = React.memo(function SettingsPanel({
aircraft and vessel feeds.
</p>
</div>
<div className="pl-5 text-[12px] font-mono text-cyan-200/80 leading-relaxed">
Configured keys stay hidden for shared dashboards. Unlock operator tools, then
use ROTATE only when you intentionally want to replace a working credential.
</div>
{envMeta && (
<div className="pl-5 text-[12px] font-mono text-[var(--text-muted)] leading-relaxed space-y-0.5">
<div>
@@ -2344,17 +2350,53 @@ const SettingsPanel = React.memo(function SettingsPanel({
{api.has_key && (
<div className="mt-2 space-y-2 text-[12px] font-mono">
{api.is_set ? (
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 border border-green-500/40 bg-green-950/20 text-green-300 tracking-wider">
CONFIGURED
</span>
<span className="text-[var(--text-muted)]">
edit{' '}
<span className="text-cyan-300 select-all break-all">
{api.env_key}
</span>{' '}
Enter a replacement below if you need to rotate it.
</span>
<div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex items-center gap-2">
<span className="px-2 py-0.5 border border-green-500/40 bg-green-950/20 text-green-300 tracking-wider">
CONFIGURED
</span>
<span className="text-[var(--text-muted)] leading-relaxed">
Secret hidden. Stored write-only on this backend as{' '}
<span className="text-cyan-300 select-all break-all">
{api.env_key}
</span>
.
</span>
</div>
{api.env_key && (
<button
type="button"
onClick={() => {
if (!(nativeProtected || adminSessionReady)) {
setApiKeyMsg({
type: 'err',
text: 'Unlock operator tools before rotating a configured key.',
});
return;
}
setApiKeyMsg(null);
setApiKeyEditing((prev) => ({
...prev,
[api.env_key as string]: !prev[api.env_key as string],
}));
}}
className={`shrink-0 px-2 py-1 border text-[11px] tracking-widest transition-colors ${
nativeProtected || adminSessionReady
? 'border-yellow-500/40 text-yellow-300 hover:bg-yellow-500/10'
: 'border-[var(--border-primary)] text-[var(--text-muted)] hover:border-yellow-500/30 hover:text-yellow-300/80'
}`}
>
{apiKeyEditing[api.env_key] ? 'CANCEL' : 'ROTATE'}
</button>
)}
</div>
{!(nativeProtected || adminSessionReady) && (
<div className="text-[11px] text-yellow-300/70 leading-relaxed">
Operator tools are locked. Viewers can see source status
but cannot replace saved credentials.
</div>
)}
</div>
) : (
<div className="flex items-center gap-2">
@@ -2366,40 +2408,42 @@ const SettingsPanel = React.memo(function SettingsPanel({
</span>
</div>
)}
<div className="flex items-center gap-2">
<input
type="password"
value={api.env_key ? apiKeyInputs[api.env_key] || '' : ''}
onChange={(event) => {
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"
/>
<button
onClick={() => void saveApiKey(api.env_key)}
disabled={
!api.env_key ||
apiKeySaving === api.env_key ||
!String(
api.env_key ? apiKeyInputs[api.env_key] || '' : '',
).trim()
}
className="h-8 px-3 border border-cyan-500/40 bg-cyan-950/20 text-cyan-300 hover:bg-cyan-500/15 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5 tracking-widest"
>
<Save size={12} />
{apiKeySaving === api.env_key ? 'SAVING' : 'SAVE'}
</button>
</div>
{(!api.is_set || (api.env_key && apiKeyEditing[api.env_key])) && (
<div className="flex items-center gap-2">
<input
type="password"
value={api.env_key ? apiKeyInputs[api.env_key] || '' : ''}
onChange={(event) => {
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"
/>
<button
onClick={() => void saveApiKey(api.env_key)}
disabled={
!api.env_key ||
apiKeySaving === api.env_key ||
!String(
api.env_key ? apiKeyInputs[api.env_key] || '' : '',
).trim()
}
className="h-8 px-3 border border-cyan-500/40 bg-cyan-950/20 text-cyan-300 hover:bg-cyan-500/15 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5 tracking-widest"
>
<Save size={12} />
{apiKeySaving === api.env_key ? 'SAVING' : 'SAVE'}
</button>
</div>
)}
</div>
)}
</div>
@@ -2416,7 +2460,7 @@ const SettingsPanel = React.memo(function SettingsPanel({
<div className="p-4 border-t border-[var(--border-primary)]/80">
<div className="flex items-center justify-between text-[13px] text-[var(--text-muted)] font-mono">
<span>{apis.length} REGISTERED APIs</span>
<span>{apis.filter((a) => a.has_key).length} KEYS CONFIGURED</span>
<span>{apis.filter((a) => a.has_key && a.is_set).length} KEYS CONFIGURED</span>
</div>
</div>
</>
@@ -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 {
@@ -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<T> = {
value: T;
+1
View File
@@ -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;
+2 -1
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "shadowbroker"
version = "0.9.7"
version = "0.9.75"
readme = "README.md"
requires-python = ">=3.10"
dependencies = []
Generated
+1 -1
View File
@@ -74,7 +74,7 @@ wheels = [
[[package]]
name = "backend"
version = "0.9.7"
version = "0.9.75"
source = { virtual = "backend" }
dependencies = [
{ name = "apscheduler" },