Files
mqtt-broker-stack/backend/app/routers/broker.py
T
2026-09-19 18:08:02 +02:00

418 lines
13 KiB
Python

"""Broker configuration management (spec sections 25-27, 30, 31, 61).
Release 1 flow: edit model -> generate -> validate via control sidecar ->
deploy (activate + reload) -> revision recorded. Diffs and approval
workflow arrive with Release 2/3 polish, but every deploy already creates
an immutable revision (spec section 26).
"""
from __future__ import annotations
import os
import uuid
from datetime import UTC, datetime
from typing import Annotated
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import audit
from app.config import get_settings
from app.configgen import RESTART_REQUIRED_KEY, generate_config
from app.db import get_db
from app.deps import CurrentUser, require_roles
from app.models import (
BrokerSettings,
Certificate,
ConfigRevision,
ListenerConfig,
ManagementRole,
)
from app.schemas import (
BrokerSettingsIn,
BrokerSettingsOut,
ConfigPreviewOut,
DeployRequest,
ListenerIn,
ListenerOut,
RevisionDetailOut,
RevisionOut,
)
router = APIRouter(prefix="/api/v1/broker", tags=["broker"])
BrokerAdmin = Annotated[CurrentUser, Depends(require_roles(ManagementRole.BROKER_ADMIN))]
AnyReader = Annotated[
CurrentUser,
Depends(
require_roles(
ManagementRole.BROKER_ADMIN,
ManagementRole.OPERATOR,
ManagementRole.AUDITOR,
)
),
]
def _next_revision_number(db: Session) -> int:
highest = db.scalar(
select(ConfigRevision.revision_number).order_by(
ConfigRevision.revision_number.desc()
)
)
return (highest or 0) + 1
# BrokerSettings key holding the pending-restart flag (value "true"/"false").
# Defined in configgen (RESERVED_SETTINGS_KEYS keeps it out of the
# generated config); the bootstrap config the broker starts with must
# match the generator's explicit listener form for the comparison.
_BOOTSTRAP_LISTENERS = {"listener 1883 0.0.0.0"}
def _listener_signature(config_text: str) -> set[str]:
"""The listener DEFINITION lines of a generated config.
Mosquitto's reload cannot add, remove or redefine listeners (spec
section 28): textual changes to `listener <port> <bind>` lines are
treated as different listeners and only take effect on restart.
Everything else (cert paths, TLS versions, max_connections, ...) is
reload-safe.
"""
import re
return set(re.findall(r"^listener \d+.*$", config_text, re.MULTILINE))
def _set_restart_required(db: Session, required: bool) -> None:
setting = db.scalar(
select(BrokerSettings).where(BrokerSettings.key == RESTART_REQUIRED_KEY)
)
if setting is None:
db.add(
BrokerSettings(
key=RESTART_REQUIRED_KEY,
value="true" if required else "false",
value_type="bool",
)
)
else:
setting.value = "true" if required else "false"
def restart_required(db: Session) -> bool:
"""Whether the deployed config has listener changes pending a restart."""
setting = db.scalar(
select(BrokerSettings.value).where(BrokerSettings.key == RESTART_REQUIRED_KEY)
)
return setting == "true"
def _call_control(
path: str, *, method: str = "POST", json_body: dict | None = None
) -> dict:
settings = get_settings()
headers = {"Authorization": f"Bearer {settings.control_api_token}"}
try:
with httpx.Client(base_url=settings.control_base_url, timeout=30) as client:
response = client.request(
method, path, headers=headers, json=json_body
)
except httpx.TransportError as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
f"broker control service unreachable: {exc}",
) from exc
if response.status_code >= 400:
detail = response.json().get("error", response.text)
raise HTTPException(response.status_code, detail)
return response.json()
# --- Listeners ---------------------------------------------------------------
@router.get("/listeners", response_model=list[ListenerOut])
def list_listeners(db: Annotated[Session, Depends(get_db)], _user: AnyReader):
return db.scalars(select(ListenerConfig)).all()
@router.post("/listeners", response_model=ListenerOut, status_code=201)
def create_listener(
body: ListenerIn,
request: Request,
db: Annotated[Session, Depends(get_db)],
_user: BrokerAdmin,
):
listener = ListenerConfig(**body.model_dump())
db.add(listener)
db.flush()
audit.record(
db,
"LISTENER_CREATED",
request=request,
object_type="listener",
object_id=str(listener.id),
new_value={"name": body.name, "port": body.port},
)
return listener
@router.put("/listeners/{listener_id}", response_model=ListenerOut)
def update_listener(
listener_id: uuid.UUID,
body: ListenerIn,
request: Request,
db: Annotated[Session, Depends(get_db)],
_user: BrokerAdmin,
):
listener = db.get(ListenerConfig, listener_id)
if listener is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "listener not found")
for key, value in body.model_dump().items():
setattr(listener, key, value)
db.flush()
# Audit the security-relevant listener fields (spec section 38): the
# full PUT payload is a replacement, so record what it now says.
audit.record(
db,
"LISTENER_UPDATED",
request=request,
object_type="listener",
object_id=str(listener.id),
new_value={
"name": body.name,
"port": body.port,
"bind_address": body.bind_address,
"protocol": body.protocol,
"allow_anonymous": body.allow_anonymous,
},
)
return listener
@router.delete("/listeners/{listener_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_listener(
listener_id: uuid.UUID,
request: Request,
db: Annotated[Session, Depends(get_db)],
_user: BrokerAdmin,
):
listener = db.get(ListenerConfig, listener_id)
if listener is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "listener not found")
db.delete(listener)
audit.record(
db,
"LISTENER_DELETED",
request=request,
object_type="listener",
object_id=str(listener.id),
)
return None
# --- Global settings -----------------------------------------------------------
@router.get("/settings", response_model=list[BrokerSettingsOut])
def list_settings(db: Annotated[Session, Depends(get_db)], _user: AnyReader):
return db.scalars(select(BrokerSettings)).all()
@router.put("/settings/{key}", response_model=BrokerSettingsIn)
def upsert_setting(
key: str,
body: BrokerSettingsIn,
request: Request,
db: Annotated[Session, Depends(get_db)],
_user: BrokerAdmin,
):
if body.key != key:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "key mismatch")
setting = db.scalar(select(BrokerSettings).where(BrokerSettings.key == key))
if setting is None:
setting = BrokerSettings(key=key, value=body.value, value_type=body.value_type)
db.add(setting)
else:
setting.value = body.value
setting.value_type = body.value_type
db.flush()
audit.record(
db,
"BROKER_SETTING_CHANGED",
request=request,
object_type="broker_setting",
object_id=key,
new_value={"value": body.value},
)
return setting
# --- Generation, validation, deployment ----------------------------------------
# The sidecar and broker share the mosquitto_certs volume; the backend
# publishes the DECRYPTED material there because Mosquitto needs
# plaintext files (uid 1883, mode 600). The DB keeps the encrypted master.
CERTS_DIR = os.environ.get("MOSQUITTO_CERTS_DIR", "/mosquitto/certs")
def _publish_certificates(db: Session) -> None:
"""Write cert/key/CA files for every TLS listener's bound certificate.
Must run BEFORE the config referencing them is validated/activated,
otherwise `mosquitto --test-config` fails on missing files.
Files are owned by uid/gid 1883 (the broker user in the official
image) with key mode 0640 — the broker must be able to read the key,
but no other container user should.
"""
listeners = db.scalars(
select(ListenerConfig).where(ListenerConfig.tls_enabled.is_(True))
).all()
seen: set[uuid.UUID] = set()
for listener in listeners:
if listener.cert_id is None or listener.cert_id in seen:
continue
seen.add(listener.cert_id)
cert = db.get(Certificate, listener.cert_id)
if cert is None or cert.private_key_pem is None:
continue # nothing to publish (warned about in configgen)
os.makedirs(CERTS_DIR, exist_ok=True)
from app.crypto import decrypt_secret
def _write(path: str, content: str, mode: int) -> None:
with open(path, "w", encoding="utf-8") as f:
f.write(content)
os.chmod(path, mode)
try:
# The broker (and this sidecar) run as uid 1883 in the
# official image; chown only works when we are root in
# the container — skip silently in local dev on Windows.
os.chown(path, 1883, 1883)
except (PermissionError, OSError):
pass
_write(
os.path.join(CERTS_DIR, f"{cert.id}.crt"),
cert.certificate_pem,
0o644,
)
_write(
os.path.join(CERTS_DIR, f"{cert.id}.key"),
decrypt_secret(cert.private_key_pem),
0o640,
)
if cert.parent_id is not None:
ca = db.get(Certificate, cert.parent_id)
if ca is not None:
_write(
os.path.join(CERTS_DIR, f"{ca.id}.crt"),
ca.certificate_pem,
0o644,
)
@router.get("/config/preview", response_model=ConfigPreviewOut)
def preview_config(
db: Annotated[Session, Depends(get_db)], _user: AnyReader
):
"""Show the generated mosquitto.conf without deploying (spec section 32)."""
result = generate_config(db)
return ConfigPreviewOut(config_text=result.config_text, warnings=result.warnings)
@router.post("/config/validate")
def validate_config(
db: Annotated[Session, Depends(get_db)], _user: BrokerAdmin
):
"""Generate + validate against the real broker binary (spec section 27)."""
result = generate_config(db)
response = _call_control("/deploy", json_body={"content": result.config_text})
# NOTE: /deploy both validates AND activates. Release 1 keeps the
# combined flow; Release 2 splits validate-only via a staged endpoint.
return response
@router.post("/config/deploy")
def deploy_config(
body: DeployRequest,
request: Request,
db: Annotated[Session, Depends(get_db)],
user: BrokerAdmin,
):
"""Generate -> publish certs -> validate -> activate + reload -> revision."""
_publish_certificates(db)
result = generate_config(db)
response = _call_control("/deploy", json_body={"content": result.config_text})
if response.get("status") != "deployed":
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, response)
# Spec section 28: a reload cannot apply listener definition changes.
# Compare the listener lines against the previously DEPLOYED revision
# (or the bootstrap config when none exists yet) and flag a pending
# restart so the UI can surface it (glowing indicator -> System page).
previous = db.scalar(
select(ConfigRevision)
.where(ConfigRevision.deployed.is_(True))
.order_by(ConfigRevision.revision_number.desc())
)
previous_listeners = (
_listener_signature(previous.config_text)
if previous is not None
else set(_BOOTSTRAP_LISTENERS)
)
needs_restart = _listener_signature(result.config_text) != previous_listeners
_set_restart_required(db, needs_restart)
revision = ConfigRevision(
revision_number=_next_revision_number(db),
created_by=user.username,
comment=body.comment,
config_text=result.config_text,
validation_result=response,
deployed=True,
deployed_at=datetime.now(UTC),
)
db.add(revision)
audit.record(
db,
"BROKER_CONFIG_DEPLOYED",
request=request,
user=user.username,
object_type="config_revision",
object_id=str(revision.revision_number),
new_value={"comment": body.comment},
)
return {
"status": "deployed",
"revision": revision.revision_number,
"broker_healthy": response.get("broker_healthy"),
"restart_required": needs_restart,
}
@router.get("/revisions", response_model=list[RevisionOut])
def list_revisions(
db: Annotated[Session, Depends(get_db)], _user: AnyReader
):
return db.scalars(
select(ConfigRevision).order_by(ConfigRevision.revision_number.desc())
).all()
@router.get("/revisions/{number}", response_model=RevisionDetailOut)
def get_revision(
number: int,
db: Annotated[Session, Depends(get_db)],
_user: AnyReader,
):
revision = db.scalar(
select(ConfigRevision).where(ConfigRevision.revision_number == number)
)
if revision is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "revision not found")
return revision