246 lines
9.4 KiB
Python
246 lines
9.4 KiB
Python
"""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. Commands are
|
|
request/response: publish a JSON command with a unique correlation topic,
|
|
then wait for the response on that topic (timeout-bounded).
|
|
|
|
Only the subset needed for Release 1 is implemented; the protocol shapes
|
|
come from the official dynsec documentation (mosquitto.org).
|
|
"""
|
|
|
|
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_TIMEOUT_S = 10.0
|
|
|
|
|
|
class DynsecError(Exception):
|
|
"""Raised when the dynamic security API returns an error response."""
|
|
|
|
|
|
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()
|
|
|
|
# -- connection lifecycle ------------------------------------------------
|
|
def connect(self) -> None:
|
|
with self._lock:
|
|
if self._client is not None:
|
|
return
|
|
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_message = self._on_message
|
|
client.connect_async(
|
|
settings.mosquitto_host, settings.mosquitto_mgmt_port, keepalive=30
|
|
)
|
|
client.loop_start()
|
|
if not self._connected.wait(timeout=_RESPONSE_TIMEOUT_S):
|
|
client.loop_stop()
|
|
raise DynsecError("could not connect to broker management channel")
|
|
self._client = client
|
|
|
|
def close(self) -> None:
|
|
with self._lock:
|
|
if self._client is None:
|
|
return
|
|
self._client.disconnect()
|
|
self._client.loop_stop()
|
|
self._client = None
|
|
self._connected.clear()
|
|
|
|
# -- paho callbacks -------------------------------------------------------
|
|
def _on_connect(self, client, userdata, flags, reason_code, properties) -> None:
|
|
if reason_code == 0:
|
|
# Subscribe once to the shared response pattern.
|
|
client.subscribe(f"{CONTROL_TOPIC}/response/#")
|
|
self._connected.set()
|
|
else:
|
|
logger.error("dynsec client connect failed: %s", reason_code)
|
|
|
|
def _on_message(self, client, userdata, msg) -> None:
|
|
try:
|
|
payload = json.loads(msg.payload.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
logger.warning("unreadable dynsec response on %s", msg.topic)
|
|
return
|
|
# Response topic: $CONTROL/dynamic-security/v1/response/<correlation>
|
|
parts = msg.topic.split("/")
|
|
correlation = parts[-1] if len(parts) >= 5 else None
|
|
if correlation and correlation in self._responses:
|
|
self._responses[correlation].put_nowait(payload)
|
|
|
|
# -- command plumbing -----------------------------------------------------
|
|
def _command(self, command: str, **kwargs: Any) -> dict[str, Any]:
|
|
self.connect()
|
|
assert self._client is not None # for type checkers
|
|
correlation = uuid.uuid4().hex
|
|
response_topic = f"{CONTROL_TOPIC}/response/{correlation}"
|
|
reply_queue: queue.Queue[dict[str, Any]] = queue.Queue()
|
|
self._responses[correlation] = reply_queue
|
|
body = {"commands": [{"command": command, **kwargs}], "correlation": correlation}
|
|
try:
|
|
result = self._client.publish(
|
|
f"{CONTROL_TOPIC}", json.dumps(body).encode("utf-8"), qos=1
|
|
)
|
|
result.wait_for_publish(timeout=_RESPONSE_TIMEOUT_S)
|
|
try:
|
|
response = reply_queue.get(timeout=_RESPONSE_TIMEOUT_S)
|
|
except queue.Empty as exc:
|
|
raise DynsecError(f"dynsec command '{command}' timed out") from exc
|
|
finally:
|
|
self._responses.pop(correlation, None)
|
|
|
|
# Protocol shape: {"commands":[{"success":true|false,"error":...}]}
|
|
commands = response.get("commands", [])
|
|
entry = commands[0] if commands else {}
|
|
if not entry.get("success", False):
|
|
raise DynsecError(entry.get("error", f"dynsec command '{command}' failed"))
|
|
return entry
|
|
|
|
# -- 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 get_client(self, username: str) -> dict[str, Any]:
|
|
return self._command("getClient", username=username).get("client", {})
|
|
|
|
def list_clients(self) -> list[dict[str, Any]]:
|
|
entry = self._command("listClients")
|
|
return entry.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)
|
|
|
|
# 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]]:
|
|
entry = self._command("listGroups")
|
|
return entry.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_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]]:
|
|
entry = self._command("listRoles")
|
|
return entry.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",
|
|
defaultAclAccess=[{"acltype": acl_type, "allow": allow}],
|
|
)
|
|
|
|
def get_default_acl_access(self) -> list[dict[str, Any]]:
|
|
entry = self._command("getDefaultACLAccess")
|
|
return entry.get("defaultAclAccess", [])
|
|
|
|
|
|
# Module-level singleton used by FastAPI dependencies.
|
|
dynsec_client = DynsecClient() |