🚧 Add topic explorer
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
"""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()
|
||||
+42
-2
@@ -81,9 +81,48 @@ async def lifespan(app: FastAPI):
|
||||
logger.info("sys-observe role granted to the management identity")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("could not grant sys-observe: %s", exc)
|
||||
# The topic explorer (spec section 35) publishes and subscribes
|
||||
# to APPLICATION topics as the management identity. The 2.1
|
||||
# auto-generated admin role set does NOT include publish/subscribe
|
||||
# on # (deliberately: "the admin user does not have access to
|
||||
# publish to normal application topics"). A dedicated role keeps
|
||||
# the grant explicit and auditable; publishes through the
|
||||
# explorer remain audited as TOPIC_PUBLISHED (spec section 35).
|
||||
from app.dynsec import DynsecError
|
||||
|
||||
try:
|
||||
try:
|
||||
dynsec_client.create_role(
|
||||
"topic-explorer",
|
||||
acls=[
|
||||
{
|
||||
"acltype": "publishClientSend",
|
||||
"topic": "#",
|
||||
"allow": True,
|
||||
"priority": 0,
|
||||
},
|
||||
{
|
||||
"acltype": "subscribePattern",
|
||||
"topic": "#",
|
||||
"allow": True,
|
||||
"priority": 0,
|
||||
},
|
||||
],
|
||||
)
|
||||
except DynsecError:
|
||||
pass # role already exists (idempotent startup)
|
||||
dynsec_client.add_client_role(
|
||||
settings.mosquitto_dynsec_username, "topic-explorer", priority=1
|
||||
)
|
||||
logger.info("topic-explorer role granted to the management identity")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("could not grant topic-explorer: %s", exc)
|
||||
except Exception as exc: # noqa: BLE001 - startup should not crash the API
|
||||
logger.warning("dynsec channel not ready yet: %s", exc)
|
||||
yield
|
||||
from app import explorer
|
||||
|
||||
explorer.sessions.stop_all()
|
||||
dynsec_client.close()
|
||||
|
||||
|
||||
@@ -140,11 +179,12 @@ def health_ready():
|
||||
|
||||
|
||||
# Routers
|
||||
from app.routers import auth, broker, mqtt, pki, system, users # noqa: E402
|
||||
from app.routers import auth, broker, mqtt, pki, system, topics, users # noqa: E402
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(mqtt.router)
|
||||
app.include_router(broker.router)
|
||||
app.include_router(pki.router)
|
||||
app.include_router(system.router)
|
||||
app.include_router(system.router)
|
||||
app.include_router(topics.router)
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Topic explorer API (spec sections 35, 36, 41).
|
||||
|
||||
Endpoints:
|
||||
POST /topics/subscribe -> open an explorer session
|
||||
GET /topics/stream/{id} -> Server-Sent Events message stream
|
||||
POST /topics/publish -> publish a test message (audited)
|
||||
GET /topics/retained -> list retained messages (spec 36)
|
||||
POST /topics/retained/clear -> clear one retained message (audited)
|
||||
|
||||
Security model (spec section 35: access restricted through management
|
||||
RBAC; all publishes audited):
|
||||
* subscribe/stream/retained listing: OPERATOR and above (spec
|
||||
section 9: "perform permitted diagnostic operations")
|
||||
* publish / clear retained: BROKER_ADMIN and above (state-changing)
|
||||
* every publish and clear writes an audit event with topic and QoS
|
||||
metadata — NEVER the payload (audit rows are not a message store).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit, explorer
|
||||
from app.db import get_db
|
||||
from app.deps import CurrentUser, require_roles
|
||||
from app.models import ManagementRole
|
||||
|
||||
router = APIRouter(prefix="/api/v1/topics", tags=["topics"])
|
||||
|
||||
OperatorRole = Annotated[
|
||||
CurrentUser,
|
||||
Depends(
|
||||
require_roles(
|
||||
ManagementRole.BROKER_ADMIN,
|
||||
ManagementRole.OPERATOR,
|
||||
)
|
||||
),
|
||||
]
|
||||
BrokerAdmin = Annotated[CurrentUser, Depends(require_roles(ManagementRole.BROKER_ADMIN))]
|
||||
DbDep = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
class SubscribeRequest(BaseModel):
|
||||
topic_filter: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
class SubscribeResponse(BaseModel):
|
||||
session_id: str
|
||||
|
||||
|
||||
class PublishRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=200)
|
||||
payload_b64: str = Field(default="", description="Empty clears retained")
|
||||
qos: int = Field(default=0, ge=0, le=2)
|
||||
retain: bool = False
|
||||
|
||||
|
||||
class RetainedMessage(BaseModel):
|
||||
index: int
|
||||
topic: str
|
||||
qos: int
|
||||
retain: bool
|
||||
received_at: float
|
||||
payload_b64: str
|
||||
payload_text: str | None
|
||||
properties: dict | None = None
|
||||
|
||||
|
||||
class ClearRetainedRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
def _valid_topic_filter(value: str) -> None:
|
||||
# Reject wildcard misuse the broker would reject anyway, with a
|
||||
# clearer error than a generic MQTT rc.
|
||||
if "#" in value and not value.endswith("#"):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"'#' is only valid as the last level of a filter",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/subscribe", response_model=SubscribeResponse)
|
||||
def subscribe(body: SubscribeRequest, user: OperatorRole):
|
||||
"""Open an explorer session for one topic filter (spec section 35)."""
|
||||
_valid_topic_filter(body.topic_filter)
|
||||
explorer.sessions.sweep()
|
||||
try:
|
||||
session = explorer.sessions.start(user.username, body.topic_filter)
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
f"could not connect to the broker: {exc}",
|
||||
) from exc
|
||||
return SubscribeResponse(session_id=session.id)
|
||||
|
||||
|
||||
@router.get("/stream/{session_id}")
|
||||
async def stream(session_id: str, user: OperatorRole):
|
||||
"""Server-Sent Events stream of live messages for the session.
|
||||
|
||||
Closing the EventSource stops the session (and its MQTT client) via
|
||||
the generator's finally — the lifecycle is tied to the connection.
|
||||
"""
|
||||
try:
|
||||
explorer.sessions.get(session_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "session not found") from exc
|
||||
|
||||
async def event_stream():
|
||||
index = 0
|
||||
heartbeat = 0.0
|
||||
try:
|
||||
while True:
|
||||
for message in explorer.messages_since(session_id, index):
|
||||
index = message["index"]
|
||||
yield f"data: {json.dumps(message)}\n\n"
|
||||
# Heartbeat comment keeps proxies from idling out.
|
||||
heartbeat += 1
|
||||
if heartbeat % 60 == 0: # ~15s at 0.25s cadence
|
||||
yield ": keepalive\n\n"
|
||||
await asyncio.sleep(0.25)
|
||||
finally:
|
||||
explorer.sessions.stop(session_id)
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no", # nginx: disable response buffering
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/publish", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def publish(
|
||||
body: PublishRequest,
|
||||
request: Request,
|
||||
db: DbDep,
|
||||
user: BrokerAdmin,
|
||||
):
|
||||
"""Publish a test message (spec section 35: all publishes audited).
|
||||
|
||||
An empty payload with retain=true is MQTT's clear-retained mechanism
|
||||
(spec section 36) — the same call serves both purposes.
|
||||
"""
|
||||
_valid_topic_filter(body.topic)
|
||||
if any(c in body.topic for c in ("#", "+")):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"wildcards cannot be published to — use a concrete topic",
|
||||
)
|
||||
try:
|
||||
payload = base64.b64decode(body.payload_b64) if body.payload_b64 else b""
|
||||
except Exception as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "invalid base64 payload") from exc
|
||||
try:
|
||||
explorer.publish_message(body.topic, payload, body.qos, body.retain)
|
||||
except (OSError, RuntimeError) as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
f"publish failed: {exc}",
|
||||
) from exc
|
||||
audit.record(
|
||||
db,
|
||||
"TOPIC_PUBLISHED",
|
||||
request=request,
|
||||
user=user.username,
|
||||
object_type="topic",
|
||||
object_id=body.topic,
|
||||
new_value={"qos": body.qos, "retain": body.retain, "bytes": len(payload)},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/retained", response_model=list[RetainedMessage])
|
||||
def retained(user: OperatorRole):
|
||||
"""List retained messages (spec section 36).
|
||||
|
||||
Brokers have no list-retained API: this subscribes briefly to #,
|
||||
collects retain-flag deliveries, and unsubscribes. Capped; large
|
||||
brokers deliver everything on subscribe.
|
||||
"""
|
||||
try:
|
||||
return explorer.list_retained()
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
f"could not connect to the broker: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/retained/clear", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def clear_retained(
|
||||
body: ClearRetainedRequest,
|
||||
request: Request,
|
||||
db: DbDep,
|
||||
user: BrokerAdmin,
|
||||
):
|
||||
"""Clear one retained message: publish a zero-length payload with the
|
||||
retain flag (MQTT's defined mechanism, spec section 36)."""
|
||||
try:
|
||||
explorer.publish_message(body.topic, b"", qos=0, retain=True)
|
||||
except (OSError, RuntimeError) as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
f"clear failed: {exc}",
|
||||
) from exc
|
||||
audit.record(
|
||||
db,
|
||||
"RETAINED_CLEARED",
|
||||
request=request,
|
||||
user=user.username,
|
||||
object_type="topic",
|
||||
object_id=body.topic,
|
||||
)
|
||||
return None
|
||||
@@ -245,6 +245,71 @@ def client(db_session, monkeypatch):
|
||||
monkeypatch.setattr(broker_router, "_call_control", _fake_call_control)
|
||||
monkeypatch.setattr(system_router, "_call_control", _fake_call_control)
|
||||
|
||||
# The topic explorer talks to the broker via MQTT. Fake the session
|
||||
# registry + publish/retained so the endpoints can be exercised
|
||||
# without a live broker.
|
||||
from app.routers import topics as topics_router
|
||||
from app import explorer as explorer_module
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, id, username, topic_filter):
|
||||
self.id = id
|
||||
self.username = username
|
||||
self.topic_filter = topic_filter
|
||||
|
||||
class FakeSessions:
|
||||
def __init__(self):
|
||||
self._sessions = {}
|
||||
self._counter = 0
|
||||
def sweep(self):
|
||||
pass
|
||||
def start(self, username, topic_filter):
|
||||
self._counter += 1
|
||||
sid = f"fake-{self._counter}"
|
||||
self._sessions[sid] = FakeSession(sid, username, topic_filter)
|
||||
return self._sessions[sid]
|
||||
def get(self, session_id):
|
||||
if session_id not in self._sessions:
|
||||
raise KeyError(session_id)
|
||||
return self._sessions[session_id]
|
||||
def stop(self, session_id):
|
||||
self._sessions.pop(session_id, None)
|
||||
def stop_all(self):
|
||||
self._sessions.clear()
|
||||
|
||||
fake_sessions = FakeSessions()
|
||||
monkeypatch.setattr(explorer_module, "sessions", fake_sessions)
|
||||
|
||||
def fake_messages_since(session_id, index):
|
||||
return []
|
||||
|
||||
def fake_list_retained(max_results=200, timeout=3.0):
|
||||
return [
|
||||
{
|
||||
"index": 0,
|
||||
"topic": "site/config/level",
|
||||
"qos": 1,
|
||||
"retain": True,
|
||||
"received_at": 12345.0,
|
||||
"payload_b64": "MTIz",
|
||||
"payload_text": "123",
|
||||
"properties": None,
|
||||
}
|
||||
]
|
||||
|
||||
def fake_publish(topic, payload, qos, retain, timeout=10.0):
|
||||
fake_publish.calls.append((topic, payload, qos, retain))
|
||||
|
||||
fake_publish.calls = []
|
||||
monkeypatch.setattr(explorer_module, "messages_since", fake_messages_since)
|
||||
monkeypatch.setattr(explorer_module, "list_retained", fake_list_retained)
|
||||
monkeypatch.setattr(explorer_module, "publish_message", fake_publish)
|
||||
# The router imports names from the module: patch there too.
|
||||
monkeypatch.setattr(topics_router.explorer, "sessions", fake_sessions)
|
||||
monkeypatch.setattr(topics_router.explorer, "messages_since", fake_messages_since)
|
||||
monkeypatch.setattr(topics_router.explorer, "list_retained", fake_list_retained)
|
||||
monkeypatch.setattr(topics_router.explorer, "publish_message", fake_publish)
|
||||
|
||||
with TestClient(main_module.app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from tests.conftest import login
|
||||
|
||||
|
||||
@@ -239,6 +241,113 @@ def test_broker_status_endpoint(admin_client, auditor_client):
|
||||
metrics.reset()
|
||||
|
||||
|
||||
# --- Topic explorer (spec sections 35, 36) ------------------------------------
|
||||
|
||||
|
||||
def test_topic_explorer_rbac(auditor_client):
|
||||
"""Subscribe/stream/retained need OPERATOR+; publish/clear need
|
||||
BROKER_ADMIN (spec section 35: access restricted through
|
||||
management RBAC)."""
|
||||
# Auditor may not even subscribe (read-only role is not OPERATOR).
|
||||
assert (
|
||||
auditor_client.post("/api/v1/topics/subscribe", json={"topic_filter": "#"}).status_code
|
||||
== 403
|
||||
)
|
||||
assert (
|
||||
auditor_client.get("/api/v1/topics/retained").status_code == 403
|
||||
)
|
||||
assert (
|
||||
auditor_client.post(
|
||||
"/api/v1/topics/publish", json={"topic": "t", "payload_b64": ""}
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
|
||||
def test_topic_explorer_flow(admin_client):
|
||||
"""Operator-level (admin is superuser) subscribe/stream/retained."""
|
||||
sub = admin_client.post("/api/v1/topics/subscribe", json={"topic_filter": "#"})
|
||||
assert sub.status_code == 200
|
||||
session_id = sub.json()["session_id"]
|
||||
|
||||
# The stream endpoint exists and is SSE. NOTE: consuming the stream
|
||||
# itself would hang (infinite generator by design) — verify the
|
||||
# session's liveness through the subscribe/404 contract instead:
|
||||
# an unknown session must 404, a live one was accepted above.
|
||||
missing = admin_client.get("/api/v1/topics/stream/does-not-exist")
|
||||
assert missing.status_code == 404
|
||||
|
||||
# Retained listing works.
|
||||
retained = admin_client.get("/api/v1/topics/retained")
|
||||
assert retained.status_code == 200
|
||||
assert any(m["topic"] == "site/config/level" for m in retained.json())
|
||||
|
||||
|
||||
def test_topic_explorer_publish_audited(admin_client, db_session):
|
||||
"""Publishes through the test console are audited (spec section 35),
|
||||
and wildcards are rejected."""
|
||||
import base64
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import AuditEvent
|
||||
|
||||
payload = {"cmd": "restart"}
|
||||
response = admin_client.post(
|
||||
"/api/v1/topics/publish",
|
||||
json={
|
||||
"topic": "site/test/command",
|
||||
"payload_b64": base64.b64encode(json.dumps(payload).encode()).decode(),
|
||||
"qos": 1,
|
||||
"retain": False,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
events = db_session.scalars(
|
||||
select(AuditEvent).where(AuditEvent.action == "TOPIC_PUBLISHED")
|
||||
).all()
|
||||
assert any(e.object_id == "site/test/command" for e in events)
|
||||
# The audit row records metadata, never the payload content.
|
||||
for e in events:
|
||||
if e.object_id == "site/test/command":
|
||||
assert e.new_value["qos"] == 1
|
||||
assert "payload" not in json.dumps(e.new_value).lower()
|
||||
|
||||
# Wildcards cannot be published to.
|
||||
wildcard = admin_client.post(
|
||||
"/api/v1/topics/publish",
|
||||
json={"topic": "site/+/command", "payload_b64": "", "qos": 0},
|
||||
)
|
||||
assert wildcard.status_code == 400
|
||||
|
||||
# A mis-placed # is rejected on subscribe too.
|
||||
bad = admin_client.post("/api/v1/topics/subscribe", json={"topic_filter": "#/deep"})
|
||||
assert bad.status_code == 400
|
||||
|
||||
|
||||
def test_topic_explorer_clear_retained(admin_client, db_session):
|
||||
"""Clearing a retained message publishes a zero-length retained
|
||||
payload and audits RETAINED_CLEARED (spec section 36)."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import AuditEvent
|
||||
|
||||
cleared = admin_client.post(
|
||||
"/api/v1/topics/retained/clear", json={"topic": "site/config/level"}
|
||||
)
|
||||
assert cleared.status_code == 204
|
||||
events = db_session.scalars(
|
||||
select(AuditEvent).where(AuditEvent.action == "RETAINED_CLEARED")
|
||||
).all()
|
||||
assert any(e.object_id == "site/config/level" for e in events)
|
||||
|
||||
|
||||
def test_topic_explorer_stream_404_for_unknown_session(admin_client):
|
||||
missing = admin_client.get("/api/v1/topics/stream/does-not-exist")
|
||||
assert missing.status_code == 404
|
||||
|
||||
|
||||
def test_role_acl_lifecycle(admin_client):
|
||||
admin_client.post("/api/v1/mqtt/roles", json={"rolename": "telemetry"})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user