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} +
+ )}
diff --git a/frontend/src/pages/Groups.tsx b/frontend/src/pages/Groups.tsx index 65af628..9f53188 100644 --- a/frontend/src/pages/Groups.tsx +++ b/frontend/src/pages/Groups.tsx @@ -102,6 +102,12 @@ export function GroupsPage() { group manages its own roles and members.

{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} +
+ )} diff --git a/frontend/src/pages/Roles.tsx b/frontend/src/pages/Roles.tsx index 13a7b1b..bbc645b 100644 --- a/frontend/src/pages/Roles.tsx +++ b/frontend/src/pages/Roles.tsx @@ -142,6 +142,12 @@ export function RolesPage() { username and client id.

{error &&
{error}
} + {(roles.error || detail.error) && ( +
+ Failed to load roles from the broker management API:{" "} + {((roles.error ?? detail.error) as Error).message} +
+ )} {banner && !error &&
{banner}
}