feat: bot admin HTTP server + reload_config.sh CLI

- 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
This commit is contained in:
Stacy Olivas
2026-03-22 11:37:43 -07:00
committed by agessaman
parent 887068faa2
commit 773b80f6ae
3 changed files with 288 additions and 0 deletions

View File

@@ -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 = <secret> ; 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()

63
scripts/reload_config.sh Executable file
View File

@@ -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 = <secret>
#
# 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

View File

@@ -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"
# ---------------------------------------------------------------------------