Files
2026-09-20 09:23:04 +02:00

318 lines
11 KiB
Python

"""Topic explorer sessions (spec sections 35, 36).
One MQTT v5 client per active browser stream. Sessions live only while
the Server-Sent-Events connection is open; the stream generator's
finally-clause stops the session, which disconnects the client — no
lingering subscriptions, no manual cleanup by the operator.
Why MQTT v5: the explorer must surface MQTT 5 publish properties (spec
section 35), which only arrive when the client itself speaks v5. The
dynsec management channel deliberately stays on its own connection.
Payloads are base64 with a looks-like-UTF-8 flag: messages can be binary,
and the UI offers text/hex views accordingly.
"""
from __future__ import annotations
import base64
import json
import logging
import threading
import time
import uuid
from collections import deque
from dataclasses import dataclass, field
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__)
_BUFFER_SIZE = 1000 # ring buffer per session
_GRACE_SECONDS = 30.0 # page reload / SSE reconnect window
def _payload_info(payload: bytes) -> dict[str, Any]:
"""base64 + heuristic UTF-8 decoding for the UI."""
info: dict[str, Any] = {"payload_b64": base64.b64encode(payload).decode()}
try:
info["payload_text"] = payload.decode("utf-8")
except UnicodeDecodeError:
info["payload_text"] = None # binary — UI shows hex
return info
@dataclass
class _Message:
index: int
topic: str
qos: int
retain: bool
received_at: float
payload_b64: str
payload_text: str | None
properties: dict[str, Any] | None = None
def as_dict(self) -> dict[str, Any]:
return {
"index": self.index,
"topic": self.topic,
"qos": self.qos,
"retain": self.retain,
"received_at": self.received_at,
"payload_b64": self.payload_b64,
"payload_text": self.payload_text,
"properties": self.properties,
}
@dataclass
class ExplorerSession:
id: str
username: str
topic_filter: str
client: mqtt.Client = field(repr=False)
buffer: deque[_Message] = field(default_factory=lambda: deque(maxlen=_BUFFER_SIZE))
_next_index: int = 0
_cond: threading.Condition = field(default_factory=threading.Condition)
_subscribed: threading.Event = field(default_factory=threading.Event)
created_at: float = field(default_factory=time.time)
def _on_connect(self, client, userdata, flags, reason_code, properties) -> None:
if reason_code == 0:
client.subscribe(self.topic_filter, qos=0)
else:
logger.error("explorer session %s connect failed: %s", self.id, reason_code)
def _on_subscribe(self, client, userdata, mid, reason_codes, properties) -> None:
# SUBACK received: the subscription is now live on the broker.
self._subscribed.set()
def _on_message(self, client, userdata, msg) -> None:
# MQTT v5: surface publish properties (content type, response
# topic, user properties, ...). paho exposes them as msg.properties.
props: dict[str, Any] | None = None
raw_props = getattr(msg, "properties", None)
if raw_props is not None:
try:
props = {}
if getattr(raw_props, "ContentType", None):
props["content_type"] = raw_props.ContentType
if getattr(raw_props, "ResponseTopic", None):
props["response_topic"] = raw_props.ResponseTopic
if getattr(raw_props, "CorrelationData", None):
props["correlation_data"] = raw_props.CorrelationData.decode(
"utf-8", errors="replace"
)
if getattr(raw_props, "UserProperty", None):
props["user_properties"] = [
[k, v] for k, v in raw_props.UserProperty
]
except Exception: # noqa: BLE001 - properties must never break capture
props = None
info = _payload_info(msg.payload)
with self._cond:
message = _Message(
index=self._next_index,
topic=msg.topic,
qos=msg.qos,
retain=bool(getattr(msg, "retain", False)),
received_at=time.time(),
payload_b64=info["payload_b64"],
payload_text=info["payload_text"],
properties=props,
)
self._next_index += 1
self.buffer.append(message)
self._cond.notify_all()
class ExplorerSessions:
"""Thread-safe registry of live explorer sessions."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._sessions: dict[str, ExplorerSession] = {}
def start(self, username: str, topic_filter: str) -> ExplorerSession:
settings = get_settings()
session_id = uuid.uuid4().hex
client = mqtt.Client(
callback_api_version=CallbackAPIVersion.VERSION2,
client_id=f"mqtt-explorer-{session_id[:8]}",
protocol=mqtt.MQTTv5,
)
client.username_pw_set(
settings.mosquitto_dynsec_username, settings.mosquitto_dynsec_password
)
session = ExplorerSession(
id=session_id,
username=username,
topic_filter=topic_filter,
client=client,
)
client.on_connect = session._on_connect
client.on_subscribe = session._on_subscribe
client.on_message = session._on_message
client.connect(
settings.mosquitto_host, settings.mosquitto_mgmt_port, keepalive=30
)
client.loop_start()
# Block until the broker acknowledges the subscription: without
# this, a message published immediately after subscribe returns
# can be missed (SUBACK arrives asynchronously on the network
# loop). 5 s covers a cold broker start; on timeout the session
# still proceeds — delivery may just start slightly later.
if not session._subscribed.wait(timeout=5.0):
logger.warning(
"explorer session %s: SUBACK not received within 5 s",
session_id,
)
with self._lock:
self._sessions[session_id] = session
return session
def get(self, session_id: str) -> ExplorerSession:
with self._lock:
session = self._sessions.get(session_id)
if session is None:
raise KeyError(session_id)
return session
def stop(self, session_id: str) -> None:
with self._lock:
session = self._sessions.pop(session_id, None)
if session is None:
return
try:
session.client.disconnect()
except OSError:
pass
session.client.loop_stop()
def sweep(self) -> None:
"""Drop sessions whose buffer has gone silent past the grace
window (browser tab closed without the SSE finally firing)."""
cutoff = time.time() - _GRACE_SECONDS
with self._lock:
stale = [
sid
for sid, s in self._sessions.items()
if s.buffer and s.buffer[-1].received_at < cutoff
and time.time() - s.created_at > _GRACE_SECONDS
]
for sid in stale:
self.stop(sid)
def stop_all(self) -> None:
with self._lock:
ids = list(self._sessions)
for sid in ids:
self.stop(sid)
sessions = ExplorerSessions()
def messages_since(session_id: str, index: int) -> list[dict[str, Any]]:
"""All buffered messages with index > the given one."""
session = sessions.get(session_id)
with session._cond:
return [m.as_dict() for m in session.buffer if m.index > index]
def list_retained(max_results: int = 200, timeout: float = 3.0) -> list[dict[str, Any]]:
"""Enumerate retained messages (spec section 36).
MQTT brokers expose no "list retained" API: the defined mechanism is
to subscribe to # — the broker immediately delivers every retained
message matching the filter (retain flag set on delivery) — collect
briefly, then unsubscribe. $SYS is filtered out of the listing.
"""
settings = get_settings()
collected: dict[str, _Message] = {}
done = threading.Event()
client_id = f"mqtt-explorer-retained-{uuid.uuid4().hex[:8]}"
client = mqtt.Client(
callback_api_version=CallbackAPIVersion.VERSION2,
client_id=client_id,
protocol=mqtt.MQTTv5,
)
client.username_pw_set(
settings.mosquitto_dynsec_username, settings.mosquitto_dynsec_password
)
def _on_connect(cl, userdata, flags, reason_code, properties) -> None:
if reason_code == 0:
cl.subscribe("#", qos=0)
else:
done.set() # nothing to collect without a subscription
def _on_message(cl, userdata, msg) -> None:
if not getattr(msg, "retain", False):
return # only retained deliveries belong in the listing
if msg.topic.startswith("$SYS"):
return # telemetry noise, not retained app data
if len(collected) >= max_results:
done.set()
return
info = _payload_info(msg.payload)
collected[msg.topic] = _Message(
index=len(collected),
topic=msg.topic,
qos=msg.qos,
retain=True,
received_at=time.time(),
payload_b64=info["payload_b64"],
payload_text=info["payload_text"],
)
client.on_connect = _on_connect
client.on_message = _on_message
client.connect(settings.mosquitto_host, settings.mosquitto_mgmt_port, keepalive=30)
client.loop_start()
try:
done.wait(timeout)
finally:
try:
client.disconnect()
except OSError:
pass
client.loop_stop()
return [m.as_dict() for _, m in sorted(collected.items())]
def publish_message(
topic: str, payload: bytes, qos: int, retain: bool, timeout: float = 10.0
) -> None:
"""Publish a test message as the management identity (spec section 35).
A short-lived dedicated client keeps the explorer's session clients
purely read-only.
"""
settings = get_settings()
client = mqtt.Client(
callback_api_version=CallbackAPIVersion.VERSION2,
client_id=f"mqtt-explorer-pub-{uuid.uuid4().hex[:8]}",
protocol=mqtt.MQTTv5,
)
client.username_pw_set(
settings.mosquitto_dynsec_username, settings.mosquitto_dynsec_password
)
client.connect(settings.mosquitto_host, settings.mosquitto_mgmt_port, keepalive=30)
client.loop_start()
try:
info = client.publish(topic, payload, qos=qos, retain=retain)
info.wait_for_publish(timeout=timeout)
if not info.is_published():
raise RuntimeError("publish did not complete in time")
finally:
try:
client.disconnect()
except OSError:
pass
client.loop_stop()