256 lines
9.0 KiB
Python
256 lines
9.0 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.
|
|
|
|
NOTE: this sidecar shares the broker's PID namespace but NOT its
|
|
network namespace — 127.0.0.1 here is the SIDECAR's loopback, where
|
|
nothing listens. The broker is reached via Docker DNS ("mosquitto")
|
|
on the backend-net network.
|
|
"""
|
|
username = os.environ.get("MOSQUITTO_DYNSEC_USERNAME", "admin")
|
|
token_pw = os.environ.get("MOSQUITTO_DYNSEC_PASSWORD", "")
|
|
cmd = [
|
|
"mosquitto_ctrl",
|
|
"-h",
|
|
"mosquitto",
|
|
"-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() |