Release v0.9.75 runtime and onboarding update

Ship the 0.9.75 source update with improved startup/runtime hardening, operator API key onboarding, Meshtastic MQTT controls, Infonet/MeshChat separation, desktop package versioning, and aircraft telemetry refinements.

Also updates focused backend/frontend tests for node settings, Meshtastic MQTT settings, and desktop runtime behavior.
This commit is contained in:
BigBodyCobain
2026-05-06 01:15:54 -06:00
parent a017ba86d6
commit 6ffd54931c
48 changed files with 2024 additions and 375 deletions
+3 -2
View File
@@ -54,8 +54,9 @@ AIS_API_KEY= # https://aisstream.io/ — free tier WebSocket key
# MESH_MQTT_INCLUDE_DEFAULT_ROOTS=true
# MESH_MQTT_BROKER=mqtt.meshtastic.org
# MESH_MQTT_PORT=1883
# MESH_MQTT_USER=meshdev
# MESH_MQTT_PASS=large4cats
# Leave user/pass blank for the public Meshtastic broker default.
# MESH_MQTT_USER=
# MESH_MQTT_PASS=
# Optional Meshtastic node ID (e.g. "!abcd1234"). When set, included in the
# User-Agent sent to meshtastic.liamcottle.net so the upstream service operator
+3 -1
View File
@@ -22,10 +22,12 @@ FROM python:3.11-slim-bookworm
WORKDIR /app
# Install Node.js (for AIS WebSocket proxy) and curl (for network fallback)
# Install Node.js (for AIS WebSocket proxy), curl (for network fallback), and
# Tor (for Wormhole/remote-agent .onion transport).
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
tor \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
+33 -2
View File
@@ -14,7 +14,7 @@ from dataclasses import dataclass, field
from typing import Any
from json import JSONDecodeError
APP_VERSION = "0.9.7"
APP_VERSION = "0.9.75"
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@@ -1489,6 +1489,33 @@ def _run_public_sync_cycle() -> SyncWorkerState:
)
_NODE_SYNC_KICK_LOCK = threading.Lock()
def _kick_public_sync_background(reason: str = "") -> None:
"""Start one immediate Infonet sync attempt without waiting for the poll loop."""
if not _node_runtime_supported() or not _participant_node_enabled():
return
def _runner() -> None:
if not _NODE_SYNC_KICK_LOCK.acquire(blocking=False):
return
try:
label = f" ({reason})" if reason else ""
logger.info("Infonet sync kick starting%s", label)
_run_public_sync_cycle()
except Exception:
logger.exception("Infonet sync kick failed")
finally:
_NODE_SYNC_KICK_LOCK.release()
threading.Thread(
target=_runner,
daemon=True,
name="infonet-sync-kick",
).start()
def _public_infonet_sync_loop() -> None:
from services.mesh.mesh_hashchain import infonet
@@ -2205,6 +2232,7 @@ async def lifespan(app: FastAPI):
set_sync_state(_set_node_sync_disabled_state())
_NODE_SYNC_STOP.clear()
threading.Thread(target=_public_infonet_sync_loop, daemon=True).start()
_kick_public_sync_background("startup")
threading.Thread(target=_http_peer_push_loop, daemon=True).start()
threading.Thread(target=_http_gate_push_loop, daemon=True).start()
threading.Thread(target=_http_gate_pull_loop, daemon=True).start()
@@ -8784,7 +8812,10 @@ async def api_get_node_settings(request: Request):
@limiter.limit("10/minute")
async def api_set_node_settings(request: Request, body: NodeSettingsUpdate):
_refresh_node_peer_store()
return _set_participant_node_enabled(bool(body.enabled))
result = _set_participant_node_enabled(bool(body.enabled))
if bool(body.enabled):
_kick_public_sync_background("operator_enable")
return result
@app.get("/api/settings/wormhole")
+2 -2
View File
@@ -7,7 +7,7 @@ py-modules = []
[project]
name = "backend"
version = "0.9.7"
version = "0.9.75"
requires-python = ">=3.10"
dependencies = [
"apscheduler==3.10.3",
@@ -42,7 +42,7 @@ dev = ["pytest>=8.3.4", "pytest-asyncio==0.25.0", "ruff>=0.9.0", "black>=24.0.0"
[tool.ruff.lint]
# The current backend carries historical style debt in large legacy modules.
# Keep CI focused on actionable correctness checks for the v0.9.7 release.
# Keep CI focused on actionable correctness checks for the v0.9.75 release.
ignore = ["E401", "E402", "E701", "E731", "E741", "F401", "F402", "F541", "F811", "F841"]
[tool.black]
+76 -1
View File
@@ -28,6 +28,18 @@ class TimeMachineToggle(BaseModel):
enabled: bool
class MeshtasticMqttUpdate(BaseModel):
enabled: bool | None = None
broker: str | None = None
port: int | None = None
username: str | None = None
password: str | None = None
psk: str | None = None
include_default_roots: bool | None = None
extra_roots: str | None = None
extra_topics: str | None = None
@router.get("/api/settings/api-keys", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute")
async def api_get_keys(request: Request):
@@ -120,7 +132,70 @@ async def api_get_node_settings(request: Request):
@limiter.limit("10/minute")
async def api_set_node_settings(request: Request, body: NodeSettingsUpdate):
_refresh_node_peer_store()
return _set_participant_node_enabled(bool(body.enabled))
result = _set_participant_node_enabled(bool(body.enabled))
if bool(body.enabled):
try:
import main as _main
_main._kick_public_sync_background("operator_enable")
except Exception:
logger.debug("Unable to kick Infonet sync after node enable", exc_info=True)
return result
def _meshtastic_runtime_snapshot() -> dict[str, Any]:
from services.meshtastic_mqtt_settings import redacted_meshtastic_mqtt_settings
from services.sigint_bridge import sigint_grid
return {
**redacted_meshtastic_mqtt_settings(),
"runtime": sigint_grid.mesh.status(),
}
@router.get("/api/settings/meshtastic-mqtt", dependencies=[Depends(require_local_operator)])
@limiter.limit("30/minute")
async def api_get_meshtastic_mqtt_settings(request: Request):
return _meshtastic_runtime_snapshot()
@router.put("/api/settings/meshtastic-mqtt", dependencies=[Depends(require_local_operator)])
@limiter.limit("10/minute")
async def api_set_meshtastic_mqtt_settings(request: Request, body: MeshtasticMqttUpdate):
from services.meshtastic_mqtt_settings import write_meshtastic_mqtt_settings
from services.sigint_bridge import sigint_grid
updates = body.model_dump(exclude_unset=True)
# Empty secret fields mean "keep existing"; explicit non-empty values replace.
if updates.get("password") == "":
updates.pop("password", None)
if updates.get("psk") == "":
updates.pop("psk", None)
enabled_requested = updates.get("enabled")
settings = write_meshtastic_mqtt_settings(**updates)
if enabled_requested is True:
# Public MQTT and Wormhole are intentionally mutually exclusive lanes.
try:
from services.wormhole_settings import write_wormhole_settings
from services.wormhole_supervisor import disconnect_wormhole
write_wormhole_settings(enabled=False)
disconnect_wormhole(reason="public_mesh_enabled")
except Exception as exc:
logger.warning("Failed to disable Wormhole while enabling public mesh: %s", exc)
if bool(settings.get("enabled")):
if sigint_grid.mesh.is_running():
sigint_grid.mesh.stop()
threading.Timer(1.0, sigint_grid.mesh.start).start()
else:
sigint_grid.mesh.start()
else:
sigint_grid.mesh.stop()
return _meshtastic_runtime_snapshot()
@router.get("/api/settings/timemachine")
+2 -2
View File
@@ -1585,7 +1585,7 @@ async def agent_tool_manifest(request: Request):
return {
"ok": True,
"version": "0.9.7",
"version": "0.9.75",
"access_tier": access_tier,
"available_commands": available_commands,
"transport": {
@@ -2221,7 +2221,7 @@ async def api_capabilities(request: Request):
access_tier = str(get_settings().OPENCLAW_ACCESS_TIER or "restricted").strip().lower()
return {
"ok": True,
"version": "0.9.7",
"version": "0.9.75",
"auth": {
"method": "HMAC-SHA256",
"headers": ["X-SB-Timestamp", "X-SB-Nonce", "X-SB-Signature"],
+16 -2
View File
@@ -282,6 +282,20 @@ async def ais_feed(request: Request):
return {"status": "ok", "ingested": count}
@router.get("/api/trail/flight/{icao24}")
@limiter.limit("120/minute")
async def get_selected_flight_trail(icao24: str, request: Request): # noqa: ARG001
from services.fetchers.flights import get_flight_trail
return {"id": icao24, "trail": get_flight_trail(icao24)}
@router.get("/api/trail/ship/{mmsi}")
@limiter.limit("120/minute")
async def get_selected_ship_trail(mmsi: int, request: Request): # noqa: ARG001
from services.ais_stream import get_vessel_trail
return {"id": mmsi, "trail": get_vessel_trail(mmsi)}
@router.post("/api/viewport")
@limiter.limit("60/minute")
async def update_viewport(vp: ViewportUpdate, request: Request): # noqa: ARG001
@@ -325,8 +339,8 @@ async def update_layers(update: LayerUpdate, request: Request):
logger.info("Meshtastic MQTT bridge stopped (layer disabled)")
elif not old_mesh and new_mesh:
try:
from services.config import get_settings
mqtt_enabled = bool(getattr(get_settings(), "MESH_MQTT_ENABLED", False))
from services.meshtastic_mqtt_settings import mqtt_bridge_enabled
mqtt_enabled = mqtt_bridge_enabled()
except Exception:
mqtt_enabled = False
if mqtt_enabled:
+1 -1
View File
@@ -8,7 +8,7 @@ from services.data_fetcher import get_latest_data
from services.schemas import HealthResponse
import os
APP_VERSION = os.environ.get("_HEALTH_APP_VERSION", "0.9.7")
APP_VERSION = os.environ.get("_HEALTH_APP_VERSION", "0.9.75")
router = APIRouter()
+106
View File
@@ -754,6 +754,112 @@ async def mesh_send(request: Request):
}
@router.post("/api/mesh/meshtastic/send", dependencies=[Depends(require_local_operator)])
@limiter.limit("10/minute")
@mesh_write_exempt(MeshWriteExemption.LOCAL_OPERATOR_ONLY)
async def meshtastic_public_send(request: Request):
"""Local public-MQTT send path for standalone Meshtastic-style identities."""
body = await request.json()
destination = str(body.get("destination", "") or "").strip() or "broadcast"
message = str(body.get("message", "") or "")
sender_id = str(body.get("sender_id", "") or "").strip().lower()
if not message:
return {"ok": False, "detail": "Missing required field: message"}
from services.mesh.mesh_router import (
MeshEnvelope,
MeshtasticTransport,
Priority,
TransportResult,
mesh_router,
)
from services.meshtastic_mqtt_settings import mqtt_bridge_enabled
if MeshtasticTransport._parse_node_id(sender_id) is None:
return {"ok": False, "detail": "Missing or invalid public Meshtastic address"}
if not mqtt_bridge_enabled():
return {"ok": False, "detail": "Meshtastic MQTT bridge is disabled"}
payload_bytes = len(message.encode("utf-8"))
payload_type = str(body.get("payload_type", "text") or "text")
max_bytes = _BYTE_LIMITS.get(payload_type, 200)
if payload_bytes > max_bytes:
return {
"ok": False,
"detail": f"Message too long ({payload_bytes} bytes). Maximum: {max_bytes} bytes for {payload_type} messages.",
}
priority_str = str(body.get("priority", "normal") or "normal").lower()
throttle_ok, throttle_reason = _check_throttle(sender_id, priority_str, "meshtastic")
if not throttle_ok:
return {"ok": False, "detail": throttle_reason}
priority_map = {
"emergency": Priority.EMERGENCY,
"high": Priority.HIGH,
"normal": Priority.NORMAL,
"low": Priority.LOW,
}
priority = priority_map.get(priority_str, Priority.NORMAL)
envelope = MeshEnvelope(
sender_id=sender_id,
destination=destination,
channel=str(body.get("channel", "LongFast") or "LongFast"),
priority=priority,
payload=message,
ephemeral=bool(body.get("ephemeral", False)),
trust_tier="public_degraded",
)
if not mesh_router.meshtastic.can_reach(envelope):
results = [TransportResult(False, "meshtastic", "Message exceeds Meshtastic payload limit")]
else:
cb_ok, cb_reason = mesh_router.breakers["meshtastic"].check_and_record(envelope.priority)
if not cb_ok:
results = [TransportResult(False, "meshtastic", cb_reason)]
else:
envelope.route_reason = (
"Local public Meshtastic MQTT path"
if MeshtasticTransport._parse_node_id(destination) is None
else "Local public Meshtastic direct node path"
)
credentials = {"mesh_region": str(body.get("mesh_region", "US") or "US")}
result = mesh_router.meshtastic.send(envelope, credentials)
if result.ok:
envelope.routed_via = mesh_router.meshtastic.NAME
results = [result]
any_ok = any(r.ok for r in results)
if any_ok and envelope.routed_via == "meshtastic":
try:
from datetime import datetime
from services.sigint_bridge import sigint_grid
bridge = sigint_grid.mesh
if bridge:
bridge.messages.appendleft(
{
"from": MeshtasticTransport.mesh_address_for_sender(sender_id),
"to": destination if MeshtasticTransport._parse_node_id(destination) is not None else "broadcast",
"text": message,
"region": str(body.get("mesh_region", "US") or "US"),
"channel": str(body.get("channel", "LongFast") or "LongFast"),
"timestamp": datetime.utcnow().isoformat() + "Z",
}
)
except Exception:
pass
return {
"ok": any_ok,
"message_id": envelope.message_id,
"event_id": "",
"routed_via": envelope.routed_via,
"route_reason": envelope.route_reason,
"results": [r.to_dict() for r in results],
}
@router.get("/api/mesh/log")
@limiter.limit("30/minute")
async def mesh_log(request: Request):
+50 -1
View File
@@ -339,16 +339,61 @@ def get_country_from_mmsi(mmsi: int) -> str:
# Global vessel store: MMSI → vessel dict
_vessels: dict[int, dict] = {}
_vessel_trails: dict[int, dict] = {}
_vessels_lock = threading.Lock()
_ws_thread: threading.Thread | None = None
_ws_running = False
_proxy_process = None
_VESSEL_TRAIL_INTERVAL_S = 120
_VESSEL_TRAIL_MAX_POINTS = 240
import os
CACHE_FILE = os.path.join(os.path.dirname(__file__), "ais_cache.json")
def _record_vessel_trail_locked(mmsi: int, lat, lng, sog=0, now_ts: float | None = None) -> None:
"""Append a sampled AIS trail point. Caller must hold _vessels_lock."""
if lat is None or lng is None:
return
try:
lat_f = float(lat)
lng_f = float(lng)
except (TypeError, ValueError):
return
if abs(lat_f) > 90 or abs(lng_f) > 180 or (lat_f == 0 and lng_f == 0):
return
now = now_ts or time.time()
trail_data = _vessel_trails.setdefault(int(mmsi), {"points": [], "last_seen": now})
point = [round(lat_f, 5), round(lng_f, 5), round(float(sog or 0), 1), round(now)]
last_point_ts = trail_data["points"][-1][3] if trail_data["points"] else 0
if now - last_point_ts < _VESSEL_TRAIL_INTERVAL_S:
trail_data["last_seen"] = now
return
if (
trail_data["points"]
and trail_data["points"][-1][0] == point[0]
and trail_data["points"][-1][1] == point[1]
):
trail_data["last_seen"] = now
return
trail_data["points"].append(point)
trail_data["last_seen"] = now
if len(trail_data["points"]) > _VESSEL_TRAIL_MAX_POINTS:
trail_data["points"] = trail_data["points"][-_VESSEL_TRAIL_MAX_POINTS:]
def get_vessel_trail(mmsi: int) -> list:
"""Return the accumulated trail for a single vessel without expanding live payloads."""
try:
key = int(mmsi)
except (TypeError, ValueError):
return []
with _vessels_lock:
points = _vessel_trails.get(key, {}).get("points", [])
return [list(point) for point in points]
def _save_cache():
"""Save vessel data to disk for persistence across restarts."""
try:
@@ -391,6 +436,7 @@ def prune_stale_vessels():
stale_keys = [k for k, v in _vessels.items() if v.get("_updated", 0) < stale_cutoff]
for k in stale_keys:
del _vessels[k]
_vessel_trails.pop(k, None)
if stale_keys:
logger.info(f"AIS pruned {len(stale_keys)} stale vessels")
@@ -459,6 +505,7 @@ def ingest_ais_catcher(msgs: list[dict]) -> int:
heading = msg.get("heading", 511)
vessel["heading"] = heading if heading != 511 else vessel.get("cog", 0)
vessel["_updated"] = now
_record_vessel_trail_locked(mmsi, lat, lon, vessel["sog"], now)
if msg.get("shipname"):
vessel["name"] = msg["shipname"].strip()
count += 1
@@ -595,7 +642,9 @@ def _ais_stream_loop():
vessel["cog"] = report.get("Cog", 0)
heading = report.get("TrueHeading", 511)
vessel["heading"] = heading if heading != 511 else report.get("Cog", 0)
vessel["_updated"] = time.time()
now_ts = time.time()
vessel["_updated"] = now_ts
_record_vessel_trail_locked(mmsi, lat, lng, vessel["sog"], now_ts)
# Use metadata name if we don't have one yet
if not vessel.get("name") or vessel["name"] == "UNKNOWN":
vessel["name"] = (
@@ -32,7 +32,7 @@ _REFRESH_INTERVAL_S = 5 * 24 * 3600
_LIST_TIMEOUT_S = 30
_DOWNLOAD_TIMEOUT_S = 600
_USER_AGENT = (
"ShadowBroker-OSINT/0.9.7 "
"ShadowBroker-OSINT/0.9.75 "
"(+https://github.com/BigBodyCobain/Shadowbroker; "
"contact: bigbodycobain@gmail.com)"
)
+36 -8
View File
@@ -258,6 +258,16 @@ flight_trails = {} # {icao_hex: {points: [[lat, lng, alt, ts], ...], last_seen:
_trails_lock = threading.Lock()
_MAX_TRACKED_TRAILS = 2000
def get_flight_trail(icao24: str) -> list:
"""Return the accumulated trail for a single aircraft without expanding live payloads."""
hex_id = str(icao24 or "").strip().lower()
if not hex_id:
return []
with _trails_lock:
points = flight_trails.get(hex_id, {}).get("points", [])
return [list(point) for point in points]
# Route enrichment is now served from services.fetchers.route_database, which
# bulk-loads vrs-standing-data.adsb.lol/routes.csv.gz once per day and looks up
# callsigns from an in-memory index. Replaces the legacy /api/0/routeset POST,
@@ -612,13 +622,22 @@ def _classify_and_publish(all_adsb_flights):
)
# --- Trail Accumulation ---
_TRAIL_INTERVAL_S = 600 # only record a new trail point every 10 minutes
_TRAIL_INTERVAL_S = 60 # selected trails need enough resolution to show where unknown-route traffic came from
def _accumulate_trail(f, now_ts, check_route=True):
hex_id = f.get("icao24", "").lower()
if not hex_id:
return 0, None
if check_route and f.get("origin_name", "UNKNOWN") != "UNKNOWN":
def _known_route_name(value):
normalized = str(value or "").strip().upper()
return bool(normalized and normalized != "UNKNOWN")
has_known_route = bool(
(f.get("origin_loc") and f.get("dest_loc"))
or (_known_route_name(f.get("origin_name")) and _known_route_name(f.get("dest_name")))
)
if check_route and has_known_route:
f["trail"] = []
return 0, hex_id
lat, lng, alt = f.get("lat"), f.get("lng"), f.get("alt", 0)
@@ -629,7 +648,7 @@ def _classify_and_publish(all_adsb_flights):
if hex_id not in flight_trails:
flight_trails[hex_id] = {"points": [], "last_seen": now_ts}
trail_data = flight_trails[hex_id]
# Only append a new point if 10 minutes have passed since the last one
# Only append a new point if enough time has passed since the last one
last_point_ts = trail_data["points"][-1][3] if trail_data["points"] else 0
if now_ts - last_point_ts < _TRAIL_INTERVAL_S:
trail_data["last_seen"] = now_ts
@@ -649,14 +668,16 @@ def _classify_and_publish(all_adsb_flights):
now_ts = datetime.utcnow().timestamp()
with _data_lock:
commercial_snapshot = copy.deepcopy(latest_data.get("commercial_flights", []))
private_jets_snapshot = copy.deepcopy(latest_data.get("private_jets", []))
private_ga_snapshot = copy.deepcopy(latest_data.get("private_flights", []))
military_snapshot = copy.deepcopy(latest_data.get("military_flights", []))
tracked_snapshot = copy.deepcopy(latest_data.get("tracked_flights", []))
raw_flights_snapshot = list(latest_data.get("flights", []))
# Commercial/private: skip trail if route is known (route line replaces trail)
route_check_lists = [commercial, private_jets, private_ga]
# Tracked + military: ALWAYS accumulate trails (high-interest flights)
always_trail_lists = [existing_tracked, military_snapshot]
# Skip trails for any flight with a known route; the route line replaces historical trail.
route_check_lists = [commercial_snapshot, private_jets_snapshot, private_ga_snapshot]
always_trail_lists = [tracked_snapshot, military_snapshot]
seen_hexes = set()
trail_count = 0
with _trails_lock:
@@ -669,7 +690,7 @@ def _classify_and_publish(all_adsb_flights):
for flist in always_trail_lists:
for f in flist:
count, hex_id = _accumulate_trail(f, now_ts, check_route=False)
count, hex_id = _accumulate_trail(f, now_ts, check_route=True)
trail_count += count
if hex_id:
seen_hexes.add(hex_id)
@@ -693,6 +714,13 @@ def _classify_and_publish(all_adsb_flights):
f"Trail accumulation: {trail_count} active trails, {len(stale_keys)} pruned, {len(flight_trails)} total"
)
with _data_lock:
latest_data["commercial_flights"] = commercial_snapshot
latest_data["private_jets"] = private_jets_snapshot
latest_data["private_flights"] = private_ga_snapshot
latest_data["tracked_flights"] = tracked_snapshot
latest_data["military_flights"] = military_snapshot
# --- GPS Jamming Detection ---
# Uses NACp (Navigation Accuracy Category Position) from ADS-B to infer
# GPS interference zones, similar to GPSJam.org / Flightradar24.
+1 -1
View File
@@ -182,7 +182,7 @@ def fetch_meshtastic_nodes():
callsign = str(getattr(get_settings(), "MESHTASTIC_OPERATOR_CALLSIGN", "") or "").strip()
except Exception:
callsign = ""
ua_base = "ShadowBroker-OSINT/0.9.7 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com; 24h polling)"
ua_base = "ShadowBroker-OSINT/0.9.75 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com; 24h polling)"
user_agent = f"{ua_base}; node={callsign}" if callsign else ua_base
try:
+1 -1
View File
@@ -25,7 +25,7 @@ _REFRESH_INTERVAL_S = 5 * 24 * 3600
_HTTP_TIMEOUT_S = 60
_USER_AGENT = (
"ShadowBroker-OSINT/0.9.7 "
"ShadowBroker-OSINT/0.9.75 "
"(+https://github.com/BigBodyCobain/Shadowbroker; "
"contact: bigbodycobain@gmail.com)"
)
+12 -15
View File
@@ -390,15 +390,9 @@ class MeshtasticTransport:
def _mqtt_config() -> tuple[str, int, str, str]:
"""Return (broker, port, user, password) from settings."""
try:
from services.config import get_settings
from services.meshtastic_mqtt_settings import mqtt_connection_config
s = get_settings()
return (
str(s.MESH_MQTT_BROKER or "mqtt.meshtastic.org"),
int(s.MESH_MQTT_PORT or 1883),
str(s.MESH_MQTT_USER or "meshdev"),
str(s.MESH_MQTT_PASS or "large4cats"),
)
return mqtt_connection_config()
except Exception:
return ("mqtt.meshtastic.org", 1883, "meshdev", "large4cats")
@@ -433,8 +427,9 @@ class MeshtasticTransport:
def _resolve_psk(cls) -> bytes:
"""Return the PSK from config, or the default LongFast key if empty."""
try:
from services.config import get_settings
raw = str(getattr(get_settings(), "MESH_MQTT_PSK", "") or "").strip()
from services.meshtastic_mqtt_settings import mqtt_psk_hex
raw = mqtt_psk_hex()
except Exception:
raw = ""
if not raw:
@@ -449,7 +444,10 @@ class MeshtasticTransport:
@staticmethod
def mesh_address_for_sender(sender_id: str) -> str:
"""Return the synthetic public mesh address used for MQTT-originated sends."""
"""Return the public mesh address used for MQTT-originated sends."""
parsed = MeshtasticTransport._parse_node_id(sender_id)
if parsed is not None:
return f"!{parsed:08x}"
return f"!{MeshtasticTransport._stable_node_id(sender_id):08x}"
@staticmethod
@@ -489,7 +487,8 @@ class MeshtasticTransport:
# Generate IDs
packet_id = random.randint(1, 0xFFFFFFFF)
from_node = self._stable_node_id(envelope.sender_id)
parsed_sender = self._parse_node_id(envelope.sender_id)
from_node = parsed_sender if parsed_sender is not None else self._stable_node_id(envelope.sender_id)
direct_node = self._parse_node_id(envelope.destination)
to_node = direct_node if direct_node is not None else 0xFFFFFFFF
@@ -529,9 +528,7 @@ class MeshtasticTransport:
error_msg[0] = f"MQTT connect refused: rc={rc}"
client.disconnect()
client = mqtt.Client(
client_id=f"shadowbroker-tx-{envelope.message_id[:8]}", protocol=mqtt.MQTTv311
)
client = mqtt.Client(client_id=f"meshchat-tx-{envelope.message_id[:8]}", protocol=mqtt.MQTTv311)
broker, port, user, pw = self._mqtt_config()
client.username_pw_set(user, pw)
client.on_connect = _on_connect
+7 -2
View File
@@ -8,6 +8,7 @@ from typing import Iterable
# Default subscription roots — US-only to avoid flooding the public broker.
# Users can opt into additional regions via MESH_MQTT_EXTRA_ROOTS.
DEFAULT_ROOTS: tuple[str, ...] = ("US",)
DEFAULT_CHANNEL = "LongFast"
# Every known official region root (for UI dropdowns / manual opt-in).
ALL_OFFICIAL_ROOTS: tuple[str, ...] = (
@@ -107,6 +108,10 @@ def normalize_topic_filter(value: str) -> str | None:
return "/".join(parts)
def _default_topic_for_root(root: str) -> str:
return f"msh/{root}/2/e/{DEFAULT_CHANNEL}/#"
def build_subscription_topics(
extra_roots: str = "",
extra_topics: str = "",
@@ -119,7 +124,7 @@ def build_subscription_topics(
# via MESH_MQTT_EXTRA_ROOTS to avoid flooding the public broker.
roots.extend(root for root in (normalize_root(item) for item in _split_config_values(extra_roots)) if root)
topics = [f"msh/{root}/#" for root in _dedupe(roots)]
topics = [_default_topic_for_root(root) for root in _dedupe(roots)]
topics.extend(
topic
for topic in (
@@ -137,7 +142,7 @@ def known_roots(extra_roots: str = "", include_defaults: bool = True) -> list[st
for topic in topics:
if not topic.startswith("msh/") or not topic.endswith("/#"):
continue
root = normalize_root(topic[4:-2])
root = normalize_root(parse_topic_metadata(topic)["root"])
if root:
roots.append(root)
return _dedupe(roots)
@@ -0,0 +1,172 @@
from __future__ import annotations
import json
import os
import time
from pathlib import Path
from typing import Any
from services.config import get_settings
PUBLIC_DEFAULT_USER = "meshdev"
PUBLIC_DEFAULT_PASS = "large4cats"
DATA_DIR = Path(os.environ.get("SB_DATA_DIR", str(Path(__file__).parent.parent / "data")))
if not DATA_DIR.is_absolute():
DATA_DIR = Path(__file__).parent.parent / DATA_DIR
SETTINGS_FILE = DATA_DIR / "meshtastic_mqtt.json"
_cache: dict[str, Any] | None = None
_cache_ts: float = 0.0
_CACHE_TTL = 2.0
def _settings_defaults() -> dict[str, Any]:
try:
s = get_settings()
return {
"enabled": bool(getattr(s, "MESH_MQTT_ENABLED", False)),
"broker": str(getattr(s, "MESH_MQTT_BROKER", "") or "mqtt.meshtastic.org"),
"port": int(getattr(s, "MESH_MQTT_PORT", 1883) or 1883),
"username": str(getattr(s, "MESH_MQTT_USER", "") or PUBLIC_DEFAULT_USER),
"password": str(getattr(s, "MESH_MQTT_PASS", "") or PUBLIC_DEFAULT_PASS),
"psk": str(getattr(s, "MESH_MQTT_PSK", "") or ""),
"include_default_roots": bool(getattr(s, "MESH_MQTT_INCLUDE_DEFAULT_ROOTS", True)),
"extra_roots": str(getattr(s, "MESH_MQTT_EXTRA_ROOTS", "") or ""),
"extra_topics": str(getattr(s, "MESH_MQTT_EXTRA_TOPICS", "") or ""),
}
except Exception:
return {
"enabled": False,
"broker": "mqtt.meshtastic.org",
"port": 1883,
"username": PUBLIC_DEFAULT_USER,
"password": PUBLIC_DEFAULT_PASS,
"psk": "",
"include_default_roots": True,
"extra_roots": "",
"extra_topics": "",
}
def _safe_int(value: Any, default: int) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
return default
if parsed < 1 or parsed > 65535:
return default
return parsed
def _normalize(data: dict[str, Any]) -> dict[str, Any]:
defaults = _settings_defaults()
return {
"enabled": bool(data.get("enabled", defaults["enabled"])),
"broker": str(data.get("broker", defaults["broker"]) or defaults["broker"]).strip(),
"port": _safe_int(data.get("port", defaults["port"]), defaults["port"]),
"username": str(data.get("username", defaults["username"]) or "").strip(),
"password": str(data.get("password", defaults["password"]) or ""),
"psk": str(data.get("psk", defaults["psk"]) or "").strip(),
"include_default_roots": bool(data.get("include_default_roots", defaults["include_default_roots"])),
"extra_roots": str(data.get("extra_roots", defaults["extra_roots"]) or "").strip(),
"extra_topics": str(data.get("extra_topics", defaults["extra_topics"]) or "").strip(),
"updated_at": _safe_int(data.get("updated_at", 0), 0),
}
def read_meshtastic_mqtt_settings() -> dict[str, Any]:
global _cache, _cache_ts
now = time.monotonic()
if _cache is not None and (now - _cache_ts) < _CACHE_TTL:
return dict(_cache)
if not SETTINGS_FILE.exists():
result = {**_settings_defaults(), "updated_at": 0}
else:
try:
loaded = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
except Exception:
loaded = {}
result = _normalize(loaded if isinstance(loaded, dict) else {})
_cache = result
_cache_ts = now
return dict(result)
def write_meshtastic_mqtt_settings(**updates: Any) -> dict[str, Any]:
DATA_DIR.mkdir(parents=True, exist_ok=True)
existing = read_meshtastic_mqtt_settings()
next_data = dict(existing)
for key in (
"enabled",
"broker",
"port",
"username",
"password",
"psk",
"include_default_roots",
"extra_roots",
"extra_topics",
):
if key in updates and updates[key] is not None:
next_data[key] = updates[key]
if "username" in updates and not str(updates.get("username") or "").strip() and "password" not in updates:
next_data["password"] = PUBLIC_DEFAULT_PASS
next_data["updated_at"] = int(time.time())
normalized = _normalize(next_data)
SETTINGS_FILE.write_text(json.dumps(normalized, indent=2), encoding="utf-8")
if os.name != "nt":
os.chmod(SETTINGS_FILE, 0o600)
global _cache, _cache_ts
_cache = normalized
_cache_ts = time.monotonic()
return dict(normalized)
def redacted_meshtastic_mqtt_settings(data: dict[str, Any] | None = None) -> dict[str, Any]:
source = read_meshtastic_mqtt_settings() if data is None else dict(data)
username = str(source.get("username", "") or "")
uses_default_credentials = username in ("", PUBLIC_DEFAULT_USER) and str(source.get("password", "") or "") in (
"",
PUBLIC_DEFAULT_PASS,
)
return {
"enabled": bool(source.get("enabled")),
"broker": str(source.get("broker", "")),
"port": int(source.get("port", 1883) or 1883),
"username": "" if uses_default_credentials else username,
"uses_default_credentials": uses_default_credentials,
"has_password": bool(str(source.get("password", "") or "")),
"has_psk": bool(str(source.get("psk", "") or "")),
"include_default_roots": bool(source.get("include_default_roots", True)),
"extra_roots": str(source.get("extra_roots", "") or ""),
"extra_topics": str(source.get("extra_topics", "") or ""),
"updated_at": int(source.get("updated_at", 0) or 0),
}
def mqtt_connection_config() -> tuple[str, int, str, str]:
data = read_meshtastic_mqtt_settings()
return (
str(data.get("broker") or "mqtt.meshtastic.org"),
int(data.get("port") or 1883),
str(data.get("username") or PUBLIC_DEFAULT_USER),
str(data.get("password") or PUBLIC_DEFAULT_PASS),
)
def mqtt_bridge_enabled() -> bool:
return bool(read_meshtastic_mqtt_settings().get("enabled"))
def mqtt_psk_hex() -> str:
return str(read_meshtastic_mqtt_settings().get("psk", "") or "").strip()
def mqtt_subscription_settings() -> tuple[str, str, bool]:
data = read_meshtastic_mqtt_settings()
return (
str(data.get("extra_roots", "") or ""),
str(data.get("extra_topics", "") or ""),
bool(data.get("include_default_roots", True)),
)
+1 -1
View File
@@ -73,7 +73,7 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None,
both Python requests and the barebones Windows system curl.
"""
default_headers = {
"User-Agent": "ShadowBroker-OSINT/0.9.7 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com)",
"User-Agent": "ShadowBroker-OSINT/0.9.75 (+https://github.com/BigBodyCobain/Shadowbroker; contact: bigbodycobain@gmail.com)",
}
if headers:
default_headers.update(headers)
+14 -3
View File
@@ -10,7 +10,8 @@ _cache: dict | None = None
_cache_ts: float = 0.0
_CACHE_TTL = 5.0
_DEFAULTS = {
"enabled": False,
"enabled": True,
"operator_disabled": False,
"timemachine_enabled": False,
}
@@ -35,8 +36,16 @@ def read_node_settings() -> dict:
except Exception:
result = {**_DEFAULTS, "updated_at": 0}
else:
operator_disabled = bool(data.get("operator_disabled", False))
raw_enabled = data.get("enabled", _DEFAULTS["enabled"])
# v0.9.7 initially wrote enabled:false as a default/offline state,
# which accidentally blocked InfoNet participation. Treat legacy
# false-without-marker as auto-enabled; only an explicit operator
# disable should keep the participant sync loop off.
enabled = False if operator_disabled else bool(raw_enabled or "operator_disabled" not in data)
result = {
"enabled": bool(data.get("enabled", _DEFAULTS["enabled"])),
"enabled": enabled,
"operator_disabled": operator_disabled,
"timemachine_enabled": bool(data.get("timemachine_enabled", _DEFAULTS["timemachine_enabled"])),
"updated_at": _safe_int(data.get("updated_at", 0) or 0),
}
@@ -48,8 +57,10 @@ def read_node_settings() -> dict:
def write_node_settings(*, enabled: bool | None = None, timemachine_enabled: bool | None = None) -> dict:
DATA_DIR.mkdir(parents=True, exist_ok=True)
existing = read_node_settings()
next_enabled = bool(existing.get("enabled", _DEFAULTS["enabled"])) if enabled is None else bool(enabled)
payload = {
"enabled": bool(existing.get("enabled", _DEFAULTS["enabled"])) if enabled is None else bool(enabled),
"enabled": next_enabled,
"operator_disabled": bool(existing.get("operator_disabled", _DEFAULTS["operator_disabled"])) if enabled is None else not next_enabled,
"timemachine_enabled": bool(existing.get("timemachine_enabled", _DEFAULTS["timemachine_enabled"])) if timemachine_enabled is None else bool(timemachine_enabled),
"updated_at": int(time.time()),
}
+1 -1
View File
@@ -20,7 +20,7 @@ from cachetools import TTLCache
logger = logging.getLogger(__name__)
_SHODAN_BASE = "https://api.shodan.io"
_USER_AGENT = "ShadowBroker/0.9.7 local Shodan connector"
_USER_AGENT = "ShadowBroker/0.9.75 local Shodan connector"
_REQUEST_TIMEOUT = 15
_MIN_INTERVAL_SECONDS = 1.05 # Shodan docs say API plans are rate limited to ~1 req/sec.
_DEFAULT_SEARCH_PAGES = 1
+67 -26
View File
@@ -22,6 +22,12 @@ from collections import deque
from datetime import datetime, timezone
from services.config import get_settings
from services.meshtastic_mqtt_settings import (
mqtt_bridge_enabled,
mqtt_connection_config,
mqtt_psk_hex,
mqtt_subscription_settings,
)
from services.mesh.meshtastic_topics import all_available_roots, build_subscription_topics, known_roots, parse_topic_metadata
logger = logging.getLogger("services.sigint")
@@ -477,22 +483,13 @@ class MeshtasticBridge:
@staticmethod
def _mqtt_config() -> tuple[str, int, str, str]:
"""Return (broker, port, user, password) from settings."""
try:
s = get_settings()
return (
str(s.MESH_MQTT_BROKER or "mqtt.meshtastic.org"),
int(s.MESH_MQTT_PORT or 1883),
str(s.MESH_MQTT_USER or "meshdev"),
str(s.MESH_MQTT_PASS or "large4cats"),
)
except Exception:
return ("mqtt.meshtastic.org", 1883, "meshdev", "large4cats")
return mqtt_connection_config()
@classmethod
def _resolve_psk(cls) -> bytes:
"""Return the PSK from config, or the default LongFast key if empty."""
try:
raw = str(getattr(get_settings(), "MESH_MQTT_PSK", "") or "").strip()
raw = mqtt_psk_hex()
except Exception:
raw = ""
if not raw:
@@ -506,6 +503,11 @@ class MeshtasticBridge:
self._thread: threading.Thread | None = None
self._stop = threading.Event()
self._client_id = self._build_client_id()
self._connected = False
self._last_error = ""
self._last_connected_at = 0.0
self._last_disconnected_at = 0.0
self._last_broker = ""
# Rate-limiter: sliding window of receive timestamps
self._rx_timestamps: deque[float] = deque()
self._rx_dropped = 0
@@ -518,10 +520,11 @@ class MeshtasticBridge:
second client connects with the same id. Using a fixed id made separate
ShadowBroker instances kick each other off the broker.
Includes the app version so the Meshtastic team can track our footprint.
This is deliberately not tied to the user's public mesh address or
ShadowBroker node identity; it is only an MQTT session handle.
"""
suffix = uuid.uuid4().hex[:8]
return f"sb096-{suffix}"
return f"meshchat-{suffix}"
def _dedupe_message(
self,
@@ -544,7 +547,12 @@ class MeshtasticBridge:
def start(self):
if self._thread and self._thread.is_alive():
return
if not self._stop.is_set():
return
self._thread.join(timeout=2.0)
if self._thread.is_alive():
logger.warning("Meshtastic MQTT bridge is still stopping; start deferred")
return
self._stop.clear()
self._thread = threading.Thread(target=self._run, daemon=True, name="mesh-bridge")
self._thread.start()
@@ -552,13 +560,37 @@ class MeshtasticBridge:
def stop(self):
self._stop.set()
self._connected = False
def is_running(self) -> bool:
return bool(self._thread and self._thread.is_alive() and not self._stop.is_set())
def status(self) -> dict:
broker, port, user, _pw = self._mqtt_config()
display_user = "" if user == "meshdev" else user
return {
"enabled": mqtt_bridge_enabled(),
"running": self.is_running(),
"connected": bool(self._connected),
"broker": broker,
"port": port,
"username": display_user,
"client_id": self._client_id,
"message_log_size": len(self.messages),
"signal_log_size": len(self.signals),
"last_error": self._last_error,
"last_broker": self._last_broker,
"last_connected_at": self._last_connected_at,
"last_disconnected_at": self._last_disconnected_at,
"rx_dropped": self._rx_dropped,
}
def _subscription_topics(self) -> list[str]:
settings = get_settings()
extra_roots, extra_topics, include_defaults = mqtt_subscription_settings()
return build_subscription_topics(
extra_roots=str(getattr(settings, "MESH_MQTT_EXTRA_ROOTS", "") or ""),
extra_topics=str(getattr(settings, "MESH_MQTT_EXTRA_TOPICS", "") or ""),
include_defaults=bool(getattr(settings, "MESH_MQTT_INCLUDE_DEFAULT_ROOTS", True)),
extra_roots=extra_roots,
extra_topics=extra_topics,
include_defaults=include_defaults,
)
def _run(self):
@@ -582,6 +614,9 @@ class MeshtasticBridge:
def _on_connect(client, userdata, flags, rc):
if rc == 0:
self._connected = True
self._last_error = ""
self._last_connected_at = time.time()
logger.info(
"Meshtastic MQTT connected (%s), subscribing to %s",
self._client_id,
@@ -590,6 +625,8 @@ class MeshtasticBridge:
for topic in topics:
client.subscribe(topic, qos=0)
else:
self._connected = False
self._last_error = f"connect_refused:{rc}"
logger.error(
"Meshtastic MQTT connection refused (%s): rc=%s",
self._client_id,
@@ -597,7 +634,10 @@ class MeshtasticBridge:
)
def _on_disconnect(client, userdata, rc):
self._connected = False
self._last_disconnected_at = time.time()
if rc != 0:
self._last_error = f"disconnect:{rc}"
logger.warning(
"Meshtastic MQTT disconnected unexpectedly (%s, rc=%s), will auto-reconnect",
self._client_id,
@@ -607,6 +647,7 @@ class MeshtasticBridge:
logger.info("Meshtastic MQTT disconnected cleanly (%s)", self._client_id)
broker, port, user, pw = self._mqtt_config()
self._last_broker = f"{broker}:{port}"
client = mqtt.Client(client_id=self._client_id, protocol=mqtt.MQTTv311)
client.username_pw_set(user, pw)
client.on_connect = _on_connect
@@ -645,9 +686,6 @@ class MeshtasticBridge:
def _on_message(self, client, userdata, msg):
"""Parse Meshtastic MQTT messages — protobuf + AES decryption."""
try:
if self._rate_limited():
return
payload = msg.payload
topic = msg.topic
@@ -655,6 +693,8 @@ class MeshtasticBridge:
if "/json/" in topic:
try:
data = json.loads(payload)
if self._rate_limited():
return
self._ingest_data(data, topic)
return
except (json.JSONDecodeError, UnicodeDecodeError):
@@ -687,6 +727,8 @@ class MeshtasticBridge:
}
)
else:
if self._rate_limited():
return
self._ingest_data(data, topic)
except Exception as e:
@@ -1011,7 +1053,7 @@ class SIGINTGrid:
self._started = True
self.aprs.start()
try:
mqtt_enabled = bool(getattr(get_settings(), "MESH_MQTT_ENABLED", False))
mqtt_enabled = mqtt_bridge_enabled()
except Exception:
mqtt_enabled = False
if mqtt_enabled:
@@ -1123,13 +1165,12 @@ class SIGINTGrid:
ch = msg.get("channel", "LongFast")
channel_msgs[ch] = channel_msgs.get(ch, 0) + 1
extra_roots, _extra_topics, include_defaults = mqtt_subscription_settings()
return {
"regions": regions,
"roots": roots,
"known_roots": known_roots(
str(getattr(get_settings(), "MESH_MQTT_EXTRA_ROOTS", "") or ""),
include_defaults=bool(getattr(get_settings(), "MESH_MQTT_INCLUDE_DEFAULT_ROOTS", True)),
),
"known_roots": known_roots(extra_roots, include_defaults=include_defaults),
"all_roots": all_available_roots(),
"channel_messages": channel_msgs,
"total_nodes": len(seen_callsigns),
+69 -94
View File
@@ -1,13 +1,9 @@
"""Tor Hidden Service auto-provisioner.
"""Tor hidden-service auto-provisioner.
Manages a Tor hidden service that points to the local ShadowBroker backend.
Tor is started as a subprocess with a generated torrc no manual config needed.
Auto-installs the Tor Expert Bundle on Windows if not present.
Usage:
from services.tor_hidden_service import tor_service
status = tor_service.start() # -> {"ok": True, "onion_address": "http://xxxx.onion:8000"}
tor_service.stop()
Tor is started as a subprocess with a generated torrc. Windows source installs
can download the Tor Expert Bundle into backend/data without admin rights.
Docker images should already include the `tor` package.
"""
from __future__ import annotations
@@ -31,31 +27,33 @@ HOSTNAME_PATH = TOR_DIR / "hidden_service" / "hostname"
TOR_DATA_DIR = TOR_DIR / "data"
PIDFILE_PATH = TOR_DIR / "tor.pid"
# Bundled Tor install location (inside our data dir so no admin rights needed)
# Bundled Tor install location (inside data dir so no admin rights are needed).
TOR_INSTALL_DIR = TOR_DIR / "tor_bin"
# How long to wait for Tor to generate the hostname file
_STARTUP_TIMEOUT_S = 90
_POLL_INTERVAL_S = 1.0
# Tor Expert Bundle download URL (Windows x86_64)
_TOR_EXPERT_BUNDLE_URL = "https://dist.torproject.org/torbrowser/15.0.8/tor-expert-bundle-windows-x86_64-15.0.8.tar.gz"
# Windows x86_64 Tor Expert Bundle URLs. Keep a fallback so first-run
# onboarding does not break when Tor rotates point releases.
_TOR_EXPERT_BUNDLE_URLS = [
"https://dist.torproject.org/torbrowser/15.0.11/tor-expert-bundle-windows-x86_64-15.0.11.tar.gz",
"https://dist.torproject.org/torbrowser/15.0.8/tor-expert-bundle-windows-x86_64-15.0.8.tar.gz",
]
def _find_tor_binary() -> str | None:
"""Locate the tor binary on the system, including our bundled install."""
# Check our bundled install first
bundled = TOR_INSTALL_DIR / "tor" / "tor.exe"
if bundled.exists():
return str(bundled)
# Also check for extracted layout variants
for sub in TOR_INSTALL_DIR.rglob("tor.exe"):
return str(sub)
tor = shutil.which("tor")
if tor:
return tor
# Common locations on Windows
for candidate in [
r"C:\Program Files\Tor Browser\Browser\TorBrowser\Tor\tor.exe",
r"C:\Program Files (x86)\Tor Browser\Browser\TorBrowser\Tor\tor.exe",
@@ -67,77 +65,65 @@ def _find_tor_binary() -> str | None:
def _auto_install_tor() -> str | None:
"""Download and extract the Tor Expert Bundle. Returns path to tor binary or None."""
"""Install or download Tor when it is safe to do so."""
if os.name != "nt":
# On Linux/Mac, try package manager
try:
if shutil.which("apt-get"):
subprocess.run(["sudo", "apt-get", "install", "-y", "tor"], check=True, capture_output=True, timeout=120)
elif shutil.which("brew"):
subprocess.run(["brew", "install", "tor"], check=True, capture_output=True, timeout=120)
elif shutil.which("pacman"):
subprocess.run(["sudo", "pacman", "-S", "--noconfirm", "tor"], check=True, capture_output=True, timeout=120)
else:
logger.warning("No supported package manager found for auto-install")
return None
return shutil.which("tor")
except Exception as exc:
logger.error("Failed to auto-install Tor via package manager: %s", exc)
return None
# In Docker this should already be baked into the image. For source
# installs we avoid unattended sudo prompts from a web request path.
logger.warning("Tor is not installed. Install the tor package or use the Docker image with Tor baked in.")
return None
# Windows: download Tor Expert Bundle (no admin needed)
TOR_INSTALL_DIR.mkdir(parents=True, exist_ok=True)
archive_path = TOR_INSTALL_DIR / "tor-expert-bundle.tar.gz"
try:
logger.info("Downloading Tor Expert Bundle over HTTPS from dist.torproject.org...")
urlretrieve(_TOR_EXPERT_BUNDLE_URL, str(archive_path))
# Verify SHA-256 of the downloaded archive
sha256_url = _TOR_EXPERT_BUNDLE_URL + ".sha256sum"
sha256_file = TOR_INSTALL_DIR / "sha256sum.txt"
for bundle_url in _TOR_EXPERT_BUNDLE_URLS:
archive_path = TOR_INSTALL_DIR / "tor-expert-bundle.tar.gz"
try:
urlretrieve(sha256_url, str(sha256_file))
expected_hash = sha256_file.read_text().strip().split()[0].lower()
import hashlib
actual_hash = hashlib.sha256(archive_path.read_bytes()).hexdigest().lower()
sha256_file.unlink(missing_ok=True)
if actual_hash != expected_hash:
logger.error("SHA-256 MISMATCH — download may be compromised! Expected %s, got %s", expected_hash, actual_hash)
archive_path.unlink(missing_ok=True)
return None
logger.info("SHA-256 verified: %s", actual_hash[:16] + "...")
except Exception as hash_err:
# If we can't fetch the hash file, warn but proceed (HTTPS provides baseline integrity)
logger.warning("Could not verify SHA-256 (hash file unavailable): %s — proceeding with HTTPS-only verification", hash_err)
logger.info("Downloading Tor Expert Bundle over HTTPS from %s...", bundle_url)
urlretrieve(bundle_url, str(archive_path))
logger.info("Download complete, extracting...")
sha256_url = bundle_url + ".sha256sum"
sha256_file = TOR_INSTALL_DIR / "sha256sum.txt"
try:
urlretrieve(sha256_url, str(sha256_file))
expected_hash = sha256_file.read_text().strip().split()[0].lower()
import hashlib
# Extract .tar.gz with path traversal protection
import tarfile
with tarfile.open(str(archive_path), "r:gz") as tar:
for member in tar.getmembers():
member_path = (TOR_INSTALL_DIR / member.name).resolve()
if not str(member_path).startswith(str(TOR_INSTALL_DIR.resolve())):
logger.error("Tar path traversal blocked: %s", member.name)
actual_hash = hashlib.sha256(archive_path.read_bytes()).hexdigest().lower()
sha256_file.unlink(missing_ok=True)
if actual_hash != expected_hash:
logger.error("SHA-256 mismatch for Tor download. Expected %s, got %s", expected_hash, actual_hash)
archive_path.unlink(missing_ok=True)
return None
tar.extractall(path=str(TOR_INSTALL_DIR))
continue
logger.info("SHA-256 verified: %s", actual_hash[:16] + "...")
except Exception as hash_err:
logger.warning(
"Could not verify SHA-256 (hash file unavailable): %s; proceeding with HTTPS-only verification",
hash_err,
)
# Clean up archive
archive_path.unlink(missing_ok=True)
logger.info("Download complete, extracting...")
import tarfile
# Find the tor.exe in extracted files
for p in TOR_INSTALL_DIR.rglob("tor.exe"):
logger.info("Tor installed at: %s", p)
return str(p)
with tarfile.open(str(archive_path), "r:gz") as tar:
for member in tar.getmembers():
member_path = (TOR_INSTALL_DIR / member.name).resolve()
if not str(member_path).startswith(str(TOR_INSTALL_DIR.resolve())):
logger.error("Tar path traversal blocked: %s", member.name)
archive_path.unlink(missing_ok=True)
return None
tar.extractall(path=str(TOR_INSTALL_DIR))
logger.error("tor.exe not found after extraction")
return None
except Exception as exc:
logger.error("Failed to download/extract Tor: %s", exc)
archive_path.unlink(missing_ok=True)
return None
archive_path.unlink(missing_ok=True)
for p in TOR_INSTALL_DIR.rglob("tor.exe"):
logger.info("Tor installed at: %s", p)
return str(p)
logger.error("tor.exe not found after extracting %s", bundle_url)
except Exception as exc:
logger.error("Failed to download/extract Tor from %s: %s", bundle_url, exc)
finally:
archive_path.unlink(missing_ok=True)
return None
class TorHiddenService:
@@ -150,7 +136,6 @@ class TorHiddenService:
self._running = False
self._error: str = ""
# Check if we already have a hostname from a previous run
if HOSTNAME_PATH.exists():
try:
hostname = HOSTNAME_PATH.read_text().strip()
@@ -198,19 +183,20 @@ class TorHiddenService:
self._error = ""
tor_bin = _find_tor_binary()
if not tor_bin:
logger.info("Tor not found, attempting auto-install...")
logger.info("Tor not found, attempting bootstrap...")
tor_bin = _auto_install_tor()
if not tor_bin:
self._error = "Failed to auto-install Tor. Please install it manually."
self._error = (
"Could not prepare Tor automatically. Check network access to dist.torproject.org "
"or install Tor, then try again."
)
return {"ok": False, "detail": self._error}
# Create directories
TOR_DIR.mkdir(parents=True, exist_ok=True)
TOR_DATA_DIR.mkdir(parents=True, exist_ok=True)
hidden_service_dir = TOR_DIR / "hidden_service"
hidden_service_dir.mkdir(parents=True, exist_ok=True)
# On non-Windows, Tor requires strict permissions on HiddenServiceDir
if os.name != "nt":
try:
os.chmod(str(hidden_service_dir), 0o700)
@@ -218,19 +204,15 @@ class TorHiddenService:
except OSError:
pass
# Write torrc — enables both hidden service (inbound) and SOCKS proxy
# (outbound) so the mesh/wormhole system can route node-to-node
# traffic through Tor as well.
torrc_content = (
f"DataDirectory {TOR_DATA_DIR.as_posix()}\n"
f"HiddenServiceDir {hidden_service_dir.as_posix()}\n"
f"HiddenServicePort {target_port} 127.0.0.1:{target_port}\n"
f"SocksPort 9050\n"
f"Log notice stderr\n"
"SocksPort 9050\n"
"Log notice stderr\n"
)
TORRC_PATH.write_text(torrc_content, encoding="utf-8")
# Start Tor
try:
self._process = subprocess.Popen(
[tor_bin, "-f", str(TORRC_PATH)],
@@ -245,15 +227,12 @@ class TorHiddenService:
logger.error(self._error)
return {"ok": False, "detail": self._error}
# Wait for hostname file to appear
deadline = time.monotonic() + _STARTUP_TIMEOUT_S
while time.monotonic() < deadline:
if self._process.poll() is not None:
# Tor exited prematurely
stdout = self._process.stdout.read() if self._process.stdout else ""
self._error = f"Tor exited with code {self._process.returncode}"
if stdout:
# Get last few lines for error context
lines = stdout.strip().split("\n")
self._error += ": " + " | ".join(lines[-3:])
self._running = False
@@ -273,7 +252,6 @@ class TorHiddenService:
time.sleep(_POLL_INTERVAL_S)
# Timeout
self._error = f"Tor did not generate hostname within {_STARTUP_TIMEOUT_S}s"
self.stop()
return {"ok": False, "detail": self._error}
@@ -292,10 +270,7 @@ class TorHiddenService:
pass
self._process = None
self._running = False
# Keep the onion_address — it persists across restarts
# since the key is stored in hidden_service_dir
return {"ok": True, "detail": "stopped"}
# Singleton
tor_service = TorHiddenService()
+1 -1
View File
@@ -24,7 +24,7 @@ from cachetools import TTLCache
logger = logging.getLogger(__name__)
_FINNHUB_BASE = "https://finnhub.io/api/v1"
_USER_AGENT = "ShadowBroker/0.9.7 Finnhub connector"
_USER_AGENT = "ShadowBroker/0.9.75 Finnhub connector"
_REQUEST_TIMEOUT = 12
_MIN_INTERVAL_SECONDS = 0.35 # Stay well under 60 calls/min
@@ -0,0 +1,54 @@
import importlib
def test_meshtastic_mqtt_settings_redacts_secrets(tmp_path, monkeypatch):
monkeypatch.setenv("SB_DATA_DIR", str(tmp_path))
from services import meshtastic_mqtt_settings
settings = importlib.reload(meshtastic_mqtt_settings)
saved = settings.write_meshtastic_mqtt_settings(
enabled=True,
broker="mqtt.example.test",
port=1884,
username="mesh-user",
password="mesh-pass",
psk="001122",
include_default_roots=False,
extra_roots="EU,US",
)
redacted = settings.redacted_meshtastic_mqtt_settings(saved)
assert saved["password"] == "mesh-pass"
assert saved["psk"] == "001122"
assert redacted["enabled"] is True
assert redacted["broker"] == "mqtt.example.test"
assert redacted["port"] == 1884
assert redacted["username"] == "mesh-user"
assert redacted["has_password"] is True
assert redacted["has_psk"] is True
assert "password" not in redacted
assert "psk" not in redacted
assert settings.mqtt_connection_config() == ("mqtt.example.test", 1884, "mesh-user", "mesh-pass")
assert settings.mqtt_bridge_enabled() is True
assert settings.mqtt_psk_hex() == "001122"
assert settings.mqtt_subscription_settings() == ("EU,US", "", False)
def test_meshtastic_mqtt_settings_hide_public_defaults(tmp_path, monkeypatch):
monkeypatch.setenv("SB_DATA_DIR", str(tmp_path))
from services import meshtastic_mqtt_settings
settings = importlib.reload(meshtastic_mqtt_settings)
saved = settings.write_meshtastic_mqtt_settings(
enabled=True,
broker="mqtt.meshtastic.org",
username="",
password="",
)
redacted = settings.redacted_meshtastic_mqtt_settings(saved)
assert redacted["username"] == ""
assert redacted["uses_default_credentials"] is True
assert settings.mqtt_connection_config() == ("mqtt.meshtastic.org", 1883, "meshdev", "large4cats")
@@ -0,0 +1,21 @@
from services.mesh.meshtastic_topics import build_subscription_topics, known_roots, parse_topic_metadata
def test_default_subscription_is_longfast_only():
assert build_subscription_topics() == ["msh/US/2/e/LongFast/#"]
assert known_roots() == ["US"]
def test_extra_roots_are_longfast_only():
assert build_subscription_topics(extra_roots="EU_868,ANZ") == [
"msh/US/2/e/LongFast/#",
"msh/EU_868/2/e/LongFast/#",
"msh/ANZ/2/e/LongFast/#",
]
def test_parse_longfast_topic_root():
meta = parse_topic_metadata("msh/US/2/e/LongFast/!12345678")
assert meta["region"] == "US"
assert meta["root"] == "US"
assert meta["channel"] == "LongFast"
+36 -1
View File
@@ -7,9 +7,44 @@ def test_node_settings_roundtrip(tmp_path, monkeypatch):
monkeypatch.setattr(node_settings, "_cache_ts", 0.0)
initial = node_settings.read_node_settings()
disabled = node_settings.write_node_settings(enabled=False)
updated = node_settings.write_node_settings(enabled=True)
reread = node_settings.read_node_settings()
assert initial["enabled"] is False
assert initial["enabled"] is True
assert initial["operator_disabled"] is False
assert disabled["enabled"] is False
assert disabled["operator_disabled"] is True
assert updated["enabled"] is True
assert updated["operator_disabled"] is False
assert reread["enabled"] is True
def test_legacy_disabled_node_settings_auto_enable(tmp_path, monkeypatch):
from services import node_settings
settings_path = tmp_path / "node.json"
settings_path.write_text('{"enabled": false, "updated_at": 123}', encoding="utf-8")
monkeypatch.setattr(node_settings, "NODE_FILE", settings_path)
monkeypatch.setattr(node_settings, "_cache", None)
monkeypatch.setattr(node_settings, "_cache_ts", 0.0)
reread = node_settings.read_node_settings()
assert reread["enabled"] is True
assert reread["operator_disabled"] is False
def test_explicit_operator_disabled_stays_disabled(tmp_path, monkeypatch):
from services import node_settings
settings_path = tmp_path / "node.json"
settings_path.write_text('{"enabled": false, "operator_disabled": true, "updated_at": 123}', encoding="utf-8")
monkeypatch.setattr(node_settings, "NODE_FILE", settings_path)
monkeypatch.setattr(node_settings, "_cache", None)
monkeypatch.setattr(node_settings, "_cache_ts", 0.0)
reread = node_settings.read_node_settings()
assert reread["enabled"] is False
assert reread["operator_disabled"] is True