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_docker_contract.py \
tests/test_xquik_news.py \
tests/test_aprs_is_resource_safety.py \
-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.
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"],
)
@@ -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_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}
+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`.