From 2b327320c24f154675634bb6122d6629dde7d69e Mon Sep 17 00:00:00 2001
From: Jonas Bengtsson
Date: Sat, 19 Sep 2026 12:08:57 +0200
Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20WIP?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/dynsec.py | 56 +++++++++++++++++++++++++++++-----
frontend/src/pages/Clients.tsx | 6 ++++
frontend/src/pages/Groups.tsx | 6 ++++
frontend/src/pages/Roles.tsx | 6 ++++
4 files changed, 67 insertions(+), 7 deletions(-)
diff --git a/backend/app/dynsec.py b/backend/app/dynsec.py
index d45daee..a7c2655 100644
--- a/backend/app/dynsec.py
+++ b/backend/app/dynsec.py
@@ -65,8 +65,15 @@ class DynsecClient:
# -- connection lifecycle ------------------------------------------------
def connect(self) -> None:
with self._lock:
- if self._client is not None:
+ 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,
@@ -76,24 +83,40 @@ class DynsecClient:
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):
- client.loop_stop()
+ self._teardown_locked()
raise DynsecError("could not connect to broker management channel")
- self._client = client
+
+ 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._client.disconnect()
- self._client.loop_stop()
- self._client = None
- self._connected.clear()
+ self._teardown_locked()
# -- paho callbacks -------------------------------------------------------
def _on_connect(self, client, userdata, flags, reason_code, properties) -> None:
@@ -105,6 +128,25 @@ class DynsecClient:
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:
return
diff --git a/frontend/src/pages/Clients.tsx b/frontend/src/pages/Clients.tsx
index e890de5..b6807c7 100644
--- a/frontend/src/pages/Clients.tsx
+++ b/frontend/src/pages/Clients.tsx
@@ -164,6 +164,12 @@ export function ClientsPage() {
)}
{error && {error}
}
+ {(clients.error || roles.error || groups.error) && (
+
+ Failed to load clients/roles/groups from the broker management API:{" "}
+ {((clients.error ?? roles.error ?? groups.error) as Error).message}
+
+ )}
{error && {error}
}
+ {(groups.error || roles.error || clients.error) && (
+
+ Failed to load groups/roles/clients from the broker management API:{" "}
+ {((groups.error ?? roles.error ?? clients.error) as Error).message}
+
+ )}