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:
Shadowbroker
2026-08-23 08:50:33 -06:00
committed by GitHub
6 changed files with 758 additions and 16 deletions
+1
View File
@@ -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
+430
View File
@@ -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()
+55 -16
View File
@@ -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()
+12
View File
@@ -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}
+59
View File
@@ -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`.