154 lines
6.3 KiB
Python
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) |