Files
mqtt-broker-stack/broker-control/control.py
T
2026-09-17 06:23:29 +02:00

251 lines
8.7 KiB
Python

"""broker-control: minimal internal control API for Mosquitto.
Scope (spec section 29):
POST /deploy { "content": "<mosquitto.conf text>" } -> validate + atomic activate + reload
POST /reload -> signal config reload
GET /status -> broker process state
GET /health/live -> liveness
Security model:
* Listens on 0.0.0.0:8090 but is ONLY reachable on the backend-net
Docker network; the port is not published to the host.
* Requires a bearer token (CONTROL_API_TOKEN) shared with the backend.
* Never mounts docker.sock; signals the broker over the shared PID
namespace (broker is PID 1 in this container's view).
Implementation notes:
* Atomic activation writes content to a temp file in the SAME
directory as the target, then os.replace() renames it over the old
file. os.replace is atomic and is NOT affected by Mosquitto 2.1's
symlink-read restrictions (which we deliberately do not relax via
MOSQUITTO_UNSAFE_ALLOW_SYMLINKS).
* The broker runs as uid/gid 1883 in the official image, and so does
this sidecar (inherited from the base image), so file ownership is
consistent.
"""
from __future__ import annotations
import json
import os
import signal
import subprocess
import sys
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
TOKEN = os.environ.get("CONTROL_API_TOKEN", "")
CONFIG_DIR = os.environ.get("MOSQUITTO_CONFIG_DIR", "/mosquitto/config")
ACTIVE_CONF = os.path.join(CONFIG_DIR, "mosquitto.conf")
STAGE_DIR = os.path.join(CONFIG_DIR, "staged")
LISTEN_PORT = int(os.environ.get("CONTROL_LISTEN_PORT", "8090"))
_LOCK = threading.Lock()
class _Error(Exception):
def __init__(self, status: int, message: str) -> None:
super().__init__(message)
self.status = status
self.message = message
def _validate(content: str) -> dict[str, object]:
"""Run `mosquitto -c <staged> --test-config` against staged content."""
os.makedirs(STAGE_DIR, exist_ok=True)
# Temp file inside STAGE_DIR so --test-config sees the same filesystem
# (relative includes, cert paths etc. behave as in production).
fd, path = tempfile.mkstemp(prefix="validate-", suffix=".conf", dir=STAGE_DIR)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
proc = subprocess.run(
["/usr/sbin/mosquitto", "-c", path, "--test-config"],
capture_output=True,
text=True,
timeout=30,
)
return {
"valid": proc.returncode == 0,
"stdout": proc.stdout,
"stderr": proc.stderr,
}
except subprocess.TimeoutExpired as exc:
return {"valid": False, "stdout": "", "stderr": f"validation timed out: {exc}"}
finally:
try:
os.unlink(path)
except OSError:
pass
def _activate(content: str) -> None:
"""Atomically replace the active mosquitto.conf with validated content."""
os.makedirs(os.path.dirname(ACTIVE_CONF), exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".mosquitto.conf.", dir=CONFIG_DIR)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
# 0644: broker (uid 1883) must be able to read; same-uid sidecar writes it.
os.chmod(tmp, 0o644)
os.replace(tmp, ACTIVE_CONF)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def _signal_reload() -> None:
"""Signal the broker (PID 1 in the shared PID namespace) to reload."""
try:
proc = subprocess.run(
["mosquitto_signal", "-p", "1", "config-reload"],
capture_output=True,
text=True,
timeout=15,
)
except FileNotFoundError:
# Fallback if the helper app is unavailable for any reason.
os.kill(1, signal.SIGHUP)
return
if proc.returncode != 0:
raise _Error(500, f"mosquitto_signal failed: {proc.stderr.strip()}")
def _broker_pid_alive() -> bool:
try:
# Broker is PID 1 in the shared PID namespace.
with open("/proc/1/comm", encoding="utf-8") as f:
return f.read().strip() == "mosquitto"
except OSError:
return False
def _post_broker_health() -> bool:
"""Post-signal health probe: a real dynsec roundtrip as the admin user.
Uses mosquitto_ctrl rather than a $SYS subscription, because the 2.1
auto-generated admin role set is not guaranteed to include $SYS read
access — but it must always be able to administer dynamic security.
"""
username = os.environ.get("MOSQUITTO_DYNSEC_USERNAME", "admin")
token_pw = os.environ.get("MOSQUITTO_DYNSEC_PASSWORD", "")
cmd = [
"mosquitto_ctrl",
"-h",
"127.0.0.1",
"-u",
username,
"-P",
token_pw,
"dynsec",
"listClients",
]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
return proc.returncode == 0
except (subprocess.TimeoutExpired, OSError):
return False
class Handler(BaseHTTPRequestHandler):
server_version = "mosquitto-control/0.1"
# -- plumbing -----------------------------------------------------------
def _send(self, status: int, payload: dict[str, object]) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _authorized(self) -> bool:
if not TOKEN:
return False # refuse to run unauthenticated
header = self.headers.get("Authorization", "")
return header == f"Bearer {TOKEN}"
def _read_json(self) -> dict[str, object]:
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > 2_000_000:
raise _Error(400, "missing or oversized body")
try:
data = json.loads(self.rfile.read(length).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise _Error(400, f"invalid JSON body: {exc}") from exc
if not isinstance(data, dict):
raise _Error(400, "JSON body must be an object")
return data
def log_message(self, fmt: str, *args: object) -> None:
sys.stderr.write("[control] " + (fmt % args) + "\n")
# -- routes -------------------------------------------------------------
def do_GET(self) -> None:
if self.path == "/health/live":
self._send(200, {"status": "ok"})
return
if not self._authorized():
self._send(401, {"error": "unauthorized"})
return
if self.path == "/status":
self._send(
200,
{
"broker_pid_alive": _broker_pid_alive(),
"config_file": ACTIVE_CONF,
},
)
return
self._send(404, {"error": "not found"})
def do_POST(self) -> None:
if not self._authorized():
self._send(401, {"error": "unauthorized"})
return
try:
if self.path == "/deploy":
self._handle_deploy()
elif self.path == "/reload":
_signal_reload()
self._send(200, {"status": "reloading", "broker_healthy": _post_broker_health()})
else:
self._send(404, {"error": "not found"})
except _Error as exc:
self._send(exc.status, {"error": exc.message})
except OSError as exc:
self._send(500, {"error": f"os error: {exc}"})
def _handle_deploy(self) -> None:
body = self._read_json()
content = body.get("content")
if not isinstance(content, str) or not content.strip():
raise _Error(400, "field 'content' (mosquitto.conf text) is required")
with _LOCK:
result = _validate(content)
if not result["valid"]:
self._send(422, {"status": "validation_failed", **result})
return
_activate(content)
_signal_reload()
self._send(
200,
{"status": "deployed", "broker_healthy": _post_broker_health()},
)
def main() -> None:
if not TOKEN:
sys.stderr.write("[control] FATAL: CONTROL_API_TOKEN is not set\n")
sys.exit(1)
server = ThreadingHTTPServer(("0.0.0.0", LISTEN_PORT), Handler)
sys.stderr.write(f"[control] listening on :{LISTEN_PORT}\n")
server.serve_forever()
if __name__ == "__main__":
main()