Compare commits
10
Commits
fca27fa5fc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30194034a1 | ||
|
|
b8e7c14368 | ||
|
|
6385a27411 | ||
|
|
1e491642f1 | ||
|
|
4322446fcf | ||
|
|
dbcdc6cc2c | ||
|
|
680ad7a624 | ||
|
|
a16a01b1e7 | ||
|
|
111d6cc390 | ||
|
|
45de68f0b7 |
@@ -53,8 +53,9 @@ Bootstrap sequence on first start:
|
|||||||
the MQTT `$CONTROL/dynamic-security/v1` API. No files involved.
|
the MQTT `$CONTROL/dynamic-security/v1` API. No files involved.
|
||||||
* **Generated mosquitto.conf (rare)** — listeners/global settings are
|
* **Generated mosquitto.conf (rare)** — listeners/global settings are
|
||||||
generated from the DB model and deployed through `mosquitto-control`,
|
generated from the DB model and deployed through `mosquitto-control`,
|
||||||
which shares the broker's PID namespace (`pid: "service:mosquitto"`)
|
which OWNS the shared PID namespace (`pid: "service:mosquitto-control"`
|
||||||
to signal reloads. No Docker socket is mounted anywhere (spec §29).
|
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
|
* Mosquitto 2.1's HTTP API is **not** used as a management channel: it
|
||||||
has no authentication and (currently) exposes read-only plugin info.
|
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
|
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
|
Progress against the MVP release plan. Checked items are implemented and
|
||||||
with the six ACL types, config generation from the PostgreSQL model,
|
covered by the test suite / running stack.
|
||||||
validation, deployment with revision history.
|
|
||||||
|
|
||||||
Not yet (Release 2/3): PKI/certificates, mTLS listeners, diff view,
|
### Release 1 — Core broker administration (§68.1) — COMPLETE
|
||||||
rollback, ACL simulator, dashboard/$SYS, audit browsing UI, backup.
|
|
||||||
|
- [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
@@ -22,7 +22,8 @@ RUN pip install --no-cache-dir \
|
|||||||
"argon2-cffi>=23.1" \
|
"argon2-cffi>=23.1" \
|
||||||
"paho-mqtt>=2.1" \
|
"paho-mqtt>=2.1" \
|
||||||
"python-multipart>=0.0.17" \
|
"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).
|
# Source is plain files; uvicorn imports app.main from WORKDIR (/app).
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ class Settings(BaseSettings):
|
|||||||
session_secret: str = ""
|
session_secret: str = ""
|
||||||
session_ttl_seconds: int = 60 * 60 * 8
|
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 management channel
|
||||||
mosquitto_host: str = "mosquitto"
|
mosquitto_host: str = "mosquitto"
|
||||||
mosquitto_mgmt_port: int = 1883
|
mosquitto_mgmt_port: int = 1883
|
||||||
@@ -42,6 +46,20 @@ class Settings(BaseSettings):
|
|||||||
default_cert_validity_days: int = 365
|
default_cert_validity_days: int = 365
|
||||||
default_ca_validity_days: int = 3650
|
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
|
@property
|
||||||
def is_production(self) -> bool:
|
def is_production(self) -> bool:
|
||||||
return self.app_env == "production"
|
return self.app_env == "production"
|
||||||
|
|||||||
+63
-15
@@ -12,10 +12,18 @@ from dataclasses import dataclass, field
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
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"}
|
_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
|
@dataclass
|
||||||
class GenerationResult:
|
class GenerationResult:
|
||||||
@@ -30,11 +38,17 @@ def _global_section(db: Session, warnings: list[str]) -> list[str]:
|
|||||||
value = setting.value.strip()
|
value = setting.value.strip()
|
||||||
if not key or key.startswith("#"):
|
if not key or key.startswith("#"):
|
||||||
continue
|
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}")
|
lines.append(f"{key} {value}")
|
||||||
return lines
|
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] = []
|
lines: list[str] = []
|
||||||
if not listener.enabled:
|
if not listener.enabled:
|
||||||
return lines
|
return lines
|
||||||
@@ -51,18 +65,38 @@ def _listener_block(listener: ListenerConfig, warnings: list[str]) -> list[str]:
|
|||||||
if listener.max_connections != -1:
|
if listener.max_connections != -1:
|
||||||
lines.append(f"max_connections {listener.max_connections}")
|
lines.append(f"max_connections {listener.max_connections}")
|
||||||
if listener.tls_enabled:
|
if listener.tls_enabled:
|
||||||
# TLS blocks only become valid once PKI ships (Release 2); we
|
# Real PKI material (spec section 30): when a certificate is bound,
|
||||||
# generate them now so the model is forward-compatible, but warn.
|
# the deploy pipeline publishes cert/key/CA files under
|
||||||
warnings.append(
|
# /mosquitto/certs/<id>.crt|key BEFORE the config referencing
|
||||||
f"listener '{listener.name}': TLS requested but PKI is not "
|
# them is activated, so validation sees real files.
|
||||||
"available in Release 1; certfile/keyfile not set"
|
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 listener.tls_min_version:
|
if cert is not None:
|
||||||
lines.append(f"tls_version {listener.tls_min_version}")
|
cert_dir = "/mosquitto/certs"
|
||||||
if listener.require_certificate:
|
lines.append(f"certfile {cert_dir}/{cert.id}.crt")
|
||||||
lines.append("require_certificate true")
|
lines.append(f"keyfile {cert_dir}/{cert.id}.key")
|
||||||
if listener.use_identity_as_username:
|
if ca is not None:
|
||||||
lines.append("use_identity_as_username true")
|
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 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}")
|
||||||
|
if listener.require_certificate:
|
||||||
|
lines.append("require_certificate true")
|
||||||
|
if listener.use_identity_as_username:
|
||||||
|
lines.append("use_identity_as_username true")
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
@@ -99,9 +133,23 @@ def generate_config(db: Session) -> GenerationResult:
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
if enabled:
|
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 ---")
|
lines.append("# --- Listeners ---")
|
||||||
for listener in enabled:
|
for listener in enabled:
|
||||||
block = _listener_block(listener, warnings)
|
block = _listener_block(listener, db, warnings)
|
||||||
if block:
|
if block:
|
||||||
lines.append(f"# listener: {listener.name}")
|
lines.append(f"# listener: {listener.name}")
|
||||||
lines.extend(block)
|
lines.extend(block)
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -61,6 +61,9 @@ class DynsecClient:
|
|||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._responses: dict[str, queue.Queue[dict[str, Any]]] = {}
|
self._responses: dict[str, queue.Queue[dict[str, Any]]] = {}
|
||||||
self._connected = threading.Event()
|
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 ------------------------------------------------
|
# -- connection lifecycle ------------------------------------------------
|
||||||
def connect(self) -> None:
|
def connect(self) -> None:
|
||||||
@@ -118,12 +121,25 @@ class DynsecClient:
|
|||||||
return
|
return
|
||||||
self._teardown_locked()
|
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 -------------------------------------------------------
|
# -- paho callbacks -------------------------------------------------------
|
||||||
def _on_connect(self, client, userdata, flags, reason_code, properties) -> None:
|
def _on_connect(self, client, userdata, flags, reason_code, properties) -> None:
|
||||||
if reason_code == 0:
|
if reason_code == 0:
|
||||||
# FIXED response topic — all commands reply here; requests are
|
# FIXED response topic — all commands reply here; requests are
|
||||||
# matched by the correlationData echo inside the payload.
|
# matched by the correlationData echo inside the payload.
|
||||||
client.subscribe(RESPONSE_TOPIC, qos=1)
|
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()
|
self._connected.set()
|
||||||
else:
|
else:
|
||||||
logger.error("dynsec client connect failed: %s", reason_code)
|
logger.error("dynsec client connect failed: %s", reason_code)
|
||||||
@@ -149,6 +165,14 @@ class DynsecClient:
|
|||||||
|
|
||||||
def _on_message(self, client, userdata, msg) -> None:
|
def _on_message(self, client, userdata, msg) -> None:
|
||||||
if msg.topic != RESPONSE_TOPIC:
|
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
|
return
|
||||||
try:
|
try:
|
||||||
payload = json.loads(msg.payload.decode("utf-8"))
|
payload = json.loads(msg.payload.decode("utf-8"))
|
||||||
@@ -233,6 +257,19 @@ class DynsecClient:
|
|||||||
def set_client_password(self, username: str, password: str) -> None:
|
def set_client_password(self, username: str, password: str) -> None:
|
||||||
self._command("setClientPassword", username=username, password=password)
|
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]:
|
def get_client(self, username: str) -> dict[str, Any]:
|
||||||
return self._command("getClient", username=username).get("client", {})
|
return self._command("getClient", username=username).get("client", {})
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
+59
-2
@@ -66,9 +66,63 @@ async def lifespan(app: FastAPI):
|
|||||||
try:
|
try:
|
||||||
dynsec_client.connect()
|
dynsec_client.connect()
|
||||||
logger.info("dynamic security channel connected")
|
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
|
except Exception as exc: # noqa: BLE001 - startup should not crash the API
|
||||||
logger.warning("dynsec channel not ready yet: %s", exc)
|
logger.warning("dynsec channel not ready yet: %s", exc)
|
||||||
yield
|
yield
|
||||||
|
from app import explorer
|
||||||
|
|
||||||
|
explorer.sessions.stop_all()
|
||||||
dynsec_client.close()
|
dynsec_client.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -125,9 +179,12 @@ def health_ready():
|
|||||||
|
|
||||||
|
|
||||||
# Routers
|
# 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(auth.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
app.include_router(mqtt.router)
|
app.include_router(mqtt.router)
|
||||||
app.include_router(broker.router)
|
app.include_router(broker.router)
|
||||||
|
app.include_router(pki.router)
|
||||||
|
app.include_router(system.router)
|
||||||
|
app.include_router(topics.router)
|
||||||
@@ -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
@@ -150,15 +150,40 @@ class ConfigRevision(Base):
|
|||||||
|
|
||||||
|
|
||||||
class Certificate(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"
|
__tablename__ = "certificates"
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
common_name: Mapped[str] = mapped_column(String(200))
|
common_name: Mapped[str] = mapped_column(String(200))
|
||||||
certificate_pem: Mapped[str] = mapped_column(Text)
|
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)
|
private_key_pem: Mapped[str | None] = mapped_column(Text)
|
||||||
expiry_date: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
expiry_date: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
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")
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -8,6 +8,7 @@ an immutable revision (spec section 26).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
@@ -19,11 +20,12 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app import audit
|
from app import audit
|
||||||
from app.config import get_settings
|
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.db import get_db
|
||||||
from app.deps import CurrentUser, require_roles
|
from app.deps import CurrentUser, require_roles
|
||||||
from app.models import (
|
from app.models import (
|
||||||
BrokerSettings,
|
BrokerSettings,
|
||||||
|
Certificate,
|
||||||
ConfigRevision,
|
ConfigRevision,
|
||||||
ListenerConfig,
|
ListenerConfig,
|
||||||
ManagementRole,
|
ManagementRole,
|
||||||
@@ -31,6 +33,7 @@ from app.models import (
|
|||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
BrokerSettingsIn,
|
BrokerSettingsIn,
|
||||||
BrokerSettingsOut,
|
BrokerSettingsOut,
|
||||||
|
BrokerStatusOut,
|
||||||
ConfigPreviewOut,
|
ConfigPreviewOut,
|
||||||
DeployRequest,
|
DeployRequest,
|
||||||
ListenerIn,
|
ListenerIn,
|
||||||
@@ -63,6 +66,51 @@ def _next_revision_number(db: Session) -> int:
|
|||||||
return (highest or 0) + 1
|
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(
|
def _call_control(
|
||||||
path: str, *, method: str = "POST", json_body: dict | None = None
|
path: str, *, method: str = "POST", json_body: dict | None = None
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -207,6 +255,81 @@ def upsert_setting(
|
|||||||
# --- Generation, validation, deployment ----------------------------------------
|
# --- 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)
|
@router.get("/config/preview", response_model=ConfigPreviewOut)
|
||||||
def preview_config(
|
def preview_config(
|
||||||
db: Annotated[Session, Depends(get_db)], _user: AnyReader
|
db: Annotated[Session, Depends(get_db)], _user: AnyReader
|
||||||
@@ -235,12 +358,30 @@ def deploy_config(
|
|||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
user: BrokerAdmin,
|
user: BrokerAdmin,
|
||||||
):
|
):
|
||||||
"""Generate -> validate -> activate + reload -> record revision."""
|
"""Generate -> publish certs -> validate -> activate + reload -> revision."""
|
||||||
|
_publish_certificates(db)
|
||||||
result = generate_config(db)
|
result = generate_config(db)
|
||||||
response = _call_control("/deploy", json_body={"content": result.config_text})
|
response = _call_control("/deploy", json_body={"content": result.config_text})
|
||||||
if response.get("status") != "deployed":
|
if response.get("status") != "deployed":
|
||||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, response)
|
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 = ConfigRevision(
|
||||||
revision_number=_next_revision_number(db),
|
revision_number=_next_revision_number(db),
|
||||||
created_by=user.username,
|
created_by=user.username,
|
||||||
@@ -264,6 +405,7 @@ def deploy_config(
|
|||||||
"status": "deployed",
|
"status": "deployed",
|
||||||
"revision": revision.revision_number,
|
"revision": revision.revision_number,
|
||||||
"broker_healthy": response.get("broker_healthy"),
|
"broker_healthy": response.get("broker_healthy"),
|
||||||
|
"restart_required": needs_restart,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from app.deps import CurrentUser, require_roles
|
|||||||
from app.dynsec import DynsecError, dynsec_client
|
from app.dynsec import DynsecError, dynsec_client
|
||||||
from app.models import ManagementRole, MqttClientMetadata
|
from app.models import ManagementRole, MqttClientMetadata
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
|
ClientIdUpdate,
|
||||||
ClientRoleAssign,
|
ClientRoleAssign,
|
||||||
GroupIn,
|
GroupIn,
|
||||||
GroupMemberAssign,
|
GroupMemberAssign,
|
||||||
@@ -219,6 +220,38 @@ def reset_password(
|
|||||||
return MqttPasswordReset(username=username, password=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)
|
@router.post("/clients/{username}/roles", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def assign_client_role(
|
def assign_client_role(
|
||||||
username: str,
|
username: str,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
+131
-1
@@ -169,6 +169,18 @@ class MqttClientOut(BaseModel):
|
|||||||
last_known_connection: datetime | None
|
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):
|
class MqttPasswordReset(BaseModel):
|
||||||
"""Response for create/reset — plaintext shown exactly once (spec 11)."""
|
"""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
|
tls_min_version: Literal["tlsv1.2", "tlsv1.3"] | None = None
|
||||||
require_certificate: bool = False
|
require_certificate: bool = False
|
||||||
use_identity_as_username: 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):
|
class ListenerOut(ListenerIn):
|
||||||
@@ -216,6 +230,43 @@ class ConfigPreviewOut(BaseModel):
|
|||||||
warnings: list[str]
|
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):
|
class DeployRequest(BaseModel):
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
|
|
||||||
@@ -234,4 +285,83 @@ class RevisionOut(BaseModel):
|
|||||||
|
|
||||||
class RevisionDetailOut(RevisionOut):
|
class RevisionDetailOut(RevisionOut):
|
||||||
config_text: str
|
config_text: str
|
||||||
validation_result: dict[str, Any]
|
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."
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -14,7 +14,12 @@ from app import models # noqa: F401 (register all models with Base)
|
|||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
fileConfig(config.config_file_name)
|
# 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
|
url = get_settings().database_url
|
||||||
if url.startswith("postgresql://"):
|
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")
|
||||||
@@ -15,6 +15,7 @@ dependencies = [
|
|||||||
"paho-mqtt>=2.1",
|
"paho-mqtt>=2.1",
|
||||||
"python-multipart>=0.0.17",
|
"python-multipart>=0.0.17",
|
||||||
"httpx>=0.28",
|
"httpx>=0.28",
|
||||||
|
"cryptography>=43.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -116,7 +116,22 @@ def client(db_session, monkeypatch):
|
|||||||
def set_client_password(self, username, password):
|
def set_client_password(self, username, password):
|
||||||
pass
|
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):
|
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]
|
return self.clients[username]
|
||||||
|
|
||||||
def create_group(self, groupname, roles=None):
|
def create_group(self, groupname, roles=None):
|
||||||
@@ -204,6 +219,99 @@ def client(db_session, monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr(mqtt_router, "dynsec_client", fake)
|
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:
|
with TestClient(main_module.app) as test_client:
|
||||||
yield test_client
|
yield test_client
|
||||||
|
|
||||||
|
|||||||
+545
-1
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
from tests.conftest import login
|
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
|
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):
|
def test_role_acl_lifecycle(admin_client):
|
||||||
admin_client.post("/api/v1/mqtt/roles", json={"rolename": "telemetry"})
|
admin_client.post("/api/v1/mqtt/roles", json={"rolename": "telemetry"})
|
||||||
|
|
||||||
@@ -277,4 +575,250 @@ def test_audit_events_recorded(admin_client, db_session):
|
|||||||
|
|
||||||
admin_client.post("/api/v1/mqtt/clients", json={"username": "audited-1"})
|
admin_client.post("/api/v1/mqtt/clients", json={"username": "audited-1"})
|
||||||
actions = db_session.scalars(select(AuditEvent.action)).all()
|
actions = db_session.scalars(select(AuditEvent.action)).all()
|
||||||
assert "CLIENT_CREATED" in actions
|
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}")
|
||||||
@@ -64,6 +64,40 @@ def test_disabled_listeners_are_omitted(session):
|
|||||||
assert "listener 1884" not in result.config_text
|
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):
|
def test_tls_listener_without_pki_warns(session):
|
||||||
session.add(
|
session.add(
|
||||||
ListenerConfig(
|
ListenerConfig(
|
||||||
@@ -77,5 +111,76 @@ def test_tls_listener_without_pki_warns(session):
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
result = generate_config(session)
|
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
|
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 == []
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
# broker-control — internal control sidecar (spec §29)
|
# broker-control — internal control sidecar (spec §29)
|
||||||
#
|
#
|
||||||
# Runs in the mosquitto container's PID namespace (compose `pid: "service:mosquitto"`)
|
# PID-namespace OWNER: the broker joins THIS container's PID namespace
|
||||||
# so it can signal the broker process (PID 1) without docker.sock access.
|
# (compose `pid: "service:mosquitto-control"` on the mosquitto service).
|
||||||
# The backend calls its HTTP API over the backend-net network.
|
# 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):
|
# Responsibilities (deliberately minimal, spec §29):
|
||||||
# - validate staged configuration (mosquitto -c <staged> --test-config)
|
# - validate staged configuration (mosquitto -c <staged> --test-config)
|
||||||
# - activate a configuration revision (atomic rename into place)
|
# - 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
|
# - 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
|
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
|
# The base image ships NO Python. control.py is stdlib-only, so the
|
||||||
# distro package is sufficient (no pip, no venv needed).
|
# 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
|
COPY control.py /app/control.py
|
||||||
|
|
||||||
@@ -36,4 +39,4 @@ CMD []
|
|||||||
|
|
||||||
EXPOSE 8090
|
EXPOSE 8090
|
||||||
|
|
||||||
ENTRYPOINT ["python3", "/app/control.py"]
|
ENTRYPOINT ["/sbin/tini", "--", "python3", "/app/control.py"]
|
||||||
+63
-10
@@ -11,7 +11,10 @@ Security model:
|
|||||||
Docker network; the port is not published to the host.
|
Docker network; the port is not published to the host.
|
||||||
* Requires a bearer token (CONTROL_API_TOKEN) shared with the backend.
|
* Requires a bearer token (CONTROL_API_TOKEN) shared with the backend.
|
||||||
* Never mounts docker.sock; signals the broker over the shared PID
|
* 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:
|
Implementation notes:
|
||||||
* Atomic activation writes content to a temp file in the SAME
|
* Atomic activation writes content to a temp file in the SAME
|
||||||
@@ -98,30 +101,74 @@ def _activate(content: str) -> None:
|
|||||||
raise
|
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:
|
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:
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
["mosquitto_signal", "-p", "1", "config-reload"],
|
["mosquitto_signal", "-p", str(pid), "config-reload"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=15,
|
timeout=15,
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# Fallback if the helper app is unavailable for any reason.
|
# Fallback if the helper app is unavailable for any reason.
|
||||||
os.kill(1, signal.SIGHUP)
|
os.kill(pid, signal.SIGHUP)
|
||||||
return
|
return
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
raise _Error(500, f"mosquitto_signal failed: {proc.stderr.strip()}")
|
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:
|
def _broker_pid_alive() -> bool:
|
||||||
try:
|
return _broker_pid() is not None
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
def _post_broker_health() -> bool:
|
def _post_broker_health() -> bool:
|
||||||
@@ -215,6 +262,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
try:
|
try:
|
||||||
if self.path == "/deploy":
|
if self.path == "/deploy":
|
||||||
self._handle_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":
|
elif self.path == "/reload":
|
||||||
_signal_reload()
|
_signal_reload()
|
||||||
self._send(200, {"status": "reloading", "broker_healthy": _post_broker_health()})
|
self._send(200, {"status": "reloading", "broker_healthy": _post_broker_health()})
|
||||||
|
|||||||
+33
-8
@@ -18,6 +18,9 @@ x-backend-env: &backend-env
|
|||||||
DEFAULT_TLS_MIN_VERSION: ${DEFAULT_TLS_MIN_VERSION:-tlsv1.2}
|
DEFAULT_TLS_MIN_VERSION: ${DEFAULT_TLS_MIN_VERSION:-tlsv1.2}
|
||||||
DEFAULT_CERT_VALIDITY_DAYS: ${DEFAULT_CERT_VALIDITY_DAYS:-365}
|
DEFAULT_CERT_VALIDITY_DAYS: ${DEFAULT_CERT_VALIDITY_DAYS:-365}
|
||||||
DEFAULT_CA_VALIDITY_DAYS: ${DEFAULT_CA_VALIDITY_DAYS:-3650}
|
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:
|
services:
|
||||||
mqtt-db:
|
mqtt-db:
|
||||||
@@ -47,8 +50,18 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./mosquitto
|
context: ./mosquitto
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Spec §29: no docker.sock. Control sidecar shares the broker's PID namespace
|
# Spec §29: no docker.sock. INVERTED PID ownership: the broker joins
|
||||||
# and the mosquitto_config volume; it signals PID 1 (the broker) directly.
|
# 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:
|
environment:
|
||||||
# Used by the dynsec plugin on first start to seed the 'admin' client
|
# Used by the dynsec plugin on first start to seed the 'admin' client
|
||||||
# (verified: Mosquitto 2.1 dynamic security docs).
|
# (verified: Mosquitto 2.1 dynamic security docs).
|
||||||
@@ -57,6 +70,9 @@ services:
|
|||||||
- mosquitto_config:/mosquitto/config
|
- mosquitto_config:/mosquitto/config
|
||||||
- mosquitto_data:/mosquitto/data
|
- mosquitto_data:/mosquitto/data
|
||||||
- mosquitto_log:/mosquitto/log
|
- 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:
|
networks:
|
||||||
- backend-net
|
- backend-net
|
||||||
ports:
|
ports:
|
||||||
@@ -81,12 +97,14 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./broker-control
|
context: ./broker-control
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
# PID-namespace OWNER: the broker joins us via its `pid:` directive.
|
||||||
mosquitto:
|
# Owning the namespace means a broker restart (SIGTERM to the broker
|
||||||
condition: service_started
|
# process) only kills the broker — this sidecar keeps running to
|
||||||
# Shares PID namespace with the broker so `mosquitto_signal -p 1 ...`
|
# report status and orchestrate the revival. Runs tini as PID 1
|
||||||
# reaches the broker process without mounting docker.sock.
|
# (signal forwarding + zombie reaping). If THIS container dies, the
|
||||||
pid: "service:mosquitto"
|
# 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:
|
environment:
|
||||||
# Minimal default token for the internal control API; in production
|
# Minimal default token for the internal control API; in production
|
||||||
# this MUST be replaced (it only guards a backend-net-only endpoint).
|
# this MUST be replaced (it only guards a backend-net-only endpoint).
|
||||||
@@ -120,8 +138,14 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
<<: *backend-env
|
<<: *backend-env
|
||||||
|
# Where the backend publishes cert/key files for the broker (spec §6).
|
||||||
|
MOSQUITTO_CERTS_DIR: /mosquitto/certs
|
||||||
volumes:
|
volumes:
|
||||||
- backend_data:/data
|
- 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:
|
networks:
|
||||||
- frontend-net
|
- frontend-net
|
||||||
- backend-net
|
- backend-net
|
||||||
@@ -157,6 +181,7 @@ volumes:
|
|||||||
mosquitto_config:
|
mosquitto_config:
|
||||||
mosquitto_data:
|
mosquitto_data:
|
||||||
mosquitto_log:
|
mosquitto_log:
|
||||||
|
mosquitto_certs:
|
||||||
backend_data:
|
backend_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -17,4 +17,17 @@ server {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Generated
+1003
File diff suppressed because it is too large
Load Diff
@@ -10,11 +10,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"diff": "^7.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.0"
|
"react-router-dom": "^7.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/diff": "^5.2.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
|||||||
+143
-29
@@ -1,10 +1,17 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { NavLink, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
import { NavLink, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAuth } from "./auth";
|
import { useAuth } from "./auth";
|
||||||
|
import { api, type SystemStatus } from "./api";
|
||||||
import { LoginPage } from "./pages/Login";
|
import { LoginPage } from "./pages/Login";
|
||||||
import { ClientsPage } from "./pages/Clients";
|
import { ClientsPage } from "./pages/Clients";
|
||||||
import { RolesPage } from "./pages/Roles";
|
import { RolesPage } from "./pages/Roles";
|
||||||
import { GroupsPage } from "./pages/Groups";
|
import { GroupsPage } from "./pages/Groups";
|
||||||
import { BrokerConfigPage } from "./pages/BrokerConfig";
|
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
|
// Navigation structure per spec section 57. Entries with a `to` route are
|
||||||
// implemented; entries without one are planned pages, rendered greyed out
|
// implemented; entries without one are planned pages, rendered greyed out
|
||||||
@@ -16,7 +23,7 @@ const NAV_SECTIONS: {
|
|||||||
{
|
{
|
||||||
title: "Broker",
|
title: "Broker",
|
||||||
items: [
|
items: [
|
||||||
{ label: "Status", hint: "Dashboard / $SYS monitoring (Release 3)" },
|
{ label: "Status", to: "/broker/status" },
|
||||||
{ label: "Configuration", to: "/broker" },
|
{ label: "Configuration", to: "/broker" },
|
||||||
{ label: "Listeners", to: "/broker#listeners" },
|
{ label: "Listeners", to: "/broker#listeners" },
|
||||||
{ label: "Bridges", hint: "Bridge management (post-MVP)" },
|
{ label: "Bridges", hint: "Bridge management (post-MVP)" },
|
||||||
@@ -36,17 +43,17 @@ const NAV_SECTIONS: {
|
|||||||
{
|
{
|
||||||
title: "Certificates",
|
title: "Certificates",
|
||||||
items: [
|
items: [
|
||||||
{ label: "Authorities", hint: "PKI (Release 2)" },
|
{ label: "Authorities", to: "/certificates" },
|
||||||
{ label: "Server Certificates", hint: "PKI (Release 2)" },
|
{ label: "Server Certificates", to: "/certificates" },
|
||||||
{ label: "Client Certificates", hint: "PKI (Release 2)" },
|
{ label: "Client Certificates", to: "/certificates#client-certificates" },
|
||||||
{ label: "CSRs", hint: "CSR signing (Release 2)" },
|
{ label: "CSRs", hint: "CSR signing (Release 2, later)" },
|
||||||
{ label: "Revocations", hint: "CRL management (Release 2)" },
|
{ label: "Revocations", hint: "CRL management (Release 2, later)" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Tools",
|
title: "Tools",
|
||||||
items: [
|
items: [
|
||||||
{ label: "Topic Explorer", hint: "Test console (post-MVP)" },
|
{ label: "Topic Explorer", to: "/topics" },
|
||||||
{ label: "Connection Test", hint: "Connectivity tester (post-MVP)" },
|
{ label: "Connection Test", hint: "Connectivity tester (post-MVP)" },
|
||||||
{ label: "Client Provisioning", hint: "Provisioning wizard (post-MVP)" },
|
{ label: "Client Provisioning", hint: "Provisioning wizard (post-MVP)" },
|
||||||
],
|
],
|
||||||
@@ -58,39 +65,114 @@ const NAV_SECTIONS: {
|
|||||||
{ label: "Roles", hint: "Management roles admin (Release 3)" },
|
{ label: "Roles", hint: "Management roles admin (Release 3)" },
|
||||||
{ label: "Audit Log", hint: "Audit viewer (Release 3)" },
|
{ label: "Audit Log", hint: "Audit viewer (Release 3)" },
|
||||||
{ label: "Backup", hint: "Backup/restore (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 }) {
|
function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const { session, logout } = useAuth();
|
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 (
|
return (
|
||||||
<div className="layout">
|
<div className="layout">
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<h1>MQTT Management</h1>
|
<h1>MQTT Management</h1>
|
||||||
|
<RestartIndicator />
|
||||||
<nav>
|
<nav>
|
||||||
{NAV_SECTIONS.map((section) => (
|
{NAV_SECTIONS.map((section) => {
|
||||||
<div key={section.title} className="nav-section">
|
const isActive = activeSection?.title === section.title;
|
||||||
<h2>{section.title}</h2>
|
const isOpen =
|
||||||
{section.items.map((item) =>
|
openSection === section.title ||
|
||||||
item.to ? (
|
(isActive && openSection === null);
|
||||||
<NavLink key={item.label} to={item.to}>
|
return (
|
||||||
{item.label}
|
<div
|
||||||
</NavLink>
|
key={section.title}
|
||||||
) : (
|
className={`nav-section${isOpen ? " open" : ""}`}
|
||||||
<button
|
>
|
||||||
key={item.label}
|
<button
|
||||||
className="nav-disabled"
|
onClick={() =>
|
||||||
disabled
|
setOpenSection(
|
||||||
title={item.hint}
|
openSection === section.title ? null : section.title,
|
||||||
>
|
)
|
||||||
{item.label}
|
}
|
||||||
</button>
|
aria-expanded={isOpen}
|
||||||
),
|
>
|
||||||
)}
|
{section.title}
|
||||||
</div>
|
<span className="chevron">▸</span>
|
||||||
))}
|
</button>
|
||||||
|
<div className="nav-items">
|
||||||
|
<div>
|
||||||
|
{section.items.map((item) =>
|
||||||
|
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>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={item.label}
|
||||||
|
className="nav-disabled"
|
||||||
|
disabled
|
||||||
|
title={item.hint}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
<p style={{ marginTop: "2rem", fontSize: "0.8rem", opacity: 0.7 }}>
|
<p style={{ marginTop: "2rem", fontSize: "0.8rem", opacity: 0.7 }}>
|
||||||
{session?.username} ({session?.role})
|
{session?.username} ({session?.role})
|
||||||
@@ -149,6 +231,38 @@ export function App() {
|
|||||||
</Protected>
|
</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 />} />
|
<Route path="/" element={<Navigate to="/clients" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ export const api = {
|
|||||||
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
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) ---------------------------
|
// --- Assignment operations (spec sections 11-12) ---------------------------
|
||||||
|
|
||||||
export const assignClientRole = (username: string, rolename: string, priority = -1) =>
|
export const assignClientRole = (username: string, rolename: string, priority = -1) =>
|
||||||
@@ -162,6 +167,58 @@ export interface PasswordReset {
|
|||||||
password: string;
|
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 {
|
export interface Listener {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -175,6 +232,7 @@ export interface Listener {
|
|||||||
tls_min_version: string | null;
|
tls_min_version: string | null;
|
||||||
require_certificate: boolean;
|
require_certificate: boolean;
|
||||||
use_identity_as_username: boolean;
|
use_identity_as_username: boolean;
|
||||||
|
cert_id: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Revision {
|
export interface Revision {
|
||||||
@@ -185,4 +243,34 @@ export interface Revision {
|
|||||||
deployed: boolean;
|
deployed: boolean;
|
||||||
deployed_at: string | null;
|
deployed_at: string | null;
|
||||||
created_at: string;
|
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;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FormEvent, useEffect, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 {
|
interface ConfigPreview {
|
||||||
config_text: string;
|
config_text: string;
|
||||||
@@ -25,6 +25,7 @@ const EMPTY_LISTENER: Omit<Listener, "id"> = {
|
|||||||
tls_min_version: null,
|
tls_min_version: null,
|
||||||
require_certificate: false,
|
require_certificate: false,
|
||||||
use_identity_as_username: false,
|
use_identity_as_username: false,
|
||||||
|
cert_id: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Curated subset of spec section 31; anything else can be added later
|
// Curated subset of spec section 31; anything else can be added later
|
||||||
@@ -83,13 +84,19 @@ export function BrokerConfigPage() {
|
|||||||
});
|
});
|
||||||
const deploy = useMutation({
|
const deploy = useMutation({
|
||||||
mutationFn: (comment: string | null) =>
|
mutationFn: (comment: string | null) =>
|
||||||
api.post<{ status: string; revision: number; broker_healthy: boolean }>(
|
api.post<{
|
||||||
"/broker/config/deploy",
|
status: string;
|
||||||
{ comment },
|
revision: number;
|
||||||
),
|
broker_healthy: boolean;
|
||||||
|
restart_required: boolean;
|
||||||
|
}>("/broker/config/deploy", { comment }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
refresh();
|
refresh();
|
||||||
queryClient.invalidateQueries({ queryKey: ["revisions"] });
|
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"}
|
{deploy.isPending ? "Deploying…" : "Validate & deploy configuration"}
|
||||||
</button>
|
</button>
|
||||||
{deploy.data && (
|
{deploy.data && (
|
||||||
<div className="banner ok">
|
<div className={`banner ${deploy.data.restart_required ? "warn" : "ok"}`}>
|
||||||
Deployed revision {deploy.data.revision}. Broker health:{" "}
|
Deployed revision {deploy.data.revision}. Broker health:{" "}
|
||||||
{deploy.data.broker_healthy ? "OK" : "unknown — check logs"}
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{deploy.error && (
|
{deploy.error && (
|
||||||
@@ -276,6 +292,15 @@ function ListenerForm({
|
|||||||
const set = (patch: Partial<Listener>) => setDraft((d) => ({ ...d, ...patch }));
|
const set = (patch: Partial<Listener>) => setDraft((d) => ({ ...d, ...patch }));
|
||||||
const valid = draft.name.trim() !== "" && draft.port > 0 && draft.port <= 65535;
|
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) => {
|
const submit = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (valid) {
|
if (valid) {
|
||||||
@@ -346,6 +371,83 @@ function ListenerForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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 />
|
<p />
|
||||||
<button className="primary" disabled={!valid || pending}>
|
<button className="primary" disabled={!valid || pending}>
|
||||||
{pending ? (initial ? "Saving…" : "Adding…") : initial ? "Save changes" : "Add listener"}
|
{pending ? (initial ? "Saving…" : "Adding…") : initial ? "Save changes" : "Add listener"}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +4,11 @@ import {
|
|||||||
api,
|
api,
|
||||||
assignClientRole,
|
assignClientRole,
|
||||||
assignGroupMember,
|
assignGroupMember,
|
||||||
|
pemDataUrl,
|
||||||
removeClientRole,
|
removeClientRole,
|
||||||
removeGroupMember,
|
removeGroupMember,
|
||||||
|
type Certificate,
|
||||||
|
type CertificateBundle,
|
||||||
type MqttClient,
|
type MqttClient,
|
||||||
type PasswordReset,
|
type PasswordReset,
|
||||||
type Role,
|
type Role,
|
||||||
@@ -54,6 +57,7 @@ function AssignmentChips({
|
|||||||
export function ClientsPage() {
|
export function ClientsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [newUsername, setNewUsername] = useState("");
|
const [newUsername, setNewUsername] = useState("");
|
||||||
|
const [newClientId, setNewClientId] = useState("");
|
||||||
const [oncePassword, setOncePassword] = useState<PasswordReset | null>(null);
|
const [oncePassword, setOncePassword] = useState<PasswordReset | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -77,16 +81,37 @@ export function ClientsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: (username: string) =>
|
mutationFn: ({ username, clientid }: { username: string; clientid: string | null }) =>
|
||||||
api.post<PasswordReset>("/mqtt/clients", { username }),
|
api.post<PasswordReset>("/mqtt/clients", {
|
||||||
|
username,
|
||||||
|
clientid: clientid || null,
|
||||||
|
}),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setOncePassword(result);
|
setOncePassword(result);
|
||||||
setNewUsername("");
|
setNewUsername("");
|
||||||
|
setNewClientId("");
|
||||||
refresh();
|
refresh();
|
||||||
},
|
},
|
||||||
onError: (e: Error) => setError(e.message),
|
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({
|
const disable = useMutation({
|
||||||
mutationFn: (username: string) => api.post(`/mqtt/clients/${username}/disable`),
|
mutationFn: (username: string) => api.post(`/mqtt/clients/${username}/disable`),
|
||||||
onSuccess: refresh,
|
onSuccess: refresh,
|
||||||
@@ -110,6 +135,50 @@ export function ClientsPage() {
|
|||||||
onError: (e: Error) => setError(e.message),
|
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({
|
const assignRole = useMutation({
|
||||||
mutationFn: ({ username, rolename }: { username: string; rolename: string }) =>
|
mutationFn: ({ username, rolename }: { username: string; rolename: string }) =>
|
||||||
assignClientRole(username, rolename),
|
assignClientRole(username, rolename),
|
||||||
@@ -141,7 +210,8 @@ export function ClientsPage() {
|
|||||||
const submit = (e: FormEvent) => {
|
const submit = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
if (newUsername) create.mutate(newUsername);
|
if (newUsername)
|
||||||
|
create.mutate({ username: newUsername, clientid: newClientId.trim() });
|
||||||
};
|
};
|
||||||
|
|
||||||
const roleNames = (roles.data ?? []).map((r) => r.rolename);
|
const roleNames = (roles.data ?? []).map((r) => r.rolename);
|
||||||
@@ -163,6 +233,40 @@ export function ClientsPage() {
|
|||||||
<div className="password-once mono">{oncePassword.password}</div>
|
<div className="password-once mono">{oncePassword.password}</div>
|
||||||
</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>}
|
{error && <div className="banner error">{error}</div>}
|
||||||
{(clients.error || roles.error || groups.error) && (
|
{(clients.error || roles.error || groups.error) && (
|
||||||
<div className="banner error">
|
<div className="banner error">
|
||||||
@@ -179,6 +283,15 @@ export function ClientsPage() {
|
|||||||
onChange={(e) => setNewUsername(e.target.value)}
|
onChange={(e) => setNewUsername(e.target.value)}
|
||||||
placeholder="e.g. ps042"
|
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 />
|
<p />
|
||||||
<button className="primary" disabled={!newUsername || create.isPending}>
|
<button className="primary" disabled={!newUsername || create.isPending}>
|
||||||
{create.isPending ? "Creating…" : "Create client (random password)"}
|
{create.isPending ? "Creating…" : "Create client (random password)"}
|
||||||
@@ -213,7 +326,15 @@ export function ClientsPage() {
|
|||||||
return (
|
return (
|
||||||
<tr key={c.username}>
|
<tr key={c.username}>
|
||||||
<td className="mono">{c.username}</td>
|
<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>{c.enabled ? "yes" : "no"}</td>
|
||||||
<td>
|
<td>
|
||||||
<AssignmentChips
|
<AssignmentChips
|
||||||
@@ -291,6 +412,12 @@ export function ClientsPage() {
|
|||||||
>
|
>
|
||||||
Reset password
|
Reset password
|
||||||
</button>{" "}
|
</button>{" "}
|
||||||
|
<button
|
||||||
|
onClick={() => issueClientCertificate(c.username)}
|
||||||
|
title="Issue a client certificate (CN = username) for mTLS"
|
||||||
|
>
|
||||||
|
Issue certificate
|
||||||
|
</button>{" "}
|
||||||
<button
|
<button
|
||||||
className="danger"
|
className="danger"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -13,16 +13,50 @@ body {
|
|||||||
color: #1f2933;
|
color: #1f2933;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout {
|
/* --- Thin scrollbars everywhere ---------------------------------------- */
|
||||||
display: grid;
|
* {
|
||||||
grid-template-columns: 220px 1fr;
|
scrollbar-width: thin; /* Firefox */
|
||||||
min-height: 100vh;
|
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 {
|
.sidebar {
|
||||||
background: #1f2933;
|
background: #1f2933;
|
||||||
color: #f0f4f8;
|
color: #f0f4f8;
|
||||||
padding: 1.25rem;
|
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 {
|
.sidebar h1 {
|
||||||
@@ -39,19 +73,64 @@ body {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Grouped navigation (spec section 57) --- */
|
/* --- Collapsible accordion navigation (spec section 57) ------------------- */
|
||||||
.nav-section {
|
.nav-section {
|
||||||
margin-bottom: 0.9rem;
|
margin-bottom: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-section h2 {
|
/* Header button: always visible, opens/closes its group. */
|
||||||
font-size: 0.68rem;
|
.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;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.07em;
|
letter-spacing: 0.07em;
|
||||||
color: #7b8794;
|
text-align: left;
|
||||||
margin: 0.5rem 0 0.25rem;
|
padding: 0.5rem 0.6rem;
|
||||||
padding-left: 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. */
|
/* Unimplemented pages: greyed out, not clickable, tooltip explains why. */
|
||||||
@@ -68,11 +147,68 @@ body {
|
|||||||
opacity: 0.55;
|
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. */
|
/* Anchor targets inside pages: keep headings clear of the viewport top. */
|
||||||
h3[id] {
|
h3[id] {
|
||||||
scroll-margin-top: 1rem;
|
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.active,
|
||||||
.sidebar nav a:hover {
|
.sidebar nav a:hover {
|
||||||
background: #323f4b;
|
background: #323f4b;
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -1174,6 +1174,23 @@ set retained flag
|
|||||||
inspect MQTT 5 properties
|
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:
|
Example:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
Reference in New Issue
Block a user