"""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.config import get_settings 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/.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: # Compose publishes only a fixed set of broker ports to the host # (spec section 5). Listeners on other ports are reachable ONLY # inside the Docker network — surface that before an operator # wonders why no external device can connect. published = get_settings().published_port_set for listener in enabled: if listener.port not in published: warnings.append( f"listener '{listener.name}' (port {listener.port}) is not " "published to the host by compose.yaml — it is reachable " "only inside the Docker network. Map the port in " "compose.yaml (or use a published port like 8883) to " "make it reachable for external devices." ) 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)