mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-04-26 10:58:04 +00:00
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:
@@ -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
63
scripts/reload_config.sh
Executable 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
|
||||
@@ -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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user