diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 3d82724..fadf5c1 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -30,7 +30,6 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt pip install -r requirements-dev.txt - name: Lint with flake8 @@ -40,11 +39,18 @@ jobs: - name: Type check with mypy run: | - mypy ammb/version.py ammb/api.py ammb/api_async.py \ - ammb/bridge_async.py ammb/message_logger.py \ - ammb/metrics.py ammb/rate_limiter.py \ - run_bridge.py run_bridge_async.py + mypy ammb run_bridge.py run_bridge_async.py run_bridge_tui.py - name: Test with pytest run: | pytest --cov=ammb --cov-report=term-missing --cov-report=xml + + - name: Audit runtime dependencies + if: matrix.python-version == '3.12' + run: pip-audit -r requirements.txt + + - name: Build and validate release artifacts + if: matrix.python-version == '3.12' + run: | + python -m build + twine check dist/* diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index d9f56c7..aef3c0d 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -68,6 +68,9 @@ sudo systemctl status ammb.service ``` The unit restarts on failure. Watch logs with `journalctl -u ammb.service -f`. +The production entry points handle systemd's `SIGTERM` and flush message logs +before exiting. Fatal preflight findings prevent startup and are reported in the +journal; run the explicit `--check` command for the full diagnostic report. ## Operational Notes - Only one process can access a serial port at a time. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..0e77961 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include CHANGELOG.md +include DEPLOYMENT.md +recursive-include docs *.md +recursive-include examples *.ini *.example +recursive-include packaging *.service diff --git a/ammb/api.py b/ammb/api.py index 6aa6aaf..56dadee 100644 --- a/ammb/api.py +++ b/ammb/api.py @@ -15,6 +15,8 @@ from .health import get_health_monitor from .metrics import get_metrics from .version import __version__ +MAX_CONTROL_BODY_BYTES = 64 * 1024 + def extract_request_token( authorization: Optional[str], @@ -154,14 +156,27 @@ class BridgeAPIHandler(BaseHTTPRequestHandler): def _handle_control(self): """Handle control requests.""" - content_length = int(self.headers.get("Content-Length", 0)) + try: + content_length = int(self.headers.get("Content-Length", 0)) + except (TypeError, ValueError): + self._send_response(400, {"error": "Invalid Content-Length"}) + return if content_length == 0: self._send_response(400, {"error": "No request body"}) return + if content_length < 0: + self._send_response(400, {"error": "Invalid Content-Length"}) + return + if content_length > MAX_CONTROL_BODY_BYTES: + self._send_response(413, {"error": "Request body too large"}) + return body = self.rfile.read(content_length) try: data = json.loads(body.decode("utf-8")) + if not isinstance(data, dict): + self._send_response(400, {"error": "JSON object required"}) + return action = data.get("action") if action == "reset_metrics": @@ -181,6 +196,8 @@ class BridgeAPIHandler(BaseHTTPRequestHandler): self.send_response(status_code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(response))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") if status_code == 401: self.send_header("WWW-Authenticate", "Bearer") self.end_headers() @@ -234,6 +251,10 @@ class BridgeAPIServer: self.logger.error( "Failed to start API server: %s", e, exc_info=True ) + if self.server is not None: + self.server.server_close() + self.server = None + raise RuntimeError("Failed to start the configured API server") from e def stop(self): """Stop the API server.""" diff --git a/ammb/api_async.py b/ammb/api_async.py index 3dc720c..8a9fd11 100644 --- a/ammb/api_async.py +++ b/ammb/api_async.py @@ -2,12 +2,17 @@ """ Async REST API for monitoring and controlling the bridge using FastAPI. """ +import json from typing import Optional from fastapi import FastAPI, Header, Request from fastapi.responses import JSONResponse -from ammb.api import extract_request_token, token_matches +from ammb.api import ( + MAX_CONTROL_BODY_BYTES, + extract_request_token, + token_matches, +) from ammb.health import get_health_monitor from ammb.metrics import get_metrics from ammb.version import __version__ @@ -122,7 +127,37 @@ async def control( ): if not _authorized(authorization, x_api_token): return _unauthorized() - data = await request.json() + content_length = request.headers.get("content-length") + if content_length: + try: + if int(content_length) > MAX_CONTROL_BODY_BYTES: + return JSONResponse( + content={"error": "Request body too large"}, + status_code=413, + ) + except ValueError: + return JSONResponse( + content={"error": "Invalid Content-Length"}, + status_code=400, + ) + body = await request.body() + if len(body) > MAX_CONTROL_BODY_BYTES: + return JSONResponse( + content={"error": "Request body too large"}, + status_code=413, + ) + try: + data = json.loads(body) + except (json.JSONDecodeError, UnicodeDecodeError): + return JSONResponse( + content={"error": "Invalid JSON"}, + status_code=400, + ) + if not isinstance(data, dict): + return JSONResponse( + content={"error": "JSON object required"}, + status_code=400, + ) action = data.get("action") if action == "reset_metrics": get_metrics().reset() diff --git a/ammb/bridge_async.py b/ammb/bridge_async.py index b5dccd8..cbdf88c 100644 --- a/ammb/bridge_async.py +++ b/ammb/bridge_async.py @@ -22,7 +22,15 @@ class AsyncBridge: def __init__(self, config: BridgeConfig): self.logger = logging.getLogger(__name__) self.config = config - self.bridge = Bridge(config) + # The async wrapper owns the FastAPI server. Disable the synchronous + # HTTP server in the wrapped Bridge so both implementations never try + # to bind the same configured port. + bridge_config = ( + config._replace(api_enabled=False) + if config.api_enabled + else config + ) + self.bridge = Bridge(bridge_config) self._running = False self._bridge_thread: Optional[threading.Thread] = None @@ -70,6 +78,13 @@ class AsyncBridge: try: while self._bridge_thread.is_alive() and self._running: + if api_task is not None and api_task.done(): + failure = api_task.exception() + if failure is not None: + raise RuntimeError( + "Async API server stopped unexpectedly" + ) from failure + raise RuntimeError("Async API server stopped unexpectedly") await asyncio.sleep(0.25) except asyncio.CancelledError: self.logger.info("AsyncBridge received cancellation signal.") @@ -78,17 +93,26 @@ class AsyncBridge: self.logger.critical( "Unhandled exception in AsyncBridge: %s", e, exc_info=True ) + raise finally: if server is not None: server.should_exit = True if api_task is not None: - api_task.cancel() try: - await api_task - except (asyncio.CancelledError, Exception): - pass + await asyncio.wait_for(api_task, timeout=5) + except asyncio.TimeoutError: + api_task.cancel() + try: + await api_task + except asyncio.CancelledError: + pass await self.shutdown() + def request_shutdown(self) -> None: + """Request a graceful stop from a synchronous signal handler.""" + self._running = False + self.bridge.shutdown_event.set() + async def shutdown(self): self.logger.info("Shutting down AsyncBridge...") self._running = False diff --git a/ammb/config_handler.py b/ammb/config_handler.py index b61c3c9..158e4b6 100644 --- a/ammb/config_handler.py +++ b/ammb/config_handler.py @@ -198,8 +198,11 @@ def load_config(config_path: str = CONFIG_FILE) -> Optional[BridgeConfig]: cfg_section = config["DEFAULT"] # Only set meshtastic_port if explicitly present and not commented out + meshtastic_port: Optional[str] if "MESHTASTIC_SERIAL_PORT" in cfg_section: - meshtastic_port = cfg_section.get("MESHTASTIC_SERIAL_PORT", "").strip() + meshtastic_port = cfg_section.get( + "MESHTASTIC_SERIAL_PORT", "" + ).strip() if not meshtastic_port or meshtastic_port.startswith("#"): meshtastic_port = None else: @@ -270,17 +273,12 @@ def load_config(config_path: str = CONFIG_FILE) -> Optional[BridgeConfig]: ) return None if serial_protocol not in VALID_SERIAL_PROTOCOLS: - logger.warning( - "Unrecognized SERIAL_PROTOCOL '%s'." - " Valid options: %s.", + logger.error( + "Invalid SERIAL_PROTOCOL '%s'. Valid options: %s.", serial_protocol, VALID_SERIAL_PROTOCOLS, ) - logger.warning( - "Attempting to use '%s' - ensure a corresponding " - "handler exists.", - serial_protocol, - ) + return None try: serial_baud = cfg_section.getint("SERIAL_BAUD_RATE") if serial_baud is None or serial_baud <= 0: @@ -467,7 +465,11 @@ def load_config(config_path: str = CONFIG_FILE) -> Optional[BridgeConfig]: meshtastic_retry_on_boot=meshtastic_retry_on_boot, meshtastic_retry_delay_s=meshtastic_retry_delay_s, ) - logger.debug("Configuration loaded: %s", bridge_config) + # Never log the NamedTuple itself: it contains MQTT and API secrets. + logger.debug( + "Configuration loaded for external transport: %s", + bridge_config.external_transport, + ) return bridge_config except Exception as e: diff --git a/ammb/meshcore_handler.py b/ammb/meshcore_handler.py index 8270d97..e71d703 100644 --- a/ammb/meshcore_handler.py +++ b/ammb/meshcore_handler.py @@ -26,6 +26,9 @@ class MeshcoreHandler: RECONNECT_DELAY_S = 10 AUTO_DETECT_FAILURE_THRESHOLD = 5 + receiver_thread: Optional[threading.Thread] + sender_thread: Optional[threading.Thread] + _contacts_poll_thread: Optional[threading.Thread] def start_threads(self): """Start the receiver and sender threads for MeshcoreHandler.""" @@ -80,9 +83,9 @@ class MeshcoreHandler: self.shutdown_event = shutdown_event self.serial_port: Optional[serial.Serial] = None - self.receiver_thread: Optional[threading.Thread] = None - self.sender_thread: Optional[threading.Thread] = None - self._contacts_poll_thread: Optional[threading.Thread] = None + self.receiver_thread = None + self.sender_thread = None + self._contacts_poll_thread = None self._lock = threading.Lock() self._is_connected = threading.Event() diff --git a/ammb/message_logger.py b/ammb/message_logger.py index c6b7be2..7f400e3 100644 --- a/ammb/message_logger.py +++ b/ammb/message_logger.py @@ -5,6 +5,7 @@ Message persistence and logging for the bridge. import json import logging +import os import threading from datetime import datetime from pathlib import Path @@ -76,6 +77,8 @@ class MessageLogger: assert self.log_file is not None self._log_path = Path(self.log_file) self._log_path.parent.mkdir(parents=True, exist_ok=True) + self._log_path.touch(mode=0o600, exist_ok=True) + os.chmod(self._log_path, 0o600) self._start_worker() @property diff --git a/ammb/preflight.py b/ammb/preflight.py index cd20d18..4b9e837 100644 --- a/ammb/preflight.py +++ b/ammb/preflight.py @@ -375,6 +375,15 @@ def _mqtt_diagnostics(config: BridgeConfig) -> list[Diagnostic]: ) if config.mqtt_tls_enabled: + if config.mqtt_tls_insecure: + diagnostics.append( + Diagnostic( + "warning", + "MQTT certificate verification disabled", + "MQTT_TLS_INSECURE disables broker identity checks.", + "Set MQTT_TLS_INSECURE = False for production.", + ) + ) if config.mqtt_port == 1883: diagnostics.append( Diagnostic( diff --git a/ammb/protocol.py b/ammb/protocol.py index 2e33399..a3bfbad 100644 --- a/ammb/protocol.py +++ b/ammb/protocol.py @@ -16,7 +16,7 @@ import binascii import json import logging from abc import ABC, abstractmethod -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Type # --- Base Class --- @@ -325,7 +325,7 @@ class MeshcoreCompanionProtocol(MeshcoreProtocolHandler): return None fw_ver = raw_data[1] - device_info = {"fw_ver": fw_ver} + device_info: Dict[str, Any] = {"fw_ver": fw_ver} if fw_ver >= 3: minimum_length = 1 + 1 + 1 + 1 + 4 + 12 + 40 + 20 if len(raw_data) < minimum_length: @@ -595,7 +595,9 @@ class MeshcoreCompanionProtocol(MeshcoreProtocolHandler): return None -_serial_protocol_handlers = { +_serial_protocol_handlers: Dict[ + str, Type[MeshcoreProtocolHandler] +] = { "json_newline": JsonNewlineProtocol, "raw_serial": RawSerialProtocol, "companion_radio": MeshcoreCompanionProtocol, diff --git a/ammb/tui.py b/ammb/tui.py index ea3635a..4368d76 100644 --- a/ammb/tui.py +++ b/ammb/tui.py @@ -45,11 +45,14 @@ VISIBLE_LOG_LINES = 200 class BridgeRuntime(Protocol): """Runtime surface required by the dashboard controller.""" - external_handler: object | None shutdown_event: threading.Event to_meshtastic_queue: Queue to_external_queue: Queue + @property + def external_handler(self) -> object | None: + """Configured external transport handler, when initialized.""" + def run(self) -> None: """Run the bridge loop.""" @@ -60,6 +63,11 @@ class BridgeRuntime(Protocol): BridgeFactory = Callable[[BridgeConfig], BridgeRuntime] +def _default_bridge_factory(config: BridgeConfig) -> BridgeRuntime: + """Construct the production bridge through the controller protocol.""" + return Bridge(config) + + @dataclass(frozen=True) class LogEntry: """Single log line rendered into the live tail.""" @@ -165,7 +173,7 @@ class BridgeController: self, config: BridgeConfig, *, - bridge_factory: BridgeFactory = Bridge, + bridge_factory: BridgeFactory = _default_bridge_factory, event_sink: Optional[Callable[[str, str], None]] = None, ): self.config = config diff --git a/examples/config.ini.example b/examples/config.ini.example index eb5f886..3188172 100644 --- a/examples/config.ini.example +++ b/examples/config.ini.example @@ -41,7 +41,9 @@ SERIAL_BAUD_RATE = 9600 # raw_serial: Reads raw binary/text bytes and forwards them as Hex. # (Use this for MeshCore Companion Mode or binary protocols) # companion_radio: MeshCore Companion USB framing protocol (recommended for MeshCore radios) -SERIAL_PROTOCOL = json_newline # Use json_newline for text/JSON, raw_serial for binary/hex, companion_radio for MeshCore USB framing +# Use json_newline for text/JSON, raw_serial for binary/hex, or +# companion_radio for MeshCore USB framing. +SERIAL_PROTOCOL = json_newline # Auto-switch between json_newline and raw_serial after repeated decode failures. # Set False to disable protocol auto-detection. diff --git a/packaging/ammb.service b/packaging/ammb.service index 91fa482..832f781 100644 --- a/packaging/ammb.service +++ b/packaging/ammb.service @@ -16,6 +16,15 @@ RestartSec=5 TimeoutStopSec=20 NoNewPrivileges=true PrivateTmp=true +ProtectSystem=full +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +LockPersonality=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +UMask=0077 # Serial devices need to remain visible to the service. SupplementaryGroups=dialout diff --git a/pyproject.toml b/pyproject.toml index ab955ed..c9b42b0 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61.0", "wheel"] +requires = ["setuptools>=77.0", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -9,6 +9,28 @@ description = "Bridge between Meshtastic and MeshCore networks" readme = "README.md" requires-python = ">=3.10" dynamic = ["dependencies"] +license = "GPL-3.0-only" +license-files = ["LICENSE"] +authors = [ + {name = "Akita Engineering", email = "info@akitaengineering.com"}, +] +keywords = ["meshtastic", "meshcore", "lora", "mqtt", "bridge"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Communications", +] + +[project.urls] +Homepage = "https://www.akitaengineering.com" +Repository = "https://github.com/AkitaEngineering/Akita-Meshtastic-Meshcore-Bridge" +Documentation = "https://github.com/AkitaEngineering/Akita-Meshtastic-Meshcore-Bridge/blob/main/DEPLOYMENT.md" +Issues = "https://github.com/AkitaEngineering/Akita-Meshtastic-Meshcore-Bridge/issues" [tool.setuptools.dynamic] dependencies = {file = ["requirements.txt"]} @@ -21,6 +43,14 @@ py-modules = [ "run_bridge_tui" ] +[tool.setuptools.data-files] +"share/akita-mesh-bridge" = [ + "CHANGELOG.md", + "DEPLOYMENT.md", + "examples/config.ini.example", + "packaging/ammb.service", +] + [project.scripts] run-bridge = "run_bridge:main" run-bridge-async = "run_bridge_async:main" diff --git a/requirements-dev.txt b/requirements-dev.txt index 45be902..8434f47 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,12 +1,15 @@ -# requirements-dev.txt -# Development and testing dependencies - -# Include runtime requirements --r requirements.txt -# pytest 9.0.3+ fixes CVE-2025-71176 and requires Python 3.10+. -pytest>=9.0.3,<10.0.0 -pytest-cov>=4.0.0,<5.0.0 -pytest-mock>=3.10.0,<4.0.0 -pytest-asyncio>=1.0.0,<2.0.0 -flake8>=6.0.0,<7.0.0 -mypy>=1.0.0,<2.0.0 +# requirements-dev.txt +# Development and testing dependencies + +# Include runtime requirements +-r requirements.txt +# pytest 9.0.3+ fixes CVE-2025-71176 and requires Python 3.10+. +pytest>=9.0.3,<10.0.0 +pytest-cov>=4.0.0,<5.0.0 +pytest-mock>=3.10.0,<4.0.0 +pytest-asyncio>=1.0.0,<2.0.0 +flake8>=6.0.0,<7.0.0 +mypy>=1.0.0,<2.0.0 +build>=1.2.0,<2.0.0 +twine>=5.0.0,<7.0.0 +pip-audit>=2.10.0,<3.0.0 diff --git a/run_bridge.py b/run_bridge.py index bffe9bf..1ed60af 100644 --- a/run_bridge.py +++ b/run_bridge.py @@ -15,6 +15,7 @@ This script handles: import argparse import logging import os +import signal import sys # Ensure the script can find the 'ammb' package @@ -46,7 +47,8 @@ except ImportError as e: # --- Imports --- try: from ammb import Bridge - from ammb.config_handler import load_config, resolve_config_path + from ammb.config_handler import resolve_config_path + from ammb.preflight import run_preflight from ammb.utils import setup_logging except ImportError as e: print(f"ERROR: Failed to import AMMB modules: {e}", file=sys.stderr) @@ -83,10 +85,25 @@ def main(argv=None): args.config, fallback=os.path.join(project_root, "config.ini") ) logging.info("Loading configuration from: %s", config_path) - config = load_config(config_path) - if not config: + report = run_preflight(config_path) + config = report.config + if not report.ready or config is None: + for diagnostic in report.diagnostics: + log = ( + logging.error + if diagnostic.is_error + else logging.warning + ) + log("Preflight %s: %s", diagnostic.title, diagnostic.detail) logging.critical("Failed to load configuration. Bridge cannot start.") sys.exit(1) + for diagnostic in report.diagnostics: + if diagnostic.severity.lower() == "warning": + logging.warning( + "Preflight warning: %s: %s", + diagnostic.title, + diagnostic.detail, + ) logging.info("Configuration loaded successfully.") logging.info("Selected external transport: %s", config.external_transport) @@ -105,6 +122,14 @@ def main(argv=None): ) sys.exit(1) + def _handle_sigterm(signum, _frame): + logging.info( + "Signal %s received. Initiating graceful shutdown...", signum + ) + bridge.shutdown_event.set() + + signal.signal(signal.SIGTERM, _handle_sigterm) + try: logging.info("Starting bridge run loop...") bridge.run() diff --git a/run_bridge_async.py b/run_bridge_async.py index 15e6045..c0c5112 100644 --- a/run_bridge_async.py +++ b/run_bridge_async.py @@ -10,6 +10,7 @@ import argparse import asyncio import logging import os +import signal import sys project_root = os.path.dirname(os.path.abspath(__file__)) @@ -17,7 +18,8 @@ sys.path.insert(0, project_root) try: from ammb.bridge_async import AsyncBridge - from ammb.config_handler import load_config, resolve_config_path + from ammb.config_handler import resolve_config_path + from ammb.preflight import run_preflight from ammb.utils import setup_logging except ImportError as e: print(f"ERROR: Failed to import AMMB modules: {e}", file=sys.stderr) @@ -48,10 +50,25 @@ def main(argv=None): args.config, fallback=os.path.join(project_root, "config.ini") ) logging.info("Loading configuration from: %s", config_path) - config = load_config(config_path) - if not config: + report = run_preflight(config_path) + config = report.config + if not report.ready or config is None: + for diagnostic in report.diagnostics: + log = ( + logging.error + if diagnostic.is_error + else logging.warning + ) + log("Preflight %s: %s", diagnostic.title, diagnostic.detail) logging.critical("Failed to load configuration. Bridge cannot start.") sys.exit(1) + for diagnostic in report.diagnostics: + if diagnostic.severity.lower() == "warning": + logging.warning( + "Preflight warning: %s: %s", + diagnostic.title, + diagnostic.detail, + ) logging.info("Configuration loaded successfully.") logging.info("Selected external transport: %s", config.external_transport) @@ -59,6 +76,19 @@ def main(argv=None): logging.debug("Logging level set to %s", config.log_level) bridge = AsyncBridge(config) + if not bridge.bridge.external_handler: + logging.critical( + "Bridge initialization failed (likely handler issue). Exiting." + ) + sys.exit(1) + + def _handle_sigterm(signum, _frame): + logging.info( + "Signal %s received. Initiating graceful shutdown...", signum + ) + bridge.request_shutdown() + + signal.signal(signal.SIGTERM, _handle_sigterm) try: asyncio.run(bridge.start()) diff --git a/tests/test_api.py b/tests/test_api.py index eb9cf6e..0909eb3 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -4,7 +4,9 @@ from queue import Queue from types import SimpleNamespace from urllib.error import HTTPError from urllib.request import Request, urlopen +from unittest.mock import patch +import pytest from fastapi.testclient import TestClient from ammb.api import BridgeAPIServer, extract_request_token, token_matches @@ -76,6 +78,17 @@ def test_sync_api_info_uses_package_version(): server.stop() +def test_sync_api_startup_failure_is_fatal(): + bridge = _FakeBridge(make_bridge_config(api_enabled=True)) + server = BridgeAPIServer(bridge, host="127.0.0.1", port=8080) + + with patch( + "ammb.api.ThreadingHTTPServer", + side_effect=OSError("address unavailable"), + ), pytest.raises(RuntimeError, match="configured API server"): + server.start() + + def test_sync_api_requires_token_when_configured(): config = make_bridge_config( api_enabled=True, @@ -127,6 +140,24 @@ def test_sync_api_reset_metrics(): server.stop() +def test_sync_api_rejects_oversized_control_body(): + config = make_bridge_config(api_enabled=True, api_port=0) + server = BridgeAPIServer(_FakeBridge(config), host="127.0.0.1", port=0) + server.start() + try: + status, data = _request( + "127.0.0.1", + server.port, + "POST", + "/api/control", + body={"payload": "x" * (64 * 1024)}, + ) + assert status == 413 + assert data["error"] == "Request body too large" + finally: + server.stop() + + def test_metrics_get_all_stats_does_not_deadlock(): get_metrics().record_meshtastic_connection() stats = get_metrics().get_all_stats() @@ -147,3 +178,22 @@ def test_async_api_requires_token_and_reports_version(): assert allowed.status_code == 200 assert allowed.json()["version"] == __version__ reset_async_api() + + +def test_async_api_rejects_invalid_and_oversized_control_bodies(): + reset_async_api() + client = TestClient(app) + + invalid = client.post( + "/api/control", + content=b"not-json", + headers={"Content-Type": "application/json"}, + ) + assert invalid.status_code == 400 + + oversized = client.post( + "/api/control", + content=b"x" * (64 * 1024 + 1), + headers={"Content-Type": "application/json"}, + ) + assert oversized.status_code == 413 diff --git a/tests/test_bridge_async.py b/tests/test_bridge_async.py index 52ad994..31dad6e 100644 --- a/tests/test_bridge_async.py +++ b/tests/test_bridge_async.py @@ -50,3 +50,14 @@ def test_async_bridge_does_not_need_api_when_disabled(): config = make_bridge_config(api_enabled=False) async_bridge = AsyncBridge(config) assert async_bridge.bridge.config is config + + +def test_async_bridge_disables_duplicate_sync_api(): + config = make_bridge_config(api_enabled=True) + + with patch("ammb.bridge_async.Bridge") as bridge_class: + AsyncBridge(config) + + wrapped_config = bridge_class.call_args.args[0] + assert wrapped_config.api_enabled is False + assert config.api_enabled is True diff --git a/tests/test_config_production.py b/tests/test_config_production.py index 5b7788a..3743b19 100644 --- a/tests/test_config_production.py +++ b/tests/test_config_production.py @@ -54,3 +54,28 @@ def test_resolve_config_path_prefers_explicit_then_env(tmp_path, monkeypatch): monkeypatch.setenv("AMMB_CONFIG", str(env_file)) assert resolve_config_path(str(explicit)).endswith("explicit.ini") assert resolve_config_path().endswith("env.ini") + + +def test_load_config_never_logs_secrets(tmp_path, caplog): + secret = "must-not-appear-in-logs" + path = _write_config( + tmp_path / "config.ini", + extra=( + "API_TOKEN = %s\n" + "MQTT_PASSWORD = %s\n" + ) % (secret, secret), + ) + + with caplog.at_level("DEBUG"): + assert load_config(str(path)) is not None + + assert secret not in caplog.text + + +def test_load_config_rejects_unknown_serial_protocol(tmp_path): + path = _write_config( + tmp_path / "config.ini", + extra="SERIAL_PROTOCOL = unknown-handler\n", + ) + + assert load_config(str(path)) is None diff --git a/tests/test_message_logger.py b/tests/test_message_logger.py index be5c4fc..2feb164 100644 --- a/tests/test_message_logger.py +++ b/tests/test_message_logger.py @@ -1,4 +1,5 @@ import json +import stat import time from pathlib import Path @@ -39,6 +40,7 @@ def test_logger_writes_json_lines(tmp_path): assert entry["payload"] == "hello" assert entry["direction"] == "meshtastic_to_external" assert "logged_at" in entry + assert stat.S_IMODE(log_file.stat().st_mode) == 0o600 def test_configure_message_logger_is_process_wide(tmp_path): diff --git a/tests/test_preflight.py b/tests/test_preflight.py index d4afff0..22079b3 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -68,6 +68,32 @@ LOG_LEVEL = INFO assert report.diagnostics[0].title == "MQTT CA certificate missing" +def test_preflight_warns_when_mqtt_certificate_checks_are_disabled(tmp_path): + config_path = _write_config( + tmp_path / "mqtt.ini", + """ +EXTERNAL_TRANSPORT = mqtt +MQTT_BROKER = broker.example +MQTT_PORT = 8883 +MQTT_TOPIC_IN = ammb/in +MQTT_TOPIC_OUT = ammb/out +MQTT_CLIENT_ID = bridge-client +MQTT_QOS = 1 +MQTT_RETAIN_OUT = False +MQTT_TLS_ENABLED = True +MQTT_TLS_INSECURE = True +LOG_LEVEL = INFO +""", + ) + + report = run_preflight(str(config_path), importer=_fake_importer) + + assert report.ready is True + assert "MQTT certificate verification disabled" in { + item.title for item in report.diagnostics + } + + def test_preflight_allows_warnings_without_blocking_start(tmp_path): config_path = _write_config( tmp_path / "serial.ini",