mirror of
https://github.com/AkitaEngineering/Akita-Meshtastic-Meshcore-Bridge.git
synced 2026-10-31 01:42:01 +01:00
Harden production readiness
This commit is contained in:
1 parent
f4adf90eb0
commit
933aac3f9f
23 files changed
+385
-51
No files matched your search
@@ -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/*
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
include CHANGELOG.md
|
||||
include DEPLOYMENT.md
|
||||
recursive-include docs *.md
|
||||
recursive-include examples *.ini *.example
|
||||
recursive-include packaging *.service
|
||||
+22
-1
@@ -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."""
|
||||
|
||||
+37
-2
@@ -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()
|
||||
|
||||
+29
-5
@@ -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
|
||||
|
||||
+12
-10
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
+5
-3
@@ -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,
|
||||
|
||||
+10
-2
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+31
-1
@@ -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"
|
||||
|
||||
+15
-12
@@ -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
|
||||
+28
-3
@@ -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()
|
||||
|
||||
+33
-3
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user