🚧 WIP
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
# broker-control — internal control sidecar (spec §29)
|
||||
#
|
||||
# Runs in the mosquitto container's PID namespace (compose `pid: "service:mosquitto"`)
|
||||
# so it can signal the broker process (PID 1) without docker.sock access.
|
||||
# The backend calls its HTTP API over the backend-net network.
|
||||
#
|
||||
# Responsibilities (deliberately minimal, spec §29):
|
||||
# - validate staged configuration (mosquitto -c <staged> --test-config)
|
||||
# - activate a configuration revision (atomic rename into place)
|
||||
# - signal config reload (mosquitto_signal -p 1 config-reload == SIGHUP)
|
||||
# - check broker process state
|
||||
#
|
||||
# Release 1 ships validate/activate/reload/status only; restarts are a
|
||||
# Release 2 concern (reload-vs-restart classification, spec §28).
|
||||
|
||||
FROM eclipse-mosquitto:2.1.2-alpine
|
||||
|
||||
# Same image as the broker → --test-config validates with the exact
|
||||
# broker binary that will load the config.
|
||||
|
||||
# The base image ships NO Python. control.py is stdlib-only, so the
|
||||
# distro package is sufficient (no pip, no venv needed).
|
||||
RUN apk add --no-cache python3
|
||||
|
||||
COPY control.py /app/control.py
|
||||
|
||||
# The base image has no USER directive, so this sidecar runs as root.
|
||||
# Deliberate: it writes /mosquitto/config (owned by uid 1883 in the fresh
|
||||
# volume) and signals the broker via the shared PID namespace. The API
|
||||
# is token-guarded, internal-network-only, and no docker.sock is mounted
|
||||
# (spec §29).
|
||||
|
||||
# Clear the CMD inherited from the base image — its default CMD
|
||||
# (`mosquitto -c ...`) would otherwise be appended to our entrypoint.
|
||||
CMD []
|
||||
|
||||
EXPOSE 8090
|
||||
|
||||
ENTRYPOINT ["python3", "/app/control.py"]
|
||||
@@ -0,0 +1,250 @@
|
||||
"""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:
|
||||
"""Cheap post-signal health probe: subscribe to $SYS/broker/version."""
|
||||
username = os.environ.get("MOSQUITTO_DYNSEC_USERNAME", "admin")
|
||||
token_pw = os.environ.get("MOSQUITTO_DYNSEC_PASSWORD", "")
|
||||
cmd = [
|
||||
"mosquitto_sub",
|
||||
"-h",
|
||||
"127.0.0.1",
|
||||
"-u",
|
||||
username,
|
||||
"-P",
|
||||
token_pw,
|
||||
"-t",
|
||||
"$SYS/broker/version",
|
||||
"-C",
|
||||
"1",
|
||||
"-W",
|
||||
"3",
|
||||
]
|
||||
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()
|
||||
Reference in New Issue
Block a user