mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-24 19:32:42 +02:00
Merge pull request #534 from BigBodyCobain/fix/aprs-is-resource-safety-533
fix(aprs): stop global APRS-IS load and bound receive lifecycle
This commit is contained in:
@@ -84,4 +84,5 @@ jobs:
|
|||||||
tests/test_liveuamap_provider.py \
|
tests/test_liveuamap_provider.py \
|
||||||
tests/test_liveuamap_docker_contract.py \
|
tests/test_liveuamap_docker_contract.py \
|
||||||
tests/test_xquik_news.py \
|
tests/test_xquik_news.py \
|
||||||
|
tests/test_aprs_is_resource_safety.py \
|
||||||
-v --tb=short
|
-v --tb=short
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
"""SIGINT fetcher — pulls latest signals from the SIGINT Grid into latest_data.
|
"""SIGINT fetcher — pulls latest signals from the SIGINT Grid into latest_data.
|
||||||
|
|
||||||
Merges live MQTT signals with cached Meshtastic map API nodes.
|
Merges live APRS/MQTT/JS8Call signals with cached Meshtastic map API nodes.
|
||||||
Live MQTT signals always take priority (fresher) — API nodes fill in the gaps
|
Each external bridge is reconciled independently so enabling one transport does
|
||||||
for the thousands of nodes our MQTT listener hasn't heard yet.
|
not implicitly start another.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from services.aprs_is_bridge import aprs_is_bridge
|
||||||
from services.fetchers._store import latest_data, _data_lock, _mark_fresh
|
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")
|
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
|
# Shallow-copy every entry so the published list owns its own dicts. The
|
||||||
# inputs alias objects that other threads keep mutating in place: live
|
# 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
|
# and api_nodes are the same objects published under latest_data
|
||||||
# ["meshtastic_map_nodes"]. Publishing those references into
|
# ["meshtastic_map_nodes"]. Publishing those references into
|
||||||
# latest_data["sigint"] lets a concurrent mutation race the lock-free
|
# 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
|
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 = sigint_grid.get_all_signals()
|
||||||
|
live_signals.extend(aprs_is_bridge.get_signals())
|
||||||
with _data_lock:
|
with _data_lock:
|
||||||
api_nodes = list(latest_data.get("meshtastic_map_nodes", []))
|
api_nodes = list(latest_data.get("meshtastic_map_nodes", []))
|
||||||
merged = _merge_sigint_snapshot(live_signals, api_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
|
return signals, channel_stats, totals
|
||||||
|
|
||||||
|
|
||||||
def fetch_sigint():
|
def _reconcile_sigint_bridges(aprs_requested: bool, mesh_requested: bool) -> None:
|
||||||
"""Fetch all signals from the SIGINT Grid, merge with Meshtastic map nodes."""
|
"""Start/stop each bridge independently from current operator state."""
|
||||||
from services.fetchers._store import is_any_active
|
|
||||||
|
|
||||||
if not is_any_active("sigint_meshtastic", "sigint_aprs"):
|
|
||||||
return
|
|
||||||
from services.sigint_bridge import sigint_grid
|
from services.sigint_bridge import sigint_grid
|
||||||
|
|
||||||
# Start bridges on first call (idempotent)
|
# Defense-in-depth: the old SIGINTGrid APRS client used an effectively
|
||||||
sigint_grid.start()
|
# 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()
|
signals, channel_stats, totals = refresh_sigint_snapshot()
|
||||||
|
from services.sigint_bridge import sigint_grid
|
||||||
|
|
||||||
status = sigint_grid.status
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"SIGINT: {len(signals)} signals "
|
"SIGINT: %d signals (APRS:%d MESH:%d JS8:%d MAP:%d)",
|
||||||
f"(APRS:{status['aprs']} MESH:{status['meshtastic']} "
|
len(signals),
|
||||||
f"JS8:{status['js8call']} MAP:{totals['meshtastic_map']})"
|
totals["aprs"],
|
||||||
|
totals["meshtastic_live"],
|
||||||
|
len(sigint_grid.js8.signals),
|
||||||
|
totals["meshtastic_map"],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -80,6 +80,18 @@ services:
|
|||||||
- MESH_MQTT_EXTRA_ROOTS=${MESH_MQTT_EXTRA_ROOTS:-}
|
- MESH_MQTT_EXTRA_ROOTS=${MESH_MQTT_EXTRA_ROOTS:-}
|
||||||
- MESH_MQTT_EXTRA_TOPICS=${MESH_MQTT_EXTRA_TOPICS:-}
|
- MESH_MQTT_EXTRA_TOPICS=${MESH_MQTT_EXTRA_TOPICS:-}
|
||||||
- MESHTASTIC_OPERATOR_CALLSIGN=${MESHTASTIC_OPERATOR_CALLSIGN:-}
|
- 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.
|
# 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.
|
# 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}
|
- SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR=${SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR:-1}
|
||||||
|
|||||||
@@ -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/<latitude>/<longitude>/<radius-km>
|
||||||
|
```
|
||||||
|
|
||||||
|
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`.
|
||||||
Reference in New Issue
Block a user