From 5bdd6302c18fa3aad8d8645dca55d7a5b5e1d1ab Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 21 Aug 2026 09:57:23 -0700 Subject: [PATCH] fix(airplanes): switch Airplanes API to adsb.lol default --- CHANGELOG.md | 5 + config.ini.example | 12 +- docs/command-reference.md | 6 +- docs/configuration.md | 2 +- modules/commands/airplanes_command.py | 103 +++++++++++--- tests/commands/test_airplanes_command.py | 162 +++++++++++++++++++++++ 6 files changed, 258 insertions(+), 32 deletions(-) create mode 100644 tests/commands/test_airplanes_command.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7133044..d01cf14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ semantic versioning. adverts. Empty-path adverts are stored in `observed_paths` with SNR/RSSI; neighbor-discover cycles refresh SNR on those rows. A one-time backfill copies recent zero-hop ADVERTs out of `packet_stream`. +- **Airplanes / ADS-B** no longer depends on the public airplanes.live API, + which now returns HTTP 403 for unregistered clients (#244). Default + endpoint is `https://api.adsb.lol/v2/`; existing `api_url` values pointing + at `api.airplanes.live` are remapped automatically. Local readsb URLs are + unchanged. ### Added diff --git a/config.ini.example b/config.ini.example index 64caa2f..864db17 100644 --- a/config.ini.example +++ b/config.ini.example @@ -1383,14 +1383,14 @@ announce.other = This is a different announcement on another topic. # Enable or disable the airplanes command (true/false) enabled = true -# API endpoint URL for ADS-B aircraft data -# Default: airplanes.live API -# Supports any standardized ADS-B API using readsb/airplanes.live format +# API endpoint URL for ADS-B aircraft data (readsb / ADSBExchange v2 format) +# Default: adsb.lol. The previous public airplanes.live API now returns 403 +# for unregistered clients; that host is remapped to this default at runtime. +# A local readsb URL or any other host is left unchanged. # Examples: -# http://api.airplanes.live/v2/ (default) -# https://adsbexchange-com1.p.rapidapi.com/v2/ (if compatible) +# https://api.adsb.lol/v2/ (default) # http://localhost:8080/data/ (local readsb instance) -api_url = http://api.airplanes.live/v2/ +api_url = https://api.adsb.lol/v2/ # Default search radius in nautical miles # Maximum: 250 nautical miles diff --git a/docs/command-reference.md b/docs/command-reference.md index bd82cfd..44ce95e 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -282,7 +282,7 @@ When no location is given, uses the sender's companion location if known, then t ### `airplanes [location] [options]` / `overhead [lat,lon]` -Get aircraft tracking information using ADS-B data from airplanes.live or compatible APIs. +Get aircraft tracking information using ADS-B data from adsb.lol or any compatible readsb/ADSBExchange v2 API. **Aliases:** `aircraft`, `planes`, `adsb`, `overhead` @@ -342,12 +342,12 @@ airplanes 47.6,-122.3 radius=25 closest **Configuration:** The command can be configured in `config.ini` under `[Airplanes_Command]`: - `enabled` - Enable/disable the command -- `api_url` - API endpoint URL (default: `http://api.airplanes.live/v2/`) +- `api_url` - API endpoint URL (default: `https://api.adsb.lol/v2/`). Existing configs that still point at `api.airplanes.live` are remapped to this default. A local readsb instance or any other host is left as-is. - `default_radius` - Default search radius in nautical miles - `max_results` - Maximum number of results to return - `url_timeout` - API request timeout in seconds -**Note:** Uses companion location from database if available, otherwise falls back to bot location from config. The API is rate-limited to 1 request per second. +**Note:** Uses companion location from database if available, otherwise falls back to bot location from config. Keep the command cooldown at 2 seconds to stay within typical public ADS-B rate limits. --- diff --git a/docs/configuration.md b/docs/configuration.md index e54a7b8..f4be04c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -146,7 +146,7 @@ Examples of sections that configure specific commands or features: - **`[Prefix_Command]`** – Prefix lookup, prefix best, range limits. - **`[Cmd_Command]`** – `cmd` behavior. Set `cmd_reference_url` to return `Full command reference: ` instead of the generated compact command list. - **`[Weather]`** – Used by the `wx` / `gwx` commands and the Weather Service plugin (see [Weather Service](weather-service.md)). -- **`[Airplanes_Command]`** – Aircraft/ADS-B command (API URL, radius, limits). +- **`[Airplanes_Command]`** – Aircraft/ADS-B command (API URL, radius, limits). Default `api_url` is `https://api.adsb.lol/v2/`. - **`[Aurora_Command]`** – Aurora command (default coordinates). - **`[Alert_Command]`** – Emergency alerts (agency IDs, etc.). - **`[Sports_Command]`** – Sports scores (teams, leagues). diff --git a/modules/commands/airplanes_command.py b/modules/commands/airplanes_command.py index 4f87b23..d2deb4e 100644 --- a/modules/commands/airplanes_command.py +++ b/modules/commands/airplanes_command.py @@ -1,26 +1,54 @@ #!/usr/bin/env python3 """ Airplanes command for the MeshCore Bot -Provides aircraft tracking using ADS-B data from airplanes.live or compatible APIs +Provides aircraft tracking using ADS-B data from adsb.lol or compatible APIs """ import asyncio import math import re from typing import Any, Optional +from urllib.parse import urlparse import requests from ..models import MeshMessage +from ..security_utils import sanitize_name from ..utils import calculate_distance from .base_command import BaseCommand +DEFAULT_API_URL = "https://api.adsb.lol/v2/" +USER_AGENT = "MeshCoreBot (https://github.com/agessaman/meshcore-bot)" +# Public airplanes.live v2 now returns 403 for unregistered clients. +_DEPRECATED_PUBLIC_API_HOSTS = frozenset({"api.airplanes.live"}) + + +def normalize_api_url(url: str) -> str: + """Strip whitespace and ensure a trailing slash on a non-empty API base URL.""" + url = (url or "").strip() + if url and not url.endswith("/"): + url += "/" + return url + + +def is_deprecated_public_airplanes_live_url(url: str) -> bool: + """Return True for the old public airplanes.live host (not the Pro REST host).""" + host = (urlparse(url).hostname or "").lower() + return host in _DEPRECATED_PUBLIC_API_HOSTS + + +def _response_body_snippet(response: requests.Response, max_length: int = 200) -> str: + """Return a log-safe snippet of an HTTP response body.""" + text = response.text or response.reason or "" + return sanitize_name(text, max_length=max_length) or "(empty body)" + class AirplanesCommand(BaseCommand): """Handles aircraft tracking commands using ADS-B data. Provides aircraft information overhead at companion location, bot location, or specified coordinates. Supports filtering and detailed single-aircraft display. + Uses adsb.lol by default; any readsb/ADSBExchange v2 endpoint can be configured. """ # Plugin metadata @@ -44,8 +72,8 @@ class AirplanesCommand(BaseCommand): # Web-viewer settings schema (see modules/settings_schema.py) settings_schema = [ {"key": "api_url", "label": "API URL", "type": "str", - "default": "http://api.airplanes.live/v2/", - "help": "ADS-B API endpoint (readsb/airplanes.live format)."}, + "default": DEFAULT_API_URL, + "help": "ADS-B API endpoint (readsb/ADSBExchange v2 format). Default is adsb.lol."}, {"key": "default_radius", "label": "Default radius", "type": "float", "min": 1, "max": 250, "default": 25, "unit": "nm", "help": "Default search radius in nautical miles (max 250)."}, @@ -60,17 +88,16 @@ class AirplanesCommand(BaseCommand): def __init__(self, bot): super().__init__(bot) self.airplanes_enabled = self.get_config_value('Airplanes_Command', 'enabled', fallback=True, value_type='bool') - self.api_url = self.get_config_value('Airplanes_Command', 'api_url', fallback='http://api.airplanes.live/v2/', value_type='str') + configured_url = self.get_config_value( + 'Airplanes_Command', 'api_url', fallback=DEFAULT_API_URL, value_type='str' + ) + self.api_url = self._resolve_api_url(configured_url) self.default_radius = self.get_config_value('Airplanes_Command', 'default_radius', fallback=25, value_type='float') # Default chosen to fit single-message constraints on the smallest channel budget. # Channel payload can be as low as 130 bytes; three compact lines are reliable. self.max_results = self.get_config_value('Airplanes_Command', 'max_results', fallback=3, value_type='int') self.url_timeout = self.get_config_value('Airplanes_Command', 'url_timeout', fallback=10, value_type='int') - # Ensure API URL ends with / - if self.api_url and not self.api_url.endswith('/'): - self.api_url += '/' - def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: """Check if this command can be executed with the given message. @@ -92,6 +119,26 @@ class AirplanesCommand(BaseCommand): """ return self.translate('commands.airplanes.description') + def _resolve_api_url(self, configured: Optional[str]) -> str: + """Normalize api_url and remap the blocked public airplanes.live host. + + Custom URLs (local readsb, airplanes.live Pro, other v2 hosts) are left + unchanged so a private feeder is never redirected to a public API. + """ + url = normalize_api_url(configured or "") + if not url: + return DEFAULT_API_URL + if is_deprecated_public_airplanes_live_url(url): + self.logger.warning( + "Airplanes_Command api_url %s is no longer publicly accessible; " + "using %s instead. Point api_url at a local readsb instance or " + "another compatible ADS-B API to override.", + url, + DEFAULT_API_URL, + ) + return DEFAULT_API_URL + return url + def _calculate_bearing(self, lat1: float, lon1: float, lat2: float, lon2: float) -> float: """Calculate bearing from point 1 to point 2 in degrees. @@ -319,27 +366,39 @@ class AirplanesCommand(BaseCommand): Returns: Optional[Dict[str, Any]]: API response JSON or None on error. """ + url = f"{self.api_url}point/{lat}/{lon}/{radius}" + headers = {"User-Agent": USER_AGENT, "Accept": "application/json"} + self.logger.debug("Fetching aircraft data from %s", url) + try: - # Convert radius from nautical miles to approximate degrees (rough conversion) - # 1 nm ≈ 0.0167 degrees at equator, but we'll use a simple approximation - # More accurate: use the API's native radius parameter if it accepts nm - url = f"{self.api_url}point/{lat}/{lon}/{radius}" - - self.logger.debug(f"Fetching aircraft data from {url}") - response = requests.get(url, timeout=self.url_timeout) - response.raise_for_status() - - data = response.json() - return data + response = requests.get(url, headers=headers, timeout=self.url_timeout) except requests.exceptions.Timeout: - self.logger.warning("API request timed out") + self.logger.warning("Aircraft API request timed out: %s", url) return None except requests.exceptions.RequestException as e: - self.logger.warning(f"API request failed: {e}") + self.logger.warning("Aircraft API request failed: %s", e) return None + + if not response.ok: + self.logger.warning( + "Aircraft API request failed: HTTP %s from %s: %s", + response.status_code, + url, + _response_body_snippet(response), + ) + return None + + try: + data = response.json() except ValueError as e: - self.logger.warning(f"Invalid JSON response: {e}") + self.logger.warning( + "Invalid JSON from aircraft API %s: %s (%s)", + url, + e, + _response_body_snippet(response), + ) return None + return data async def _fetch_aircraft_data_async( self, diff --git a/tests/commands/test_airplanes_command.py b/tests/commands/test_airplanes_command.py new file mode 100644 index 0000000..fd67661 --- /dev/null +++ b/tests/commands/test_airplanes_command.py @@ -0,0 +1,162 @@ +"""Tests for airplanes/ADS-B command API URL handling and fetch errors.""" + +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from modules.commands.airplanes_command import ( + DEFAULT_API_URL, + USER_AGENT, + AirplanesCommand, + _response_body_snippet, + is_deprecated_public_airplanes_live_url, + normalize_api_url, +) + + +class TestNormalizeApiUrl: + def test_adds_trailing_slash(self): + assert normalize_api_url("https://api.adsb.lol/v2") == "https://api.adsb.lol/v2/" + + def test_keeps_trailing_slash(self): + assert normalize_api_url("https://api.adsb.lol/v2/") == "https://api.adsb.lol/v2/" + + def test_empty_stays_empty(self): + assert normalize_api_url(" ") == "" + + +class TestDeprecatedAirplanesLiveUrl: + @pytest.mark.parametrize( + "url", + [ + "http://api.airplanes.live/v2/", + "https://api.airplanes.live/v2/", + "http://api.airplanes.live/v2", + "https://API.AIRPLANES.LIVE/v2/", + ], + ) + def test_detects_public_host(self, url): + assert is_deprecated_public_airplanes_live_url(url) is True + + @pytest.mark.parametrize( + "url", + [ + DEFAULT_API_URL, + "http://localhost:8080/data/", + "https://rest.api.airplanes.live/", + "https://api.airplanes.live.example.com/v2/", + ], + ) + def test_ignores_other_hosts(self, url): + assert is_deprecated_public_airplanes_live_url(url) is False + + +class TestResolveApiUrl: + def test_default_is_adsb_lol(self, command_mock_bot): + cmd = AirplanesCommand(command_mock_bot) + assert cmd.api_url == DEFAULT_API_URL + + @pytest.mark.parametrize( + "configured", + [ + "http://api.airplanes.live/v2/", + "https://api.airplanes.live/v2", + ], + ) + def test_remaps_legacy_airplanes_live(self, command_mock_bot, configured): + command_mock_bot.config.add_section("Airplanes_Command") + command_mock_bot.config.set("Airplanes_Command", "api_url", configured) + cmd = AirplanesCommand(command_mock_bot) + assert cmd.api_url == DEFAULT_API_URL + command_mock_bot.logger.warning.assert_called() + warning = " ".join(str(arg) for arg in command_mock_bot.logger.warning.call_args[0]) + assert "api.airplanes.live" in warning + assert DEFAULT_API_URL in warning + + def test_preserves_local_readsb_url(self, command_mock_bot): + command_mock_bot.config.add_section("Airplanes_Command") + command_mock_bot.config.set("Airplanes_Command", "api_url", "http://localhost:8080/data") + cmd = AirplanesCommand(command_mock_bot) + assert cmd.api_url == "http://localhost:8080/data/" + command_mock_bot.logger.warning.assert_not_called() + + def test_preserves_airplanes_live_pro_host(self, command_mock_bot): + command_mock_bot.config.add_section("Airplanes_Command") + command_mock_bot.config.set( + "Airplanes_Command", "api_url", "https://rest.api.airplanes.live/" + ) + cmd = AirplanesCommand(command_mock_bot) + assert cmd.api_url == "https://rest.api.airplanes.live/" + + def test_empty_api_url_uses_default(self, command_mock_bot): + command_mock_bot.config.add_section("Airplanes_Command") + command_mock_bot.config.set("Airplanes_Command", "api_url", " ") + cmd = AirplanesCommand(command_mock_bot) + assert cmd.api_url == DEFAULT_API_URL + + +class TestFetchAircraftData: + def test_sends_user_agent_and_builds_point_url(self, command_mock_bot): + mock_response = MagicMock() + mock_response.ok = True + mock_response.json.return_value = {"ac": []} + cmd = AirplanesCommand(command_mock_bot) + + with patch( + "modules.commands.airplanes_command.requests.get", + return_value=mock_response, + ) as mock_get: + data = cmd._fetch_aircraft_data(47.6, -122.3, 25) + + assert data == {"ac": []} + mock_get.assert_called_once() + args, kwargs = mock_get.call_args + assert args[0] == f"{DEFAULT_API_URL}point/47.6/-122.3/25" + assert kwargs["headers"]["User-Agent"] == USER_AGENT + assert kwargs["headers"]["Accept"] == "application/json" + assert kwargs["timeout"] == cmd.url_timeout + + def test_logs_http_error_status_and_sanitized_body(self, command_mock_bot): + mock_response = MagicMock() + mock_response.ok = False + mock_response.status_code = 403 + mock_response.reason = "Forbidden" + mock_response.text = ( + '{"error": "Please contact us at contact@airplanes.live."}\nsecret' + ) + cmd = AirplanesCommand(command_mock_bot) + + with patch( + "modules.commands.airplanes_command.requests.get", + return_value=mock_response, + ): + assert cmd._fetch_aircraft_data(47.6, -122.3, 25) is None + + command_mock_bot.logger.warning.assert_called() + args = command_mock_bot.logger.warning.call_args[0] + assert args[1] == 403 + assert "api.adsb.lol" in args[2] + snippet = args[3] + assert "Please contact us" in snippet + assert "\n" not in snippet + + def test_timeout_returns_none(self, command_mock_bot): + cmd = AirplanesCommand(command_mock_bot) + with patch( + "modules.commands.airplanes_command.requests.get", + side_effect=requests.exceptions.Timeout(), + ): + assert cmd._fetch_aircraft_data(47.6, -122.3, 25) is None + command_mock_bot.logger.warning.assert_called() + + +class TestResponseBodySnippet: + def test_strips_newlines_and_truncates(self): + response = MagicMock() + response.text = "line1\nline2" + ("x" * 300) + response.reason = "Forbidden" + snippet = _response_body_snippet(response, max_length=20) + assert "\n" not in snippet + assert len(snippet) <= 20 + assert snippet.startswith("line1")