Files
mqtt-broker-stack/backend/app/configgen.py
T

154 lines
6.3 KiB
Python

"""mosquitto.conf generator — PostgreSQL model to config text (spec sections 61, 25).
The generated configuration MUST avoid the options deprecated in
Mosquitto 2.1 (password_file, acl_file, per_listener_settings): those are
removed in 3.0 (spec section 3.1 note).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import BrokerSettings, Certificate, ListenerConfig
_BOOL = {True: "true", False: "false"}
@dataclass
class GenerationResult:
config_text: str
warnings: list[str] = field(default_factory=list)
def _global_section(db: Session, warnings: list[str]) -> list[str]:
lines: list[str] = []
for setting in db.scalars(select(BrokerSettings)).all():
key = setting.key.strip()
value = setting.value.strip()
if not key or key.startswith("#"):
continue
lines.append(f"{key} {value}")
return lines
def _listener_block(
listener: ListenerConfig, db: Session, warnings: list[str]
) -> list[str]:
lines: list[str] = []
if not listener.enabled:
return lines
# Always emit an EXPLICIT bind address. Mosquitto's reload compares
# listeners textually; "listener 1883" vs "listener 1883 0.0.0.0" are
# treated as different listeners, and reload CANNOT add/remove
# listeners (spec section 28). Matching the bootstrap's explicit
# form keeps the management listener reloadable.
bind = listener.bind_address or "0.0.0.0"
lines.append(f"listener {listener.port} {bind}")
lines.append(f"protocol {listener.protocol}")
if listener.allow_anonymous:
lines.append("listener_allow_anonymous true")
if listener.max_connections != -1:
lines.append(f"max_connections {listener.max_connections}")
if listener.tls_enabled:
# Real PKI material (spec section 30): when a certificate is bound,
# the deploy pipeline publishes cert/key/CA files under
# /mosquitto/certs/<id>.crt|key BEFORE the config referencing
# them is activated, so validation sees real files.
cert = db.get(Certificate, listener.cert_id) if listener.cert_id else None
ca = db.get(Certificate, cert.parent_id) if cert and cert.parent_id else None
if cert is not None:
cert_dir = "/mosquitto/certs"
lines.append(f"certfile {cert_dir}/{cert.id}.crt")
lines.append(f"keyfile {cert_dir}/{cert.id}.key")
if ca is not None:
lines.append(f"cafile {cert_dir}/{ca.id}.crt")
if listener.tls_min_version:
lines.append(f"tls_version {listener.tls_min_version}")
if listener.require_certificate:
lines.append("require_certificate true")
if listener.use_identity_as_username:
lines.append("use_identity_as_username true")
else:
# TLS requested but no certificate bound: forward-compatible
# block without certfile/keyfile, with an actionable warning.
warnings.append(
f"listener '{listener.name}': TLS requested but no certificate "
"is bound; certfile/keyfile not set. Issue a server certificate "
"and bind it to this listener."
)
if listener.tls_min_version:
lines.append(f"tls_version {listener.tls_min_version}")
if listener.require_certificate:
lines.append("require_certificate true")
if listener.use_identity_as_username:
lines.append("use_identity_as_username true")
return lines
def generate_config(db: Session) -> GenerationResult:
"""Build the complete mosquitto.conf from the database model.
The platform ALWAYS regenerates the full file; it never edits the
active file in place (spec section 61).
"""
warnings: list[str] = []
listeners = db.scalars(select(ListenerConfig)).all()
enabled = [item for item in listeners if item.enabled]
lines: list[str] = []
lines.append("# Generated by MQTT Management Platform — DO NOT EDIT BY HAND")
lines.append("# Ownership: PostgreSQL model -> revision -> this file (spec 61)")
lines.append("")
lines.append("# --- Global settings ---")
# NOTE: per_listener_settings is deprecated in 2.1 (removed in 3.0).
# Plugins loaded via global_plugin apply to all listeners, and auth
# defaults are already global, so the option is simply omitted.
lines.append("allow_anonymous false")
global_lines = _global_section(db, warnings)
if global_lines:
lines.extend(global_lines)
lines.append("")
# Dynamic security plugin: global, so it applies to every listener.
lines.append("# --- Dynamic Security ---")
lines.append("global_plugin /usr/lib/mosquitto_dynamic_security.so")
lines.append("plugin_opt_config_file /mosquitto/config/dynamic-security.json")
lines.append("")
if enabled:
lines.append("# --- Listeners ---")
for listener in enabled:
block = _listener_block(listener, db, warnings)
if block:
lines.append(f"# listener: {listener.name}")
lines.extend(block)
lines.append("")
else:
# Empty model is a legitimate UI state (fresh platform): generate a
# SAFE config instead of raising, so /config/preview works. With no
# listener directives Mosquitto binds loopback-only (127.0.0.1/::1)
# on port 1883 with anonymous access — reachable only inside the
# container network. The warning explains exactly what will happen.
warnings.append(
"No enabled listeners in the configuration model. The generated "
"config contains no listener directives, so Mosquitto will bind "
"loopback-only (127.0.0.1/::1) on port 1883 with anonymous access "
"— internal to the container network only. Add a listener below."
)
lines.append("# --- Persistence ---")
lines.append("persistence true")
lines.append("persistence_location /mosquitto/data/")
lines.append("")
lines.append("# --- Logging ---")
lines.append("log_dest stdout")
lines.append("log_timestamp true")
lines.append("log_timestamp_format %Y-%m-%dT%H:%M:%S")
return GenerationResult(config_text="\n".join(lines) + "\n", warnings=warnings)