diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcdc80b..32f03a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,4 +84,5 @@ jobs: tests/test_liveuamap_provider.py \ tests/test_liveuamap_docker_contract.py \ tests/test_xquik_news.py \ + tests/test_aprs_is_resource_safety.py \ -v --tb=short diff --git a/backend/services/aprs_is_bridge.py b/backend/services/aprs_is_bridge.py new file mode 100644 index 0000000..6481c47 --- /dev/null +++ b/backend/services/aprs_is_bridge.py @@ -0,0 +1,430 @@ +"""Resource-safe APRS-IS receive bridge. + +The public APRS-IS network is community infrastructure. Shadowbroker therefore +uses it only when the operator explicitly opts in and supplies a bounded +geographic range. Public APRS-IS hosts never receive an unbounded/full-feed +subscription from this client. +""" + +from __future__ import annotations + +import logging +import os +import random +import re +import socket +import threading +from collections import deque +from dataclasses import dataclass +from datetime import datetime, timezone + +from services.sigint_bridge import _decode_aprs_symbol, _parse_aprs_comment, _scan_emergency + +logger = logging.getLogger("services.sigint.aprs") + +_TRUE_VALUES = {"1", "true", "yes", "on"} +_PUBLIC_APRS_SUFFIXES = (".aprs2.net", ".aprs-is.net", ".aprs.net") +_PUBLIC_APRS_HOSTS = {"rotate.aprs2.net", "srvr.aprs-is.net", "rotate.aprs.net"} +_DEFAULT_HOST = "rotate.aprs2.net" +_DEFAULT_PORT = 14580 +_DEFAULT_RADIUS_KM = 100.0 +_MAX_PUBLIC_RADIUS_KM = 500.0 +_DEFAULT_MAX_SIGNALS = 5000 +_MAX_SIGNAL_CAP = 20000 +_SIGNAL_MAX_AGE_S = 600.0 +_RECONNECT_BASE_S = 30.0 +_RECONNECT_MAX_S = 900.0 +_CALLSIGN_RE = re.compile(r"^[A-Z0-9-]{1,9}$") + + +@dataclass(frozen=True) +class APRSISConfig: + host: str + port: int + login: str + filter_expr: str + private_server: bool + + +def _env_enabled(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in _TRUE_VALUES + + +def _bounded_int(name: str, default: int, low: int, high: int) -> int: + raw = (os.getenv(name) or "").strip() + if not raw: + return default + try: + value = int(raw) + except ValueError: + return default + return max(low, min(value, high)) + + +def aprs_is_enabled() -> bool: + """Return whether APRS-IS networking is explicitly enabled.""" + return _env_enabled("APRS_IS_ENABLED", False) + + +def _is_public_aprs_host(host: str) -> bool: + host = host.strip().lower().rstrip(".") + return host in _PUBLIC_APRS_HOSTS or host.endswith(_PUBLIC_APRS_SUFFIXES) + + +def _parse_public_range() -> tuple[float, float, float]: + lat_raw = (os.getenv("APRS_IS_LAT") or "").strip() + lon_raw = (os.getenv("APRS_IS_LON") or "").strip() + if not lat_raw or not lon_raw: + raise ValueError( + "APRS_IS_LAT and APRS_IS_LON are required for public APRS-IS; " + "global/unbounded subscriptions are intentionally unsupported" + ) + try: + lat = float(lat_raw) + lon = float(lon_raw) + except ValueError as exc: + raise ValueError("APRS_IS_LAT/APRS_IS_LON must be decimal degrees") from exc + if not -90.0 <= lat <= 90.0 or not -180.0 <= lon <= 180.0: + raise ValueError("APRS_IS_LAT/APRS_IS_LON are outside valid coordinate bounds") + + radius_raw = (os.getenv("APRS_IS_RADIUS_KM") or str(_DEFAULT_RADIUS_KM)).strip() + try: + radius = float(radius_raw) + except ValueError as exc: + raise ValueError("APRS_IS_RADIUS_KM must be numeric") from exc + if not 1.0 <= radius <= _MAX_PUBLIC_RADIUS_KM: + raise ValueError( + f"APRS_IS_RADIUS_KM must be between 1 and {_MAX_PUBLIC_RADIUS_KM:g} km " + "when using public APRS-IS" + ) + return lat, lon, radius + + +def aprs_connection_config() -> APRSISConfig | None: + """Build the receive configuration, failing closed on unsafe public filters. + + Public APRS-IS requires an explicit geographic center and enforces a 500 km + maximum range. Operators running their own APRS-IS server may set + APRS_IS_PRIVATE_SERVER=true; only then may APRS_IS_FILTER be arbitrary or + blank. The private-server override is rejected for known public APRS hosts. + """ + if not aprs_is_enabled(): + return None + + host = (os.getenv("APRS_IS_HOST") or _DEFAULT_HOST).strip() + if not host or any(ch.isspace() for ch in host): + raise ValueError("APRS_IS_HOST is invalid") + port = _bounded_int("APRS_IS_PORT", _DEFAULT_PORT, 1, 65535) + private_server = _env_enabled("APRS_IS_PRIVATE_SERVER", False) + + if private_server: + if _is_public_aprs_host(host): + raise ValueError("APRS_IS_PRIVATE_SERVER cannot be used with a public APRS-IS host") + filter_expr = (os.getenv("APRS_IS_FILTER") or "").strip() + if "\r" in filter_expr or "\n" in filter_expr: + raise ValueError("APRS_IS_FILTER cannot contain line breaks") + else: + lat, lon, radius = _parse_public_range() + filter_expr = f"r/{lat:.5f}/{lon:.5f}/{radius:g}" + + callsign = (os.getenv("APRS_IS_CALLSIGN") or "N0CALL").strip().upper() + if not _CALLSIGN_RE.fullmatch(callsign): + raise ValueError("APRS_IS_CALLSIGN must be 1-9 APRS-safe characters") + + login = f"user {callsign} pass -1 vers ShadowBroker 1.0" + if filter_expr: + login += f" filter {filter_expr}" + login += "\r\n" + return APRSISConfig( + host=host, + port=port, + login=login, + filter_expr=filter_expr, + private_server=private_server, + ) + + +class APRSISBridge: + """Long-lived, bounded, opt-in APRS-IS receive client.""" + + CONFIDENCE = 0.7 + + def __init__(self) -> None: + max_signals = _bounded_int( + "APRS_IS_MAX_SIGNALS", + _DEFAULT_MAX_SIGNALS, + 100, + _MAX_SIGNAL_CAP, + ) + self.signals: deque[dict] = deque(maxlen=max_signals) + self._thread: threading.Thread | None = None + self._stop = threading.Event() + self._socket_lock = threading.Lock() + self._socket: socket.socket | None = None + self._config: APRSISConfig | None = None + self._connected = False + self._last_error = "" + + def is_running(self) -> bool: + return bool(self._thread and self._thread.is_alive() and not self._stop.is_set()) + + def status(self) -> dict[str, object]: + return { + "enabled": aprs_is_enabled(), + "running": self.is_running(), + "connected": self._connected, + "buffered": len(self.signals), + "last_error": self._last_error, + "host": self._config.host if self._config else "", + "filter": self._config.filter_expr if self._config else "", + } + + def reconcile(self, layer_enabled: bool) -> None: + """Match the network bridge to current operator layer/config state.""" + if not layer_enabled: + self.stop() + return + try: + config = aprs_connection_config() + except ValueError as exc: + self._last_error = str(exc) + self.stop() + logger.warning("APRS-IS disabled by safety validation: %s", exc) + return + if config is None: + self._last_error = "APRS_IS_ENABLED is false" + self.stop() + return + + if self.is_running() and self._config == config: + return + if self.is_running() or self._config != config: + self.stop() + self._config = config + self._last_error = "" + self.start() + + def start(self) -> None: + if self._config is None: + try: + self._config = aprs_connection_config() + except ValueError as exc: + self._last_error = str(exc) + logger.warning("APRS-IS not started: %s", exc) + return + if self._config is None: + return + if self._thread and self._thread.is_alive(): + if not self._stop.is_set(): + return + self._thread.join(timeout=2.0) + if self._thread.is_alive(): + logger.warning("APRS-IS bridge is still stopping; start deferred") + return + self._stop.clear() + self._thread = threading.Thread(target=self._run, daemon=True, name="aprs-safe-bridge") + self._thread.start() + logger.info( + "APRS-IS bridge started on %s:%s with bounded filter %s", + self._config.host, + self._config.port, + self._config.filter_expr or "server-default/private", + ) + + def stop(self) -> None: + self._stop.set() + self._connected = False + with self._socket_lock: + sock = self._socket + self._socket = None + if sock is not None: + try: + sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + try: + sock.close() + except OSError: + pass + thread = self._thread + if thread and thread.is_alive() and thread is not threading.current_thread(): + thread.join(timeout=2.0) + + @staticmethod + def _reconnect_delay(failures: int) -> float: + exponent = max(0, min(failures - 1, 5)) + base = min(_RECONNECT_BASE_S * (2**exponent), _RECONNECT_MAX_S) + return base * random.uniform(0.85, 1.15) + + def _run(self) -> None: + failures = 0 + while not self._stop.is_set(): + config = self._config + if config is None: + return + try: + self._connect_and_read(config) + if self._stop.is_set(): + return + failures += 1 + self._last_error = "connection_closed" + except Exception as exc: + if self._stop.is_set(): + return + failures += 1 + self._last_error = f"{type(exc).__name__}: {exc}" + logger.warning("APRS-IS connection error: %s", exc) + delay = self._reconnect_delay(failures) + logger.info("APRS-IS reconnect backoff %.0fs after %d failure(s)", delay, failures) + self._stop.wait(delay) + + def _connect_and_read(self, config: APRSISConfig) -> None: + sock = socket.create_connection((config.host, config.port), timeout=30) + with self._socket_lock: + if self._stop.is_set(): + sock.close() + return + self._socket = sock + try: + sock.settimeout(30) + banner = sock.recv(512).decode("utf-8", errors="replace") + logger.info("APRS-IS: %s", banner.strip()) + sock.sendall(config.login.encode("ascii")) + self._connected = True + self._last_error = "" + buf = b"" + while not self._stop.is_set(): + try: + chunk = sock.recv(4096) + except socket.timeout: + sock.sendall(b"#keepalive\r\n") + continue + if not chunk: + break + buf += chunk + while b"\n" in buf: + line_bytes, buf = buf.split(b"\n", 1) + line_bytes = line_bytes.strip() + if not line_bytes or line_bytes.startswith(b"#"): + continue + self._parse_packet(self._decode_line(line_bytes)) + finally: + self._connected = False + with self._socket_lock: + if self._socket is sock: + self._socket = None + try: + sock.close() + except OSError: + pass + + @staticmethod + def _decode_line(raw_bytes: bytes) -> str: + try: + return raw_bytes.decode("utf-8") + except UnicodeDecodeError: + pass + try: + return raw_bytes.decode("gbk") + except UnicodeDecodeError: + pass + return raw_bytes.decode("latin-1") + + def _parse_packet(self, raw: str) -> None: + try: + if ":" not in raw: + return + header, payload = raw.split(":", 1) + callsign = header.split(">")[0].strip() + if not callsign or callsign == "N0CALL" or not payload or payload[0] not in "!@/=": + return + + pos = payload[1:] + lat = self._parse_lat(pos[:8]) + lng = self._parse_lng(pos[9:18]) + if lat is None or lng is None: + return + symbol = pos[8] + pos[18] if len(pos) > 18 else "" + comment = pos[19:].strip() if len(pos) > 19 else "" + meta = _parse_aprs_comment(comment) + signal = { + "callsign": callsign, + "lat": lat, + "lng": lng, + "source": "aprs", + "confidence": self.CONFIDENCE, + "timestamp": datetime.now(timezone.utc).isoformat(), + "raw_message": raw[:200], + "symbol": symbol, + "station_type": _decode_aprs_symbol(symbol), + "comment": comment[:100], + } + for key in ( + "frequency", + "altitude_ft", + "speed_knots", + "battery_v", + "power_watts", + "status", + ): + if meta.get(key) is not None: + signal[key] = meta[key] + if meta.get("speed_knots"): + signal["course"] = meta.get("course", 0) + emergency_kw = _scan_emergency(comment) or _scan_emergency(signal.get("status", "")) + if emergency_kw: + signal["emergency"] = True + signal["emergency_keyword"] = emergency_kw + self.signals.append(signal) + except (ValueError, IndexError): + return + + def get_signals(self) -> list[dict]: + """Return only recent APRS signals while keeping memory hard-bounded.""" + now = datetime.now(timezone.utc) + result: list[dict] = [] + for signal in list(self.signals): + try: + timestamp = datetime.fromisoformat(str(signal["timestamp"]).replace("Z", "+00:00")) + if (now - timestamp).total_seconds() > _SIGNAL_MAX_AGE_S: + continue + except (KeyError, TypeError, ValueError): + continue + result.append(dict(signal)) + return result + + @staticmethod + def _parse_lat(value: str) -> float | None: + try: + if len(value) < 8: + return None + degrees = int(value[:2]) + minutes = float(value[2:7]) + direction = value[7].upper() + lat = degrees + minutes / 60.0 + if direction == "S": + lat = -lat + return round(lat, 5) if -90 <= lat <= 90 else None + except (ValueError, IndexError): + return None + + @staticmethod + def _parse_lng(value: str) -> float | None: + try: + if len(value) < 9: + return None + degrees = int(value[:3]) + minutes = float(value[3:8]) + direction = value[8].upper() + lng = degrees + minutes / 60.0 + if direction == "W": + lng = -lng + return round(lng, 5) if -180 <= lng <= 180 else None + except (ValueError, IndexError): + return None + + +aprs_is_bridge = APRSISBridge() diff --git a/backend/services/fetchers/sigint.py b/backend/services/fetchers/sigint.py index 185cbe0..f1b3fd0 100644 --- a/backend/services/fetchers/sigint.py +++ b/backend/services/fetchers/sigint.py @@ -1,12 +1,15 @@ """SIGINT fetcher — pulls latest signals from the SIGINT Grid into latest_data. -Merges live MQTT signals with cached Meshtastic map API nodes. -Live MQTT signals always take priority (fresher) — API nodes fill in the gaps -for the thousands of nodes our MQTT listener hasn't heard yet. +Merges live APRS/MQTT/JS8Call signals with cached Meshtastic map API nodes. +Each external bridge is reconciled independently so enabling one transport does +not implicitly start another. """ import logging + +from services.aprs_is_bridge import aprs_is_bridge from services.fetchers._store import latest_data, _data_lock, _mark_fresh +from services.meshtastic_mqtt_settings import mqtt_bridge_enabled logger = logging.getLogger("services.data_fetcher") @@ -23,7 +26,7 @@ def _merge_sigint_snapshot( # Shallow-copy every entry so the published list owns its own dicts. The # inputs alias objects that other threads keep mutating in place: live - # signals are the SIGINT bridge's own dicts (updated as packets arrive), + # signals are the SIGINT bridges' own dicts (updated as packets arrive), # and api_nodes are the same objects published under latest_data # ["meshtastic_map_nodes"]. Publishing those references into # latest_data["sigint"] lets a concurrent mutation race the lock-free @@ -69,7 +72,11 @@ def build_sigint_snapshot() -> tuple[list[dict], dict[str, object], dict[str, in from services.sigint_bridge import sigint_grid + # The legacy APRS member in SIGINTGrid is intentionally never started by + # the production fetch path after #533. Safe APRS-IS receive traffic comes + # from aprs_is_bridge, which requires explicit bounded configuration. live_signals = sigint_grid.get_all_signals() + live_signals.extend(aprs_is_bridge.get_signals()) with _data_lock: api_nodes = list(latest_data.get("meshtastic_map_nodes", [])) merged = _merge_sigint_snapshot(live_signals, api_nodes) @@ -90,22 +97,54 @@ def refresh_sigint_snapshot() -> tuple[list[dict], dict[str, object], dict[str, return signals, channel_stats, totals -def fetch_sigint(): - """Fetch all signals from the SIGINT Grid, merge with Meshtastic map nodes.""" - from services.fetchers._store import is_any_active - - if not is_any_active("sigint_meshtastic", "sigint_aprs"): - return +def _reconcile_sigint_bridges(aprs_requested: bool, mesh_requested: bool) -> None: + """Start/stop each bridge independently from current operator state.""" from services.sigint_bridge import sigint_grid - # Start bridges on first call (idempotent) - sigint_grid.start() + # Defense-in-depth: the old SIGINTGrid APRS client used an effectively + # global range subscription. It is no longer part of the production fetch + # path; force it stopped even if another caller started it accidentally. + sigint_grid.aprs.stop() + + aprs_is_bridge.reconcile(aprs_requested) + + try: + mesh_network_enabled = mqtt_bridge_enabled() + except Exception: + mesh_network_enabled = False + if mesh_requested and mesh_network_enabled: + sigint_grid.mesh.start() + else: + sigint_grid.mesh.stop() + + # JS8Call is localhost-only and historically accompanies the SIGINT view. + # Keep that behavior without coupling it to either public network bridge. + if aprs_requested or mesh_requested: + sigint_grid.js8.start() + else: + sigint_grid.js8.stop() + + +def fetch_sigint(): + """Refresh SIGINT while matching bridge lifecycles to operator settings.""" + from services.fetchers._store import effective_layers + + layers = effective_layers() + aprs_requested = bool(layers.get("sigint_aprs", False)) + mesh_requested = bool(layers.get("sigint_meshtastic", False)) + _reconcile_sigint_bridges(aprs_requested, mesh_requested) + + if not aprs_requested and not mesh_requested: + return signals, channel_stats, totals = refresh_sigint_snapshot() + from services.sigint_bridge import sigint_grid - status = sigint_grid.status logger.info( - f"SIGINT: {len(signals)} signals " - f"(APRS:{status['aprs']} MESH:{status['meshtastic']} " - f"JS8:{status['js8call']} MAP:{totals['meshtastic_map']})" + "SIGINT: %d signals (APRS:%d MESH:%d JS8:%d MAP:%d)", + len(signals), + totals["aprs"], + totals["meshtastic_live"], + len(sigint_grid.js8.signals), + totals["meshtastic_map"], ) diff --git a/backend/tests/test_aprs_is_resource_safety.py b/backend/tests/test_aprs_is_resource_safety.py new file mode 100644 index 0000000..297f501 --- /dev/null +++ b/backend/tests/test_aprs_is_resource_safety.py @@ -0,0 +1,201 @@ +"""Regression coverage for APRS-IS resource safety (#533).""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from services.aprs_is_bridge import ( + APRSISBridge, + aprs_connection_config, +) + + +_APRS_ENV = ( + "APRS_IS_ENABLED", + "APRS_IS_HOST", + "APRS_IS_PORT", + "APRS_IS_PRIVATE_SERVER", + "APRS_IS_FILTER", + "APRS_IS_LAT", + "APRS_IS_LON", + "APRS_IS_RADIUS_KM", + "APRS_IS_CALLSIGN", + "APRS_IS_MAX_SIGNALS", +) + + +def _clear_aprs_env(monkeypatch: pytest.MonkeyPatch) -> None: + for name in _APRS_ENV: + monkeypatch.delenv(name, raising=False) + + +def _enable_public(monkeypatch: pytest.MonkeyPatch) -> None: + _clear_aprs_env(monkeypatch) + monkeypatch.setenv("APRS_IS_ENABLED", "true") + monkeypatch.setenv("APRS_IS_LAT", "40.7128") + monkeypatch.setenv("APRS_IS_LON", "-74.0060") + + +def test_aprs_is_is_network_off_by_default(monkeypatch: pytest.MonkeyPatch) -> None: + _clear_aprs_env(monkeypatch) + assert aprs_connection_config() is None + + +def test_public_aprs_uses_bounded_geographic_filter(monkeypatch: pytest.MonkeyPatch) -> None: + _enable_public(monkeypatch) + monkeypatch.setenv("APRS_IS_RADIUS_KM", "250") + + config = aprs_connection_config() + + assert config is not None + assert config.host == "rotate.aprs2.net" + assert config.port == 14580 + assert config.filter_expr == "r/40.71280/-74.00600/250" + assert "filter r/40.71280/-74.00600/250" in config.login + assert "25000" not in config.login + + +def test_public_aprs_rejects_global_scale_radius(monkeypatch: pytest.MonkeyPatch) -> None: + _enable_public(monkeypatch) + monkeypatch.setenv("APRS_IS_RADIUS_KM", "25000") + + with pytest.raises(ValueError, match="between 1 and 500 km"): + aprs_connection_config() + + +def test_public_aprs_requires_explicit_center(monkeypatch: pytest.MonkeyPatch) -> None: + _clear_aprs_env(monkeypatch) + monkeypatch.setenv("APRS_IS_ENABLED", "true") + + with pytest.raises(ValueError, match="APRS_IS_LAT and APRS_IS_LON"): + aprs_connection_config() + + +def test_private_full_feed_override_cannot_target_public_aprs(monkeypatch: pytest.MonkeyPatch) -> None: + _clear_aprs_env(monkeypatch) + monkeypatch.setenv("APRS_IS_ENABLED", "true") + monkeypatch.setenv("APRS_IS_PRIVATE_SERVER", "true") + monkeypatch.setenv("APRS_IS_HOST", "rotate.aprs2.net") + + with pytest.raises(ValueError, match="cannot be used with a public APRS-IS host"): + aprs_connection_config() + + +def test_private_operator_server_may_use_its_own_filter(monkeypatch: pytest.MonkeyPatch) -> None: + _clear_aprs_env(monkeypatch) + monkeypatch.setenv("APRS_IS_ENABLED", "true") + monkeypatch.setenv("APRS_IS_PRIVATE_SERVER", "true") + monkeypatch.setenv("APRS_IS_HOST", "aprs.internal.example") + monkeypatch.setenv("APRS_IS_FILTER", "t/p") + + config = aprs_connection_config() + + assert config is not None + assert config.private_server is True + assert config.filter_expr == "t/p" + assert config.login.endswith("filter t/p\r\n") + + +def test_aprs_buffer_is_hard_bounded(monkeypatch: pytest.MonkeyPatch) -> None: + _clear_aprs_env(monkeypatch) + monkeypatch.setenv("APRS_IS_MAX_SIGNALS", "100") + bridge = APRSISBridge() + + for index in range(150): + bridge.signals.append({"id": index}) + + assert bridge.signals.maxlen == 100 + assert len(bridge.signals) == 100 + assert bridge.signals[0]["id"] == 50 + + +def test_reconnect_backoff_is_exponential_and_capped(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("services.aprs_is_bridge.random.uniform", lambda _a, _b: 1.0) + + assert APRSISBridge._reconnect_delay(1) == 30.0 + assert APRSISBridge._reconnect_delay(2) == 60.0 + assert APRSISBridge._reconnect_delay(3) == 120.0 + assert APRSISBridge._reconnect_delay(4) == 240.0 + assert APRSISBridge._reconnect_delay(5) == 480.0 + assert APRSISBridge._reconnect_delay(6) == 900.0 + assert APRSISBridge._reconnect_delay(99) == 900.0 + + +def test_invalid_public_config_fails_closed_without_starting_thread( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _clear_aprs_env(monkeypatch) + monkeypatch.setenv("APRS_IS_ENABLED", "true") + bridge = APRSISBridge() + + bridge.reconcile(True) + + assert bridge.is_running() is False + assert "APRS_IS_LAT" in bridge.status()["last_error"] + + +def test_sigint_lifecycle_does_not_start_legacy_aprs_when_only_mesh_is_requested( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from services.fetchers import sigint as sigint_fetcher + from services.sigint_bridge import sigint_grid + + legacy_stop = MagicMock() + mesh_start = MagicMock() + mesh_stop = MagicMock() + js8_start = MagicMock() + safe_reconcile = MagicMock() + + monkeypatch.setattr(sigint_grid.aprs, "stop", legacy_stop) + monkeypatch.setattr(sigint_grid.mesh, "start", mesh_start) + monkeypatch.setattr(sigint_grid.mesh, "stop", mesh_stop) + monkeypatch.setattr(sigint_grid.js8, "start", js8_start) + monkeypatch.setattr(sigint_fetcher.aprs_is_bridge, "reconcile", safe_reconcile) + monkeypatch.setattr(sigint_fetcher, "mqtt_bridge_enabled", lambda: True) + + sigint_fetcher._reconcile_sigint_bridges(aprs_requested=False, mesh_requested=True) + + legacy_stop.assert_called_once_with() + safe_reconcile.assert_called_once_with(False) + mesh_start.assert_called_once_with() + mesh_stop.assert_not_called() + js8_start.assert_called_once_with() + + +def test_turning_aprs_layer_off_stops_aprs_independently( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from services.fetchers import sigint as sigint_fetcher + from services.sigint_bridge import sigint_grid + + safe_reconcile = MagicMock() + monkeypatch.setattr(sigint_fetcher.aprs_is_bridge, "reconcile", safe_reconcile) + monkeypatch.setattr(sigint_fetcher, "mqtt_bridge_enabled", lambda: False) + monkeypatch.setattr(sigint_grid.aprs, "stop", MagicMock()) + monkeypatch.setattr(sigint_grid.mesh, "stop", MagicMock()) + monkeypatch.setattr(sigint_grid.js8, "start", MagicMock()) + + sigint_fetcher._reconcile_sigint_bridges(aprs_requested=False, mesh_requested=True) + + safe_reconcile.assert_called_once_with(False) + + +def test_fetch_path_never_calls_legacy_grid_start(monkeypatch: pytest.MonkeyPatch) -> None: + from services.fetchers import sigint as sigint_fetcher + from services.fetchers import _store + from services.sigint_bridge import sigint_grid + + monkeypatch.setattr( + _store, + "effective_layers", + lambda: {"sigint_aprs": False, "sigint_meshtastic": False}, + ) + legacy_start = MagicMock(side_effect=AssertionError("legacy grid start must not run")) + monkeypatch.setattr(sigint_grid, "start", legacy_start) + monkeypatch.setattr(sigint_fetcher, "_reconcile_sigint_bridges", MagicMock()) + + sigint_fetcher.fetch_sigint() + + legacy_start.assert_not_called() diff --git a/docker-compose.yml b/docker-compose.yml index d91feab..306e28a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,6 +80,18 @@ services: - MESH_MQTT_EXTRA_ROOTS=${MESH_MQTT_EXTRA_ROOTS:-} - MESH_MQTT_EXTRA_TOPICS=${MESH_MQTT_EXTRA_TOPICS:-} - MESHTASTIC_OPERATOR_CALLSIGN=${MESHTASTIC_OPERATOR_CALLSIGN:-} + # APRS-IS receive is opt-in and geographically bounded on public servers. + # Configure a center before enabling; public radius is hard-capped at 500 km. + - APRS_IS_ENABLED=${APRS_IS_ENABLED:-false} + - APRS_IS_HOST=${APRS_IS_HOST:-rotate.aprs2.net} + - APRS_IS_PORT=${APRS_IS_PORT:-14580} + - APRS_IS_LAT=${APRS_IS_LAT:-} + - APRS_IS_LON=${APRS_IS_LON:-} + - APRS_IS_RADIUS_KM=${APRS_IS_RADIUS_KM:-100} + - APRS_IS_CALLSIGN=${APRS_IS_CALLSIGN:-N0CALL} + - APRS_IS_MAX_SIGNALS=${APRS_IS_MAX_SIGNALS:-5000} + - APRS_IS_PRIVATE_SERVER=${APRS_IS_PRIVATE_SERVER:-false} + - APRS_IS_FILTER=${APRS_IS_FILTER:-} # The bundled Docker UI talks to the backend across Docker's private bridge. # Treat that bridge as local operator access while ports remain bound to 127.0.0.1 by default. - SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR=${SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR:-1} diff --git a/docs/APRS_IS.md b/docs/APRS_IS.md new file mode 100644 index 0000000..ed44d31 --- /dev/null +++ b/docs/APRS_IS.md @@ -0,0 +1,59 @@ +# APRS-IS receive configuration + +Shadowbroker treats public APRS-IS as community infrastructure. Receive access is therefore **off by default** and public-server subscriptions must be geographically bounded. + +## Public APRS-IS (recommended client mode) + +Set a geographic center and a reasonable radius in `.env`: + +```env +APRS_IS_ENABLED=true +APRS_IS_LAT=40.7128 +APRS_IS_LON=-74.0060 +APRS_IS_RADIUS_KM=100 +``` + +Docker Compose passes these values to the backend automatically. The default host is `rotate.aprs2.net:14580`, the APRS-IS user-defined filtered client port. + +Shadowbroker constructs a server-side range filter of the form: + +```text +r/// +``` + +For public APRS-IS hosts, `APRS_IS_RADIUS_KM` must be between **1 and 500 km**. Missing coordinates, invalid coordinates, or a larger radius fail closed: the APRS receive bridge does not connect. + +Optional public-client settings: + +```env +APRS_IS_HOST=rotate.aprs2.net +APRS_IS_PORT=14580 +APRS_IS_CALLSIGN=N0CALL +APRS_IS_MAX_SIGNALS=5000 +``` + +`APRS_IS_MAX_SIGNALS` is hard-capped at 20,000 and only recent observations are published. + +## Dedicated/private APRS-IS server + +Operators who run or control their own APRS-IS aggregation server can opt into private-server mode: + +```env +APRS_IS_ENABLED=true +APRS_IS_PRIVATE_SERVER=true +APRS_IS_HOST=aprs.internal.example +APRS_IS_PORT=14580 +APRS_IS_FILTER=t/p +``` + +In private-server mode, `APRS_IS_FILTER` may be any filter accepted by that server, or blank to use the server's default. Shadowbroker refuses to enable private/full-feed mode when the configured host is a known public APRS-IS domain such as `*.aprs2.net`, `*.aprs-is.net`, or `*.aprs.net`. + +Use dedicated infrastructure for genuinely global/full-feed APRS collection. Do not point an unbounded client at public Tier-2 filtered servers. + +## Runtime behavior + +- APRS and Meshtastic now have independent connection lifecycles. +- Turning the APRS layer off stops the APRS receive bridge; leaving Meshtastic on does not restart it. +- Failed APRS connections use exponential reconnect backoff with jitter (30 seconds up to 15 minutes) instead of reconnecting every 15 seconds forever. +- APRS receive memory is bounded, and only observations from the recent retention window are published. +- APRS transmit remains a separate operator-initiated path and is not enabled by `APRS_IS_ENABLED`.