"""Dynamic Security client — the runtime control channel (spec sections 10-14). The backend connects to Mosquitto as the seeded `admin` client and issues commands on the $CONTROL/dynamic-security/v1 topic API. Wire protocol (verified against Mosquitto 2.1 source: src/control_common.c, plugins/dynamic-security/control.c and clients.c): Request -> $CONTROL/dynamic-security/v1 {"commands": [{"command": "createClient", ..., "correlationData": ""}]} Response <- $CONTROL/dynamic-security/v1/response (FIXED topic) {"responses": [{"command": "createClient", "correlationData": "", "error": "..." (only on failure), "data": {...}}]} Responses are matched to requests by the echoed correlationData in the PAYLOAD, not by topic. Command results live under `data` (data.client, data.clients, ...). Client objects report `disabled` (absent == enabled). """ from __future__ import annotations import json import logging import queue import threading import uuid from typing import Any import paho.mqtt.client as mqtt from paho.mqtt.enums import CallbackAPIVersion from app.config import get_settings logger = logging.getLogger(__name__) CONTROL_TOPIC = "$CONTROL/dynamic-security/v1" RESPONSE_TOPIC = "$CONTROL/dynamic-security/v1/response" _RESPONSE_TIMEOUT_S = 10.0 class DynsecError(Exception): """The dynamic security API returned an error for a command.""" def __init__(self, message: str) -> None: super().__init__(message) self.message = message class DynsecTimeout(DynsecError): """No response from the broker management channel.""" class DynsecClient: """Thin, thread-safe wrapper around the dynsec $CONTROL API.""" def __init__(self) -> None: self._client: mqtt.Client | None = None self._lock = threading.Lock() self._responses: dict[str, queue.Queue[dict[str, Any]]] = {} self._connected = threading.Event() # Optional tap for $SYS telemetry (app.metrics registers itself # at startup). Receives (topic, payload) for every $SYS message. self._event_sink = None # -- connection lifecycle ------------------------------------------------ def connect(self) -> None: with self._lock: if self._client is not None and self._connected.is_set(): return if self._client is not None: # The previous client lost its connection (broker restart, # kicked credentials). paho's auto-reconnect retries with # the SAME credentials forever, so it can never recover # from a stale password — tear it down and start fresh, # which also re-reads the current settings. self._teardown_locked() settings = get_settings() client = mqtt.Client( callback_api_version=CallbackAPIVersion.VERSION2, client_id=f"mqtt-backend-{uuid.uuid4().hex[:8]}", ) client.username_pw_set( settings.mosquitto_dynsec_username, settings.mosquitto_dynsec_password ) client.on_connect = self._on_connect client.on_disconnect = self._on_disconnect client.on_message = self._on_message # Register BEFORE loop_start so the guard in the callbacks # (client is not self._client) recognises this client from # the very first callback onward. self._client = client client.connect_async( settings.mosquitto_host, settings.mosquitto_mgmt_port, keepalive=30 ) client.loop_start() if not self._connected.wait(timeout=_RESPONSE_TIMEOUT_S): self._teardown_locked() raise DynsecError("could not connect to broker management channel") def _teardown_locked(self) -> None: """Stop and discard the current paho client. Call with _lock held.""" client = self._client assert client is not None # Null the reference FIRST: _on_disconnect's guard then treats any # late callback from this client as stale and ignores it, so a # deliberate teardown cannot clear a successor's connected flag. self._client = None self._connected.clear() try: client.disconnect() except OSError: pass # socket may already be gone client.loop_stop() def close(self) -> None: with self._lock: if self._client is None: return self._teardown_locked() def set_event_sink(self, sink) -> None: """Register a callable receiving every $SYS message (topic, payload). Used by app.metrics for the broker dashboard (spec sections 33, 55). The sink must never raise: telemetry must not be able to disturb the management channel. """ self._event_sink = sink # -- paho callbacks ------------------------------------------------------- def _on_connect(self, client, userdata, flags, reason_code, properties) -> None: if reason_code == 0: # FIXED response topic — all commands reply here; requests are # matched by the correlationData echo inside the payload. client.subscribe(RESPONSE_TOPIC, qos=1) # $SYS telemetry for the dashboard (spec sections 33, 55). # qos 0: telemetry is periodically republished, losing one # is harmless. client.subscribe("$SYS/#", qos=0) self._connected.set() else: logger.error("dynsec client connect failed: %s", reason_code) def _on_disconnect( self, client, userdata, disconnect_flags, reason_code, properties ) -> None: # paho VERSION2 signature: all params are required even when unused. if client is not self._client: return # stale callback from a client already torn down # Without this handler a connection loss went completely unnoticed: # the flag stayed set, every command published into the void and # burned its full response timeout (10 s per call). Clearing the # flag makes health/ready report honestly and lets connect() # rebuild the client on the next command. paho's auto-reconnect # still fires _on_connect on success for transient drops. self._connected.clear() logger.warning( "dynsec management channel disconnected (rc=%s); commands will " "fail until the connection is re-established", reason_code, ) def _on_message(self, client, userdata, msg) -> None: if msg.topic != RESPONSE_TOPIC: # Not a dynsec reply: forward $SYS telemetry to the sink, if # one is registered. Swallow sink errors — telemetry must # never disturb the management channel. if msg.topic.startswith("$SYS/") and self._event_sink is not None: try: self._event_sink(msg.topic, msg.payload) except Exception: # noqa: BLE001 logger.warning("$SYS event sink raised; message dropped") return try: payload = json.loads(msg.payload.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): logger.warning("unreadable dynsec response payload") return # Envelope is {"responses": [...]}; fan entries out to waiters by # their echoed correlationData. for entry in payload.get("responses", []): correlation = entry.get("correlationData") if correlation and correlation in self._responses: self._responses[correlation].put_nowait(entry) # -- command plumbing ----------------------------------------------------- def _command(self, command: str, **kwargs: Any) -> dict[str, Any]: """Send one command; return its `data` object on success. Raises DynsecError when the broker replies with an error, and DynsecTimeout when no response arrives in time. """ self.connect() assert self._client is not None # for type checkers correlation = uuid.uuid4().hex reply_queue: queue.Queue[dict[str, Any]] = queue.Queue() self._responses[correlation] = reply_queue body = { "commands": [ {"command": command, "correlationData": correlation, **kwargs} ] } try: result = self._client.publish( CONTROL_TOPIC, json.dumps(body).encode("utf-8"), qos=1 ) result.wait_for_publish(timeout=_RESPONSE_TIMEOUT_S) try: entry = reply_queue.get(timeout=_RESPONSE_TIMEOUT_S) except queue.Empty as exc: raise DynsecTimeout(f"dynsec command '{command}' timed out") from exc finally: self._responses.pop(correlation, None) if "error" in entry: raise DynsecError(f"{command}: {entry['error']}") return entry.get("data") or {} # -- Release 1 API surface (spec sections 11-14) --------------------------- # Clients def create_client( self, username: str, password: str, clientid: str | None = None, text_name: str | None = None, text_description: str | None = None, groups: list[dict[str, Any]] | None = None, roles: list[dict[str, Any]] | None = None, ) -> None: params: dict[str, Any] = {"username": username, "password": password} if clientid: params["clientid"] = clientid if text_name: params["textname"] = text_name if text_description: params["textdescription"] = text_description if groups: params["groups"] = groups if roles: params["roles"] = roles self._command("createClient", **params) def delete_client(self, username: str) -> None: self._command("deleteClient", username=username) def enable_client(self, username: str) -> None: self._command("enableClient", username=username) def disable_client(self, username: str) -> None: self._command("disableClient", username=username) def set_client_password(self, username: str, password: str) -> None: self._command("setClientPassword", username=username, password=password) def set_client_id(self, username: str, clientid: str | None) -> None: """Bind (or clear) the expected client ID for a username. With a bound client ID, connections must present exactly that client id (username + password + client id must all match) — this is what stops one device's credentials being reused by another program (spec section 11: "bind an expected client ID"). """ if clientid is None or clientid == "": self._command("setClientId", username=username, clientid="") else: self._command("setClientId", username=username, clientid=clientid) def get_client(self, username: str) -> dict[str, Any]: return self._command("getClient", username=username).get("client", {}) def list_clients(self) -> list[dict[str, Any]]: """Full client objects (verbose=true gives groups/roles/disabled).""" data = self._command("listClients", verbose=True) return data.get("clients", []) def set_client_group_memberships( self, username: str, groups: list[dict[str, Any]] ) -> None: """Replace a client's complete group membership list.""" self._command("modifyClient", username=username, groups=groups) def set_client_roles(self, username: str, roles: list[dict[str, Any]]) -> None: """Replace a client's direct role assignments.""" self._command("modifyClient", username=username, roles=roles) def add_client_role(self, username: str, rolename: str, priority: int = -1) -> None: """Incrementally add one role to a client (spec section 11). Deliberately incremental rather than modifyClient full-list replacement: safer against accidental clobbering from the UI. """ self._command( "addClientRole", username=username, rolename=rolename, priority=priority ) def remove_client_role(self, username: str, rolename: str) -> None: self._command("removeClientRole", username=username, rolename=rolename) # Groups def create_group(self, groupname: str, roles: list[dict[str, Any]] | None = None) -> None: params: dict[str, Any] = {"groupname": groupname} if roles: params["roles"] = roles self._command("createGroup", **params) def delete_group(self, groupname: str) -> None: self._command("deleteGroup", groupname=groupname) def get_group(self, groupname: str) -> dict[str, Any]: return self._command("getGroup", groupname=groupname).get("group", {}) def list_groups(self) -> list[dict[str, Any]]: data = self._command("listGroups", verbose=True) return data.get("groups", []) def set_group_roles(self, groupname: str, roles: list[dict[str, Any]]) -> None: self._command("modifyGroup", groupname=groupname, roles=roles) def add_group_role(self, groupname: str, rolename: str, priority: int = -1) -> None: """Incrementally add one role to a group (spec section 12).""" self._command( "addGroupRole", groupname=groupname, rolename=rolename, priority=priority ) def remove_group_role(self, groupname: str, rolename: str) -> None: self._command("removeGroupRole", groupname=groupname, rolename=rolename) def add_group_client(self, groupname: str, username: str, priority: int = 1) -> None: self._command( "addGroupClient", groupname=groupname, username=username, priority=priority ) def remove_group_client(self, groupname: str, username: str) -> None: self._command("removeGroupClient", groupname=groupname, username=username) # Roles def create_role(self, rolename: str, acls: list[dict[str, Any]] | None = None) -> None: params: dict[str, Any] = {"rolename": rolename} if acls: params["acls"] = acls self._command("createRole", **params) def delete_role(self, rolename: str) -> None: self._command("deleteRole", rolename=rolename) def get_role(self, rolename: str) -> dict[str, Any]: return self._command("getRole", rolename=rolename).get("role", {}) def list_roles(self) -> list[dict[str, Any]]: data = self._command("listRoles", verbose=True) return data.get("roles", []) def set_role_acls(self, rolename: str, acls: list[dict[str, Any]]) -> None: """Replace the full ACL list of a role (spec section 14 editor).""" self._command("modifyRole", rolename=rolename, acls=acls) # Default ACL behaviour (spec section 58: deny by default) def set_default_acl_access(self, acl_type: str, allow: bool) -> None: self._command( "setDefaultACLAccess", acls=[{"acltype": acl_type, "allow": allow}], ) def get_default_acl_access(self) -> list[dict[str, Any]]: return self._command("getDefaultACLAccess").get("acls", []) # Module-level singleton used by FastAPI dependencies. dynsec_client = DynsecClient()