Compare commits

...
10 Commits
Author SHA1 Message Date
Jonas Bengtsson 30194034a1 🚧 resolve conflict 2026-09-20 18:03:35 +02:00
Jonas Bengtsson b8e7c14368 🚧 Update topic explorer 2026-09-20 17:57:05 +02:00
Jonas Bengtsson 6385a27411 🚧 Add topic explorer 2026-09-20 09:23:04 +02:00
Jonas Bengtsson 1e491642f1 🚧 Add broker status page 2026-09-20 07:55:18 +02:00
Jonas Bengtsson 4322446fcf 🚧 Update menu looks and behaviour 2026-09-19 18:29:00 +02:00
Jonas Bengtsson dbcdc6cc2c 🐛 Fix container stop issue 2026-09-19 18:19:58 +02:00
Jonas Bengtsson 680ad7a624 🚧 WIP 2026-09-19 18:08:02 +02:00
Jonas Bengtsson a16a01b1e7 🚧 Update certificate management 2026-09-19 17:12:58 +02:00
Jonas Bengtsson 111d6cc390 🚧 Add CA and server cert management 2026-09-19 13:35:49 +02:00
Jonas Bengtsson 45de68f0b7 📝 Update README.md 2026-09-19 13:05:38 +02:00
43 changed files with 6153 additions and 3965 deletions
+136 -8
View File
@@ -53,8 +53,9 @@ Bootstrap sequence on first start:
the MQTT `$CONTROL/dynamic-security/v1` API. No files involved.
* **Generated mosquitto.conf (rare)** — listeners/global settings are
generated from the DB model and deployed through `mosquitto-control`,
which shares the broker's PID namespace (`pid: "service:mosquitto"`)
to signal reloads. No Docker socket is mounted anywhere (spec §29).
which OWNS the shared PID namespace (`pid: "service:mosquitto-control"`
on the broker); it signals the broker process and can restart it — and
survives broker restarts — without Docker sockets (spec §29).
* Mosquitto 2.1's HTTP API is **not** used as a management channel: it
has no authentication and (currently) exposes read-only plugin info.
@@ -85,11 +86,138 @@ backend/ FastAPI app, models, dynsec client, configgen
frontend/ React + Vite SPA served by nginx
```
## Release 1 scope (see specification.md §68.1)
## Status & TODO — per release plan (specification.md §68)
Included: management authentication & RBAC, dynsec clients/groups/roles
with the six ACL types, config generation from the PostgreSQL model,
validation, deployment with revision history.
Progress against the MVP release plan. Checked items are implemented and
covered by the test suite / running stack.
Not yet (Release 2/3): PKI/certificates, mTLS listeners, diff view,
rollback, ACL simulator, dashboard/$SYS, audit browsing UI, backup.
### Release 1 — Core broker administration (§68.1) — COMPLETE
- [x] Docker Compose stack (5 services, 2 networks, no Docker socket)
- [x] Mosquitto 2.1.2 (pinned) + PostgreSQL
- [x] FastAPI backend + React (Vite) frontend
- [x] Management authentication (HttpOnly session cookies, Argon2id,
`ADMIN_INITIAL_*` bootstrap, must-change-password flagging; the
backend exposes the change-password API — UI enforcement of the
forced change is still pending, see Release 3)
- [x] Management RBAC (super_admin / broker_admin / operator / auditor)
- [x] Dynamic Security integration (backend as the dynsec admin client)
- [x] MQTT clients: create / disable / enable / delete, password reset,
random passwords (returned exactly once), expected client-ID
binding (anti-credential-sharing, spec section 11)
- [x] Groups: create / delete, member + role assignment with priorities
- [x] Roles: create / delete, ACL editor (all six ACL types, `%u`/`%c`
substitution, priorities, draft-then-save)
- [x] Broker configuration editor, basic mode (structured forms +
read-only preview of the generated `mosquitto.conf`)
- [x] Listener management (port, bind address, protocol, anonymous,
max-connections; enable/disable, edit, delete)
- [x] Configuration validation (`mosquitto --test-config` via sidecar)
- [x] Configuration generation from the PostgreSQL model
Hardening done beyond the plan:
- [x] Management identity protection: the dynsec `admin` client is hidden
from Clients and refuses reset/disable/delete (resetting it kicks
every connection using it — including the backend's own control
channel)
- [x] Dynsec channel resilience: disconnects are detected immediately and
the MQTT client is rebuilt instead of failing silently
- [x] Login flow returns to the originally requested page; mid-session
expiry redirects to login with a return target
- [x] Navigation follows spec §57; unimplemented pages are greyed out
with the planned release as tooltip
### Release 2 — PKI, versioning, and controlled change (§68.2) — PARTIAL
- [x] Configuration revisions (immutable history; list + detail API and UI)
- [x] Configuration deployment with health check (dynsec roundtrip probe)
- [x] Broker reload (`mosquitto_signal config-reload` via sidecar)
- [x] mosquitto-control service (validate / atomic activate / signal /
broker health)
- [x] CA creation — internal CA (RSA 3072/4096, ECDSA P-256/P-384),
private keys encrypted at rest (AES-256-GCM)
- [x] Server certificate generation (CN, DNS/IP SANs, serverAuth EKU,
signed by a platform CA; PEM download)
- [x] Client certificates (CN = MQTT username, clientAuth EKU,
one-time bundle with cert + key + CA chain as file downloads;
identity binding to the client metadata)
- [x] mTLS listeners end-to-end: TLS section in the listener form
(certificate picker, min TLS version, require_certificate,
use_identity_as_username) — verified live with a client-cert
publish through an mTLS listener (spec section 24)
- [x] Certificate inventory (serial, fingerprint, SANs, status, expiry)
- [x] TLS config generation: listeners with a bound certificate emit
certfile/keyfile/cafile; cert files are published to a shared
certs volume (uid 1883, key mode 0640) before validation/deploy
- [x] Unpublished-port warning: listeners on ports not mapped by
compose.yaml warn in the preview/deploy (reachable only inside
the Docker network; keep PUBLISHED_PORTS in sync with compose)
- [x] Restart-required detection + restart from the UI: deploy compares
listener lines against the previous deployed revision (reload
cannot apply listener changes, §28) and sets a pending-restart
flag; a glowing sidebar indicator links to Administration →
System, where the broker restarts via the control sidecar
(SIGTERM to PID 1, Docker policy revives it; audited as
BROKER_RESTARTED, §29/§38)
- [ ] Configuration diff view (revision N-1 → N)
- [ ] Configuration rollback (creates a new revision, §62)
- [ ] CA import (existing external CA)
- [ ] Root-key export + removal workflow (§18)
- [ ] CSR signing (client-side key generation)
- [ ] Certificate revocation + CRL generation/publishing
- [ ] Client-cert disable: revoking access for a connected device
(needs CRL or dynsec disable + kick; the interim path — disabling
the client — already refuses reconnects even with a valid cert)
### Release 3 — Operations, safety, and visibility (§68.3) — PARTIAL
- [x] Audit logging (backend records every privileged action: LOGIN,
LOGOUT, CLIENT_*, GROUP_*, ROLE_*, ACL_CHANGED, LISTENER_*,
BROKER_CONFIG_DEPLOYED, BROKER_RESTARTED, USER_*, …)
- [x] Client metadata/tags (owner / purpose / tags in PostgreSQL; API
accepts them on create)
- [x] Administration → System page: broker status + restart (the
§28 restart gap is closed end-to-end from the UI)
- [x] Broker Status dashboard (§33/§55): $SYS telemetry collected via
the existing dynsec MQTT connection (extra $SYS/# subscription +
sys-observe role grant at startup), exposed at
GET /broker/status and shown on Broker → Status (version,
uptime, connected/stored clients, subscriptions, retained
messages, message/publish counters, 1-min load averages, bytes)
- [ ] Audit browsing UI (events are recorded; no viewer page yet)
- [ ] Forced password-change UI (backend flag + API exist; the frontend
does not enforce `must_change_password` yet)
- [ ] Client metadata editing in the UI
- [ ] ACL simulator (§15)
- [ ] Certificate expiry monitoring
- [ ] Backup and restore (logical, encrypted, §45)
- [ ] Configuration drift detection (§48)
- [ ] Last-known-connection tracking + connected-client view (§34)
### Post-MVP (§68.4 / §69 / §70) — PARTIAL
- [x] Topic explorer (spec §35/§36): live message stream over SSE
(one MQTT v5 client per browser session, SUBACK-gated subscribe),
publish with QoS/retain (audited as TOPIC_PUBLISHED — metadata
only, never the payload), retained-message listing and clearing
(audited as RETAINED_CLEARED); RBAC: viewing = operator+,
publishing/clearing = broker-admin+ — verified live end-to-end
- [x] Topic explorer: consolidated topic view (spec §35.1) — one row
per known topic (messages folded as they arrive); changed cells
(payload, QoS, retain, MQTT 5 properties) highlighted against the
previous message; changed tokens WITHIN a text payload highlighted
word-level (jsdiff diffWords — e.g. `{"cmd": "start"}` →
`{"cmd": "stop"}` marks only `stop`); fallbacks to plain text for
a topic's first message, binary payloads, and payloads >4 KB; row
glows briefly on new-message arrival; per-topic history browsable
(most recent first, per-topic ring buffer of 100 in the UI,
session buffer of 1000 server-side)
- [ ] Connection tester + client provisioning wizard
- [ ] Bridge configuration
- [ ] Retained-message administration (partially covered by the topic
explorer: listing + clearing; a dedicated §36 page can follow)
- [ ] Certificate expiry alerts
- [ ] Prometheus metrics + external notifications
- [ ] Change approval (four-eyes) workflow
- [ ] Topic namespace / JSON-schema registries, and later Phase-3 items
+2 -1
View File
@@ -22,7 +22,8 @@ RUN pip install --no-cache-dir \
"argon2-cffi>=23.1" \
"paho-mqtt>=2.1" \
"python-multipart>=0.0.17" \
"httpx>=0.28"
"httpx>=0.28" \
"cryptography>=43.0"
# Source is plain files; uvicorn imports app.main from WORKDIR (/app).
COPY . .
+18
View File
@@ -27,6 +27,10 @@ class Settings(BaseSettings):
session_secret: str = ""
session_ttl_seconds: int = 60 * 60 * 8
# Private-key encryption at rest (spec section 43) — AES-256-GCM
# master key. Never stored in PostgreSQL.
secret_encryption_key: str = ""
# Mosquitto management channel
mosquitto_host: str = "mosquitto"
mosquitto_mgmt_port: int = 1883
@@ -42,6 +46,20 @@ class Settings(BaseSettings):
default_cert_validity_days: int = 365
default_ca_validity_days: int = 3650
# Broker container ports published to the host by compose.yaml —
# listeners on other ports are only reachable INSIDE the Docker
# network (the platform generates any port; compose gates access).
# Comma-separated container ports; see compose.yaml `ports:`.
published_ports: str = "1883,8883"
@property
def published_port_set(self) -> set[int]:
return {
int(p.strip())
for p in self.published_ports.split(",")
if p.strip().isdigit()
}
@property
def is_production(self) -> bool:
return self.app_env == "production"
+55 -7
View File
@@ -12,10 +12,18 @@ from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import BrokerSettings, ListenerConfig
from app.config import get_settings
from app.models import BrokerSettings, Certificate, ListenerConfig
_BOOL = {True: "true", False: "false"}
# BrokerSettings keys that are INTERNAL platform state, not broker
# options — they must never be emitted into the generated mosquitto.conf
# (a leaked key makes `mosquitto --test-config` fail with "Unknown
# configuration variable").
RESTART_REQUIRED_KEY = "restart_required"
RESERVED_SETTINGS_KEYS = frozenset({RESTART_REQUIRED_KEY})
@dataclass
class GenerationResult:
@@ -30,11 +38,17 @@ def _global_section(db: Session, warnings: list[str]) -> list[str]:
value = setting.value.strip()
if not key or key.startswith("#"):
continue
if key in RESERVED_SETTINGS_KEYS:
# Internal platform state, not a broker option — emitting it
# would fail validation (see RESERVED_SETTINGS_KEYS above).
continue
lines.append(f"{key} {value}")
return lines
def _listener_block(listener: ListenerConfig, warnings: list[str]) -> list[str]:
def _listener_block(
listener: ListenerConfig, db: Session, warnings: list[str]
) -> list[str]:
lines: list[str] = []
if not listener.enabled:
return lines
@@ -51,11 +65,31 @@ def _listener_block(listener: ListenerConfig, warnings: list[str]) -> list[str]:
if listener.max_connections != -1:
lines.append(f"max_connections {listener.max_connections}")
if listener.tls_enabled:
# TLS blocks only become valid once PKI ships (Release 2); we
# generate them now so the model is forward-compatible, but warn.
# Real PKI material (spec section 30): when a certificate is bound,
# the deploy pipeline publishes cert/key/CA files under
# /mosquitto/certs/<id>.crt|key BEFORE the config referencing
# them is activated, so validation sees real files.
cert = db.get(Certificate, listener.cert_id) if listener.cert_id else None
ca = db.get(Certificate, cert.parent_id) if cert and cert.parent_id else None
if cert is not None:
cert_dir = "/mosquitto/certs"
lines.append(f"certfile {cert_dir}/{cert.id}.crt")
lines.append(f"keyfile {cert_dir}/{cert.id}.key")
if ca is not None:
lines.append(f"cafile {cert_dir}/{ca.id}.crt")
if listener.tls_min_version:
lines.append(f"tls_version {listener.tls_min_version}")
if listener.require_certificate:
lines.append("require_certificate true")
if listener.use_identity_as_username:
lines.append("use_identity_as_username true")
else:
# TLS requested but no certificate bound: forward-compatible
# block without certfile/keyfile, with an actionable warning.
warnings.append(
f"listener '{listener.name}': TLS requested but PKI is not "
"available in Release 1; certfile/keyfile not set"
f"listener '{listener.name}': TLS requested but no certificate "
"is bound; certfile/keyfile not set. Issue a server certificate "
"and bind it to this listener."
)
if listener.tls_min_version:
lines.append(f"tls_version {listener.tls_min_version}")
@@ -99,9 +133,23 @@ def generate_config(db: Session) -> GenerationResult:
lines.append("")
if enabled:
# Compose publishes only a fixed set of broker ports to the host
# (spec section 5). Listeners on other ports are reachable ONLY
# inside the Docker network — surface that before an operator
# wonders why no external device can connect.
published = get_settings().published_port_set
for listener in enabled:
if listener.port not in published:
warnings.append(
f"listener '{listener.name}' (port {listener.port}) is not "
"published to the host by compose.yaml — it is reachable "
"only inside the Docker network. Map the port in "
"compose.yaml (or use a published port like 8883) to "
"make it reachable for external devices."
)
lines.append("# --- Listeners ---")
for listener in enabled:
block = _listener_block(listener, warnings)
block = _listener_block(listener, db, warnings)
if block:
lines.append(f"# listener: {listener.name}")
lines.extend(block)
+59
View File
@@ -0,0 +1,59 @@
"""Key encryption at rest (spec sections 43-44).
Private keys that the platform must keep (CA and server keys) are stored
in PostgreSQL ONLY in encrypted form, using AES-256-GCM authenticated
encryption. The master key comes from the SECRET_ENCRYPTION_KEY
environment variable (Docker secret in production) and never touches
the database.
Wire format: "v1:<nonce-hex>:<ciphertext-hex>" — the version prefix
keeps the format evolvable without breaking existing rows.
"""
from __future__ import annotations
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from app.config import get_settings
_FORMAT = "v1"
def _master_key() -> bytes:
"""Derive the 32-byte AES key from the configured secret.
The secret is hashed to exactly 32 bytes (AES-256) regardless of the
length the operator chose for SECRET_ENCRYPTION_KEY.
"""
import hashlib
secret = get_settings().secret_encryption_key.encode("utf-8")
return hashlib.sha256(secret).digest()
def encrypt_secret(plaintext: str) -> str:
"""Encrypt a PEM string into the storage format."""
nonce = os.urandom(12) # 96-bit nonce, GCM standard
aesgcm = AESGCM(_master_key())
ciphertext = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
return f"{_FORMAT}:{nonce.hex()}:{ciphertext.hex()}"
def decrypt_secret(blob: str) -> str:
"""Decrypt a stored secret back to plaintext PEM."""
try:
version, nonce_hex, ciphertext_hex = blob.split(":", 2)
except ValueError as exc:
raise ValueError("corrupt secret blob: bad format") from exc
if version != _FORMAT:
raise ValueError(f"unsupported secret blob version: {version}")
aesgcm = AESGCM(_master_key())
try:
plaintext = aesgcm.decrypt(
bytes.fromhex(nonce_hex), bytes.fromhex(ciphertext_hex), None
)
except Exception as exc:
raise ValueError("secret decryption failed (wrong master key?)") from exc
return plaintext.decode("utf-8")
+37
View File
@@ -61,6 +61,9 @@ class DynsecClient:
self._lock = threading.Lock()
self._responses: dict[str, queue.Queue[dict[str, Any]]] = {}
self._connected = threading.Event()
# Optional tap for $SYS telemetry (app.metrics registers itself
# at startup). Receives (topic, payload) for every $SYS message.
self._event_sink = None
# -- connection lifecycle ------------------------------------------------
def connect(self) -> None:
@@ -118,12 +121,25 @@ class DynsecClient:
return
self._teardown_locked()
def set_event_sink(self, sink) -> None:
"""Register a callable receiving every $SYS message (topic, payload).
Used by app.metrics for the broker dashboard (spec sections 33,
55). The sink must never raise: telemetry must not be able to
disturb the management channel.
"""
self._event_sink = sink
# -- paho callbacks -------------------------------------------------------
def _on_connect(self, client, userdata, flags, reason_code, properties) -> None:
if reason_code == 0:
# FIXED response topic — all commands reply here; requests are
# matched by the correlationData echo inside the payload.
client.subscribe(RESPONSE_TOPIC, qos=1)
# $SYS telemetry for the dashboard (spec sections 33, 55).
# qos 0: telemetry is periodically republished, losing one
# is harmless.
client.subscribe("$SYS/#", qos=0)
self._connected.set()
else:
logger.error("dynsec client connect failed: %s", reason_code)
@@ -149,6 +165,14 @@ class DynsecClient:
def _on_message(self, client, userdata, msg) -> None:
if msg.topic != RESPONSE_TOPIC:
# Not a dynsec reply: forward $SYS telemetry to the sink, if
# one is registered. Swallow sink errors — telemetry must
# never disturb the management channel.
if msg.topic.startswith("$SYS/") and self._event_sink is not None:
try:
self._event_sink(msg.topic, msg.payload)
except Exception: # noqa: BLE001
logger.warning("$SYS event sink raised; message dropped")
return
try:
payload = json.loads(msg.payload.decode("utf-8"))
@@ -233,6 +257,19 @@ class DynsecClient:
def set_client_password(self, username: str, password: str) -> None:
self._command("setClientPassword", username=username, password=password)
def set_client_id(self, username: str, clientid: str | None) -> None:
"""Bind (or clear) the expected client ID for a username.
With a bound client ID, connections must present exactly that
client id (username + password + client id must all match) — this
is what stops one device's credentials being reused by another
program (spec section 11: "bind an expected client ID").
"""
if clientid is None or clientid == "":
self._command("setClientId", username=username, clientid="")
else:
self._command("setClientId", username=username, clientid=clientid)
def get_client(self, username: str) -> dict[str, Any]:
return self._command("getClient", username=username).get("client", {})
+318
View File
@@ -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()
+58 -1
View File
@@ -66,9 +66,63 @@ async def lifespan(app: FastAPI):
try:
dynsec_client.connect()
logger.info("dynamic security channel connected")
from app import metrics
dynsec_client.set_event_sink(metrics.handle_sys_message)
# Best effort: ensure the management identity can read $SYS —
# the 2.1 auto-generated admin role set does not guarantee it
# (see the compose healthcheck note). sys-observe is one of the
# plugin's default roles (read-only $SYS/#); addClientRole is
# idempotent broker-side.
try:
dynsec_client.add_client_role(
settings.mosquitto_dynsec_username, "sys-observe", priority=1
)
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()
@@ -125,9 +179,12 @@ def health_ready():
# Routers
from app.routers import auth, broker, mqtt, 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(topics.router)
+123
View File
@@ -0,0 +1,123 @@
"""$SYS broker telemetry (spec sections 33, 55).
The dynsec channel's MQTT connection subscribes to $SYS/# and forwards
every message here (see DynsecClient.set_event_sink). Values are parsed
ONCE at ingest; snapshot() returns the latest known state. The broker
publishes $SYS on the configured sys_interval (10 s in the bootstrap
config), so a fresh backend needs a few seconds before the first
snapshot is populated — every field is therefore Optional and the
`available` flag says whether ANY $SYS message has been seen.
"""
from __future__ import annotations
import threading
from datetime import UTC, datetime
from typing import Any, Callable
SYS_PREFIX = "$SYS/broker/"
_lock = threading.Lock()
_values: dict[str, Any] = {}
_last_update: datetime | None = None
def _as_int(payload: str) -> object:
try:
return int(payload)
except ValueError:
return payload
def _as_float(payload: str) -> object:
try:
return float(payload)
except ValueError:
return payload
# $SYS topic -> (snapshot field, parser). Field names mirror
# BrokerStatusOut (schemas.py). NOTE: "retained messages/count"
# genuinely contains a space in the Mosquitto topic name.
_TOPIC_FIELDS: dict[str, tuple[str, Callable[[str], object]]] = {
f"{SYS_PREFIX}version": ("version", str),
f"{SYS_PREFIX}uptime": ("uptime", str),
f"{SYS_PREFIX}timestamp": ("build_timestamp", str),
# clients/disconnected = persistent clients currently offline,
# i.e. the "stored sessions" of the dashboard (spec section 33).
f"{SYS_PREFIX}clients/connected": ("clients_connected", _as_int),
f"{SYS_PREFIX}clients/disconnected": ("clients_disconnected", _as_int),
f"{SYS_PREFIX}clients/maximum": ("clients_maximum", _as_int),
f"{SYS_PREFIX}clients/total": ("clients_total", _as_int),
f"{SYS_PREFIX}clients/expired": ("clients_expired", _as_int),
f"{SYS_PREFIX}messages/received": ("messages_received", _as_int),
f"{SYS_PREFIX}messages/sent": ("messages_sent", _as_int),
f"{SYS_PREFIX}messages/stored": ("messages_stored", _as_int),
f"{SYS_PREFIX}messages/dropped": ("messages_dropped", _as_int),
f"{SYS_PREFIX}publish/messages/received": ("publish_received", _as_int),
f"{SYS_PREFIX}publish/messages/sent": ("publish_sent", _as_int),
f"{SYS_PREFIX}publish/messages/dropped": ("publish_dropped", _as_int),
f"{SYS_PREFIX}bytes/received": ("bytes_received", _as_int),
f"{SYS_PREFIX}bytes/sent": ("bytes_sent", _as_int),
f"{SYS_PREFIX}subscriptions/count": ("subscriptions_count", _as_int),
f"{SYS_PREFIX}retained messages/count": ("retained_count", _as_int),
# load/* are per-second averages over the last minute ("x.xx").
f"{SYS_PREFIX}load/messages/received/1min": (
"load_messages_received_1min",
_as_float,
),
f"{SYS_PREFIX}load/messages/sent/1min": (
"load_messages_sent_1min",
_as_float,
),
f"{SYS_PREFIX}load/publish/received/1min": (
"load_publish_received_1min",
_as_float,
),
f"{SYS_PREFIX}load/publish/sent/1min": (
"load_publish_sent_1min",
_as_float,
),
f"{SYS_PREFIX}load/bytes/received/1min": (
"load_bytes_received_1min",
_as_float,
),
f"{SYS_PREFIX}load/bytes/sent/1min": (
"load_bytes_sent_1min",
_as_float,
),
}
_FIELDS = [field for field, _ in _TOPIC_FIELDS.values()]
def handle_sys_message(topic: str, payload: bytes) -> None:
"""Ingest one $SYS message (wired as the dynsec client's event sink)."""
entry = _TOPIC_FIELDS.get(topic)
if entry is None:
return # unknown $SYS topic — deliberately ignored
field, parser = entry
text = payload.decode("utf-8", errors="replace").strip()
global _last_update
with _lock:
_values[field] = parser(text)
_last_update = datetime.now(UTC)
def reset() -> None:
"""Clear all telemetry (used by tests)."""
global _last_update
with _lock:
_values.clear()
_last_update = None
def snapshot() -> dict[str, Any]:
"""Latest known $SYS state; every field None until first publish."""
with _lock:
values = dict(_values)
last_update = _last_update
snap: dict[str, Any] = {field: values.get(field) for field in _FIELDS}
snap["available"] = last_update is not None
snap["last_update"] = last_update
return snap
+26 -1
View File
@@ -150,15 +150,40 @@ class ConfigRevision(Base):
class Certificate(Base):
"""A CA or leaf certificate in the platform PKI store (spec 16-21).
Hierarchy: a CA row has is_ca=True and parent_id=None (internal
self-signed root/intermediate, Release 2 MVP keeps exactly one level).
A leaf row points at the CA that signed it. Private keys are stored
ENCRYPTED (AES-256-GCM, see app/crypto.py) — never in plaintext.
"""
__tablename__ = "certificates"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
common_name: Mapped[str] = mapped_column(String(200))
certificate_pem: Mapped[str] = mapped_column(Text)
# Encrypted at rest (AES-256-GCM) once keys are managed (Release 2).
# Encrypted at rest (AES-256-GCM via app/crypto.py), spec section 43.
private_key_pem: Mapped[str | None] = mapped_column(Text)
expiry_date: Mapped[datetime] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
# --- Release 2 additions ---
is_ca: Mapped[bool] = mapped_column(Boolean, default=False)
# CA that signed this certificate (None for self-signed CAs).
parent_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("certificates.id", ondelete="SET NULL")
)
# server|client — client certificates are bound to MQTT identities.
cert_type: Mapped[str] = mapped_column(String(20), default="server")
# MQTT client identity this certificate authenticates (client certs).
username: Mapped[str | None] = mapped_column(String(100), index=True)
key_algorithm: Mapped[str] = mapped_column(String(20), default="ecdsa-p256")
serial_number: Mapped[str] = mapped_column(String(64), unique=True, index=True)
dns_sans: Mapped[list] = mapped_column(JSON, default=list)
ip_sans: Mapped[list] = mapped_column(JSON, default=list)
fingerprint: Mapped[str] = mapped_column(String(200), index=True)
# issued|expired|revoked — derived/stored at issue time and on expiry checks
status: Mapped[str] = mapped_column(String(20), default="issued")
# --------------------------------------------------------------------------
+285
View File
@@ -0,0 +1,285 @@
"""PKI operations: internal CA + server certificate issuance (spec 16-19).
All private-key and signing operations happen here, in the backend —
the frontend only triggers them (spec section 16). Keys are returned in
PEM form to the storage layer, which encrypts them at rest (spec 43).
Release 2 MVP scope:
* create an internal CA (RSA 3072/4096, ECDSA P-256/P-384)
* issue a server certificate from that CA (CN + DNS/IP SANs, serverAuth)
CSR signing, client certificates, revocation/CRL arrive later.
"""
from __future__ import annotations
import datetime
import uuid
from dataclasses import dataclass
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, ed448, ed25519, rsa
from cryptography.x509.oid import NameOID
# Key algorithms supported for both CAs and leaf certificates (spec 17).
KEY_ALGORITHMS = ("rsa3072", "rsa4096", "ecdsa-p256", "ecdsa-p384")
class PkiError(ValueError):
"""Invalid PKI request (bad algorithm, empty subject, ...)."""
def _generate_key(algorithm: str):
"""Generate a private key for the given algorithm identifier."""
if algorithm == "rsa3072":
return rsa.generate_private_key(public_exponent=65537, key_size=3072)
if algorithm == "rsa4096":
return rsa.generate_private_key(public_exponent=65537, key_size=4096)
if algorithm == "ecdsa-p256":
return ec.generate_private_key(ec.SECP256R1())
if algorithm == "ecdsa-p384":
return ec.generate_private_key(ec.SECP384R1())
raise PkiError(
f"unsupported key algorithm {algorithm!r}; expected one of {KEY_ALGORITHMS}"
)
def _name(common_name: str, organization: str | None, unit: str | None,
country: str | None) -> x509.Name:
attrs = [x509.NameAttribute(NameOID.COMMON_NAME, common_name)]
if organization:
attrs.append(x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization))
if unit:
attrs.append(x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, unit))
if country:
attrs.append(x509.NameAttribute(NameOID.COUNTRY_NAME, country))
return x509.Name(attrs)
def _pem_key(key) -> str:
return key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")
def _pem_cert(cert: x509.Certificate) -> str:
return cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
def _serial() -> int:
# Positive, 159-bit (20-byte) random serial per RFC 5280 conventions.
return int.from_bytes(uuid.uuid4().bytes[:20], "big") >> 1
def _not_after(now: datetime.datetime, validity_days: int) -> datetime.datetime:
if validity_days <= 0:
raise PkiError("validity must be a positive number of days")
return now + datetime.timedelta(days=validity_days)
@dataclass
class IssuedPair:
"""A certificate + its private key, both as PEM text."""
certificate_pem: str
private_key_pem: str
serial_number: int
not_valid_after: datetime.datetime
def create_ca(
*,
common_name: str,
validity_days: int,
key_algorithm: str,
organization: str | None = None,
organizational_unit: str | None = None,
country: str | None = None,
) -> IssuedPair:
"""Create a self-signed CA certificate (spec section 17)."""
if not common_name.strip():
raise PkiError("common name must not be empty")
now = datetime.datetime.now(datetime.UTC)
key = _generate_key(key_algorithm)
subject = issuer = _name(common_name, organization, organizational_unit, country)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(key.public_key())
.serial_number(_serial())
.not_valid_before(now - datetime.timedelta(minutes=5)) # clock skew
.not_valid_after(_not_after(now, validity_days))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.add_extension(x509.KeyUsage(
digital_signature=True,
content_commitment=False,
key_encipherment=False,
data_encipherment=False,
key_agreement=False,
key_cert_sign=True,
crl_sign=True,
encipher_only=False,
decipher_only=False,
), critical=True)
.sign(key, hashes.SHA256())
)
return IssuedPair(
certificate_pem=_pem_cert(cert),
private_key_pem=_pem_key(key),
serial_number=cert.serial_number,
not_valid_after=cert.not_valid_after_utc,
)
def issue_server_certificate(
*,
ca_certificate_pem: str,
ca_private_key_pem: str,
common_name: str,
validity_days: int,
dns_sans: list[str] | None = None,
ip_sans: list[str] | None = None,
organization: str | None = None,
organizational_unit: str | None = None,
country: str | None = None,
) -> IssuedPair:
"""Issue a server certificate signed by the given CA (spec section 19).
The certificate carries the serverAuth extended key usage and the
CN plus any DNS/IP SANs — exactly what a Mosquitto TLS listener needs.
"""
if not common_name.strip():
raise PkiError("common name must not be empty")
dns_sans = [s.strip() for s in (dns_sans or []) if s.strip()]
ip_sans = [s.strip() for s in (ip_sans or []) if s.strip()]
ca_cert = x509.load_pem_x509_certificate(ca_certificate_pem.encode("utf-8"))
ca_key = serialization.load_pem_private_key(
ca_private_key_pem.encode("utf-8"), password=None
)
# Type narrowing for the checker: certificate signing requires an
# RSA/EC/Ed25519/Ed448 key — DH/DSA keys load but cannot sign certs.
if not isinstance(
ca_key,
(
rsa.RSAPrivateKey,
ec.EllipticCurvePrivateKey,
ed25519.Ed25519PrivateKey,
ed448.Ed448PrivateKey,
),
):
raise PkiError("the CA private key type cannot sign certificates")
# The leaf key is always ECDSA P-256 for Release 2: fast to generate,
# modern, and universally supported by MQTT clients.
key = _generate_key("ecdsa-p256")
now = datetime.datetime.now(datetime.UTC)
subject = _name(common_name, organization, organizational_unit, country)
san_entries: list[x509.GeneralName] = [x509.DNSName(common_name)]
for name in dns_sans:
san_entries.append(x509.DNSName(name))
for ip in ip_sans:
try:
import ipaddress
san_entries.append(x509.IPAddress(ipaddress.ip_address(ip)))
except ValueError as exc:
raise PkiError(f"invalid IP SAN {ip!r}") from exc
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(ca_cert.subject)
.public_key(key.public_key())
.serial_number(_serial())
.not_valid_before(now - datetime.timedelta(minutes=5))
.not_valid_after(_not_after(now, validity_days))
.add_extension(
x509.SubjectAlternativeName(san_entries), critical=False
)
.add_extension(
x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]),
critical=False,
)
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.sign(ca_key, hashes.SHA256())
)
return IssuedPair(
certificate_pem=_pem_cert(cert),
private_key_pem=_pem_key(key),
serial_number=cert.serial_number,
not_valid_after=cert.not_valid_after_utc,
)
def fingerprint(certificate_pem: str) -> str:
"""SHA-256 fingerprint of a certificate, colon-separated hex."""
cert = x509.load_pem_x509_certificate(certificate_pem.encode("utf-8"))
digest = cert.fingerprint(hashes.SHA256())
return ":".join(f"{b:02x}" for b in digest)
def issue_client_certificate(
*,
ca_certificate_pem: str,
ca_private_key_pem: str,
common_name: str,
validity_days: int,
) -> IssuedPair:
"""Issue a client certificate signed by the given CA (spec section 20).
The CN MUST equal the MQTT username of the dynsec client this
certificate authenticates: with use_identity_as_username the broker
maps CN -> MQTT identity (spec section 24), so a mismatch means the
certificate authenticates as a username that has no dynsec client.
clientAuth EKU, no SANs — device identity only.
"""
if not common_name.strip():
raise PkiError("common name must not be empty")
ca_cert = x509.load_pem_x509_certificate(ca_certificate_pem.encode("utf-8"))
ca_key = serialization.load_pem_private_key(
ca_private_key_pem.encode("utf-8"), password=None
)
# Type narrowing for the checker: certificate signing requires an
# RSA/EC/Ed25519/Ed448 key — DH/DSA keys load but cannot sign certs.
if not isinstance(
ca_key,
(
rsa.RSAPrivateKey,
ec.EllipticCurvePrivateKey,
ed25519.Ed25519PrivateKey,
ed448.Ed448PrivateKey,
),
):
raise PkiError("the CA private key type cannot sign certificates")
key = _generate_key("ecdsa-p256")
now = datetime.datetime.now(datetime.UTC)
subject = _name(common_name, None, None, None)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(ca_cert.subject)
.public_key(key.public_key())
.serial_number(_serial())
.not_valid_before(now - datetime.timedelta(minutes=5))
.not_valid_after(_not_after(now, validity_days))
.add_extension(
x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH]),
critical=False,
)
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.sign(ca_key, hashes.SHA256())
)
return IssuedPair(
certificate_pem=_pem_cert(cert),
private_key_pem=_pem_key(key),
serial_number=cert.serial_number,
not_valid_after=cert.not_valid_after_utc,
)
+144 -2
View File
@@ -8,6 +8,7 @@ an immutable revision (spec section 26).
from __future__ import annotations
import os
import uuid
from datetime import UTC, datetime
from typing import Annotated
@@ -19,11 +20,12 @@ from sqlalchemy.orm import Session
from app import audit
from app.config import get_settings
from app.configgen import generate_config
from app.configgen import RESTART_REQUIRED_KEY, generate_config
from app.db import get_db
from app.deps import CurrentUser, require_roles
from app.models import (
BrokerSettings,
Certificate,
ConfigRevision,
ListenerConfig,
ManagementRole,
@@ -31,6 +33,7 @@ from app.models import (
from app.schemas import (
BrokerSettingsIn,
BrokerSettingsOut,
BrokerStatusOut,
ConfigPreviewOut,
DeployRequest,
ListenerIn,
@@ -63,6 +66,51 @@ def _next_revision_number(db: Session) -> int:
return (highest or 0) + 1
# BrokerSettings key holding the pending-restart flag (value "true"/"false").
# Defined in configgen (RESERVED_SETTINGS_KEYS keeps it out of the
# generated config); the bootstrap config the broker starts with must
# match the generator's explicit listener form for the comparison.
_BOOTSTRAP_LISTENERS = {"listener 1883 0.0.0.0"}
def _listener_signature(config_text: str) -> set[str]:
"""The listener DEFINITION lines of a generated config.
Mosquitto's reload cannot add, remove or redefine listeners (spec
section 28): textual changes to `listener <port> <bind>` lines are
treated as different listeners and only take effect on restart.
Everything else (cert paths, TLS versions, max_connections, ...) is
reload-safe.
"""
import re
return set(re.findall(r"^listener \d+.*$", config_text, re.MULTILINE))
def _set_restart_required(db: Session, required: bool) -> None:
setting = db.scalar(
select(BrokerSettings).where(BrokerSettings.key == RESTART_REQUIRED_KEY)
)
if setting is None:
db.add(
BrokerSettings(
key=RESTART_REQUIRED_KEY,
value="true" if required else "false",
value_type="bool",
)
)
else:
setting.value = "true" if required else "false"
def restart_required(db: Session) -> bool:
"""Whether the deployed config has listener changes pending a restart."""
setting = db.scalar(
select(BrokerSettings.value).where(BrokerSettings.key == RESTART_REQUIRED_KEY)
)
return setting == "true"
def _call_control(
path: str, *, method: str = "POST", json_body: dict | None = None
) -> dict:
@@ -207,6 +255,81 @@ def upsert_setting(
# --- Generation, validation, deployment ----------------------------------------
# The sidecar and broker share the mosquitto_certs volume; the backend
# publishes the DECRYPTED material there because Mosquitto needs
# plaintext files (uid 1883, mode 600). The DB keeps the encrypted master.
CERTS_DIR = os.environ.get("MOSQUITTO_CERTS_DIR", "/mosquitto/certs")
def _publish_certificates(db: Session) -> None:
"""Write cert/key/CA files for every TLS listener's bound certificate.
Must run BEFORE the config referencing them is validated/activated,
otherwise `mosquitto --test-config` fails on missing files.
Files are owned by uid/gid 1883 (the broker user in the official
image) with key mode 0640 — the broker must be able to read the key,
but no other container user should.
"""
listeners = db.scalars(
select(ListenerConfig).where(ListenerConfig.tls_enabled.is_(True))
).all()
seen: set[uuid.UUID] = set()
for listener in listeners:
if listener.cert_id is None or listener.cert_id in seen:
continue
seen.add(listener.cert_id)
cert = db.get(Certificate, listener.cert_id)
if cert is None or cert.private_key_pem is None:
continue # nothing to publish (warned about in configgen)
os.makedirs(CERTS_DIR, exist_ok=True)
from app.crypto import decrypt_secret
def _write(path: str, content: str, mode: int) -> None:
with open(path, "w", encoding="utf-8") as f:
f.write(content)
os.chmod(path, mode)
try:
# The broker (and this sidecar) run as uid 1883 in the
# official image; chown only works when we are root in
# the container — skip silently in local dev on Windows.
os.chown(path, 1883, 1883)
except (PermissionError, OSError):
pass
_write(
os.path.join(CERTS_DIR, f"{cert.id}.crt"),
cert.certificate_pem,
0o644,
)
_write(
os.path.join(CERTS_DIR, f"{cert.id}.key"),
decrypt_secret(cert.private_key_pem),
0o640,
)
if cert.parent_id is not None:
ca = db.get(Certificate, cert.parent_id)
if ca is not None:
_write(
os.path.join(CERTS_DIR, f"{ca.id}.crt"),
ca.certificate_pem,
0o644,
)
@router.get("/status", response_model=BrokerStatusOut)
def broker_status(_user: AnyReader):
"""Live $SYS telemetry for the dashboard (spec sections 33, 55).
Read-only: every management role may view (spec section 9). Fields
are None until the first $SYS publish arrives — the broker publishes
on sys_interval (10 s), so a fresh backend needs a few seconds.
"""
from app import metrics
return BrokerStatusOut(**metrics.snapshot())
@router.get("/config/preview", response_model=ConfigPreviewOut)
def preview_config(
db: Annotated[Session, Depends(get_db)], _user: AnyReader
@@ -235,12 +358,30 @@ def deploy_config(
db: Annotated[Session, Depends(get_db)],
user: BrokerAdmin,
):
"""Generate -> validate -> activate + reload -> record revision."""
"""Generate -> publish certs -> validate -> activate + reload -> revision."""
_publish_certificates(db)
result = generate_config(db)
response = _call_control("/deploy", json_body={"content": result.config_text})
if response.get("status") != "deployed":
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, response)
# Spec section 28: a reload cannot apply listener definition changes.
# Compare the listener lines against the previously DEPLOYED revision
# (or the bootstrap config when none exists yet) and flag a pending
# restart so the UI can surface it (glowing indicator -> System page).
previous = db.scalar(
select(ConfigRevision)
.where(ConfigRevision.deployed.is_(True))
.order_by(ConfigRevision.revision_number.desc())
)
previous_listeners = (
_listener_signature(previous.config_text)
if previous is not None
else set(_BOOTSTRAP_LISTENERS)
)
needs_restart = _listener_signature(result.config_text) != previous_listeners
_set_restart_required(db, needs_restart)
revision = ConfigRevision(
revision_number=_next_revision_number(db),
created_by=user.username,
@@ -264,6 +405,7 @@ def deploy_config(
"status": "deployed",
"revision": revision.revision_number,
"broker_healthy": response.get("broker_healthy"),
"restart_required": needs_restart,
}
+33
View File
@@ -19,6 +19,7 @@ from app.deps import CurrentUser, require_roles
from app.dynsec import DynsecError, dynsec_client
from app.models import ManagementRole, MqttClientMetadata
from app.schemas import (
ClientIdUpdate,
ClientRoleAssign,
GroupIn,
GroupMemberAssign,
@@ -219,6 +220,38 @@ def reset_password(
return MqttPasswordReset(username=username, password=password)
@router.put("/clients/{username}/clientid", status_code=status.HTTP_204_NO_CONTENT)
def set_client_id(
username: str,
body: ClientIdUpdate,
request: Request,
db: DbDep,
_user: BrokerAdmin,
):
"""Bind (or clear) the expected client ID (spec section 11).
With a bound client id, connections must present exactly that id —
preventing one device's credentials from being reused by another
program. The broker kicks active connections when the id changes.
"""
_guard_mgmt_identity(username)
clientid = body.clientid.strip() or None
try:
dynsec_client.set_client_id(username, clientid)
except DynsecError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
audit.record(
db,
"CLIENT_ID_BOUND" if clientid else "CLIENT_ID_CLEARED",
request=request,
object_type="mqtt_client",
object_id=username,
old_value=None,
new_value={"clientid": clientid or ""},
)
return None
@router.post("/clients/{username}/roles", status_code=status.HTTP_204_NO_CONTENT)
def assign_client_role(
username: str,
+355
View File
@@ -0,0 +1,355 @@
"""PKI management: internal CA + server certificates (spec sections 16-19).
All endpoints require the PKI_ADMIN role (or super_admin). Private keys
never appear in any response — the PEM download endpoints expose only
the public certificate material.
"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import PlainTextResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import audit
from app.crypto import decrypt_secret, encrypt_secret
from app.db import get_db
from app.deps import require_roles
from app.dynsec import DynsecError, dynsec_client
from app.models import Certificate, ManagementRole, MqttClientMetadata
from app.pki import (
PkiError,
create_ca as pki_create_ca,
fingerprint as pki_fingerprint,
issue_client_certificate,
issue_server_certificate,
)
from app.schemas import (
CaCreate,
CertificateBundle,
CertificateOut,
ClientCertCreate,
ServerCertCreate,
)
router = APIRouter(prefix="/api/v1/pki", tags=["pki"])
PkiAdmin = Annotated[
object, Depends(require_roles(ManagementRole.PKI_ADMIN))
]
DbDep = Annotated[Session, Depends(get_db)]
def _get_certificate(db: Session, cert_id: uuid.UUID) -> Certificate:
cert = db.get(Certificate, cert_id)
if cert is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "certificate not found")
return cert
@router.get("/ca", response_model=list[CertificateOut])
def list_cas(db: DbDep, _user: PkiAdmin):
"""All certificate authorities in the store."""
return db.scalars(
select(Certificate).where(Certificate.is_ca.is_(True))
).all()
@router.post("/ca", response_model=CertificateOut, status_code=201)
def create_ca(body: CaCreate, request: Request, db: DbDep, _user: PkiAdmin):
"""Create a self-signed internal CA (spec section 17)."""
try:
issued = pki_create_ca(
common_name=body.common_name,
validity_days=body.validity_days,
key_algorithm=body.key_algorithm,
organization=body.organization,
organizational_unit=body.organizational_unit,
country=body.country,
)
except PkiError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
ca = Certificate(
common_name=body.common_name,
certificate_pem=issued.certificate_pem,
private_key_pem=encrypt_secret(issued.private_key_pem),
expiry_date=issued.not_valid_after,
is_ca=True,
cert_type="ca",
key_algorithm=body.key_algorithm,
serial_number=str(issued.serial_number),
dns_sans=[],
ip_sans=[],
fingerprint=pki_fingerprint(issued.certificate_pem),
status="issued",
)
db.add(ca)
db.flush()
audit.record(
db,
"CA_CREATED",
request=request,
object_type="certificate_authority",
object_id=str(ca.id),
new_value={"common_name": body.common_name},
)
return ca
@router.get("/certificates", response_model=list[CertificateOut])
def list_certificates(db: DbDep, _user: PkiAdmin):
"""Full certificate inventory (spec section 21), newest first."""
return db.scalars(
select(Certificate).order_by(Certificate.created_at.desc())
).all()
@router.post("/certificates", response_model=CertificateOut, status_code=201)
def issue_certificate(
body: ServerCertCreate, request: Request, db: DbDep, _user: PkiAdmin
):
"""Issue a server certificate signed by the given CA (spec section 19)."""
from app.crypto import decrypt_secret
ca = _get_certificate(db, body.ca_id)
if not ca.is_ca:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "the referenced certificate is not a CA"
)
if ca.private_key_pem is None:
raise HTTPException(
status.HTTP_409_CONFLICT,
"the CA private key is not held by the platform (offline CA); "
"it cannot issue certificates",
)
try:
ca_key_pem = decrypt_secret(ca.private_key_pem)
except ValueError as exc:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
f"could not decrypt the CA key: {exc}",
) from exc
try:
issued = issue_server_certificate(
ca_certificate_pem=ca.certificate_pem,
ca_private_key_pem=ca_key_pem,
common_name=body.common_name,
validity_days=body.validity_days,
dns_sans=body.dns_sans,
ip_sans=body.ip_sans,
organization=body.organization,
organizational_unit=body.organizational_unit,
country=body.country,
)
except PkiError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
cert = Certificate(
common_name=body.common_name,
certificate_pem=issued.certificate_pem,
private_key_pem=encrypt_secret(issued.private_key_pem),
expiry_date=issued.not_valid_after,
is_ca=False,
parent_id=ca.id,
cert_type="server",
key_algorithm="ecdsa-p256",
serial_number=str(issued.serial_number),
dns_sans=body.dns_sans,
ip_sans=body.ip_sans,
fingerprint=pki_fingerprint(issued.certificate_pem),
status="issued",
)
db.add(cert)
db.flush()
audit.record(
db,
"CERTIFICATE_ISSUED",
request=request,
object_type="certificate",
object_id=str(cert.id),
new_value={
"common_name": body.common_name,
"ca": ca.common_name,
"cert_type": "server",
"dns_sans": body.dns_sans,
},
)
return cert
@router.post(
"/certificates/client", response_model=CertificateBundle, status_code=201
)
def issue_client_cert(
body: ClientCertCreate, request: Request, db: DbDep, _user: PkiAdmin
):
"""Issue a client certificate for an MQTT client (spec sections 20, 24).
Validates that the dynsec client exists (the certificate CN becomes
the MQTT identity via use_identity_as_username), issues the cert,
binds it to the client metadata, and returns the one-time bundle —
the private key is NEVER retrievable again afterwards.
"""
try:
dynsec_client.get_client(body.username)
except DynsecError as exc:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
f"MQTT client {body.username!r} does not exist at the broker; "
"create it first — the certificate CN must match the username",
) from exc
ca = _get_certificate(db, body.ca_id)
if not ca.is_ca:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "the referenced certificate is not a CA"
)
if ca.private_key_pem is None:
raise HTTPException(
status.HTTP_409_CONFLICT,
"the CA private key is not held by the platform (offline CA); "
"it cannot issue certificates",
)
try:
ca_key_pem = decrypt_secret(ca.private_key_pem)
except ValueError as exc:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
f"could not decrypt the CA key: {exc}",
) from exc
try:
issued = issue_client_certificate(
ca_certificate_pem=ca.certificate_pem,
ca_private_key_pem=ca_key_pem,
common_name=body.username,
validity_days=body.validity_days,
)
except PkiError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
cert = Certificate(
common_name=body.username,
certificate_pem=issued.certificate_pem,
private_key_pem=encrypt_secret(issued.private_key_pem),
expiry_date=issued.not_valid_after,
is_ca=False,
parent_id=ca.id,
cert_type="client",
username=body.username,
key_algorithm="ecdsa-p256",
serial_number=str(issued.serial_number),
dns_sans=[],
ip_sans=[],
fingerprint=pki_fingerprint(issued.certificate_pem),
status="issued",
)
db.add(cert)
db.flush()
# Bind the certificate to the client's PostgreSQL metadata (the
# dynsec identity link, spec section 24).
meta = db.get(MqttClientMetadata, body.username)
if meta is None:
meta = MqttClientMetadata(username=body.username, tags=[])
db.add(meta)
meta.certificate_id = cert.id
db.flush()
audit.record(
db,
"CERTIFICATE_ISSUED",
request=request,
object_type="certificate",
object_id=str(cert.id),
new_value={
"common_name": body.username,
"ca": ca.common_name,
"cert_type": "client",
"mqtt_client": body.username,
},
)
return CertificateBundle(
certificate_id=cert.id,
username=body.username,
certificate_pem=issued.certificate_pem,
private_key_pem=issued.private_key_pem,
ca_chain_pem=ca.certificate_pem,
)
@router.get(
"/certificates/{cert_id}/download",
response_class=PlainTextResponse,
)
def download_certificate(cert_id: uuid.UUID, db: DbDep, _user: PkiAdmin):
"""Download the certificate (public part only) as PEM."""
cert = _get_certificate(db, cert_id)
return PlainTextResponse(
cert.certificate_pem,
media_type="application/x-pem-file",
headers={
"Content-Disposition": f'attachment; filename="{cert.common_name}.crt"'
},
)
@router.delete(
"/certificates/{cert_id}", status_code=status.HTTP_204_NO_CONTENT
)
def delete_certificate(
cert_id: uuid.UUID, request: Request, db: DbDep, _user: PkiAdmin
):
"""Remove a certificate from the store.
CAs with still-issued children are refused, and a certificate that a
listener is bound to cannot be deleted (the generated config would
reference a missing file).
"""
cert = _get_certificate(db, cert_id)
if cert.is_ca:
children = db.scalar(
select(Certificate.id).where(Certificate.parent_id == cert.id)
)
if children is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
"the CA still has issued certificates; delete them first",
)
from app.models import ListenerConfig, MqttClientMetadata
bound = db.scalar(
select(ListenerConfig.id).where(ListenerConfig.cert_id == cert.id)
)
if bound is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
"the certificate is bound to a listener; unbind it first",
)
if cert.cert_type == "client":
# Auto-unlink the identity binding (FK is SET NULL at the DB
# level too); the MQTT client itself is not affected. Explicit
# flush: no query follows, so autoflush would never push this.
linked = db.scalar(
select(MqttClientMetadata).where(
MqttClientMetadata.certificate_id == cert.id
)
)
if linked is not None:
linked.certificate_id = None
db.flush()
db.delete(cert)
audit.record(
db,
"CERTIFICATE_DELETED",
request=request,
object_type="certificate",
object_id=str(cert_id),
)
return None
+80
View File
@@ -0,0 +1,80 @@
"""System administration (spec sections 29, 41, 46).
Broker status and restart through the control sidecar. The restart
primitive is SIGTERM to PID 1 in the broker's (shared) PID namespace —
the broker shuts down gracefully (persistence saved) and Docker's
`restart: unless-stopped` policy brings the container back. No Docker
socket is involved (spec section 29).
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app import audit
from app.db import get_db
from app.deps import CurrentUser, require_roles
from app.models import ManagementRole
from app.routers.broker import _call_control, restart_required
router = APIRouter(prefix="/api/v1/system", tags=["system"])
BrokerAdmin = Annotated[CurrentUser, Depends(require_roles(ManagementRole.BROKER_ADMIN))]
AnyReader = Annotated[
CurrentUser,
Depends(
require_roles(
ManagementRole.BROKER_ADMIN,
ManagementRole.OPERATOR,
ManagementRole.AUDITOR,
)
),
]
DbDep = Annotated[Session, Depends(get_db)]
@router.get("/status")
def system_status(db: DbDep, _user: AnyReader):
"""Broker process state + pending-restart flag (spec section 46).
Used by the sidebar indicator (polls lightly) and the System page.
The control call is allowed to fail — an unreachable sidecar is a
status, not an error.
"""
try:
control = _call_control("/status", method="GET")
broker_alive = bool(control.get("broker_pid_alive"))
except Exception: # noqa: BLE001 - status endpoint must not 500
broker_alive = False
return {
"broker_alive": broker_alive,
"restart_required": restart_required(db),
}
@router.post("/broker/restart")
def restart_broker(request: Request, db: DbDep, user: BrokerAdmin):
"""Restart the broker via the control sidecar (spec section 29).
The sidecar acknowledges and then SIGTERMs PID 1; Docker restarts
the container. The pending-restart flag is cleared here — the config
on disk is what the broker will boot with, so listener changes take
effect by definition once the restart completes.
"""
from app.routers.broker import _set_restart_required
response = _call_control("/restart")
_set_restart_required(db, False)
audit.record(
db,
"BROKER_RESTARTED",
request=request,
user=user.username,
object_type="broker",
object_id="mosquitto",
result="initiated",
)
return {"status": "restarting", "broker_alive": False}
+229
View File
@@ -0,0 +1,229 @@
"""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():
# Start BELOW the first index: messages_since uses strictly-
# greater semantics, and a fresh session's first message has
# index 0 — a cursor starting at 0 would drop it.
index = -1
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
+130
View File
@@ -169,6 +169,18 @@ class MqttClientOut(BaseModel):
last_known_connection: datetime | None
class ClientIdUpdate(BaseModel):
"""PUT /clients/{username}/clientid — bind or clear the expected
client ID (spec section 11: "bind an expected client ID").
An empty string clears the binding: any client id may then connect
with these credentials. A bound ID restricts connections to exactly
that client id (anti-credential-sharing).
"""
clientid: str = Field(default="", max_length=200)
class MqttPasswordReset(BaseModel):
"""Response for create/reset — plaintext shown exactly once (spec 11)."""
@@ -203,6 +215,8 @@ class ListenerIn(BaseModel):
tls_min_version: Literal["tlsv1.2", "tlsv1.3"] | None = None
require_certificate: bool = False
use_identity_as_username: bool = False
# Bound server certificate (PKI): emitted as certfile/keyfile/cafile.
cert_id: uuid.UUID | None = None
class ListenerOut(ListenerIn):
@@ -216,6 +230,43 @@ class ConfigPreviewOut(BaseModel):
warnings: list[str]
class BrokerStatusOut(BaseModel):
"""$SYS snapshot for the dashboard (spec sections 33, 55).
Every metric is None until the first $SYS publish arrives (the
broker publishes on sys_interval, 10 s in the bootstrap config);
`available` says whether ANY $SYS message has been ingested.
"""
available: bool
last_update: datetime | None
version: str | None = None
uptime: str | None = None
build_timestamp: str | None = None
clients_connected: int | None = None
clients_disconnected: int | None = None
clients_maximum: int | None = None
clients_total: int | None = None
clients_expired: int | None = None
messages_received: int | None = None
messages_sent: int | None = None
messages_stored: int | None = None
messages_dropped: int | None = None
publish_received: int | None = None
publish_sent: int | None = None
publish_dropped: int | None = None
bytes_received: int | None = None
bytes_sent: int | None = None
subscriptions_count: int | None = None
retained_count: int | None = None
load_messages_received_1min: float | None = None
load_messages_sent_1min: float | None = None
load_publish_received_1min: float | None = None
load_publish_sent_1min: float | None = None
load_bytes_received_1min: float | None = None
load_bytes_sent_1min: float | None = None
class DeployRequest(BaseModel):
comment: str | None = None
@@ -235,3 +286,82 @@ class RevisionOut(BaseModel):
class RevisionDetailOut(RevisionOut):
config_text: str
validation_result: dict[str, Any]
# --- PKI: certificate authorities + server certificates (spec 16-19) ---------
class CaCreate(BaseModel):
common_name: str = Field(min_length=1, max_length=200)
validity_days: int = Field(gt=0, le=36500)
key_algorithm: Literal["rsa3072", "rsa4096", "ecdsa-p256", "ecdsa-p384"]
organization: str | None = None
organizational_unit: str | None = None
country: str | None = Field(default=None, max_length=2)
class ServerCertCreate(BaseModel):
"""Issue a server certificate signed by an existing CA (spec 19)."""
ca_id: uuid.UUID
common_name: str = Field(min_length=1, max_length=200)
validity_days: int = Field(gt=0, le=36500)
dns_sans: list[str] = Field(default_factory=list)
ip_sans: list[str] = Field(default_factory=list)
organization: str | None = None
organizational_unit: str | None = None
country: str | None = Field(default=None, max_length=2)
class CertificateOut(BaseModel):
"""Inventory view — NEVER includes the private key (spec section 21)."""
id: uuid.UUID
common_name: str
is_ca: bool
parent_id: uuid.UUID | None
cert_type: str
# MQTT client this certificate authenticates (client certs only;
# the CN equals the username — spec section 24).
username: str | None = None
key_algorithm: str
serial_number: str
dns_sans: list[str]
ip_sans: list[str]
fingerprint: str
status: str
expiry_date: datetime
created_at: datetime
model_config = {"from_attributes": True}
class ClientCertCreate(BaseModel):
"""Issue a client certificate bound to an MQTT client (spec section 20).
The certificate CN is the MQTT username — with
use_identity_as_username the broker maps CN -> dynsec identity
(spec section 24), so the dynsec client must already exist.
"""
username: str = Field(min_length=1, max_length=100)
ca_id: uuid.UUID
validity_days: int = Field(gt=0, le=36500)
class CertificateBundle(BaseModel):
"""One-time download payload (spec sections 20, 44).
Contains EVERYTHING including the plaintext private key — shown
exactly once, never retrievable afterwards from the inventory.
"""
certificate_id: uuid.UUID
username: str
certificate_pem: str
private_key_pem: str
ca_chain_pem: str
download_hint: str = (
"Store these files securely now — the private key cannot be "
"retrieved again."
)
+196
View File
@@ -0,0 +1,196 @@
"""E2E: client-certificate mTLS publish chain (spec sections 20, 24).
Phase deploy: CA -> MQTT client -> role with publish ACL -> client cert
(CN=username) -> server cert -> mTLS listener -> deploy. State is saved
to /tmp/e2e_state.json; the caller then RESTARTS mosquitto (a new listener
needs a restart, not a reload — spec section 28) and runs phase verify.
Phase verify: connects to mosquitto:8884 with the client certificate,
publishes to an allowed topic (expect OK) and a forbidden topic (expect
deny), then cleans everything up.
Usage: python /tmp/e2e.py [--phase deploy|verify|all]
"""
import http.cookiejar
import json
import os
import ssl
import sys
import tempfile
import time
import urllib.request
BASE = "http://127.0.0.1:8000/api/v1"
STATE = "/tmp/e2e_state.json"
phase = "all"
if "--phase" in sys.argv:
phase = sys.argv[sys.argv.index("--phase") + 1]
cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
def call(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
f"{BASE}{path}",
data=data,
headers={"Content-Type": "application/json"},
method=method,
)
resp = opener.open(req)
return json.load(resp) if resp.status != 204 else None
call("POST", "/auth/login", {
"username": os.environ["ADMIN_INITIAL_USERNAME"],
"password": os.environ["ADMIN_INITIAL_PASSWORD"],
})
state = {}
if phase == "verify":
with open(STATE) as f:
state = json.load(f)
# 1. CA
if phase in ("deploy", "all"):
ca = call("POST", "/pki/ca", {
"common_name": "E2E mTLS CA", "validity_days": 3650,
"key_algorithm": "ecdsa-p256",
})
print("CA:", ca["id"][:8])
# 2. MQTT client + role with a publish ACL (idempotent: clear any
# residue from a previous aborted run first).
for path in (
"/mqtt/clients/e2e-device",
"/mqtt/roles/e2e-publisher",
):
try:
call("DELETE", path)
except urllib.error.HTTPError:
pass
call("POST", "/mqtt/clients", {"username": "e2e-device"})
call("POST", "/mqtt/roles", {
"rolename": "e2e-publisher", "acls": [{
"acltype": "publishClientSend", "topic": "e2e/%u/data/#",
"allow": True, "priority": 1,
}],
})
call("POST", "/mqtt/clients/e2e-device/roles",
{"rolename": "e2e-publisher", "priority": 1})
print("client + role + ACL ready")
# 3. Client certificate (CN = username)
bundle = call("POST", "/pki/certificates/client", {
"username": "e2e-device", "ca_id": ca["id"], "validity_days": 365,
})
print("client cert issued:", bundle["certificate_id"][:8])
# 4. Server certificate for the listener
server_cert = call("POST", "/pki/certificates", {
"ca_id": ca["id"], "common_name": "mosquitto", "validity_days": 365,
"dns_sans": ["mosquitto", "localhost"],
})
print("server cert issued:", server_cert["id"][:8])
# 5. mTLS listener on 8884 + deploy
listener = call("POST", "/broker/listeners", {
"name": "E2E mTLS", "port": 8884, "protocol": "mqtt",
"tls_enabled": True, "tls_min_version": "tlsv1.2",
"require_certificate": True, "use_identity_as_username": True,
"cert_id": server_cert["id"],
})
deploy = call("POST", "/broker/config/deploy", {"comment": "E2E mTLS test"})
print("deployed revision:", deploy.get("revision"),
"broker_healthy:", deploy.get("broker_healthy"))
state = {
"ca": ca["id"],
"bundle": bundle,
"server_cert": server_cert["id"],
"listener": listener["id"],
}
with open(STATE, "w") as f:
json.dump(state, f)
if phase == "deploy":
print("state saved — restart mosquitto, then run --phase verify")
sys.exit(0)
# A NEW listener cannot be added by config reload (spec section 28) —
# the broker must be restarted for it to start listening. The restart is
# performed externally (docker compose restart mosquitto) between the
# deploy phase and the connect phase; this script is invoked with
# --phase deploy|verify to allow that.
import sys
import time
phase = "all"
if len(sys.argv) > 1 and sys.argv[1] == "--phase":
phase = sys.argv[2]
# 6. Connect with the client certificate and publish
if phase in ("verify", "all"):
import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion
bundle = state["bundle"]
tmp = tempfile.mkdtemp()
cert_file = os.path.join(tmp, "dev.crt")
key_file = os.path.join(tmp, "dev.key")
ca_file = os.path.join(tmp, "ca.crt")
open(cert_file, "w").write(bundle["certificate_pem"])
open(key_file, "w").write(bundle["private_key_pem"])
open(ca_file, "w").write(bundle["ca_chain_pem"])
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.load_verify_locations(ca_file)
ctx.load_cert_chain(cert_file, key_file)
ctx.check_hostname = False # CN is "mosquitto"; connect by DNS name
ctx.verify_mode = ssl.CERT_REQUIRED
results = {}
client = mqtt.Client(CallbackAPIVersion.VERSION2, client_id="e2e-test")
def on_connect(cl, u, f, rc, props):
results["connack"] = rc
client.on_connect = on_connect
client.tls_set_context(ctx)
# The backend container shares backend-net with mosquitto; Docker
# DNS resolves the name (127.0.0.1 here is the BACKEND, not broker).
client.connect("mosquitto", 8884, keepalive=30)
client.loop_start()
time.sleep(2)
if results.get("connack") == 0:
print("CONNECT: OK — certificate accepted, CN mapped to identity")
allowed = client.publish("e2e/e2e-device/data/1", b"hello", qos=1)
allowed.wait_for_publish(timeout=5)
print("PUBLISH allowed topic rc:", allowed.rc, "(0 = OK)")
denied = client.publish("forbidden/secret", b"x", qos=1)
denied.wait_for_publish(timeout=5)
print("PUBLISH forbidden topic rc:", denied.rc,
"(non-zero = ACL deny enforced)")
else:
print("CONNECT FAILED rc:", results.get("connack"))
client.loop_stop()
# 7. Cleanup
if phase in ("verify", "all"):
def try_call(method, path, body=None):
try:
call(method, path, body)
except urllib.error.HTTPError:
pass
try_call("DELETE", f"/broker/listeners/{state['listener']}")
try_call("POST", "/broker/config/deploy", {"comment": "E2E cleanup"})
try_call("DELETE", f"/pki/certificates/{state['bundle']['certificate_id']}")
try_call("DELETE", f"/pki/certificates/{state['server_cert']}")
try_call("DELETE", f"/pki/certificates/{state['ca']}")
try_call("DELETE", "/mqtt/clients/e2e-device")
try_call("DELETE", "/mqtt/roles/e2e-publisher")
print("cleanup done")
+5
View File
@@ -14,7 +14,12 @@ from app import models # noqa: F401 (register all models with Base)
config = context.config
if config.config_file_name is not None:
# Our minimal alembic.ini intentionally has no [loggers]/[handlers]/
# [formatters] sections; fileConfig raises KeyError without them.
try:
fileConfig(config.config_file_name)
except Exception: # noqa: BLE001 - logging config is optional here
pass
url = get_settings().database_url
if url.startswith("postgresql://"):
@@ -0,0 +1,61 @@
"""Release 2: expand certificates table for CA/server-certificate PKI.
Revision ID: r2_0002
Revises: r1_0001
Create Date: 2026-09-19
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "r2_0002"
down_revision: Union[str, None] = "r1_0001"
branch_labels: Union[str, Sequence[str] | None] = None
depends_on: Union[str, Sequence[str] | None] = None
def upgrade() -> None:
# The certificates table exists since r1_0001 (forward-compatible
# placeholder). Release 2 adds CA semantics, hierarchy, SANs,
# serial/fingerprint identity, and a lifecycle status.
op.add_column("certificates", sa.Column("is_ca", sa.Boolean(), nullable=False, server_default=sa.false()))
op.add_column("certificates", sa.Column("parent_id", sa.Uuid(), nullable=True))
op.add_column("certificates", sa.Column("key_algorithm", sa.String(20), nullable=False, server_default="ecdsa-p256"))
# Serial numbers are unique per RFC 5280; existing rows have none, so
# the column is added nullable first, then made NOT NULL below (the
# table has never been written to, so no backfill is needed).
op.add_column("certificates", sa.Column("serial_number", sa.String(64), nullable=True))
op.add_column("certificates", sa.Column("dns_sans", sa.JSON(), nullable=False, server_default="[]"))
op.add_column("certificates", sa.Column("ip_sans", sa.JSON(), nullable=False, server_default="[]"))
op.add_column("certificates", sa.Column("fingerprint", sa.String(200), nullable=True))
op.add_column("certificates", sa.Column("status", sa.String(20), nullable=False, server_default="issued"))
op.create_foreign_key(
"fk_certificates_parent",
"certificates",
"certificates",
["parent_id"],
["id"],
ondelete="SET NULL",
)
# No rows exist yet in practice (table was never written to), so the
# unique index is safe; if a legacy row existed without a serial it
# must be removed manually first.
op.create_index("ix_certificates_serial_number", "certificates", ["serial_number"], unique=True)
op.create_index("ix_certificates_fingerprint", "certificates", ["fingerprint"])
op.alter_column("certificates", "serial_number", nullable=False)
def downgrade() -> None:
op.alter_column("certificates", "serial_number", nullable=True)
op.drop_index("ix_certificates_fingerprint", table_name="certificates")
op.drop_index("ix_certificates_serial_number", table_name="certificates")
op.drop_constraint("fk_certificates_parent", "certificates", type_="foreignkey")
op.drop_column("certificates", "status")
op.drop_column("certificates", "fingerprint")
op.drop_column("certificates", "ip_sans")
op.drop_column("certificates", "dns_sans")
op.drop_column("certificates", "serial_number")
op.drop_column("certificates", "key_algorithm")
op.drop_column("certificates", "parent_id")
op.drop_column("certificates", "is_ca")
@@ -0,0 +1,30 @@
"""Release 2: client certificates — cert_type + username columns.
Revision ID: r2_0003
Revises: r2_0002
Create Date: 2026-09-19
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "r2_0003"
down_revision: Union[str, None] = "r2_0002"
branch_labels: Union[str, Sequence[str] | None] = None
depends_on: Union[str, Sequence[str] | None] = None
def upgrade() -> None:
op.add_column(
"certificates",
sa.Column("cert_type", sa.String(20), nullable=False, server_default="server"),
)
op.add_column("certificates", sa.Column("username", sa.String(100), nullable=True))
op.create_index("ix_certificates_username", "certificates", ["username"])
def downgrade() -> None:
op.drop_index("ix_certificates_username", table_name="certificates")
op.drop_column("certificates", "username")
op.drop_column("certificates", "cert_type")
+1
View File
@@ -15,6 +15,7 @@ dependencies = [
"paho-mqtt>=2.1",
"python-multipart>=0.0.17",
"httpx>=0.28",
"cryptography>=43.0",
]
[project.optional-dependencies]
File diff suppressed because it is too large Load Diff
+108
View File
@@ -116,7 +116,22 @@ def client(db_session, monkeypatch):
def set_client_password(self, username, password):
pass
def set_client_id(self, username, clientid=None):
# Mirrors the real broker's setClientId: empty string unbinds.
client = self.clients.get(username)
if client is None:
from app.dynsec import DynsecError
raise DynsecError(f"Client not found: {username}")
client["clientid"] = clientid or ""
def get_client(self, username):
# Mirrors the real broker: getClient on a missing client
# returns a dynsec error, which the router maps to 404.
if username not in self.clients:
from app.dynsec import DynsecError
raise DynsecError(f"Client not found: {username}")
return self.clients[username]
def create_group(self, groupname, roles=None):
@@ -204,6 +219,99 @@ def client(db_session, monkeypatch):
monkeypatch.setattr(mqtt_router, "dynsec_client", fake)
from app.routers import pki as pki_router
# The PKI client-cert endpoint validates the dynsec client exists.
monkeypatch.setattr(pki_router, "dynsec_client", fake)
# The control sidecar is not running in tests: fake its responses so
# deploy/system endpoints can be exercised (broker_alive always true).
from app.routers import broker as broker_router
from app.routers import system as system_router
def _fake_call_control(path, *, method="POST", json_body=None):
if path == "/deploy":
return {
"status": "deployed",
"broker_healthy": True,
"validation": {"valid": True},
}
if path == "/restart":
return {"status": "restarting"}
if path == "/status":
return {"broker_pid_alive": True, "config_file": "/mosquitto/config/mosquitto.conf"}
raise AssertionError(f"unexpected control path in tests: {path}")
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
self.buffer = [] # list of message dicts, index-tagged
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):
session = fake_sessions.get(session_id)
return [dict(m) for m in session.buffer if m["index"] > index]
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
+544
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import json
from tests.conftest import login
@@ -102,6 +104,302 @@ def test_password_reset_still_works_for_normal_clients(admin_client):
assert len(body["password"]) >= 20 # random password, shown once
def test_client_id_binding(admin_client):
"""Bind/clear the expected client ID (spec section 11) and refuse
it for the management identity."""
admin_client.post("/api/v1/mqtt/clients", json={"username": "device-1"})
bound = admin_client.put(
"/api/v1/mqtt/clients/device-1/clientid",
json={"clientid": "device-ps042"},
)
assert bound.status_code == 204
client = admin_client.get("/api/v1/mqtt/clients/device-1").json()
assert client["clientid"] == "device-ps042"
# Clearing with an empty string unbinds (any client id may connect).
cleared = admin_client.put(
"/api/v1/mqtt/clients/device-1/clientid", json={"clientid": ""}
)
assert cleared.status_code == 204
client = admin_client.get("/api/v1/mqtt/clients/device-1").json()
assert client["clientid"] in (None, "")
# The management identity is never manageable (existing guard).
refused = admin_client.put(
"/api/v1/mqtt/clients/admin/clientid", json={"clientid": "x"}
)
assert refused.status_code == 400
# --- System: broker restart + restart-required flag (spec 28, 29) -------------
def test_deploy_sets_restart_required_on_listener_change(admin_client):
"""Adding a listener definition vs the bootstrap config must flag a
pending restart (reload cannot add listeners, spec section 28)."""
# No deployed revision yet -> compared against the bootstrap config
# (listener 1883 0.0.0.0). A listener on 8883 differs from that.
admin_client.post(
"/api/v1/broker/listeners",
json={"name": "New TLS", "port": 8883, "protocol": "mqtt"},
)
deploy = admin_client.post(
"/api/v1/broker/config/deploy", json={"comment": "listener added"}
)
assert deploy.status_code == 200
body = deploy.json()
assert body["restart_required"] is True
# The status endpoint reflects the same flag (drives the glow).
status = admin_client.get("/api/v1/system/status").json()
assert status["restart_required"] is True
assert status["broker_alive"] is True
def test_deploy_without_listener_change_needs_no_restart(admin_client):
"""Deploying a config whose listener lines match the previous deployed
revision must NOT set the flag (everything else is reload-safe)."""
admin_client.post(
"/api/v1/broker/listeners",
json={"name": "Baseline", "port": 1883, "protocol": "mqtt"},
)
first = admin_client.post(
"/api/v1/broker/config/deploy", json={"comment": "baseline"}
).json()
# 1883 matches the bootstrap signature on the FIRST deploy; the flag
# may or may not be set then. The SECOND deploy (identical listener
# lines) must clear it.
second = admin_client.post(
"/api/v1/broker/config/deploy", json={"comment": "no change"}
).json()
assert second["restart_required"] is False
def test_restart_clears_the_flag(admin_client):
admin_client.post(
"/api/v1/broker/config/deploy", json={"comment": "flag set"}
)
restarted = admin_client.post("/api/v1/system/broker/restart")
assert restarted.status_code == 200
assert restarted.json()["status"] == "restarting"
status = admin_client.get("/api/v1/system/status").json()
assert status["restart_required"] is False
def test_system_endpoints_rbac(auditor_client):
"""Status is read-only (auditor may read); restart is privileged."""
assert auditor_client.get("/api/v1/system/status").status_code == 200
assert (
auditor_client.post("/api/v1/system/broker/restart").status_code == 403
)
def test_broker_status_endpoint(admin_client, auditor_client):
"""GET /broker/status returns the $SYS snapshot (spec 33/55).
No $SYS messages are ingested in tests (no real broker), so the
endpoint must report available: False with all metrics None; after
ingesting fake $SYS messages the snapshot reflects them. Read-only:
the auditor role may view (spec section 9).
"""
from app import metrics
metrics.reset()
empty = admin_client.get("/api/v1/broker/status")
assert empty.status_code == 200
body = empty.json()
assert body["available"] is False
assert body["version"] is None
assert body["clients_connected"] is None
# Ingest fake $SYS telemetry and verify the snapshot reflects it.
metrics.handle_sys_message("$SYS/broker/version", b"2.1.2")
metrics.handle_sys_message("$SYS/broker/clients/connected", b"4")
metrics.handle_sys_message("$SYS/broker/retained messages/count", b"7")
metrics.handle_sys_message("$SYS/broker/load/messages/received/1min", b"1.25")
body = admin_client.get("/api/v1/broker/status").json()
assert body["available"] is True
assert body["version"] == "2.1.2"
assert body["clients_connected"] == 4
assert body["retained_count"] == 7 # topic contains a literal space
assert body["load_messages_received_1min"] == 1.25
assert body["last_update"] is not None
# Unknown $SYS topics are ignored without breaking the snapshot.
metrics.handle_sys_message("$SYS/broker/something/unknown", b"x")
body = admin_client.get("/api/v1/broker/status").json()
assert body["available"] is True
# Read-only for every management role (spec section 9: auditor has
# read access to configuration/status).
assert auditor_client.get("/api/v1/broker/status").status_code == 200
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_stream_delivers_first_message(admin_client, monkeypatch):
"""Regression test: the SSE cursor must include a session's FIRST
buffered message (index 0). messages_since is strictly-greater, so
a cursor starting at 0 dropped it — subscribe, publish {"cmd":"start"}
then {"cmd":"stop"}, and the stream must deliver BOTH."""
from app import explorer as explorer_module
sub = admin_client.post("/api/v1/topics/subscribe", json={"topic_filter": "e2e/#"})
assert sub.status_code == 200
session_id = sub.json()["session_id"]
session = explorer_module.sessions.get(session_id)
# Simulate the broker delivering both payloads (as _on_message would).
session.buffer = [
{
"index": 0,
"topic": "e2e/diff/device1",
"qos": 1,
"retain": False,
"received_at": 1.0,
"payload_b64": "eyJjbWQiOiAic3RhcnQifQ==",
"payload_text": '{"cmd": "start"}',
"properties": None,
},
{
"index": 1,
"topic": "e2e/diff/device1",
"qos": 1,
"retain": False,
"received_at": 2.0,
"payload_b64": "eyJjbWQiOiAic3RvcCJ9",
"payload_text": '{"cmd": "stop"}',
"properties": None,
},
]
with admin_client.stream("GET", f"/api/v1/topics/stream/{session_id}") as response:
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/event-stream")
received = []
for line in response.iter_lines():
if line.startswith("data:"):
received.append(json.loads(line[len("data:"):].strip()))
if len(received) == 2:
break
assert [m["payload_text"] for m in received] == [
'{"cmd": "start"}',
'{"cmd": "stop"}',
], "stream must deliver the session's first message (index 0), not drop it"
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"})
@@ -278,3 +576,249 @@ def test_audit_events_recorded(admin_client, db_session):
admin_client.post("/api/v1/mqtt/clients", json={"username": "audited-1"})
actions = db_session.scalars(select(AuditEvent.action)).all()
assert "CLIENT_CREATED" in actions
# --- PKI: CA + server certificates (spec sections 16-19) -----------------------
def test_ca_creation_and_inventory(admin_client):
response = admin_client.post(
"/api/v1/pki/ca",
json={
"common_name": "MQTT Platform Root CA",
"validity_days": 3650,
"key_algorithm": "ecdsa-p256",
},
)
assert response.status_code == 201, response.text
ca = response.json()
assert ca["is_ca"] is True
assert ca["status"] == "issued"
assert len(ca["serial_number"]) > 10
assert ca["fingerprint"].count(":") == 31 # sha-256 hex pairs
# No private key material ever appears in a response.
assert "private_key_pem" not in ca
assert "BEGIN PRIVATE KEY" not in response.text
cas = admin_client.get("/api/v1/pki/ca").json()
assert any(c["common_name"] == "MQTT Platform Root CA" for c in cas)
def test_server_certificate_issuance(admin_client):
ca_id = admin_client.post(
"/api/v1/pki/ca",
json={
"common_name": "Test CA",
"validity_days": 3650,
"key_algorithm": "ecdsa-p256",
},
).json()["id"]
response = admin_client.post(
"/api/v1/pki/certificates",
json={
"ca_id": ca_id,
"common_name": "mqtt.example.local",
"validity_days": 365,
"dns_sans": ["mqtt", "mqtt.local"],
"ip_sans": ["10.20.30.40"],
},
)
assert response.status_code == 201, response.text
cert = response.json()
assert cert["is_ca"] is False
assert cert["parent_id"] == ca_id
assert cert["dns_sans"] == ["mqtt", "mqtt.local"]
assert cert["ip_sans"] == ["10.20.30.40"]
assert "BEGIN PRIVATE KEY" not in response.text
# The download endpoint returns the public PEM.
download = admin_client.get(f"/api/v1/pki/certificates/{cert['id']}/download")
assert download.status_code == 200
assert "BEGIN CERTIFICATE" in download.text
assert "PRIVATE KEY" not in download.text
def test_server_certificate_is_cryptographically_valid(admin_client):
"""The issued chain must verify against the issuing CA (real crypto)."""
from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import ec
ca_id = admin_client.post(
"/api/v1/pki/ca",
json={
"common_name": "Verify CA",
"validity_days": 3650,
"key_algorithm": "ecdsa-p256",
},
).json()["id"]
cert_id = admin_client.post(
"/api/v1/pki/certificates",
json={"ca_id": ca_id, "common_name": "broker.local", "validity_days": 30},
).json()["id"]
ca_pem = admin_client.get(f"/api/v1/pki/certificates/{ca_id}/download").text
cert_pem = admin_client.get(f"/api/v1/pki/certificates/{cert_id}/download").text
ca_cert = x509.load_pem_x509_certificate(ca_pem.encode())
cert = x509.load_pem_x509_certificate(cert_pem.encode())
# Signature verification: the leaf must validate against the CA key.
ca_cert.public_key().verify(
cert.signature,
cert.tbs_certificate_bytes,
ec.ECDSA(cert.signature_hash_algorithm),
)
# Serial and fingerprint match the inventory fields.
inv = next(
c
for c in admin_client.get("/api/v1/pki/certificates").json()
if c["id"] == cert_id
)
assert int(inv["serial_number"]) == cert.serial_number
from app.pki import fingerprint as pki_fingerprint
assert inv["fingerprint"] == pki_fingerprint(cert_pem)
# --- Client certificates (spec sections 20, 24) ---------------------------------
def test_client_certificate_issuance_and_binding(admin_client, db_session):
"""Issue-client-cert requires an existing dynsec client; the bundle
carries cert+key+CA exactly once and binds the identity link."""
from sqlalchemy import select
from app.models import MqttClientMetadata
ca_id = admin_client.post(
"/api/v1/pki/ca",
json={
"common_name": "Client CA",
"validity_days": 3650,
"key_algorithm": "ecdsa-p256",
},
).json()["id"]
# Without the dynsec client it must 404, not issue a useless cert.
missing = admin_client.post(
"/api/v1/pki/certificates/client",
json={"username": "ghost-device", "ca_id": ca_id, "validity_days": 365},
)
assert missing.status_code == 404
admin_client.post("/api/v1/mqtt/clients", json={"username": "ps042"})
response = admin_client.post(
"/api/v1/pki/certificates/client",
json={"username": "ps042", "ca_id": ca_id, "validity_days": 365},
)
assert response.status_code == 201, response.text
bundle = response.json()
assert bundle["username"] == "ps042"
assert "BEGIN CERTIFICATE" in bundle["certificate_pem"]
assert "BEGIN PRIVATE KEY" in bundle["private_key_pem"]
assert "BEGIN CERTIFICATE" in bundle["ca_chain_pem"]
# Identity binding recorded in metadata (spec section 24).
meta = db_session.get(MqttClientMetadata, "ps042")
assert meta is not None
assert str(meta.certificate_id) == bundle["certificate_id"]
# Inventory shows the client cert with cert_type=client. The bundle
# legitimately carries the private key ONCE (spec section 20); the
# INVENTORY must never leak it.
inventory = admin_client.get("/api/v1/pki/certificates")
certs = inventory.json()
entry = next(c for c in certs if c["id"] == bundle["certificate_id"])
assert entry["cert_type"] == "client"
assert entry["common_name"] == "ps042"
# The bound MQTT identity is exposed for differentiation in the UI
# (client certs list the device they authenticate, spec section 24).
assert entry["username"] == "ps042"
assert "PRIVATE KEY" not in inventory.text
# Server certs carry no username.
server_entries = [c for c in certs if c.get("cert_type") == "server"]
assert all(c["username"] is None for c in server_entries)
def test_client_cert_delete_unlinks_metadata(admin_client, db_session):
from app.models import MqttClientMetadata
ca_id = admin_client.post(
"/api/v1/pki/ca",
json={"common_name": "Del CA", "validity_days": 3650, "key_algorithm": "ecdsa-p256"},
).json()["id"]
admin_client.post("/api/v1/mqtt/clients", json={"username": "unlink-me"})
bundle = admin_client.post(
"/api/v1/pki/certificates/client",
json={"username": "unlink-me", "ca_id": ca_id, "validity_days": 365},
).json()
deleted = admin_client.delete(
f"/api/v1/pki/certificates/{bundle['certificate_id']}"
)
assert deleted.status_code == 204
# The metadata row survives, unlinked (FK behaviour, mirrored in API).
db_session.expire_all()
meta = db_session.get(MqttClientMetadata, "unlink-me")
assert meta is not None
assert meta.certificate_id is None
def test_listener_cert_binding_roundtrip(admin_client):
"""Listener cert_id must round-trip through create/update (the gap that
previously made TLS unusable from the API)."""
ca_id = admin_client.post(
"/api/v1/pki/ca",
json={"common_name": "Listener CA", "validity_days": 3650, "key_algorithm": "ecdsa-p256"},
).json()["id"]
cert_id = admin_client.post(
"/api/v1/pki/certificates",
json={"ca_id": ca_id, "common_name": "broker.local", "validity_days": 365},
).json()["id"]
created = admin_client.post(
"/api/v1/broker/listeners",
json={
"name": "TLS listener",
"port": 8883,
"protocol": "mqtt",
"tls_enabled": True,
"tls_min_version": "tlsv1.2",
"require_certificate": True,
"use_identity_as_username": True,
"cert_id": cert_id,
},
)
assert created.status_code == 201, created.text
listener = created.json()
assert listener["cert_id"] == cert_id
assert listener["require_certificate"] is True
# The generated config now emits the real certfile/keyfile/cafile.
preview = admin_client.get("/api/v1/broker/config/preview").json()
assert f"certfile /mosquitto/certs/{cert_id}.crt" in preview["config_text"]
assert f"keyfile /mosquitto/certs/{cert_id}.key" in preview["config_text"]
assert f"cafile /mosquitto/certs/{ca_id}.crt" in preview["config_text"]
assert "require_certificate true" in preview["config_text"]
assert "use_identity_as_username true" in preview["config_text"]
# Update keeps the binding (full-object PUT).
updated = admin_client.put(
f"/api/v1/broker/listeners/{listener['id']}",
json={
"name": "TLS listener",
"port": 8883,
"protocol": "mqtt",
"tls_enabled": True,
"tls_min_version": "tlsv1.2",
"require_certificate": True,
"use_identity_as_username": True,
"cert_id": cert_id,
},
)
assert updated.status_code == 200
assert updated.json()["cert_id"] == cert_id
admin_client.delete(f"/api/v1/broker/listeners/{listener['id']}")
admin_client.delete(f"/api/v1/pki/certificates/{cert_id}")
admin_client.delete(f"/api/v1/pki/certificates/{ca_id}")
+106 -1
View File
@@ -64,6 +64,40 @@ def test_disabled_listeners_are_omitted(session):
assert "listener 1884" not in result.config_text
def test_unpublished_port_listener_warns(session):
"""Listeners on ports not published by compose warn: they are only
reachable inside the Docker network (spec section 5)."""
session.add(ListenerConfig(name="internal-only", port=8884, protocol="mqtt"))
session.commit()
result = generate_config(session)
assert any(
"not published to the host" in w and "8884" in w for w in result.warnings
)
def test_published_port_listener_does_not_warn(session):
session.add(ListenerConfig(name="prod", port=8883, protocol="mqtt"))
session.commit()
result = generate_config(session)
assert not any("not published to the host" in w for w in result.warnings)
def test_internal_settings_never_leak_into_config(session):
"""Platform-internal BrokerSettings keys (restart_required) are state,
not broker options: emitting them fails `--test-config` with 'Unknown
configuration variable' (found live in the restart E2E)."""
session.add(ListenerConfig(name="l", port=1883, protocol="mqtt"))
session.add(BrokerSettings(key="restart_required", value="true", value_type="bool"))
session.add(BrokerSettings(key="max_packet_size", value="2000000", value_type="int"))
session.commit()
result = generate_config(session)
assert "max_packet_size 2000000" in result.config_text
assert "restart_required" not in result.config_text
def test_tls_listener_without_pki_warns(session):
session.add(
ListenerConfig(
@@ -77,5 +111,76 @@ def test_tls_listener_without_pki_warns(session):
session.commit()
result = generate_config(session)
assert any("PKI is not available" in w for w in result.warnings)
assert any("no certificate is bound" in w for w in result.warnings)
assert "tls_version tlsv1.2" in result.config_text
def test_tls_listener_with_bound_certificate_emits_certfile(session):
"""With PKI material bound, the generator emits real cert paths (spec 30)."""
from datetime import UTC, datetime
from app.crypto import encrypt_secret
from app.models import Certificate
from app.pki import create_ca, issue_server_certificate
ca_issued = create_ca(
common_name="Test CA", validity_days=3650, key_algorithm="ecdsa-p256"
)
ca = Certificate(
common_name="Test CA",
certificate_pem=ca_issued.certificate_pem,
private_key_pem=encrypt_secret(ca_issued.private_key_pem),
expiry_date=ca_issued.not_valid_after,
is_ca=True,
key_algorithm="ecdsa-p256",
serial_number=str(ca_issued.serial_number),
dns_sans=[],
ip_sans=[],
fingerprint="aa:bb",
status="issued",
)
session.add(ca)
session.flush()
leaf = issue_server_certificate(
ca_certificate_pem=ca_issued.certificate_pem,
ca_private_key_pem=ca_issued.private_key_pem,
common_name="broker.local",
validity_days=365,
)
cert = Certificate(
common_name="broker.local",
certificate_pem=leaf.certificate_pem,
private_key_pem=encrypt_secret(leaf.private_key_pem),
expiry_date=leaf.not_valid_after,
is_ca=False,
parent_id=ca.id,
key_algorithm="ecdsa-p256",
serial_number=str(leaf.serial_number),
dns_sans=["broker.local"],
ip_sans=[],
fingerprint="cc:dd",
status="issued",
)
session.add(cert)
session.flush()
session.add(
ListenerConfig(
name="tls-prod",
port=8883,
protocol="mqtt",
tls_enabled=True,
tls_min_version="tlsv1.2",
cert_id=cert.id,
)
)
session.commit()
result = generate_config(session)
text = result.config_text
assert f"certfile /mosquitto/certs/{cert.id}.crt" in text
assert f"keyfile /mosquitto/certs/{cert.id}.key" in text
assert f"cafile /mosquitto/certs/{ca.id}.crt" in text
assert "tls_version tlsv1.2" in text
assert result.warnings == []
+12 -9
View File
@@ -1,17 +1,19 @@
# broker-control — internal control sidecar (spec §29)
#
# Runs in the mosquitto container's PID namespace (compose `pid: "service:mosquitto"`)
# so it can signal the broker process (PID 1) without docker.sock access.
# The backend calls its HTTP API over the backend-net network.
# PID-namespace OWNER: the broker joins THIS container's PID namespace
# (compose `pid: "service:mosquitto-control"` on the mosquitto service).
# Owning the namespace means the sidecar survives broker restarts — it
# was previously the other way around and the kernel killed the sidecar
# whenever the broker exited. tini runs as PID 1: it forwards signals to
# python and reaps the broker's zombies (the broker re-parents to tini
# when its container entrypoint forks).
#
# Responsibilities (deliberately minimal, spec §29):
# - validate staged configuration (mosquitto -c <staged> --test-config)
# - activate a configuration revision (atomic rename into place)
# - signal config reload (mosquitto_signal -p 1 config-reload == SIGHUP)
# - signal config reload (mosquitto_signal -p <broker-pid> config-reload)
# - signal broker restart (SIGTERM; Docker policy revives the broker)
# - check broker process state
#
# Release 1 ships validate/activate/reload/status only; restarts are a
# Release 2 concern (reload-vs-restart classification, spec §28).
FROM eclipse-mosquitto:2.1.2-alpine
@@ -20,7 +22,8 @@ FROM eclipse-mosquitto:2.1.2-alpine
# The base image ships NO Python. control.py is stdlib-only, so the
# distro package is sufficient (no pip, no venv needed).
RUN apk add --no-cache python3
# tini: minimal init as PID 1 — signal forwarding + zombie reaping.
RUN apk add --no-cache python3 tini
COPY control.py /app/control.py
@@ -36,4 +39,4 @@ CMD []
EXPOSE 8090
ENTRYPOINT ["python3", "/app/control.py"]
ENTRYPOINT ["/sbin/tini", "--", "python3", "/app/control.py"]
+63 -10
View File
@@ -11,7 +11,10 @@ Security model:
Docker network; the port is not published to the host.
* Requires a bearer token (CONTROL_API_TOKEN) shared with the backend.
* Never mounts docker.sock; signals the broker over the shared PID
namespace (broker is PID 1 in this container's view).
namespace. OWNERSHIP IS INVERTED: this sidecar owns the namespace
(tini is PID 1) and the broker joins it — so the sidecar survives
broker restarts and can orchestrate them (spec section 29). The
broker's PID is discovered via /proc, not hardcoded.
Implementation notes:
* Atomic activation writes content to a temp file in the SAME
@@ -98,30 +101,74 @@ def _activate(content: str) -> None:
raise
def _broker_pid() -> int | None:
"""Find the broker process in this PID namespace.
The namespace is OWNED by this sidecar (compose inversion: the broker
joins us via `pid: "service:mosquitto-control"`), so the broker is
NOT PID 1 — discover it by scanning /proc for the mosquitto binary.
PID 1 here is tini (the sidecar's init).
"""
try:
for entry in os.listdir("/proc"):
if not entry.isdigit():
continue
pid = int(entry)
if pid == 1:
continue # tini (this sidecar's init), never the broker
try:
with open(f"/proc/{pid}/comm", encoding="utf-8") as f:
if f.read().strip() == "mosquitto":
return pid
except OSError:
continue # process vanished mid-scan
except OSError:
pass
return None
def _signal_reload() -> None:
"""Signal the broker (PID 1 in the shared PID namespace) to reload."""
"""Signal the broker to reload its configuration."""
pid = _broker_pid()
if pid is None:
raise _Error(503, "broker process not found in the PID namespace")
try:
proc = subprocess.run(
["mosquitto_signal", "-p", "1", "config-reload"],
["mosquitto_signal", "-p", str(pid), "config-reload"],
capture_output=True,
text=True,
timeout=15,
)
except FileNotFoundError:
# Fallback if the helper app is unavailable for any reason.
os.kill(1, signal.SIGHUP)
os.kill(pid, signal.SIGHUP)
return
if proc.returncode != 0:
raise _Error(500, f"mosquitto_signal failed: {proc.stderr.strip()}")
def _restart_broker() -> None:
"""Gracefully stop the broker; Docker's restart policy revives it.
The broker is a normal process in THIS sidecar's PID namespace (we
own it; the broker joined us). SIGTERM makes it shut down cleanly
(persistence saved); `restart: unless-stopped` on the mosquitto
service then starts the container again. Because the sidecar is the
namespace init, it SURVIVES the broker's death and keeps reporting
status through the outage (spec section 29 — no Docker socket).
"""
# The HTTP response MUST be flushed before the broker dies, so the
# kill happens after the handler returns — orchestrated by the route.
pid = _broker_pid()
if pid is None:
# Already down (restart in flight): nothing to signal, and the
# caller still gets a clean "restarting" response.
return
os.kill(pid, signal.SIGTERM)
def _broker_pid_alive() -> bool:
try:
# Broker is PID 1 in the shared PID namespace.
with open("/proc/1/comm", encoding="utf-8") as f:
return f.read().strip() == "mosquitto"
except OSError:
return False
return _broker_pid() is not None
def _post_broker_health() -> bool:
@@ -215,6 +262,12 @@ class Handler(BaseHTTPRequestHandler):
try:
if self.path == "/deploy":
self._handle_deploy()
elif self.path == "/restart":
# Acknowledge BEFORE the broker dies: the response must
# reach the client first, then the process is signaled
# in a daemon thread so the handler completes cleanly.
self._send(200, {"status": "restarting"})
threading.Thread(target=_restart_broker, daemon=True).start()
elif self.path == "/reload":
_signal_reload()
self._send(200, {"status": "reloading", "broker_healthy": _post_broker_health()})
+33 -8
View File
@@ -18,6 +18,9 @@ x-backend-env: &backend-env
DEFAULT_TLS_MIN_VERSION: ${DEFAULT_TLS_MIN_VERSION:-tlsv1.2}
DEFAULT_CERT_VALIDITY_DAYS: ${DEFAULT_CERT_VALIDITY_DAYS:-365}
DEFAULT_CA_VALIDITY_DAYS: ${DEFAULT_CA_VALIDITY_DAYS:-3650}
# Broker container ports published to the host (compose `ports:`);
# keep in sync when editing the mosquitto port mappings.
PUBLISHED_PORTS: ${PUBLISHED_PORTS:-1883,8883}
services:
mqtt-db:
@@ -47,8 +50,18 @@ services:
build:
context: ./mosquitto
restart: unless-stopped
# Spec §29: no docker.sock. Control sidecar shares the broker's PID namespace
# and the mosquitto_config volume; it signals PID 1 (the broker) directly.
# Spec §29: no docker.sock. INVERTED PID ownership: the broker joins
# the CONTROL SIDECAR's PID namespace, not the other way around.
# The sidecar is the namespace init and survives broker restarts
# (previously the sidecar joined the broker's namespace and was
# kernel-killed whenever the broker exited — with a race on its
# own restart because it could not re-join a still-down broker).
# The broker is therefore NOT PID 1: control.py discovers its PID
# via /proc instead of hardcoding PID 1.
pid: "service:mosquitto-control"
depends_on:
mosquitto-control:
condition: service_started
environment:
# Used by the dynsec plugin on first start to seed the 'admin' client
# (verified: Mosquitto 2.1 dynamic security docs).
@@ -57,6 +70,9 @@ services:
- mosquitto_config:/mosquitto/config
- mosquitto_data:/mosquitto/data
- mosquitto_log:/mosquitto/log
# Shared PKI material (spec §6): backend writes decrypted cert/key
# files here before a TLS config deploy; the broker reads them.
- mosquitto_certs:/mosquitto/certs
networks:
- backend-net
ports:
@@ -81,12 +97,14 @@ services:
build:
context: ./broker-control
restart: unless-stopped
depends_on:
mosquitto:
condition: service_started
# Shares PID namespace with the broker so `mosquitto_signal -p 1 ...`
# reaches the broker process without mounting docker.sock.
pid: "service:mosquitto"
# PID-namespace OWNER: the broker joins us via its `pid:` directive.
# Owning the namespace means a broker restart (SIGTERM to the broker
# process) only kills the broker — this sidecar keeps running to
# report status and orchestrate the revival. Runs tini as PID 1
# (signal forwarding + zombie reaping). If THIS container dies, the
# kernel tears down the namespace and kills the broker too — the
# sidecar is deliberately minimal so that path should never trigger.
# No depends_on: the broker depends on us (inverted), not vice versa.
environment:
# Minimal default token for the internal control API; in production
# this MUST be replaced (it only guards a backend-net-only endpoint).
@@ -120,8 +138,14 @@ services:
condition: service_healthy
environment:
<<: *backend-env
# Where the backend publishes cert/key files for the broker (spec §6).
MOSQUITTO_CERTS_DIR: /mosquitto/certs
volumes:
- backend_data:/data
# Same volume the broker mounts at /mosquitto/certs — the deploy
# pipeline writes decrypted PEM files here (spec §29: files, not
# sockets).
- mosquitto_certs:/mosquitto/certs
networks:
- frontend-net
- backend-net
@@ -157,6 +181,7 @@ volumes:
mosquitto_config:
mosquitto_data:
mosquitto_log:
mosquitto_certs:
backend_data:
networks:
+13
View File
@@ -17,4 +17,17 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
# Server-Sent Events (topic explorer): buffering would hold stream
# chunks until the buffer fills — disable it and allow long-lived
# connections. The backend also sends X-Accel-Buffering: no.
location /api/v1/topics/stream/ {
proxy_pass http://mqtt-backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
+1003
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -10,11 +10,13 @@
},
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"diff": "^7.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
},
"devDependencies": {
"@types/diff": "^5.2.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
+127 -13
View File
@@ -1,10 +1,17 @@
import { useState } from "react";
import { NavLink, Navigate, Route, Routes, useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "./auth";
import { api, type SystemStatus } from "./api";
import { LoginPage } from "./pages/Login";
import { ClientsPage } from "./pages/Clients";
import { RolesPage } from "./pages/Roles";
import { GroupsPage } from "./pages/Groups";
import { BrokerConfigPage } from "./pages/BrokerConfig";
import { CertificatesPage } from "./pages/Certificates";
import { SystemPage } from "./pages/System";
import { StatusPage } from "./pages/Status";
import { TopicExplorerPage } from "./pages/TopicExplorer";
// Navigation structure per spec section 57. Entries with a `to` route are
// implemented; entries without one are planned pages, rendered greyed out
@@ -16,7 +23,7 @@ const NAV_SECTIONS: {
{
title: "Broker",
items: [
{ label: "Status", hint: "Dashboard / $SYS monitoring (Release 3)" },
{ label: "Status", to: "/broker/status" },
{ label: "Configuration", to: "/broker" },
{ label: "Listeners", to: "/broker#listeners" },
{ label: "Bridges", hint: "Bridge management (post-MVP)" },
@@ -36,17 +43,17 @@ const NAV_SECTIONS: {
{
title: "Certificates",
items: [
{ label: "Authorities", hint: "PKI (Release 2)" },
{ label: "Server Certificates", hint: "PKI (Release 2)" },
{ label: "Client Certificates", hint: "PKI (Release 2)" },
{ label: "CSRs", hint: "CSR signing (Release 2)" },
{ label: "Revocations", hint: "CRL management (Release 2)" },
{ label: "Authorities", to: "/certificates" },
{ label: "Server Certificates", to: "/certificates" },
{ label: "Client Certificates", to: "/certificates#client-certificates" },
{ label: "CSRs", hint: "CSR signing (Release 2, later)" },
{ label: "Revocations", hint: "CRL management (Release 2, later)" },
],
},
{
title: "Tools",
items: [
{ label: "Topic Explorer", hint: "Test console (post-MVP)" },
{ label: "Topic Explorer", to: "/topics" },
{ label: "Connection Test", hint: "Connectivity tester (post-MVP)" },
{ label: "Client Provisioning", hint: "Provisioning wizard (post-MVP)" },
],
@@ -58,24 +65,96 @@ const NAV_SECTIONS: {
{ label: "Roles", hint: "Management roles admin (Release 3)" },
{ label: "Audit Log", hint: "Audit viewer (Release 3)" },
{ label: "Backup", hint: "Backup/restore (Release 3)" },
{ label: "System", hint: "System settings (Release 3)" },
{ label: "System", to: "/system" },
],
},
];
function RestartIndicator() {
const { data } = useQuery({
queryKey: ["system-status"],
queryFn: () => api.get<SystemStatus>("/system/status"),
refetchInterval: 15000,
});
if (!data?.restart_required) return null;
return (
<NavLink to="/system" className="restart-needed">
⚠ Restart needed — listener changes are pending
</NavLink>
);
}
// Hash-aware active matching: an item is active only when BOTH its path
// AND its hash (if any) match the current location. NavLink's default
// prefix matching lights up every /broker* item on /broker/status, and
// hash items (#listeners, #revisions) all match on pathname alone.
function isItemActive(
to: string,
location: { pathname: string; hash: string },
): boolean {
const [path, hash] = to.split("#");
if (path !== location.pathname) return false;
return hash ? location.hash === `#${hash}` : location.hash === "";
}
function Layout({ children }: { children: React.ReactNode }) {
const { session, logout } = useAuth();
const location = useLocation();
// Exactly ONE group is open at a time (null = all closed). The group
// containing the ACTIVE page auto-opens, so navigation (including the
// restart-needed indicator link) always reveals its section.
const [openSection, setOpenSection] = useState<string | null>(null);
const activeSection = NAV_SECTIONS.find((section) =>
section.items.some(
(item) => item.to && isItemActive(item.to, location),
),
);
return (
<div className="layout">
<aside className="sidebar">
<h1>MQTT Management</h1>
<RestartIndicator />
<nav>
{NAV_SECTIONS.map((section) => (
<div key={section.title} className="nav-section">
<h2>{section.title}</h2>
{NAV_SECTIONS.map((section) => {
const isActive = activeSection?.title === section.title;
const isOpen =
openSection === section.title ||
(isActive && openSection === null);
return (
<div
key={section.title}
className={`nav-section${isOpen ? " open" : ""}`}
>
<button
onClick={() =>
setOpenSection(
openSection === section.title ? null : section.title,
)
}
aria-expanded={isOpen}
>
{section.title}
<span className="chevron">▸</span>
</button>
<div className="nav-items">
<div>
{section.items.map((item) =>
item.to ? (
<NavLink key={item.label} to={item.to}>
<NavLink
key={item.label}
to={item.to}
end
// Full control of the active class: NavLink's
// own prefix matching would mark every /broker*
// item active on /broker/status.
className={() =>
isItemActive(item.to!, location)
? "active"
: ""
}
>
{item.label}
</NavLink>
) : (
@@ -90,7 +169,10 @@ function Layout({ children }: { children: React.ReactNode }) {
),
)}
</div>
))}
</div>
</div>
);
})}
</nav>
<p style={{ marginTop: "2rem", fontSize: "0.8rem", opacity: 0.7 }}>
{session?.username} ({session?.role})
@@ -149,6 +231,38 @@ export function App() {
</Protected>
}
/>
<Route
path="/broker/status"
element={
<Protected>
<StatusPage />
</Protected>
}
/>
<Route
path="/certificates"
element={
<Protected>
<CertificatesPage />
</Protected>
}
/>
<Route
path="/system"
element={
<Protected>
<SystemPage />
</Protected>
}
/>
<Route
path="/topics"
element={
<Protected>
<TopicExplorerPage />
</Protected>
}
/>
<Route path="/" element={<Navigate to="/clients" replace />} />
</Routes>
);
+88
View File
@@ -93,6 +93,11 @@ export const api = {
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
};
/** Build a text/plain download URL for a PEM payload (one-time bundles). */
export function pemDataUrl(pem: string): string {
return `data:application/x-pem-file;charset=utf-8,${encodeURIComponent(pem)}`;
}
// --- Assignment operations (spec sections 11-12) ---------------------------
export const assignClientRole = (username: string, rolename: string, priority = -1) =>
@@ -162,6 +167,58 @@ export interface PasswordReset {
password: string;
}
export interface Certificate {
id: string;
common_name: string;
is_ca: boolean;
parent_id: string | null;
cert_type: string;
username: string | null;
key_algorithm: string;
serial_number: string;
dns_sans: string[];
ip_sans: string[];
fingerprint: string;
status: string;
expiry_date: string;
created_at: string;
}
export interface CertificateBundle {
certificate_id: string;
username: string;
certificate_pem: string;
private_key_pem: string;
ca_chain_pem: string;
download_hint: string;
}
export interface ClientCertCreate {
username: string;
ca_id: string;
validity_days: number;
}
export interface CaCreate {
common_name: string;
validity_days: number;
key_algorithm: "rsa3072" | "rsa4096" | "ecdsa-p256" | "ecdsa-p384";
organization?: string | null;
organizational_unit?: string | null;
country?: string | null;
}
export interface ServerCertCreate {
ca_id: string;
common_name: string;
validity_days: number;
dns_sans: string[];
ip_sans: string[];
organization?: string | null;
organizational_unit?: string | null;
country?: string | null;
}
export interface Listener {
id: string;
name: string;
@@ -175,6 +232,7 @@ export interface Listener {
tls_min_version: string | null;
require_certificate: boolean;
use_identity_as_username: boolean;
cert_id: string | null;
}
export interface Revision {
@@ -186,3 +244,33 @@ export interface Revision {
deployed_at: string | null;
created_at: string;
}
export interface SystemStatus {
broker_alive: boolean;
restart_required: boolean;
}
export interface SubscribeResponse {
session_id: string;
}
export interface ExplorerMessage {
index: number;
topic: string;
qos: number;
retain: boolean;
received_at: number;
payload_b64: string;
payload_text: string | null;
properties: Record<string, unknown> | null;
}
export interface RetainedMessage {
index: number;
topic: string;
qos: number;
retain: boolean;
received_at: number;
payload_b64: string;
payload_text: string | null;
}
+108 -6
View File
@@ -1,6 +1,6 @@
import { FormEvent, useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type Listener, type Revision } from "../api";
import { api, type Certificate, type Listener, type Revision } from "../api";
interface ConfigPreview {
config_text: string;
@@ -25,6 +25,7 @@ const EMPTY_LISTENER: Omit<Listener, "id"> = {
tls_min_version: null,
require_certificate: false,
use_identity_as_username: false,
cert_id: null,
};
// Curated subset of spec section 31; anything else can be added later
@@ -83,13 +84,19 @@ export function BrokerConfigPage() {
});
const deploy = useMutation({
mutationFn: (comment: string | null) =>
api.post<{ status: string; revision: number; broker_healthy: boolean }>(
"/broker/config/deploy",
{ comment },
),
api.post<{
status: string;
revision: number;
broker_healthy: boolean;
restart_required: boolean;
}>("/broker/config/deploy", { comment }),
onSuccess: () => {
refresh();
queryClient.invalidateQueries({ queryKey: ["revisions"] });
// The glowing restart-needed indicator polls on its own cycle;
// invalidate its query so a listener-changing deploy shows it
// IMMEDIATELY instead of up to 15s later.
queryClient.invalidateQueries({ queryKey: ["system-status"] });
},
});
@@ -222,9 +229,18 @@ export function BrokerConfigPage() {
{deploy.isPending ? "Deploying…" : "Validate & deploy configuration"}
</button>
{deploy.data && (
<div className="banner ok">
<div className={`banner ${deploy.data.restart_required ? "warn" : "ok"}`}>
Deployed revision {deploy.data.revision}. Broker health:{" "}
{deploy.data.broker_healthy ? "OK" : "unknown — check logs"}
{deploy.data.restart_required && (
<>
{" "}
<strong>
⚠ Restart required — listener changes are pending. Go to
Administration → System to restart the broker.
</strong>
</>
)}
</div>
)}
{deploy.error && (
@@ -276,6 +292,15 @@ function ListenerForm({
const set = (patch: Partial<Listener>) => setDraft((d) => ({ ...d, ...patch }));
const valid = draft.name.trim() !== "" && draft.port > 0 && draft.port <= 65535;
// Server certificates for the certfile/keyfile/cafile binding.
const serverCerts = useQuery({
queryKey: ["certificates"],
queryFn: () => api.get<Certificate[]>("/pki/certificates"),
});
const certOptions = (serverCerts.data ?? []).filter(
(c) => !c.is_ca && c.cert_type === "server",
);
const submit = (e: FormEvent) => {
e.preventDefault();
if (valid) {
@@ -346,6 +371,83 @@ function ListenerForm({
/>
</div>
</div>
<div
style={{
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
alignItems: "flex-end",
marginTop: "0.5rem",
borderTop: "1px solid #e4e7eb",
paddingTop: "0.5rem",
}}
>
<div>
<label>TLS</label>
<select
value={draft.tls_enabled ? "true" : "false"}
onChange={(e) => set({ tls_enabled: e.target.value === "true" })}
>
<option value="false">disabled</option>
<option value="true">enabled</option>
</select>
</div>
{draft.tls_enabled && (
<>
<div>
<label>Server certificate</label>
<select
value={draft.cert_id ?? ""}
onChange={(e) => set({ cert_id: e.target.value || null })}
>
<option value="">
{certOptions.length === 0 ? "none issued yet…" : "select certificate…"}
</option>
{certOptions.map((c) => (
<option key={c.id} value={c.id}>
{c.common_name} ({new Date(c.expiry_date).toLocaleDateString()})
</option>
))}
</select>
</div>
<div>
<label>Min TLS version</label>
<select
value={draft.tls_min_version ?? ""}
onChange={(e) => set({ tls_min_version: e.target.value || null })}
>
<option value="">broker default</option>
<option value="tlsv1.2">tlsv1.2</option>
<option value="tlsv1.3">tlsv1.3</option>
</select>
</div>
<div>
<label>Client certificates</label>
<select
value={draft.require_certificate ? "true" : "false"}
onChange={(e) => set({ require_certificate: e.target.value === "true" })}
>
<option value="false">not required</option>
<option value="true">required (mTLS)</option>
</select>
</div>
{draft.require_certificate && (
<div>
<label>Cert CN as username</label>
<select
value={draft.use_identity_as_username ? "true" : "false"}
onChange={(e) =>
set({ use_identity_as_username: e.target.value === "true" })
}
>
<option value="false">no</option>
<option value="true">yes — CN must match a client username</option>
</select>
</div>
)}
</>
)}
</div>
<p />
<button className="primary" disabled={!valid || pending}>
{pending ? (initial ? "Saving…" : "Adding…") : initial ? "Save changes" : "Add listener"}
+396
View File
@@ -0,0 +1,396 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type Certificate } from "../api";
const KEY_ALGORITHMS = ["rsa3072", "rsa4096", "ecdsa-p256", "ecdsa-p384"] as const;
// --- CA creation form ---------------------------------------------------------
function CaForm({ pending }: { pending: boolean }) {
const queryClient = useQueryClient();
const [cn, setCn] = useState("");
const [days, setDays] = useState(3650);
const [algorithm, setAlgorithm] = useState<(typeof KEY_ALGORITHMS)[number]>("ecdsa-p256");
const [org, setOrg] = useState("");
const [error, setError] = useState<string | null>(null);
const create = useMutation({
mutationFn: () =>
api.post<Certificate>("/pki/ca", {
common_name: cn,
validity_days: days,
key_algorithm: algorithm,
organization: org || null,
}),
onSuccess: () => {
setCn("");
setError(null);
queryClient.invalidateQueries({ queryKey: ["cas"] });
queryClient.invalidateQueries({ queryKey: ["certificates"] });
},
onError: (e: Error) => setError(e.message),
});
const submit = (e: FormEvent) => {
e.preventDefault();
if (cn) create.mutate();
};
return (
<form className="stack" onSubmit={submit}>
{error && <div className="banner error">{error}</div>}
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "flex-end" }}>
<div>
<label>Common name</label>
<input
value={cn}
onChange={(e) => setCn(e.target.value)}
placeholder="MQTT Platform Root CA"
/>
</div>
<div>
<label>Validity (days)</label>
<input
type="number"
value={days}
min={1}
max={36500}
onChange={(e) => setDays(Number(e.target.value))}
/>
</div>
<div>
<label>Key algorithm</label>
<select value={algorithm} onChange={(e) => setAlgorithm(e.target.value as (typeof KEY_ALGORITHMS)[number])}>
{KEY_ALGORITHMS.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</div>
<div>
<label>Organization</label>
<input value={org} onChange={(e) => setOrg(e.target.value)} placeholder="optional" />
</div>
<button className="primary" disabled={!cn || pending} type="submit">
{pending ? "Generating…" : "Create internal CA"}
</button>
</div>
</form>
);
}
// --- Server certificate issuance form ------------------------------------------
function ServerCertForm({
cas,
pending,
}: {
cas: Certificate[];
pending: boolean;
}) {
const queryClient = useQueryClient();
const [caId, setCaId] = useState("");
const [cn, setCn] = useState("");
const [days, setDays] = useState(365);
const [dns, setDns] = useState("");
const [ips, setIps] = useState("");
const [error, setError] = useState<string | null>(null);
const issue = useMutation({
mutationFn: () =>
api.post<Certificate>("/pki/certificates", {
ca_id: caId,
common_name: cn,
validity_days: days,
dns_sans: dns.split(",").map((s) => s.trim()).filter(Boolean),
ip_sans: ips.split(",").map((s) => s.trim()).filter(Boolean),
}),
onSuccess: () => {
setCn("");
setDns("");
setIps("");
setError(null);
queryClient.invalidateQueries({ queryKey: ["certificates"] });
},
onError: (e: Error) => setError(e.message),
});
const submit = (e: FormEvent) => {
e.preventDefault();
if (cn && caId) issue.mutate();
};
if (cas.length === 0) {
return (
<p style={{ color: "#52606d" }}>
No CA exists yet — create a certificate authority first; server
certificates are signed by one.
</p>
);
}
return (
<form className="stack" onSubmit={submit}>
{error && <div className="banner error">{error}</div>}
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "flex-end" }}>
<div>
<label>Signing CA</label>
<select value={caId} onChange={(e) => setCaId(e.target.value)}>
<option value="">select CA…</option>
{cas.map((ca) => (
<option key={ca.id} value={ca.id}>
{ca.common_name} ({ca.key_algorithm})
</option>
))}
</select>
</div>
<div>
<label>Common name</label>
<input
value={cn}
onChange={(e) => setCn(e.target.value)}
placeholder="mqtt.example.local"
/>
</div>
<div>
<label>Validity (days)</label>
<input
type="number"
value={days}
min={1}
max={36500}
onChange={(e) => setDays(Number(e.target.value))}
/>
</div>
<div>
<label>DNS SANs (comma-separated)</label>
<input value={dns} onChange={(e) => setDns(e.target.value)} placeholder="mqtt, mqtt.local" />
</div>
<div>
<label>IP SANs (comma-separated)</label>
<input value={ips} onChange={(e) => setIps(e.target.value)} placeholder="10.20.30.40" />
</div>
<button className="primary" disabled={!cn || !caId || pending} type="submit">
{pending ? "Issuing…" : "Issue server certificate"}
</button>
</div>
</form>
);
}
// --- Inventory table -----------------------------------------------------------
function expiryClass(expiry: string): string | null {
const days = (new Date(expiry).getTime() - Date.now()) / 86400000;
if (days < 0) return "banner error";
if (days < 30) return "banner warn";
return null;
}
export function CertificatesPage() {
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const cas = useQuery({
queryKey: ["cas"],
queryFn: () => api.get<Certificate[]>("/pki/ca"),
});
const certificates = useQuery({
queryKey: ["certificates"],
queryFn: () => api.get<Certificate[]>("/pki/certificates"),
});
const remove = useMutation({
mutationFn: (id: string) => api.delete(`/pki/certificates/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cas"] });
queryClient.invalidateQueries({ queryKey: ["certificates"] });
},
onError: (e: Error) => setError(e.message),
});
const caList = cas.data ?? [];
return (
<div>
<h2>Certificate authorities</h2>
<p>
Internal CAs sign all platform-issued certificates. Private keys are
stored encrypted (AES-256-GCM) and never leave the backend; only the
public certificate is downloadable.
</p>
{(cas.error || certificates.error) && (
<div className="banner error">
{((cas.error ?? certificates.error) as Error).message}
</div>
)}
{error && <div className="banner error">{error}</div>}
<CaForm pending={cas.isFetching && false} />
{caList.length === 0 ? (
<p style={{ color: "#52606d" }}>No CA yet — create one above.</p>
) : (
<table style={{ marginTop: "1.5rem" }}>
<thead>
<tr>
<th>Common name</th>
<th>Algorithm</th>
<th>Serial</th>
<th>Expires</th>
<th></th>
</tr>
</thead>
<tbody>
{caList.map((ca) => (
<tr key={ca.id}>
<td>{ca.common_name}</td>
<td className="mono">{ca.key_algorithm}</td>
<td className="mono">{ca.serial_number.slice(0, 16)}…</td>
<td>{new Date(ca.expiry_date).toLocaleDateString()}</td>
<td>
<a href={`/api/v1/pki/certificates/${ca.id}/download`}>Download PEM</a>{" "}
<button
className="danger"
onClick={() => {
if (confirm(`Delete CA ${ca.common_name} and its stored key?`))
remove.mutate(ca.id);
}}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<h3 style={{ marginTop: "2rem" }}>Server certificates</h3>
<p style={{ color: "#52606d" }}>
Bound to TLS listeners (certfile/keyfile). Issue one, then select it
in the listener form under Broker configuration.
</p>
<ServerCertForm cas={caList} pending={certificates.isFetching && false} />
<table style={{ marginTop: "1.5rem" }}>
<thead>
<tr>
<th>Common name</th>
<th>Signed by</th>
<th>SANs</th>
<th>Status</th>
<th>Expires</th>
<th></th>
</tr>
</thead>
<tbody>
{(certificates.data ?? [])
.filter((c) => c.cert_type === "server")
.map((c) => {
const signer = caList.find((ca) => ca.id === c.parent_id);
return (
<tr key={c.id}>
<td>{c.common_name}</td>
<td>{signer ? signer.common_name : "—"}</td>
<td className="mono">
{[...c.dns_sans, ...c.ip_sans].join(", ") || "—"}
</td>
<td>{c.status}</td>
<td className={expiryClass(c.expiry_date) ?? undefined}>
{new Date(c.expiry_date).toLocaleDateString()}
</td>
<td>
<a href={`/api/v1/pki/certificates/${c.id}/download`}>
Download PEM
</a>{" "}
<button
className="danger"
onClick={() => {
if (confirm(`Delete certificate ${c.common_name}?`))
remove.mutate(c.id);
}}
>
Delete
</button>
</td>
</tr>
);
})}
</tbody>
</table>
{(certificates.data ?? []).filter((c) => c.cert_type === "server").length === 0 && (
<p style={{ color: "#52606d" }}>
No server certificates yet. Issue one above, then bind it to a TLS
listener in Broker configuration.
</p>
)}
<h3 id="client-certificates" style={{ marginTop: "2rem" }}>
Client certificates
</h3>
<p style={{ color: "#52606d" }}>
Authenticate MQTT devices (mTLS): the certificate CN is the MQTT
username, so with use_identity_as_username the broker maps the
certificate to that client. Issue them from the Clients page — the
private key is shown exactly once there.
</p>
<table style={{ marginTop: "1.5rem" }}>
<thead>
<tr>
<th>MQTT client</th>
<th>Signed by</th>
<th>Status</th>
<th>Expires</th>
<th></th>
</tr>
</thead>
<tbody>
{(certificates.data ?? [])
.filter((c) => c.cert_type === "client")
.map((c) => {
const signer = caList.find((ca) => ca.id === c.parent_id);
return (
<tr key={c.id}>
<td className="mono">{c.username ?? c.common_name}</td>
<td>{signer ? signer.common_name : "—"}</td>
<td>{c.status}</td>
<td className={expiryClass(c.expiry_date) ?? undefined}>
{new Date(c.expiry_date).toLocaleDateString()}
</td>
<td>
<a href={`/api/v1/pki/certificates/${c.id}/download`}>
Download PEM
</a>{" "}
<button
className="danger"
onClick={() => {
const target = c.username ?? c.common_name;
if (
confirm(
`Delete the certificate for ${target}? The client ` +
"will no longer be able to authenticate with it.",
)
)
remove.mutate(c.id);
}}
>
Delete
</button>
</td>
</tr>
);
})}
</tbody>
</table>
{(certificates.data ?? []).filter((c) => c.cert_type === "client").length === 0 && (
<p style={{ color: "#52606d" }}>
No client certificates yet. Open Clients and use “Issue certificate”
on a client to provision a device for mTLS.
</p>
)}
</div>
);
}
+131 -4
View File
@@ -4,8 +4,11 @@ import {
api,
assignClientRole,
assignGroupMember,
pemDataUrl,
removeClientRole,
removeGroupMember,
type Certificate,
type CertificateBundle,
type MqttClient,
type PasswordReset,
type Role,
@@ -54,6 +57,7 @@ function AssignmentChips({
export function ClientsPage() {
const queryClient = useQueryClient();
const [newUsername, setNewUsername] = useState("");
const [newClientId, setNewClientId] = useState("");
const [oncePassword, setOncePassword] = useState<PasswordReset | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -77,16 +81,37 @@ export function ClientsPage() {
};
const create = useMutation({
mutationFn: (username: string) =>
api.post<PasswordReset>("/mqtt/clients", { username }),
mutationFn: ({ username, clientid }: { username: string; clientid: string | null }) =>
api.post<PasswordReset>("/mqtt/clients", {
username,
clientid: clientid || null,
}),
onSuccess: (result) => {
setOncePassword(result);
setNewUsername("");
setNewClientId("");
refresh();
},
onError: (e: Error) => setError(e.message),
});
const bindClientId = useMutation({
mutationFn: ({ username, clientid }: { username: string; clientid: string }) =>
api.put(`/mqtt/clients/${username}/clientid`, { clientid }),
onSuccess: refresh,
onError: (e: Error) => setError(e.message),
});
const setClientIdFor = (c: MqttClient) => {
const current = c.clientid ?? "";
const answer = prompt(
`Expected client ID for ${c.username} (empty = any client id may connect):`,
current,
);
if (answer === null) return; // cancelled
bindClientId.mutate({ username: c.username, clientid: answer.trim() });
};
const disable = useMutation({
mutationFn: (username: string) => api.post(`/mqtt/clients/${username}/disable`),
onSuccess: refresh,
@@ -110,6 +135,50 @@ export function ClientsPage() {
onError: (e: Error) => setError(e.message),
});
const [certBundle, setCertBundle] = useState<CertificateBundle | null>(null);
const cas = useQuery({
queryKey: ["cas"],
queryFn: () => api.get<Certificate[]>("/pki/ca"),
});
const issueCert = useMutation({
mutationFn: ({
username,
caId,
days,
}: {
username: string;
caId: string;
days: number;
}) =>
api.post<CertificateBundle>("/pki/certificates/client", {
username,
ca_id: caId,
validity_days: days,
}),
onSuccess: (result) => setCertBundle(result),
onError: (e: Error) => setError(e.message),
});
const issueClientCertificate = (username: string) => {
const caList = cas.data ?? [];
if (caList.length === 0) {
setError(
"No certificate authority exists yet — create one under Certificates first.",
);
return;
}
const daysStr = prompt("Validity in days (e.g. 365):", "365");
if (!daysStr) return;
const days = Number(daysStr);
if (!Number.isFinite(days) || days <= 0) {
setError("Validity must be a positive number of days.");
return;
}
issueCert.mutate({ username, caId: caList[0].id, days });
};
const assignRole = useMutation({
mutationFn: ({ username, rolename }: { username: string; rolename: string }) =>
assignClientRole(username, rolename),
@@ -141,7 +210,8 @@ export function ClientsPage() {
const submit = (e: FormEvent) => {
e.preventDefault();
setError(null);
if (newUsername) create.mutate(newUsername);
if (newUsername)
create.mutate({ username: newUsername, clientid: newClientId.trim() });
};
const roleNames = (roles.data ?? []).map((r) => r.rolename);
@@ -163,6 +233,40 @@ export function ClientsPage() {
<div className="password-once mono">{oncePassword.password}</div>
</div>
)}
{certBundle && (
<div className="banner warn">
<strong>
Certificate bundle for {certBundle.username} — save these files now,
the private key is shown only once:
</strong>
<p style={{ margin: "0.4rem 0 0.2rem", fontSize: "0.85rem" }}>
<a href={pemDataUrl(certBundle.certificate_pem)} download={`${certBundle.username}.crt`}>
Download {certBundle.username}.crt
</a>{" "}
<a href={pemDataUrl(certBundle.private_key_pem)} download={`${certBundle.username}.key`}>
Download {certBundle.username}.key
</a>{" "}
<a href={pemDataUrl(certBundle.ca_chain_pem)} download="ca.crt">
Download ca.crt (issuing CA)
</a>
</p>
<p style={{ margin: "0.4rem 0 0.2rem", fontSize: "0.85rem" }}>
or copy manually — <span className="mono">{certBundle.username}.crt</span>:
</p>
<pre className="config mono">{certBundle.certificate_pem}</pre>
<p style={{ margin: "0.4rem 0 0.2rem", fontSize: "0.85rem" }}>
<span className="mono">{certBundle.username}.key</span>:
</p>
<pre className="config mono">{certBundle.private_key_pem}</pre>
<p style={{ margin: "0.4rem 0 0.2rem", fontSize: "0.85rem" }}>
<span className="mono">ca.crt</span> (issuing CA):
</p>
<pre className="config mono">{certBundle.ca_chain_pem}</pre>
<button onClick={() => setCertBundle(null)}>
I have saved the files — dismiss
</button>
</div>
)}
{error && <div className="banner error">{error}</div>}
{(clients.error || roles.error || groups.error) && (
<div className="banner error">
@@ -179,6 +283,15 @@ export function ClientsPage() {
onChange={(e) => setNewUsername(e.target.value)}
placeholder="e.g. ps042"
/>
<label htmlFor="new-client-id">
Expected client ID (optional — binds the credential to one device)
</label>
<input
id="new-client-id"
value={newClientId}
onChange={(e) => setNewClientId(e.target.value)}
placeholder="e.g. device-ps042"
/>
<p />
<button className="primary" disabled={!newUsername || create.isPending}>
{create.isPending ? "Creating…" : "Create client (random password)"}
@@ -213,7 +326,15 @@ export function ClientsPage() {
return (
<tr key={c.username}>
<td className="mono">{c.username}</td>
<td className="mono">{c.clientid ?? "—"}</td>
<td className="mono">
{c.clientid ?? "—"}{" "}
<button
onClick={() => setClientIdFor(c)}
title="Bind an expected client ID so this credential can only be used by that device"
>
Set
</button>
</td>
<td>{c.enabled ? "yes" : "no"}</td>
<td>
<AssignmentChips
@@ -291,6 +412,12 @@ export function ClientsPage() {
>
Reset password
</button>{" "}
<button
onClick={() => issueClientCertificate(c.username)}
title="Issue a client certificate (CN = username) for mTLS"
>
Issue certificate
</button>{" "}
<button
className="danger"
onClick={() => {
+160
View File
@@ -0,0 +1,160 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../api";
export interface BrokerStatus {
available: boolean;
last_update: string | null;
version: string | null;
uptime: string | null;
clients_connected: number | null;
clients_disconnected: number | null;
clients_total: number | null;
messages_received: number | null;
messages_sent: number | null;
messages_stored: number | null;
messages_dropped: number | null;
publish_received: number | null;
publish_sent: number | null;
bytes_received: number | null;
bytes_sent: number | null;
subscriptions_count: number | null;
retained_count: number | null;
load_messages_received_1min: number | null;
load_messages_sent_1min: number | null;
load_publish_received_1min: number | null;
load_publish_sent_1min: number | null;
load_bytes_received_1min: number | null;
load_bytes_sent_1min: number | null;
}
function fmt(n: number | null | undefined): string {
return n === null || n === undefined ? "—" : n.toLocaleString();
}
function Metric({
label,
value,
hint,
}: {
label: string;
value: string;
hint?: string;
}) {
return (
<div
style={{
background: "#fff",
border: "1px solid #e4e7eb",
borderRadius: "8px",
padding: "0.8rem 1rem",
minWidth: "150px",
flex: "1 1 150px",
}}
title={hint}
>
<div style={{ fontSize: "0.75rem", color: "#52606d" }}>{label}</div>
<div className="mono" style={{ fontSize: "1.15rem" }}>
{value}
</div>
</div>
);
}
export function StatusPage() {
const status = useQuery({
queryKey: ["broker-status"],
queryFn: () => api.get<BrokerStatus>("/broker/status"),
// Match the broker's sys_interval (10 s in the bootstrap config).
refetchInterval: 10000,
});
const s = status.data;
return (
<div>
<h2>Broker status</h2>
<p>
Live telemetry from the broker's <code>$SYS</code> topics. The broker
publishes every 10 seconds; this page refreshes to match.
</p>
{status.error && (
<div className="banner error">{(status.error as Error).message}</div>
)}
{!s?.available && !status.error && (
<div className="banner warn">
No $SYS telemetry received yet. This happens for a few seconds after
backend startup, or if the management identity lacks $SYS read access
(the sys-observe grant is applied at backend startup).
</div>
)}
{s?.available && (
<>
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}>
<Metric label="Version" value={s.version ?? "—"} />
<Metric label="Uptime" value={s.uptime ?? "—"} />
<Metric
label="Connected clients"
value={fmt(s.clients_connected)}
hint="devices currently connected"
/>
<Metric
label="Stored sessions"
value={fmt(s.clients_disconnected)}
hint="persistent clients currently offline"
/>
<Metric label="Subscriptions" value={fmt(s.subscriptions_count)} />
<Metric label="Retained messages" value={fmt(s.retained_count)} />
</div>
<h3 style={{ marginTop: "1.5rem" }}>Messages</h3>
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}>
<Metric label="Received (total)" value={fmt(s.messages_received)} />
<Metric label="Sent (total)" value={fmt(s.messages_sent)} />
<Metric label="Stored" value={fmt(s.messages_stored)} />
<Metric label="Dropped" value={fmt(s.messages_dropped)} />
<Metric label="Publish received" value={fmt(s.publish_received)} />
<Metric label="Publish sent" value={fmt(s.publish_sent)} />
</div>
<h3 style={{ marginTop: "1.5rem" }}>Throughput (1 min avg / s)</h3>
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}>
<Metric
label="Messages in"
value={fmt(s.load_messages_received_1min)}
/>
<Metric
label="Messages out"
value={fmt(s.load_messages_sent_1min)}
/>
<Metric
label="Publishes in"
value={fmt(s.load_publish_received_1min)}
/>
<Metric
label="Publishes out"
value={fmt(s.load_publish_sent_1min)}
/>
<Metric label="Bytes in" value={fmt(s.load_bytes_received_1min)} />
<Metric label="Bytes out" value={fmt(s.load_bytes_sent_1min)} />
</div>
<h3 style={{ marginTop: "1.5rem" }}>Bytes (total)</h3>
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}>
<Metric label="Received" value={fmt(s.bytes_received)} />
<Metric label="Sent" value={fmt(s.bytes_sent)} />
</div>
<p style={{ color: "#52606d", fontSize: "0.8rem", marginTop: "1rem" }}>
Last $SYS update:{" "}
{s.last_update
? new Date(s.last_update).toLocaleTimeString()
: "—"}
</p>
</>
)}
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type SystemStatus } from "../api";
export function SystemPage() {
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const [banner, setBanner] = useState<string | null>(null);
const status = useQuery({
queryKey: ["system-status"],
queryFn: () => api.get<SystemStatus>("/system/status"),
// Poll while a restart is in flight so "broker down" resolves by itself.
refetchInterval: (query) =>
query.state.data && !query.state.data.broker_alive ? 3000 : 15000,
});
const restart = useMutation({
mutationFn: () => api.post<{ status: string }>("/system/broker/restart"),
onSuccess: () => {
setBanner("Restart signal sent — the broker stops and Docker brings it back.");
setError(null);
// Force an immediate re-poll; the alive flag flips within seconds.
queryClient.invalidateQueries({ queryKey: ["system-status"] });
},
onError: (e: Error) => setError(e.message),
});
const data = status.data;
const restarting = data !== undefined && !data.broker_alive;
// Clear the transient banner once the broker is back.
useEffect(() => {
if (data?.broker_alive) setBanner(null);
}, [data?.broker_alive]);
return (
<div>
<h2>System</h2>
<p>
Broker lifecycle operations through the control sidecar (spec section
29). The restart stops Mosquitto gracefully — persistent sessions and
queued messages are saved — and Docker's restart policy brings the
container back within seconds.
</p>
{error && <div className="banner error">{error}</div>}
{banner && !error && <div className="banner ok">{banner}</div>}
{status.error && (
<div className="banner error">
Failed to load system status: {(status.error as Error).message}
</div>
)}
{data?.restart_required && (
<div className="banner warn">
<strong>Restart needed:</strong> the deployed configuration contains
listener changes that a config reload cannot apply (spec section 28).
Restart the broker below to activate them.
</div>
)}
<table>
<thead>
<tr>
<th>Service</th>
<th>State</th>
</tr>
</thead>
<tbody>
<tr>
<td>Mosquitto broker</td>
<td>
{restarting ? (
<span style={{ color: "#8a5a00" }}>
restarting… (polling until it is back)
</span>
) : data?.broker_alive ? (
<span style={{ color: "#1e6b3a" }}>running</span>
) : (
<span style={{ color: "#a4342d" }}>unreachable</span>
)}
</td>
</tr>
<tr>
<td>Listener changes pending a restart</td>
<td>{data?.restart_required ? "yes" : "no"}</td>
</tr>
</tbody>
</table>
<p />
<button
className="primary"
disabled={restart.isPending || restarting}
onClick={() => {
if (
confirm(
"Restart the Mosquitto broker?\n\n" +
"All connected devices are disconnected (they can reconnect " +
"once it is back). Persistent sessions and queued messages are " +
"saved. The broker is unavailable for a few seconds.",
)
)
restart.mutate();
}}
>
{restart.isPending
? "Sending restart signal…"
: restarting
? "Broker is restarting…"
: "Restart broker"}
</button>
</div>
);
}
+568
View File
@@ -0,0 +1,568 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { diffWords } from "diff";
import { api, type ExplorerMessage, type RetainedMessage } from "../api";
const HISTORY_PER_TOPIC = 100; // history kept per topic in the UI
// --- Consolidated topic view (spec 35.1) ------------------------------------
interface TopicRow {
topic: string;
latest: ExplorerMessage;
previous: ExplorerMessage | null;
history: ExplorerMessage[]; // most recent first
updated_at: number; // for the glow animation key
}
/** Which fields changed between the previous and the latest message. */
function diffFields(
latest: ExplorerMessage,
previous: ExplorerMessage | null,
): Set<string> {
if (previous === null) return new Set(["payload", "qos", "retain", "properties"]);
const changed = new Set<string>();
if (latest.payload_b64 !== previous.payload_b64) changed.add("payload");
if (latest.qos !== previous.qos) changed.add("qos");
if (latest.retain !== previous.retain) changed.add("retain");
if (JSON.stringify(latest.properties ?? null) !== JSON.stringify(previous.properties ?? null))
changed.add("properties");
return changed;
}
/** Fold a live message into the per-topic map (spec 35.1: one row per topic). */
function foldMessage(
rows: Map<string, TopicRow>,
msg: ExplorerMessage,
): Map<string, TopicRow> {
const existing = rows.get(msg.topic);
if (existing) {
existing.previous = existing.latest;
existing.latest = msg;
existing.history = [msg, ...existing.history].slice(0, HISTORY_PER_TOPIC);
existing.updated_at = msg.received_at;
} else {
rows.set(msg.topic, {
topic: msg.topic,
latest: msg,
previous: null,
history: [msg],
updated_at: msg.received_at,
});
}
return rows;
}
/** Cell that highlights when its field changed vs the previous message. */
function DiffCell({
changed,
children,
className,
style,
}: {
changed: boolean;
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}) {
return (
<td
className={className}
style={{
...style,
...(changed ? { background: "#fff3cd" } : {}),
transition: "background 1.5s ease",
}}
>
{children}
</td>
);
}
function toHex(b64: string): string {
const bytes = atob(b64);
return Array.from(bytes)
.map((b) => b.charCodeAt(0).toString(16).padStart(2, "0"))
.join(" ");
}
// Word-level payload highlighting (spec 35.1): only the tokens that
// CHANGED between the previous and the latest payload are marked —
// e.g. {"cmd": "start"} -> {"cmd": "stop"} highlights just `stop`.
const DIFF_MAX_BYTES = 4096; // diffing huge payloads on every render is not worth it
function PayloadDiff({
latest,
previous,
}: {
latest: ExplorerMessage;
previous: ExplorerMessage | null;
}) {
const text = latest.payload_text;
const prev = previous?.payload_text ?? null;
// Fallbacks: first message for the topic, either side binary, or
// oversized payloads render plain (the amber cell background from
// diffFields still marks the change at field level).
if (
text === null ||
prev === null ||
atob(latest.payload_b64).length > DIFF_MAX_BYTES ||
atob(previous!.payload_b64).length > DIFF_MAX_BYTES
) {
return <>{text ?? `(binary, ${atob(latest.payload_b64).length} B)`}</>;
}
try {
const parts = diffWords(prev, text);
return (
<>
{parts.map((part, i) =>
part.added ? (
<mark
key={i}
style={{
background: "#bcf2a2",
color: "inherit",
padding: 0,
}}
>
{part.value}
</mark>
) : part.removed ? null : (
<span key={i}>{part.value}</span>
),
)}
</>
);
} catch {
// diff can throw on pathological inputs — never break rendering.
return <>{text}</>;
}
}
function PayloadView({ msg }: { msg: ExplorerMessage | RetainedMessage }) {
const [mode, setMode] = useState<"text" | "hex">(
msg.payload_text !== null ? "text" : "hex",
);
return (
<div>
<button onClick={() => setMode(mode === "text" ? "hex" : "text")}>
{mode === "text" ? "Show hex" : "Show text"}
</button>
<pre className="config mono" style={{ maxHeight: "180px" }}>
{mode === "text"
? msg.payload_text ?? "(not valid UTF-8 — use hex view)"
: toHex(msg.payload_b64) || "(empty payload)"}
</pre>
{"properties" in msg && msg.properties && (
<div style={{ fontSize: "0.8rem", color: "#52606d" }}>
<strong>MQTT 5 properties:</strong>{" "}
<span className="mono">{JSON.stringify(msg.properties)}</span>
</div>
)}
</div>
);
}
export function TopicExplorerPage() {
const queryClient = useQueryClient();
const [filter, setFilter] = useState("");
// Consolidated view (spec 35.1): one entry per KNOWN TOPIC. The flat
// message stream is folded into this map as messages arrive.
const [topicRows, setTopicRows] = useState<Map<string, TopicRow>>(new Map());
// Topics that just received a message — drives the brief glow.
const [glowing, setGlowing] = useState<Set<string>>(new Set());
const [selectedTopic, setSelectedTopic] = useState<string | null>(null);
const [selected, setSelected] = useState<ExplorerMessage | null>(null);
const [streamError, setStreamError] = useState<string | null>(null);
const [streaming, setStreaming] = useState(false);
// Publish form state
const [pubTopic, setPubTopic] = useState("");
const [pubPayload, setPubPayload] = useState("");
const [pubQos, setPubQos] = useState(0);
const [pubRetain, setPubRetain] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
// Retained panel
const [showRetained, setShowRetained] = useState(false);
const [retainedSelected, setRetainedSelected] =
useState<RetainedMessage | null>(null);
const eventSource = useRef<EventSource | null>(null);
const stopStream = () => {
eventSource.current?.close();
eventSource.current = null;
setStreaming(false);
};
const startStream = async (topicFilter: string) => {
stopStream();
setTopicRows(new Map());
setSelectedTopic(null);
setSelected(null);
setStreamError(null);
try {
const { session_id } = await api.post<{ session_id: string }>(
"/topics/subscribe",
{ topic_filter: topicFilter },
);
const es = new EventSource(`/api/v1/topics/stream/${session_id}`);
es.onopen = () => setStreaming(true);
es.onmessage = (event) => {
const msg: ExplorerMessage = JSON.parse(event.data);
// Fold into the per-topic map (spec 35.1: one row per topic).
setTopicRows((prev) => {
const next = new Map(prev);
return foldMessage(next, msg);
});
// Brief glow for this topic, cleared after the animation.
setGlowing((prev) => {
const next = new Set(prev);
next.add(msg.topic);
return next;
});
window.setTimeout(() => {
setGlowing((prev) => {
const next = new Set(prev);
next.delete(msg.topic);
return next;
});
}, 1200);
};
es.onerror = () => {
setStreamError(
"Stream closed (session ended or broker unreachable). Restart the subscription to continue.",
);
stopStream();
};
eventSource.current = es;
} catch (e) {
setStreamError((e as Error).message);
}
};
// Cleanup on unmount: closing the EventSource triggers the backend
// session teardown (stream generator's finally).
useEffect(() => () => eventSource.current?.close(), []);
const publish = useMutation({
mutationFn: () =>
api.post("/topics/publish", {
topic: pubTopic,
payload_b64: btoa(pubPayload),
qos: pubQos,
retain: pubRetain,
}),
onSuccess: () => {
setActionError(null);
setPubPayload("");
},
onError: (e: Error) => setActionError(e.message),
});
const retainedQuery = useQuery({
queryKey: ["retained"],
queryFn: () => api.get<RetainedMessage[]>("/topics/retained"),
enabled: showRetained,
});
const clearRetained = useMutation({
mutationFn: (topic: string) =>
api.post("/topics/retained/clear", { topic }),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["retained"] }),
onError: (e: Error) => setActionError(e.message),
});
const submitSubscribe = (e: FormEvent) => {
e.preventDefault();
if (filter.trim()) void startStream(filter.trim());
};
const submitPublish = (e: FormEvent) => {
e.preventDefault();
if (pubTopic.trim()) publish.mutate();
};
return (
<div>
<h2>Topic explorer</h2>
<p>
Live view of broker traffic (spec section 35). Subscribes with the
management identity — every topic the broker carries is visible.
Publishes are recorded in the audit log.
</p>
{streamError && <div className="banner error">{streamError}</div>}
{actionError && <div className="banner error">{actionError}</div>}
<form className="stack" onSubmit={submitSubscribe}>
<label htmlFor="topic-filter">Topic filter</label>
<div style={{ display: "flex", gap: "0.5rem" }}>
<input
id="topic-filter"
className="mono"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="e.g. site/+/telemetry/#"
style={{ maxWidth: "300px" }}
/>
{streaming ? (
<button type="button" onClick={stopStream}>
Stop
</button>
) : null}
<button className="primary" type="submit" disabled={!filter.trim()}>
{streaming ? "Restart subscription" : "Subscribe"}
</button>
</div>
</form>
<h3 style={{ marginTop: "1.5rem" }}>Topics</h3>
<p style={{ color: "#52606d", fontSize: "0.8rem" }}>
One row per known topic (spec §35.1). Highlighted cells changed vs the
previous message; the row glows briefly on new-message arrival. Click
a topic to browse its known history.
</p>
<table>
<thead>
<tr>
<th>Topic</th>
<th>Last update</th>
<th>QoS</th>
<th>Retain</th>
<th>Latest payload</th>
</tr>
</thead>
<tbody>
{Array.from(topicRows.values())
.sort((a, b) => b.updated_at - a.updated_at)
.map((row) => {
const changed = diffFields(row.latest, row.previous);
return (
<tr
key={row.topic}
onClick={() => {
setSelectedTopic(
selectedTopic === row.topic ? null : row.topic,
);
setSelected(null);
}}
style={{ cursor: "pointer" }}
className={glowing.has(row.topic) ? "row-glow" : undefined}
>
<td className="mono">{row.topic}</td>
<DiffCell changed={glowing.has(row.topic)} className="mono">
{new Date(row.latest.received_at * 1000).toLocaleTimeString()}
</DiffCell>
<DiffCell changed={changed.has("qos")}>{row.latest.qos}</DiffCell>
<DiffCell changed={changed.has("retain")}>
{row.latest.retain ? "yes" : "—"}
</DiffCell>
<DiffCell
changed={changed.has("payload")}
className="mono"
style={{
maxWidth: "260px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
<PayloadDiff latest={row.latest} previous={row.previous} />
</DiffCell>
</tr>
);
})}
</tbody>
</table>
{topicRows.size === 0 && !streamError && (
<p style={{ color: "#52606d" }}>
{streaming
? "Waiting for messages matching the filter…"
: "Not subscribed — enter a filter above."}
</p>
)}
{selectedTopic && topicRows.get(selectedTopic) && (
<section style={{ marginTop: "1.5rem" }}>
<h3>
History of <span className="mono">{selectedTopic}</span>{" "}
(most recent first)
</h3>
<table>
<thead>
<tr>
<th>Time</th>
<th>QoS</th>
<th>Retain</th>
<th>Payload</th>
</tr>
</thead>
<tbody>
{topicRows.get(selectedTopic)!.history.map((m) => (
<tr
key={m.index}
onClick={() => setSelected(m)}
style={{ cursor: "pointer" }}
>
<td className="mono">
{new Date(m.received_at * 1000).toLocaleTimeString()}
</td>
<td>{m.qos}</td>
<td>{m.retain ? "yes" : "—"}</td>
<td
className="mono"
style={{
maxWidth: "260px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{m.payload_text ?? `(binary, ${atob(m.payload_b64).length} B)`}
</td>
</tr>
))}
</tbody>
</table>
{topicRows.get(selectedTopic)!.history.length === 0 && (
<p style={{ color: "#52606d" }}>No messages known for this topic.</p>
)}
</section>
)}
{selected && (
<section style={{ marginTop: "1.5rem" }}>
<h3>
Message on <span className="mono">{selected.topic}</span>
</h3>
<PayloadView msg={selected} />
</section>
)}
<h3 style={{ marginTop: "2rem" }}>Publish test message</h3>
<form className="stack" onSubmit={submitPublish}>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "flex-end" }}>
<div>
<label htmlFor="pub-topic">Topic</label>
<input
id="pub-topic"
className="mono"
value={pubTopic}
onChange={(e) => setPubTopic(e.target.value)}
placeholder="e.g. site/test/command"
/>
</div>
<div>
<label htmlFor="pub-qos">QoS</label>
<select
id="pub-qos"
value={pubQos}
onChange={(e) => setPubQos(Number(e.target.value))}
>
<option value={0}>0</option>
<option value={1}>1</option>
<option value={2}>2</option>
</select>
</div>
<div>
<label htmlFor="pub-retain">Retain</label>
<select
id="pub-retain"
value={pubRetain ? "true" : "false"}
onChange={(e) => setPubRetain(e.target.value === "true")}
>
<option value="false">false</option>
<option value="true">true</option>
</select>
</div>
<div style={{ flex: "1 1 240px" }}>
<label htmlFor="pub-payload">Payload (UTF-8 text)</label>
<input
id="pub-payload"
className="mono"
value={pubPayload}
onChange={(e) => setPubPayload(e.target.value)}
placeholder='e.g. {"cmd": "restart"}'
/>
</div>
<button
className="primary"
type="submit"
disabled={!pubTopic.trim() || publish.isPending}
>
{publish.isPending ? "Publishing…" : "Publish"}
</button>
</div>
</form>
<h3 style={{ marginTop: "2rem" }}>Retained messages</h3>
<p>
<button onClick={() => setShowRetained(!showRetained)}>
{showRetained ? "Hide" : "List retained messages"}
</button>{" "}
<span style={{ color: "#52606d", fontSize: "0.8rem" }}>
Briefly subscribes to # on the broker (spec section 36).
</span>
</p>
{showRetained && (
<>
{retainedQuery.error && (
<div className="banner error">
{(retainedQuery.error as Error).message}
</div>
)}
<table>
<thead>
<tr>
<th>Topic</th>
<th>QoS</th>
<th>Payload</th>
<th></th>
</tr>
</thead>
<tbody>
{(retainedQuery.data ?? []).map((m) => (
<tr key={m.topic}>
<td className="mono">{m.topic}</td>
<td>{m.qos}</td>
<td
className="mono"
style={{ maxWidth: "260px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
onClick={() => setRetainedSelected(m)}
>
{m.payload_text ?? "(binary)"}
</td>
<td>
<button
className="danger"
onClick={() => {
if (confirm(`Clear the retained message on ${m.topic}?`))
clearRetained.mutate(m.topic);
}}
>
Clear
</button>
</td>
</tr>
))}
</tbody>
</table>
{(retainedQuery.data ?? []).length === 0 && !retainedQuery.isLoading && (
<p style={{ color: "#52606d" }}>No retained messages.</p>
)}
</>
)}
{retainedSelected && (
<section style={{ marginTop: "1rem" }}>
<h4>
Retained payload on <span className="mono">{retainedSelected.topic}</span>
</h4>
<PayloadView msg={retainedSelected} />
</section>
)}
</div>
);
}
+147 -11
View File
@@ -13,16 +13,50 @@ body {
color: #1f2933;
}
.layout {
display: grid;
grid-template-columns: 220px 1fr;
min-height: 100vh;
/* --- Thin scrollbars everywhere ---------------------------------------- */
* {
scrollbar-width: thin; /* Firefox */
scrollbar-color: #9aa5b1 transparent;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: #9aa5b1;
border-radius: 3px;
}
*::-webkit-scrollbar-thumb:hover {
background: #7b8794;
}
.layout {
display: grid;
grid-template-columns: 240px 1fr;
height: 100vh;
overflow: hidden; /* the two columns scroll independently */
}
/* The menu stays put while the page scrolls: the sidebar is its own
scroll area (long menus scroll internally, never with the viewport). */
.sidebar {
background: #1f2933;
color: #f0f4f8;
padding: 1.25rem;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.sidebar nav {
flex: 1; /* user info + sign out stay at the bottom */
}
.sidebar h1 {
@@ -39,19 +73,64 @@ body {
text-decoration: none;
}
/* --- Grouped navigation (spec section 57) --- */
/* --- Collapsible accordion navigation (spec section 57) ------------------- */
.nav-section {
margin-bottom: 0.9rem;
margin-bottom: 0.4rem;
}
.nav-section h2 {
font-size: 0.68rem;
/* Header button: always visible, opens/closes its group. */
.nav-section > button {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
background: transparent;
border: none;
color: #9aa5b1;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #7b8794;
margin: 0.5rem 0 0.25rem;
padding-left: 0.6rem;
text-align: left;
padding: 0.5rem 0.6rem;
margin: 0.15rem 0;
border-radius: 6px;
cursor: pointer;
}
.nav-section > button:hover {
color: #f0f4f8;
background: #323f4b;
}
/* Chevron rotates while the group is open — smooth open/close cue. */
.nav-section > button .chevron {
display: inline-block;
transition: transform 0.25s ease;
font-size: 0.65rem;
}
.nav-section.open > button .chevron {
transform: rotate(90deg);
}
/* The item list animates its height. grid-template-rows is the modern
height-animation trick: 0fr -> 1fr transitions smoothly without
hardcoding pixel heights. */
.nav-items {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.25s ease;
}
.nav-section.open .nav-items {
grid-template-rows: 1fr;
}
/* grid-row child that actually clips the content. */
.nav-items > div {
overflow: hidden;
min-height: 0;
}
/* Unimplemented pages: greyed out, not clickable, tooltip explains why. */
@@ -68,11 +147,68 @@ body {
opacity: 0.55;
}
/* The main viewport scrolls independently of the fixed menu. */
.content {
padding: 1.5rem 2rem;
max-width: 1100px;
overflow-y: auto;
}
.content h2 {
margin-top: 0;
}
/* Anchor targets inside pages: keep headings clear of the viewport top. */
h3[id] {
scroll-margin-top: 1rem;
}
/* --- Consolidated topic view (spec 35.1) ---------------------------------- */
/* Brief glow on a topic row when a new message arrives. Applied for
~1.2s via JS (topic removed from the `glowing` set), so the animation
runs once per message and the row settles back to normal. */
@keyframes topic-row-glow {
0% {
background: #ffe9a8;
box-shadow: 0 0 8px rgba(255, 200, 80, 0.7);
}
100% {
background: transparent;
box-shadow: none;
}
}
tr.row-glow {
animation: topic-row-glow 1.2s ease-out;
}
/* --- Restart-needed indicator (sidebar, below the header) ------------- */
.restart-needed {
display: block;
padding: 0.45rem 0.6rem;
margin: 0.2rem 0 0.8rem;
border-radius: 6px;
color: #ffd9a0;
background: #6b4d00;
border: 1px solid #b8860b;
text-decoration: none;
font-size: 0.8rem;
text-align: center;
animation: restart-glow 1.8s ease-in-out infinite;
}
@keyframes restart-glow {
0%,
100% {
box-shadow: 0 0 4px rgba(255, 200, 80, 0.25);
background: #6b4d00;
}
50% {
box-shadow: 0 0 14px rgba(255, 200, 80, 0.85);
background: #8a6404;
}
}
.sidebar nav a.active,
.sidebar nav a:hover {
background: #323f4b;
+1
View File
@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/api.ts","./src/auth.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/pages/BrokerConfig.tsx","./src/pages/Certificates.tsx","./src/pages/Clients.tsx","./src/pages/Groups.tsx","./src/pages/Login.tsx","./src/pages/Roles.tsx","./src/pages/Status.tsx","./src/pages/System.tsx","./src/pages/TopicExplorer.tsx"],"version":"5.7.3"}
+17
View File
@@ -1174,6 +1174,23 @@ set retained flag
inspect MQTT 5 properties
```
## 35.1 Consolidated topic view
The live message view shall be organized as ONE ROW PER KNOWN TOPIC
(rather than one row per received message), so an active topic tree is
browsable at a glance. For each row:
* on receiving a new message, the row shall HIGHLIGHT THE DIFFERENCES
between the new message and the previously known message for that
topic (payload, QoS, retain flag and MQTT 5 properties that changed)
* the row shall GLOW briefly when a new message arrives, so the user
notices activity without watching every row
The full KNOWN HISTORY of each topic shall be browsable: selecting a
topic row opens its message history (most recent first), from which any
entry can be inspected in detail (payload as text/hex, QoS, retain
flag, MQTT 5 properties, arrival time).
Example:
```text