Files
Shadowbroker/backend/services/aprs_is_bridge.py
T

431 lines
15 KiB
Python

"""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()