mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-08 02:16:41 +02:00
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:
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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,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
@@ -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",
|
||||
|
||||
Generated
+2
-2
@@ -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,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' },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "shadowbroker"
|
||||
version = "0.9.7"
|
||||
version = "0.9.75"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = []
|
||||
|
||||
Reference in New Issue
Block a user