From 773b80f6ae0ebcaaed9cbea24090988caabd7fcc Mon Sep 17 00:00:00 2001 From: Stacy Olivas Date: Sun, 22 Mar 2026 11:37:43 -0700 Subject: [PATCH] feat: bot admin HTTP server + reload_config.sh CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core.py: add _BotAdminServer daemon thread (Flask, 127.0.0.1 only, bearer token auth); POST /api/admin/reload calls reload_config() and returns JSON {success, message}; GET /api/admin/health; started from start() when [Admin] enabled = true and token is set - scripts/reload_config.sh: curl wrapper for the reload API; reads port/token from config.ini [Admin] section; exits 1 on rejection - tests/test_core.py: TestBotAdminServer — 7 tests covering server creation, missing token guard, reload success/failure/auth, health --- modules/core.py | 73 +++++++++++++++++++ scripts/reload_config.sh | 63 ++++++++++++++++ tests/test_core.py | 152 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 288 insertions(+) create mode 100755 scripts/reload_config.sh diff --git a/modules/core.py b/modules/core.py index fcca085..6246650 100644 --- a/modules/core.py +++ b/modules/core.py @@ -62,6 +62,61 @@ class _JsonFormatter(logging.Formatter): return json.dumps(obj, ensure_ascii=False) +class _BotAdminServer(threading.Thread): + """Minimal Flask HTTP server exposing bot admin endpoints. + + Runs in a daemon thread alongside the bot's asyncio loop. + Configured via ``[Admin]`` section in config.ini: + + [Admin] + enabled = true + port = 5001 + token = ; required; requests without matching Bearer token are rejected + """ + + def __init__(self, bot: "MeshCoreBot", port: int, token: str) -> None: + super().__init__(daemon=True, name="BotAdminServer") + self._bot = bot + self._port = port + self._token = token + + def run(self) -> None: + try: + from flask import Flask, Response, jsonify + from flask import request as flask_request + + app = Flask("bot_admin") + # Suppress Flask startup banner and request logs + import logging as _logging + _logging.getLogger("werkzeug").setLevel(_logging.ERROR) + + def _check_auth() -> "Response | None": + auth = flask_request.headers.get("Authorization", "") + if not auth.startswith("Bearer ") or auth[7:] != self._token: + return jsonify({"error": "unauthorized"}), 401 + return None + + @app.post("/api/admin/reload") + def reload_config(): # type: ignore[no-untyped-def] + denied = _check_auth() + if denied is not None: + return denied + success, msg = self._bot.reload_config() + status = 200 if success else 409 + return jsonify({"success": success, "message": msg}), status + + @app.get("/api/admin/health") + def health(): # type: ignore[no-untyped-def] + denied = _check_auth() + if denied is not None: + return denied + return jsonify({"status": "ok"}) + + app.run(host="127.0.0.1", port=self._port, threaded=True) + except Exception as exc: # noqa: BLE001 + self._bot.logger.error("BotAdminServer failed to start: %s", exc) + + class MeshCoreBot: """MeshCore Bot using official meshcore package. @@ -142,6 +197,16 @@ class MeshCoreBot: self.logger.error("Web viewer integration failed: %s", e) self.web_viewer_integration = None + # Admin HTTP server (optional — [Admin] section) + self._admin_server: _BotAdminServer | None = None + if self.config.getboolean('Admin', 'enabled', fallback=False): + admin_port = self.config.getint('Admin', 'port', fallback=5001) + admin_token = self.config.get('Admin', 'token', fallback='') + if admin_token: + self._admin_server = _BotAdminServer(self, admin_port, admin_token) + else: + self.logger.warning("Admin server enabled but no token configured — skipping") + # Initialize modules self.rate_limiter = RateLimiter( self.config.getint('Bot', 'rate_limit_seconds', fallback=10) @@ -1728,6 +1793,14 @@ long_jokes = false # Start scheduler thread self.scheduler.start() + # Start admin server if configured + if self._admin_server is not None: + self._admin_server.start() + self.logger.info( + "Admin server started on http://127.0.0.1:%d", + self.config.getint('Admin', 'port', fallback=5001), + ) + # Start web viewer if enabled if self.web_viewer_integration and self.web_viewer_integration.enabled: self.web_viewer_integration.start_viewer() diff --git a/scripts/reload_config.sh b/scripts/reload_config.sh new file mode 100755 index 0000000..55cb8b3 --- /dev/null +++ b/scripts/reload_config.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# reload_config.sh — Reload bot configuration via the admin API. +# +# Calls POST /api/admin/reload on the bot's built-in admin HTTP server. +# Equivalent to the !reload DM admin command: triggers bot.reload_config() +# in-process and returns the result immediately as JSON. +# +# Prerequisites: +# [Admin] section in config.ini with enabled = true, port = 5001, token = +# +# Usage: +# ./scripts/reload_config.sh # uses config.ini in cwd +# ./scripts/reload_config.sh /path/to/config.ini # explicit config path +# +# Environment overrides: +# ADMIN_PORT — override port (default: read from config, fallback 5001) +# ADMIN_TOKEN — override token (default: read from config) + +set -euo pipefail + +CONFIG="${1:-config.ini}" + +# --- read port and token from config.ini unless overridden by env ----- +_read_config_value() { + local key="$1" default="$2" + if [ -f "$CONFIG" ]; then + grep -A20 '^\[Admin\]' "$CONFIG" \ + | grep -m1 "^${key}[[:space:]]*=" \ + | sed 's/^[^=]*=[[:space:]]*//' \ + | tr -d '[:space:]' \ + || true + fi + # fall through to default if nothing printed +} + +ADMIN_PORT="${ADMIN_PORT:-$(_read_config_value port 5001)}" +ADMIN_PORT="${ADMIN_PORT:-5001}" + +ADMIN_TOKEN="${ADMIN_TOKEN:-$(_read_config_value token '')}" + +if [ -z "$ADMIN_TOKEN" ]; then + echo "ERROR: no admin token found in $CONFIG [Admin] section and ADMIN_TOKEN env not set." >&2 + exit 1 +fi + +URL="http://127.0.0.1:${ADMIN_PORT}/api/admin/reload" + +echo "Calling ${URL} ..." +RESPONSE=$(curl -sf -X POST "$URL" \ + -H "Authorization: Bearer ${ADMIN_TOKEN}" \ + -H "Content-Type: application/json" \ + --max-time 10 2>&1) || { + echo "ERROR: could not reach admin server at ${URL}" >&2 + echo " Is the bot running with [Admin] enabled = true?" >&2 + exit 1 +} + +echo "$RESPONSE" + +# Surface the 'success' flag as the exit code (0 = success, 1 = config rejected) +if echo "$RESPONSE" | grep -q '"success": *false'; then + exit 1 +fi diff --git a/tests/test_core.py b/tests/test_core.py index 641b158..d7e0c8a 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -524,6 +524,158 @@ class TestRadioOfflineState: assert bot.is_radio_offline is False bot._record_send_failure() assert bot.is_radio_offline is True +class TestBotAdminServer: + """Admin HTTP server: /api/admin/reload and /api/admin/health.""" + + def _make_bot_with_admin(self, tmp_path, port=15001): + """Write config with [Admin] enabled and return a bot + token.""" + token = "test-secret-token" + config_file = tmp_path / "config.ini" + db_path = tmp_path / "bot.db" + config_file.write_text( + f"""[Connection] +connection_type = serial +serial_port = /dev/ttyUSB0 +timeout = 30 + +[Bot] +db_path = {db_path.as_posix()} +prefix_bytes = 1 + +[Channels] +monitor_channels = #general + +[Admin] +enabled = true +port = {port} +token = {token} +""", + encoding="utf-8", + ) + bot = MeshCoreBot(config_file=str(config_file)) + return bot, token, port + + def test_admin_server_created_when_enabled(self, tmp_path): + bot, _token, _port = self._make_bot_with_admin(tmp_path) + assert bot._admin_server is not None + + def test_admin_server_none_when_disabled(self, tmp_path): + config_file = tmp_path / "config.ini" + db_path = tmp_path / "bot.db" + _write_config(config_file, db_path) + bot = MeshCoreBot(config_file=str(config_file)) + assert bot._admin_server is None + + def test_admin_server_none_when_token_missing(self, tmp_path): + config_file = tmp_path / "config.ini" + db_path = tmp_path / "bot.db" + config_file.write_text( + f"""[Connection] +connection_type = serial +serial_port = /dev/ttyUSB0 +timeout = 30 + +[Bot] +db_path = {db_path.as_posix()} +prefix_bytes = 1 + +[Channels] +monitor_channels = #general + +[Admin] +enabled = true +port = 15002 +token = +""", + encoding="utf-8", + ) + bot = MeshCoreBot(config_file=str(config_file)) + assert bot._admin_server is None + + def test_reload_endpoint_success(self, tmp_path): + """POST /api/admin/reload returns 200 and success=true when reload succeeds.""" + import time + import urllib.request + + bot, token, port = self._make_bot_with_admin(tmp_path, port=15003) + + with patch.object(bot, "reload_config", return_value=(True, "Config reloaded")): + server = bot._admin_server + server.start() + time.sleep(0.4) + + req = urllib.request.Request( + f"http://127.0.0.1:{port}/api/admin/reload", + method="POST", + headers={"Authorization": f"Bearer {token}"}, + ) + with urllib.request.urlopen(req, timeout=5) as resp: + import json + body = json.loads(resp.read()) + assert body["success"] is True + assert "Config reloaded" in body["message"] + + def test_reload_endpoint_failure(self, tmp_path): + """POST /api/admin/reload returns 409 when reload is rejected.""" + import time + import urllib.request + from urllib.error import HTTPError + + bot, token, port = self._make_bot_with_admin(tmp_path, port=15004) + + with patch.object(bot, "reload_config", return_value=(False, "Radio settings changed")): + server = bot._admin_server + server.start() + time.sleep(0.4) + + req = urllib.request.Request( + f"http://127.0.0.1:{port}/api/admin/reload", + method="POST", + headers={"Authorization": f"Bearer {token}"}, + ) + with pytest.raises(HTTPError) as exc_info: + urllib.request.urlopen(req, timeout=5) + assert exc_info.value.code == 409 + + def test_reload_endpoint_rejects_bad_token(self, tmp_path): + """POST /api/admin/reload returns 401 with wrong token.""" + import time + import urllib.request + from urllib.error import HTTPError + + bot, _token, port = self._make_bot_with_admin(tmp_path, port=15005) + server = bot._admin_server + server.start() + time.sleep(0.4) + + req = urllib.request.Request( + f"http://127.0.0.1:{port}/api/admin/reload", + method="POST", + headers={"Authorization": "Bearer wrong-token"}, + ) + with pytest.raises(HTTPError) as exc_info: + urllib.request.urlopen(req, timeout=5) + assert exc_info.value.code == 401 + + def test_health_endpoint_returns_ok(self, tmp_path): + """GET /api/admin/health returns 200 and status=ok.""" + import time + import urllib.request + + bot, token, port = self._make_bot_with_admin(tmp_path, port=15006) + server = bot._admin_server + server.start() + time.sleep(0.4) + + req = urllib.request.Request( + f"http://127.0.0.1:{port}/api/admin/health", + method="GET", + headers={"Authorization": f"Bearer {token}"}, + ) + with urllib.request.urlopen(req, timeout=5) as resp: + import json + body = json.loads(resp.read()) + assert body["status"] == "ok" # ---------------------------------------------------------------------------