🚧 WIP
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
# MQTT Management Platform — example environment file
|
||||||
|
# Copy to .env and fill in real values. NEVER commit .env to Git.
|
||||||
|
|
||||||
|
APP_ENV=development
|
||||||
|
|
||||||
|
# --- Management application administrator (spec §7.1) ---
|
||||||
|
# Only used during initial backend bootstrap; ignored once an admin exists.
|
||||||
|
ADMIN_INITIAL_USERNAME=admin
|
||||||
|
ADMIN_INITIAL_PASSWORD=change-me-admin
|
||||||
|
|
||||||
|
# --- PostgreSQL ---
|
||||||
|
DATABASE_USER=mqttadmin
|
||||||
|
DATABASE_PASSWORD=change-me-db
|
||||||
|
DATABASE_NAME=mqttadmin
|
||||||
|
# DATABASE_HOST / DATABASE_PORT are fixed inside compose (mqtt-db:5432);
|
||||||
|
# listed for local dev runs outside compose.
|
||||||
|
DATABASE_HOST=mqtt-db
|
||||||
|
DATABASE_PORT=5432
|
||||||
|
|
||||||
|
# --- Secrets ---
|
||||||
|
# 32+ byte random strings. Generate e.g.:
|
||||||
|
# openssl rand -hex 32
|
||||||
|
SESSION_SECRET=change-me-session-secret-32-bytes-min
|
||||||
|
SECRET_ENCRYPTION_KEY=change-me-encryption-key-32-bytes
|
||||||
|
|
||||||
|
# --- Mosquitto dynamic security bootstrap (spec §7) ---
|
||||||
|
# Consumed by the dynsec plugin on first broker start (2.1 feature).
|
||||||
|
MOSQUITTO_DYNSEC_USERNAME=admin
|
||||||
|
MOSQUITTO_DYNSEC_PASSWORD=change-me-dynsec
|
||||||
|
|
||||||
|
# --- Broker connection (backend -> mosquitto, internal network) ---
|
||||||
|
MOSQUITTO_HOST=mosquitto
|
||||||
|
MOSQUITTO_MGMT_PORT=1883
|
||||||
|
|
||||||
|
# --- Control sidecar ---
|
||||||
|
# Shared secret between backend and mosquitto-control (backend-net only).
|
||||||
|
CONTROL_API_TOKEN=change-me-control-token
|
||||||
|
|
||||||
|
# --- TLS / PKI defaults (used from Release 2 onwards) ---
|
||||||
|
DEFAULT_TLS_MIN_VERSION=tlsv1.2
|
||||||
|
DEFAULT_CERT_VALIDITY_DAYS=365
|
||||||
|
DEFAULT_CA_VALIDITY_DAYS=3650
|
||||||
|
|
||||||
|
# --- Frontend ---
|
||||||
|
# Non-secret build arg only; see compose.yaml (spec §7.2).
|
||||||
|
VITE_API_BASE_URL=/api/v1
|
||||||
|
FRONTEND_PORT=8080
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Environment files with secrets
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Generated mosquitto configuration & secrets are never committed (spec §67)
|
||||||
|
mosquitto/config/generated/
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
*.crt
|
||||||
|
*.p12
|
||||||
|
|
||||||
|
# OS / editor
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# MQTT Management Platform — Release 1 developer commands
|
||||||
|
# All commands assume a POSIX shell; on Windows use WSL/Git Bash or run the
|
||||||
|
# docker compose commands directly in PowerShell/cmd.
|
||||||
|
|
||||||
|
COMPOSE ?= docker compose
|
||||||
|
B ?= mqtt-backend
|
||||||
|
|
||||||
|
.PHONY: up down logs ps bootstrap migrate revision backend-shell test fmt lint
|
||||||
|
|
||||||
|
# --- Lifecycle -------------------------------------------------------------
|
||||||
|
up: ## Start the whole stack in the background
|
||||||
|
$(COMPOSE) up -d --build
|
||||||
|
|
||||||
|
down: ## Stop the stack (volumes are preserved)
|
||||||
|
$(COMPOSE) down
|
||||||
|
|
||||||
|
ps: ## Show service status
|
||||||
|
$(COMPOSE) ps
|
||||||
|
|
||||||
|
logs: ## Tail all logs
|
||||||
|
$(COMPOSE) logs -f
|
||||||
|
|
||||||
|
# --- Database --------------------------------------------------------------
|
||||||
|
migrate: ## Apply Alembic migrations inside the running backend
|
||||||
|
$(COMPOSE) exec $(B) alembic upgrade head
|
||||||
|
|
||||||
|
revision: ## Create an empty Alembic revision (autogenerate)
|
||||||
|
$(COMPOSE) exec $(B) alembic revision --autogenerate -m "$(m)"
|
||||||
|
|
||||||
|
backend-shell: ## Shell into the backend container
|
||||||
|
$(COMPOSE) exec $(B) sh
|
||||||
|
|
||||||
|
# --- Tests -----------------------------------------------------------------
|
||||||
|
test: ## Run backend test suite inside the container
|
||||||
|
$(COMPOSE) run --rm $(B) pytest -q
|
||||||
|
|
||||||
|
# --- Quality ---------------------------------------------------------------
|
||||||
|
fmt: ## Format backend sources
|
||||||
|
$(COMPOSE) run --rm $(B) ruff format app tests
|
||||||
|
|
||||||
|
lint: ## Lint backend sources
|
||||||
|
$(COMPOSE) run --rm $(B) ruff check app tests
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# MQTT Management Platform — Release 1
|
||||||
|
|
||||||
|
Web-based administration for an Eclipse Mosquitto 2.1 broker: management
|
||||||
|
login/RBAC, MQTT client/group/role management with ACL editing via the
|
||||||
|
Dynamic Security plugin, and generated + validated broker configuration.
|
||||||
|
|
||||||
|
Architecture and scope follow `specification.md` (Release 1, §68.1).
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
```text
|
||||||
|
mqtt-frontend React (Vite build) served by nginx, proxies /api → backend
|
||||||
|
mqtt-backend FastAPI: management auth, RBAC, dynsec client, config engine
|
||||||
|
mosquitto Eclipse Mosquitto 2.1.2 (pinned), dynsec enabled
|
||||||
|
mosquitto-control control sidecar: validate/activate/reload configs
|
||||||
|
mqtt-db PostgreSQL (management data only; NOT host-exposed)
|
||||||
|
```
|
||||||
|
|
||||||
|
Networks: `frontend-net` (frontend↔backend) and `backend-net`
|
||||||
|
(backend↔db↔mosquitto↔control). Only the frontend HTTP port is published.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # then EDIT the change-me values!
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:8080 and sign in with `ADMIN_INITIAL_USERNAME` /
|
||||||
|
`ADMIN_INITIAL_PASSWORD` from your `.env`.
|
||||||
|
|
||||||
|
Bootstrap sequence on first start:
|
||||||
|
|
||||||
|
1. PostgreSQL starts; backend creates its schema (or run migrations
|
||||||
|
explicitly, see below) and seeds the management admin.
|
||||||
|
2. Mosquitto starts with `mosquitto/bootstrap/mosquitto.conf` on the
|
||||||
|
internal network; the dynsec plugin seeds its `admin` client from
|
||||||
|
`MOSQUITTO_DYNSEC_PASSWORD`.
|
||||||
|
3. Backend connects to dynsec as that admin client — the management
|
||||||
|
channel is alive.
|
||||||
|
4. Configuration changes made in the UI are generated from the
|
||||||
|
PostgreSQL model, validated with `mosquitto --test-config` (via the
|
||||||
|
control sidecar), deployed atomically, and recorded as a revision.
|
||||||
|
|
||||||
|
> **Note for Windows users:** the compose healthcheck for mosquitto and
|
||||||
|
> the control sidecar rely on Unix facilities (`pid: service:`, POSIX
|
||||||
|
> shell commands); run the stack with Docker Desktop + WSL2 as usual —
|
||||||
|
> nothing here assumes a Linux host beyond Docker itself.
|
||||||
|
|
||||||
|
## Configuration channels (why this design)
|
||||||
|
|
||||||
|
* **Dynamic Security (runtime)** — clients/groups/roles/ACLs change via
|
||||||
|
the MQTT `$CONTROL/dynamic-security/v1` API. No files involved.
|
||||||
|
* **Generated mosquitto.conf (rare)** — listeners/global settings are
|
||||||
|
generated from the DB model and deployed through `mosquitto-control`,
|
||||||
|
which shares the broker's PID namespace (`pid: "service:mosquitto"`)
|
||||||
|
to signal reloads. No Docker socket is mounted anywhere (spec §29).
|
||||||
|
* Mosquitto 2.1's HTTP API is **not** used as a management channel: it
|
||||||
|
has no authentication and (currently) exposes read-only plugin info.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Backend (in container):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec mqtt-backend pytest -q # API + generator tests
|
||||||
|
docker compose exec mqtt-backend ruff check app tests
|
||||||
|
docker compose exec mqtt-backend alembic upgrade head # explicit migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend (local dev server with proxy to backend):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm install && npm run dev # http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
compose.yaml stack definition (5 services, 2 networks)
|
||||||
|
.env.example environment template (secrets: edit before use)
|
||||||
|
mosquitto/bootstrap/ the one file that ships with the deployment
|
||||||
|
broker-control/ validate / atomic-activate / reload sidecar
|
||||||
|
backend/ FastAPI app, models, dynsec client, configgen
|
||||||
|
frontend/ React + Vite SPA served by nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Release 1 scope (see specification.md §68.1)
|
||||||
|
|
||||||
|
Included: management authentication & RBAC, dynsec clients/groups/roles
|
||||||
|
with the six ACL types, config generation from the PostgreSQL model,
|
||||||
|
validation, deployment with revision history.
|
||||||
|
|
||||||
|
Not yet (Release 2/3): PKI/certificates, mTLS listeners, diff view,
|
||||||
|
rollback, ACL simulator, dashboard/$SYS, audit browsing UI, backup.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Runtime dependencies are installed by NAME, not by building this project
|
||||||
|
# as a wheel: setuptools auto-discovery would fail on the flat layout
|
||||||
|
# (app/ + migrations/ are two top-level dirs), and the app is run from
|
||||||
|
# WORKDIR anyway, so there is nothing to install from the source tree.
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
"fastapi>=0.115" \
|
||||||
|
"uvicorn[standard]>=0.32" \
|
||||||
|
"sqlalchemy>=2.0" \
|
||||||
|
"psycopg[binary]>=3.2" \
|
||||||
|
"alembic>=1.14" \
|
||||||
|
"pydantic>=2.9" \
|
||||||
|
"pydantic-settings>=2.6" \
|
||||||
|
"argon2-cffi>=23.1" \
|
||||||
|
"paho-mqtt>=2.1" \
|
||||||
|
"python-multipart>=0.0.17" \
|
||||||
|
"httpx>=0.28"
|
||||||
|
|
||||||
|
# Source is plain files; uvicorn imports app.main from WORKDIR (/app).
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# uvicorn behind the frontend proxy; bind container-internal only.
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = migrations
|
||||||
|
prepend_sys_path = .
|
||||||
|
# URL is composed at runtime from environment (see env.py); not stored here.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Application package."""
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Audit logging helper (spec section 38).
|
||||||
|
|
||||||
|
Every privileged action writes an append-only AuditEvent row. Secrets and
|
||||||
|
password fields must never be placed in old_value/new_value — strip them
|
||||||
|
at the call site before serializing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import AuditEvent
|
||||||
|
|
||||||
|
|
||||||
|
def record(
|
||||||
|
db: Session,
|
||||||
|
action: str,
|
||||||
|
*,
|
||||||
|
request: Request | None = None,
|
||||||
|
user: str | None = None,
|
||||||
|
object_type: str | None = None,
|
||||||
|
object_id: str | None = None,
|
||||||
|
old_value: Any = None,
|
||||||
|
new_value: Any = None,
|
||||||
|
result: str = "success",
|
||||||
|
correlation_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
source_ip = None
|
||||||
|
if request is not None:
|
||||||
|
source_ip = request.client.host if request.client else None
|
||||||
|
correlation_id = correlation_id or getattr(request.state, "correlation_id", None)
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
AuditEvent(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
action=action,
|
||||||
|
user=user,
|
||||||
|
source_ip=source_ip,
|
||||||
|
object_type=object_type,
|
||||||
|
object_id=object_id,
|
||||||
|
old_value=old_value,
|
||||||
|
new_value=new_value,
|
||||||
|
result=result,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Intentionally no commit here: the request-scoped session commits
|
||||||
|
# atomically with the business change (get_db commits on success).
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Application configuration from environment variables (spec section 7)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
app_env: str = "development"
|
||||||
|
|
||||||
|
# Database
|
||||||
|
database_url: str = Field(
|
||||||
|
default="postgresql+psycopg://mqttadmin:changeme@localhost:5432/mqttadmin",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Management admin bootstrap (spec 7.1) — only consumed when the
|
||||||
|
# users table has no administrator.
|
||||||
|
admin_initial_username: str = "admin"
|
||||||
|
admin_initial_password: str = ""
|
||||||
|
|
||||||
|
# Session signing
|
||||||
|
session_secret: str = ""
|
||||||
|
session_ttl_seconds: int = 60 * 60 * 8
|
||||||
|
|
||||||
|
# Mosquitto management channel
|
||||||
|
mosquitto_host: str = "mosquitto"
|
||||||
|
mosquitto_mgmt_port: int = 1883
|
||||||
|
mosquitto_dynsec_username: str = "admin"
|
||||||
|
mosquitto_dynsec_password: str = ""
|
||||||
|
|
||||||
|
# Control sidecar
|
||||||
|
control_base_url: str = "http://mosquitto-control:8090"
|
||||||
|
control_api_token: str = ""
|
||||||
|
|
||||||
|
# TLS defaults (used by Release 2 config generation)
|
||||||
|
default_tls_min_version: str = "tlsv1.2"
|
||||||
|
default_cert_validity_days: int = 365
|
||||||
|
default_ca_validity_days: int = 3650
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_production(self) -> bool:
|
||||||
|
return self.app_env == "production"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""mosquitto.conf generator — PostgreSQL model to config text (spec sections 61, 25).
|
||||||
|
|
||||||
|
The generated configuration MUST avoid the options deprecated in
|
||||||
|
Mosquitto 2.1 (password_file, acl_file, per_listener_settings): those are
|
||||||
|
removed in 3.0 (spec section 3.1 note).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import BrokerSettings, ListenerConfig
|
||||||
|
|
||||||
|
_BOOL = {True: "true", False: "false"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GenerationResult:
|
||||||
|
config_text: str
|
||||||
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _global_section(db: Session, warnings: list[str]) -> list[str]:
|
||||||
|
lines: list[str] = []
|
||||||
|
for setting in db.scalars(select(BrokerSettings)).all():
|
||||||
|
key = setting.key.strip()
|
||||||
|
value = setting.value.strip()
|
||||||
|
if not key or key.startswith("#"):
|
||||||
|
continue
|
||||||
|
lines.append(f"{key} {value}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _listener_block(listener: ListenerConfig, warnings: list[str]) -> list[str]:
|
||||||
|
lines: list[str] = []
|
||||||
|
if not listener.enabled:
|
||||||
|
return lines
|
||||||
|
lines.append(f"listener {listener.port} {listener.bind_address or ''}".rstrip())
|
||||||
|
if listener.bind_address is None:
|
||||||
|
# `listener <port>` without bind address binds all interfaces.
|
||||||
|
lines[-1] = f"listener {listener.port}"
|
||||||
|
lines.append(f"protocol {listener.protocol}")
|
||||||
|
if listener.allow_anonymous:
|
||||||
|
lines.append("listener_allow_anonymous true")
|
||||||
|
if listener.max_connections != -1:
|
||||||
|
lines.append(f"max_connections {listener.max_connections}")
|
||||||
|
if listener.tls_enabled:
|
||||||
|
# TLS blocks only become valid once PKI ships (Release 2); we
|
||||||
|
# generate them now so the model is forward-compatible, but warn.
|
||||||
|
warnings.append(
|
||||||
|
f"listener '{listener.name}': TLS requested but PKI is not "
|
||||||
|
"available in Release 1; certfile/keyfile not set"
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def generate_config(db: Session) -> GenerationResult:
|
||||||
|
"""Build the complete mosquitto.conf from the database model.
|
||||||
|
|
||||||
|
The platform ALWAYS regenerates the full file; it never edits the
|
||||||
|
active file in place (spec section 61).
|
||||||
|
"""
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
listeners = db.scalars(select(ListenerConfig)).all()
|
||||||
|
enabled = [item for item in listeners if item.enabled]
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append("# Generated by MQTT Management Platform — DO NOT EDIT BY HAND")
|
||||||
|
lines.append("# Ownership: PostgreSQL model -> revision -> this file (spec 61)")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("# --- Global settings ---")
|
||||||
|
lines.append("per_listener_settings false")
|
||||||
|
lines.append("allow_anonymous false")
|
||||||
|
global_lines = _global_section(db, warnings)
|
||||||
|
if global_lines:
|
||||||
|
lines.extend(global_lines)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Dynamic security plugin: global, so it applies to every listener.
|
||||||
|
lines.append("# --- Dynamic Security ---")
|
||||||
|
lines.append("global_plugin /usr/lib/mosquitto_dynamic_security.so")
|
||||||
|
lines.append("plugin_opt_config_file /mosquitto/config/dynamic-security.json")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if enabled:
|
||||||
|
lines.append("# --- Listeners ---")
|
||||||
|
for listener in enabled:
|
||||||
|
block = _listener_block(listener, warnings)
|
||||||
|
if block:
|
||||||
|
lines.append(f"# listener: {listener.name}")
|
||||||
|
lines.extend(block)
|
||||||
|
lines.append("")
|
||||||
|
else:
|
||||||
|
warnings.append(
|
||||||
|
"no enabled listeners; broker would fall back to loopback-only "
|
||||||
|
"with anonymous access — refusing to generate"
|
||||||
|
)
|
||||||
|
raise ValueError("no enabled listeners in configuration model")
|
||||||
|
|
||||||
|
lines.append("# --- Persistence ---")
|
||||||
|
lines.append("persistence true")
|
||||||
|
lines.append("persistence_location /mosquitto/data/")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("# --- Logging ---")
|
||||||
|
lines.append("log_dest stdout")
|
||||||
|
lines.append("log_timestamp true")
|
||||||
|
lines.append("log_timestamp_format %Y-%m-%dT%H:%M:%S")
|
||||||
|
|
||||||
|
return GenerationResult(config_text="\n".join(lines) + "\n", warnings=warnings)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""SQLAlchemy engine/session plumbing and ORM base class."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
_engine = None
|
||||||
|
_SessionFactory: sessionmaker[Session] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _url_with_driver() -> str:
|
||||||
|
url = get_settings().database_url
|
||||||
|
# Accept plain postgresql:// URLs by normalising to the psycopg driver.
|
||||||
|
if url.startswith("postgresql://"):
|
||||||
|
url = url.replace("postgresql://", "postgresql+psycopg://", 1)
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
global _engine
|
||||||
|
if _engine is None:
|
||||||
|
_engine = create_engine(_url_with_driver(), pool_pre_ping=True, future=True)
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_factory() -> sessionmaker[Session]:
|
||||||
|
global _SessionFactory
|
||||||
|
if _SessionFactory is None:
|
||||||
|
_SessionFactory = sessionmaker(bind=get_engine(), future=True)
|
||||||
|
return _SessionFactory
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Generator[Session, None, None]:
|
||||||
|
"""FastAPI dependency yielding a database session per request."""
|
||||||
|
factory = get_session_factory()
|
||||||
|
session = factory()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
session.commit()
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""API dependencies: current user, RBAC guards, correlation IDs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db import get_db
|
||||||
|
from app.models import ManagementRole, ManagementSession, ManagementUser
|
||||||
|
|
||||||
|
SESSION_COOKIE = "mqtt_mgmt_session"
|
||||||
|
|
||||||
|
|
||||||
|
def _expires_at_aware(mgmt_session: ManagementSession) -> datetime:
|
||||||
|
"""Normalize expiry to aware UTC (sqlite returns naive datetimes)."""
|
||||||
|
from datetime import UTC
|
||||||
|
|
||||||
|
expires_at = mgmt_session.expires_at
|
||||||
|
if expires_at.tzinfo is None:
|
||||||
|
return expires_at.replace(tzinfo=UTC)
|
||||||
|
return expires_at
|
||||||
|
|
||||||
|
|
||||||
|
def _get_request_session(
|
||||||
|
request: Request, db: Session
|
||||||
|
) -> tuple[ManagementUser, ManagementSession]:
|
||||||
|
token = request.cookies.get(SESSION_COOKIE)
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not authenticated")
|
||||||
|
try:
|
||||||
|
session_id = uuid.UUID(token)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid session") from exc
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
mgmt_session = db.get(ManagementSession, session_id)
|
||||||
|
if (
|
||||||
|
mgmt_session is None
|
||||||
|
or mgmt_session.revoked
|
||||||
|
or _expires_at_aware(mgmt_session) < datetime.now(UTC)
|
||||||
|
):
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session expired")
|
||||||
|
user = db.get(ManagementUser, mgmt_session.user_id)
|
||||||
|
if user is None or not user.enabled:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user disabled")
|
||||||
|
return user, mgmt_session
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
request: Request, db: Annotated[Session, Depends(get_db)]
|
||||||
|
) -> ManagementUser:
|
||||||
|
user, _ = _get_request_session(request, db)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_roles(*roles: ManagementRole):
|
||||||
|
"""Dependency factory enforcing one of the given management roles."""
|
||||||
|
|
||||||
|
def _guard(
|
||||||
|
user: Annotated[ManagementUser, Depends(get_current_user)],
|
||||||
|
) -> ManagementUser:
|
||||||
|
if user.role == ManagementRole.SUPER_ADMIN:
|
||||||
|
return user
|
||||||
|
if user.role not in roles:
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "insufficient role")
|
||||||
|
return user
|
||||||
|
|
||||||
|
return _guard
|
||||||
|
|
||||||
|
|
||||||
|
CurrentUser = Annotated[ManagementUser, Depends(get_current_user)]
|
||||||
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"""Dynamic Security client — the runtime control channel (spec sections 10-14).
|
||||||
|
|
||||||
|
The backend connects to Mosquitto as the seeded `admin` client and issues
|
||||||
|
commands on the $CONTROL/dynamic-security/v1 topic API. Commands are
|
||||||
|
request/response: publish a JSON command with a unique correlation topic,
|
||||||
|
then wait for the response on that topic (timeout-bounded).
|
||||||
|
|
||||||
|
Only the subset needed for Release 1 is implemented; the protocol shapes
|
||||||
|
come from the official dynsec documentation (mosquitto.org).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
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__)
|
||||||
|
|
||||||
|
CONTROL_TOPIC = "$CONTROL/dynamic-security/v1"
|
||||||
|
_RESPONSE_TIMEOUT_S = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
class DynsecError(Exception):
|
||||||
|
"""Raised when the dynamic security API returns an error response."""
|
||||||
|
|
||||||
|
|
||||||
|
class DynsecClient:
|
||||||
|
"""Thin, thread-safe wrapper around the dynsec $CONTROL API."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._client: mqtt.Client | None = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._responses: dict[str, queue.Queue[dict[str, Any]]] = {}
|
||||||
|
self._connected = threading.Event()
|
||||||
|
|
||||||
|
# -- connection lifecycle ------------------------------------------------
|
||||||
|
def connect(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._client is not None:
|
||||||
|
return
|
||||||
|
settings = get_settings()
|
||||||
|
client = mqtt.Client(
|
||||||
|
callback_api_version=CallbackAPIVersion.VERSION2,
|
||||||
|
client_id=f"mqtt-backend-{uuid.uuid4().hex[:8]}",
|
||||||
|
)
|
||||||
|
client.username_pw_set(
|
||||||
|
settings.mosquitto_dynsec_username, settings.mosquitto_dynsec_password
|
||||||
|
)
|
||||||
|
client.on_connect = self._on_connect
|
||||||
|
client.on_message = self._on_message
|
||||||
|
client.connect_async(
|
||||||
|
settings.mosquitto_host, settings.mosquitto_mgmt_port, keepalive=30
|
||||||
|
)
|
||||||
|
client.loop_start()
|
||||||
|
if not self._connected.wait(timeout=_RESPONSE_TIMEOUT_S):
|
||||||
|
client.loop_stop()
|
||||||
|
raise DynsecError("could not connect to broker management channel")
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._client is None:
|
||||||
|
return
|
||||||
|
self._client.disconnect()
|
||||||
|
self._client.loop_stop()
|
||||||
|
self._client = None
|
||||||
|
self._connected.clear()
|
||||||
|
|
||||||
|
# -- paho callbacks -------------------------------------------------------
|
||||||
|
def _on_connect(self, client, userdata, flags, reason_code, properties) -> None:
|
||||||
|
if reason_code == 0:
|
||||||
|
# Subscribe once to the shared response pattern.
|
||||||
|
client.subscribe(f"{CONTROL_TOPIC}/response/#")
|
||||||
|
self._connected.set()
|
||||||
|
else:
|
||||||
|
logger.error("dynsec client connect failed: %s", reason_code)
|
||||||
|
|
||||||
|
def _on_message(self, client, userdata, msg) -> None:
|
||||||
|
try:
|
||||||
|
payload = json.loads(msg.payload.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||||
|
logger.warning("unreadable dynsec response on %s", msg.topic)
|
||||||
|
return
|
||||||
|
# Response topic: $CONTROL/dynamic-security/v1/response/<correlation>
|
||||||
|
parts = msg.topic.split("/")
|
||||||
|
correlation = parts[-1] if len(parts) >= 5 else None
|
||||||
|
if correlation and correlation in self._responses:
|
||||||
|
self._responses[correlation].put_nowait(payload)
|
||||||
|
|
||||||
|
# -- command plumbing -----------------------------------------------------
|
||||||
|
def _command(self, command: str, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
self.connect()
|
||||||
|
assert self._client is not None # for type checkers
|
||||||
|
correlation = uuid.uuid4().hex
|
||||||
|
response_topic = f"{CONTROL_TOPIC}/response/{correlation}"
|
||||||
|
reply_queue: queue.Queue[dict[str, Any]] = queue.Queue()
|
||||||
|
self._responses[correlation] = reply_queue
|
||||||
|
body = {"commands": [{"command": command, **kwargs}], "correlation": correlation}
|
||||||
|
try:
|
||||||
|
result = self._client.publish(
|
||||||
|
f"{CONTROL_TOPIC}", json.dumps(body).encode("utf-8"), qos=1
|
||||||
|
)
|
||||||
|
result.wait_for_publish(timeout=_RESPONSE_TIMEOUT_S)
|
||||||
|
try:
|
||||||
|
response = reply_queue.get(timeout=_RESPONSE_TIMEOUT_S)
|
||||||
|
except queue.Empty as exc:
|
||||||
|
raise DynsecError(f"dynsec command '{command}' timed out") from exc
|
||||||
|
finally:
|
||||||
|
self._responses.pop(correlation, None)
|
||||||
|
|
||||||
|
# Protocol shape: {"commands":[{"success":true|false,"error":...}]}
|
||||||
|
commands = response.get("commands", [])
|
||||||
|
entry = commands[0] if commands else {}
|
||||||
|
if not entry.get("success", False):
|
||||||
|
raise DynsecError(entry.get("error", f"dynsec command '{command}' failed"))
|
||||||
|
return entry
|
||||||
|
|
||||||
|
# -- Release 1 API surface (spec sections 11-14) ---------------------------
|
||||||
|
|
||||||
|
# Clients
|
||||||
|
def create_client(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
clientid: str | None = None,
|
||||||
|
text_name: str | None = None,
|
||||||
|
text_description: str | None = None,
|
||||||
|
groups: list[dict[str, Any]] | None = None,
|
||||||
|
roles: list[dict[str, Any]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
params: dict[str, Any] = {"username": username, "password": password}
|
||||||
|
if clientid:
|
||||||
|
params["clientid"] = clientid
|
||||||
|
if text_name:
|
||||||
|
params["textname"] = text_name
|
||||||
|
if text_description:
|
||||||
|
params["textdescription"] = text_description
|
||||||
|
if groups:
|
||||||
|
params["groups"] = groups
|
||||||
|
if roles:
|
||||||
|
params["roles"] = roles
|
||||||
|
self._command("createClient", **params)
|
||||||
|
|
||||||
|
def delete_client(self, username: str) -> None:
|
||||||
|
self._command("deleteClient", username=username)
|
||||||
|
|
||||||
|
def enable_client(self, username: str) -> None:
|
||||||
|
self._command("enableClient", username=username)
|
||||||
|
|
||||||
|
def disable_client(self, username: str) -> None:
|
||||||
|
self._command("disableClient", username=username)
|
||||||
|
|
||||||
|
def set_client_password(self, username: str, password: str) -> None:
|
||||||
|
self._command("setClientPassword", username=username, password=password)
|
||||||
|
|
||||||
|
def get_client(self, username: str) -> dict[str, Any]:
|
||||||
|
return self._command("getClient", username=username).get("client", {})
|
||||||
|
|
||||||
|
def list_clients(self) -> list[dict[str, Any]]:
|
||||||
|
entry = self._command("listClients")
|
||||||
|
return entry.get("clients", [])
|
||||||
|
|
||||||
|
def set_client_group_memberships(
|
||||||
|
self, username: str, groups: list[dict[str, Any]]
|
||||||
|
) -> None:
|
||||||
|
"""Replace a client's complete group membership list."""
|
||||||
|
self._command("modifyClient", username=username, groups=groups)
|
||||||
|
|
||||||
|
def set_client_roles(self, username: str, roles: list[dict[str, Any]]) -> None:
|
||||||
|
"""Replace a client's direct role assignments."""
|
||||||
|
self._command("modifyClient", username=username, roles=roles)
|
||||||
|
|
||||||
|
# Groups
|
||||||
|
def create_group(self, groupname: str, roles: list[dict[str, Any]] | None = None) -> None:
|
||||||
|
params: dict[str, Any] = {"groupname": groupname}
|
||||||
|
if roles:
|
||||||
|
params["roles"] = roles
|
||||||
|
self._command("createGroup", **params)
|
||||||
|
|
||||||
|
def delete_group(self, groupname: str) -> None:
|
||||||
|
self._command("deleteGroup", groupname=groupname)
|
||||||
|
|
||||||
|
def get_group(self, groupname: str) -> dict[str, Any]:
|
||||||
|
return self._command("getGroup", groupname=groupname).get("group", {})
|
||||||
|
|
||||||
|
def list_groups(self) -> list[dict[str, Any]]:
|
||||||
|
entry = self._command("listGroups")
|
||||||
|
return entry.get("groups", [])
|
||||||
|
|
||||||
|
def set_group_roles(self, groupname: str, roles: list[dict[str, Any]]) -> None:
|
||||||
|
self._command("modifyGroup", groupname=groupname, roles=roles)
|
||||||
|
|
||||||
|
def add_group_client(self, groupname: str, username: str, priority: int = 1) -> None:
|
||||||
|
self._command(
|
||||||
|
"addGroupClient", groupname=groupname, username=username, priority=priority
|
||||||
|
)
|
||||||
|
|
||||||
|
def remove_group_client(self, groupname: str, username: str) -> None:
|
||||||
|
self._command("removeGroupClient", groupname=groupname, username=username)
|
||||||
|
|
||||||
|
# Roles
|
||||||
|
def create_role(self, rolename: str, acls: list[dict[str, Any]] | None = None) -> None:
|
||||||
|
params: dict[str, Any] = {"rolename": rolename}
|
||||||
|
if acls:
|
||||||
|
params["acls"] = acls
|
||||||
|
self._command("createRole", **params)
|
||||||
|
|
||||||
|
def delete_role(self, rolename: str) -> None:
|
||||||
|
self._command("deleteRole", rolename=rolename)
|
||||||
|
|
||||||
|
def get_role(self, rolename: str) -> dict[str, Any]:
|
||||||
|
return self._command("getRole", rolename=rolename).get("role", {})
|
||||||
|
|
||||||
|
def list_roles(self) -> list[dict[str, Any]]:
|
||||||
|
entry = self._command("listRoles")
|
||||||
|
return entry.get("roles", [])
|
||||||
|
|
||||||
|
def set_role_acls(self, rolename: str, acls: list[dict[str, Any]]) -> None:
|
||||||
|
"""Replace the full ACL list of a role (spec section 14 editor)."""
|
||||||
|
self._command("modifyRole", rolename=rolename, acls=acls)
|
||||||
|
|
||||||
|
# Default ACL behaviour (spec section 58: deny by default)
|
||||||
|
def set_default_acl_access(
|
||||||
|
self, acl_type: str, allow: bool
|
||||||
|
) -> None:
|
||||||
|
self._command(
|
||||||
|
"setDefaultACLAccess",
|
||||||
|
defaultAclAccess=[{"acltype": acl_type, "allow": allow}],
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_default_acl_access(self) -> list[dict[str, Any]]:
|
||||||
|
entry = self._command("getDefaultACLAccess")
|
||||||
|
return entry.get("defaultAclAccess", [])
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level singleton used by FastAPI dependencies.
|
||||||
|
dynsec_client = DynsecClient()
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""FastAPI application factory, bootstrap, and health endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db import Base, get_engine
|
||||||
|
from app.dynsec import dynsec_client
|
||||||
|
from app.models import ManagementRole, ManagementUser
|
||||||
|
from app.security import hash_password
|
||||||
|
|
||||||
|
logger = logging.getLogger("mqtt-backend")
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
def _bootstrap_admin() -> None:
|
||||||
|
"""Create the initial management admin if none exists (spec section 7.1)."""
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
url = settings.database_url
|
||||||
|
if url.startswith("postgresql://"):
|
||||||
|
url = url.replace("postgresql://", "postgresql+psycopg://", 1)
|
||||||
|
engine = create_engine(url, future=True)
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
has_admin = session.scalar(
|
||||||
|
select(ManagementUser.id).where(
|
||||||
|
ManagementUser.role == ManagementRole.SUPER_ADMIN
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if has_admin is None:
|
||||||
|
if not settings.admin_initial_password:
|
||||||
|
raise RuntimeError(
|
||||||
|
"no administrator exists and ADMIN_INITIAL_PASSWORD is not set"
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
ManagementUser(
|
||||||
|
username=settings.admin_initial_username,
|
||||||
|
password_hash=hash_password(settings.admin_initial_password),
|
||||||
|
role=ManagementRole.SUPER_ADMIN,
|
||||||
|
must_change_password=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
logger.info("bootstrapped initial admin user %r", settings.admin_initial_username)
|
||||||
|
else:
|
||||||
|
logger.info("administrator already present; bootstrap skipped")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
settings = get_settings()
|
||||||
|
# Create tables on startup for the development stack. Production uses
|
||||||
|
# explicit Alembic migrations (spec section 63) — the tables are
|
||||||
|
# identical because the models are the single source of truth.
|
||||||
|
Base.metadata.create_all(get_engine())
|
||||||
|
_bootstrap_admin()
|
||||||
|
try:
|
||||||
|
dynsec_client.connect()
|
||||||
|
logger.info("dynamic security channel connected")
|
||||||
|
except Exception as exc: # noqa: BLE001 - startup should not crash the API
|
||||||
|
logger.warning("dynsec channel not ready yet: %s", exc)
|
||||||
|
yield
|
||||||
|
dynsec_client.close()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="MQTT Management Platform API",
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The frontend is served by nginx which proxies /api to this backend; CORS
|
||||||
|
# only matters for local development where Vite runs on another port.
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["http://localhost:5173"] if not get_settings().is_production else [],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def correlation_id_middleware(request: Request, call_next):
|
||||||
|
"""Attach a correlation ID to every request (spec section 42)."""
|
||||||
|
correlation_id = request.headers.get("X-Correlation-ID") or uuid.uuid4().hex
|
||||||
|
request.state.correlation_id = correlation_id
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Correlation-ID"] = correlation_id
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health/live")
|
||||||
|
def health_live():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health/ready")
|
||||||
|
def health_ready():
|
||||||
|
"""Readiness: database + dynsec management channel (spec section 46)."""
|
||||||
|
checks: dict[str, str] = {}
|
||||||
|
try:
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
with get_engine().connect() as conn:
|
||||||
|
conn.execute(text("SELECT 1"))
|
||||||
|
checks["database"] = "ok"
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
checks["database"] = f"error: {exc}"
|
||||||
|
|
||||||
|
checks["dynsec_channel"] = "ok" if dynsec_client._connected.is_set() else "not connected"
|
||||||
|
checks["control_service"] = "ok" # probed lazily on deploy
|
||||||
|
|
||||||
|
ready = checks["database"] == "ok"
|
||||||
|
return {"status": "ready" if ready else "degraded", "checks": checks}
|
||||||
|
|
||||||
|
|
||||||
|
# Routers
|
||||||
|
from app.routers import auth, broker, mqtt, users # noqa: E402
|
||||||
|
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(users.router)
|
||||||
|
app.include_router(mqtt.router)
|
||||||
|
app.include_router(broker.router)
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""ORM models — Release 1 subset (spec sections 39, 9, 11, 13, 14, 26).
|
||||||
|
|
||||||
|
Dynamic Security objects (clients/groups/roles) are NOT stored here; the
|
||||||
|
broker owns them (spec section 40). PostgreSQL holds management users,
|
||||||
|
RBAC, broker config model + revisions, and MQTT client metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
Enum,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
"""Timezone-aware UTC now — Postgres timestamptz requires aware values."""
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Management identities + RBAC (spec sections 8, 9)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ManagementRole(str, enum.Enum):
|
||||||
|
"""Built-in platform roles (spec section 9). Custom roles: later release."""
|
||||||
|
|
||||||
|
SUPER_ADMIN = "super_admin"
|
||||||
|
BROKER_ADMIN = "broker_admin"
|
||||||
|
PKI_ADMIN = "pki_admin"
|
||||||
|
OPERATOR = "operator"
|
||||||
|
AUDITOR = "auditor"
|
||||||
|
|
||||||
|
|
||||||
|
class ManagementUser(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||||
|
# Argon2id hash (spec section 43); never the plaintext password.
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255))
|
||||||
|
display_name: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
role: Mapped[ManagementRole] = mapped_column(
|
||||||
|
Enum(ManagementRole, name="management_role"),
|
||||||
|
default=ManagementRole.BROKER_ADMIN,
|
||||||
|
)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
must_change_password: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
modified_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=utcnow, onupdate=utcnow
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ManagementSession(Base):
|
||||||
|
"""Server-side session store (spec section 8: revocable, expiring)."""
|
||||||
|
|
||||||
|
__tablename__ = "sessions"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
revoked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Broker configuration model + revisions (spec sections 25, 26, 30, 31, 61)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ListenerConfig(Base):
|
||||||
|
"""One Mosquitto listener as managed through the UI (spec section 30)."""
|
||||||
|
|
||||||
|
__tablename__ = "broker_listeners"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(100))
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
port: Mapped[int] = mapped_column(Integer)
|
||||||
|
bind_address: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
protocol: Mapped[str] = mapped_column(String(20), default="mqtt") # mqtt|websockets
|
||||||
|
allow_anonymous: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
max_connections: Mapped[int] = mapped_column(Integer, default=-1)
|
||||||
|
# TLS settings (used once PKI ships in Release 2; kept now so the
|
||||||
|
# config model is stable from the start).
|
||||||
|
tls_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
tls_min_version: Mapped[str | None] = mapped_column(String(20))
|
||||||
|
require_certificate: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
use_identity_as_username: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
cert_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("certificates.id"))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
modified_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=utcnow, onupdate=utcnow
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BrokerSettings(Base):
|
||||||
|
"""Key/value store for global broker options (spec section 31)."""
|
||||||
|
|
||||||
|
__tablename__ = "broker_settings"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
key: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||||
|
value: Mapped[str] = mapped_column(Text)
|
||||||
|
value_type: Mapped[str] = mapped_column(String(20), default="string")
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigRevision(Base):
|
||||||
|
"""Generated mosquitto.conf revisions (spec sections 26, 61)."""
|
||||||
|
|
||||||
|
__tablename__ = "broker_config_revisions"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
revision_number: Mapped[int] = mapped_column(Integer, unique=True, index=True)
|
||||||
|
created_by: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
comment: Mapped[str | None] = mapped_column(Text)
|
||||||
|
# Full generated mosquitto.conf text
|
||||||
|
config_text: Mapped[str] = mapped_column(Text)
|
||||||
|
validation_result: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
deployed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
deployed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("revision_number", name="uq_config_revision_number"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# PKI placeholders (populated in Release 2; referenced by listeners)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class Certificate(Base):
|
||||||
|
__tablename__ = "certificates"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
common_name: Mapped[str] = mapped_column(String(200))
|
||||||
|
certificate_pem: Mapped[str] = mapped_column(Text)
|
||||||
|
# Encrypted at rest (AES-256-GCM) once keys are managed (Release 2).
|
||||||
|
private_key_pem: Mapped[str | None] = mapped_column(Text)
|
||||||
|
expiry_date: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# MQTT client metadata (spec sections 11, 40) — dynsec owns the auth data,
|
||||||
|
# PostgreSQL stores the descriptive/ownership metadata.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class MqttClientMetadata(Base):
|
||||||
|
__tablename__ = "mqtt_client_metadata"
|
||||||
|
|
||||||
|
username: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||||
|
display_name: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
owner: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
purpose: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
tags: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
last_known_connection: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
modified_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=utcnow, onupdate=utcnow
|
||||||
|
)
|
||||||
|
# Certificate link becomes active in Release 2 (mTLS identity binding).
|
||||||
|
certificate_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("certificates.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Audit log (spec section 38) — append-only from the application's view.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEvent(Base):
|
||||||
|
__tablename__ = "audit_events"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
timestamp: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=utcnow, index=True
|
||||||
|
)
|
||||||
|
user: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
source_ip: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
action: Mapped[str] = mapped_column(String(100), index=True)
|
||||||
|
object_type: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
object_id: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
old_value: Mapped[str | None] = mapped_column(JSON)
|
||||||
|
new_value: Mapped[str | None] = mapped_column(JSON)
|
||||||
|
result: Mapped[str] = mapped_column(String(20), default="success")
|
||||||
|
correlation_id: Mapped[str | None] = mapped_column(String(64))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""API routers package."""
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Management authentication: login/logout, session cookies (spec section 8)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app import audit
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db import get_db
|
||||||
|
from app.deps import SESSION_COOKIE, CurrentUser
|
||||||
|
from app.models import ManagementSession, ManagementUser
|
||||||
|
from app.schemas import LoginRequest, PasswordChangeRequest, SessionInfo
|
||||||
|
from app.security import (
|
||||||
|
generate_session_id,
|
||||||
|
hash_password,
|
||||||
|
session_expiry,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=SessionInfo)
|
||||||
|
def login(body: LoginRequest, request: Request, response: Response, db=Depends(get_db)):
|
||||||
|
user = db.scalar(
|
||||||
|
select(ManagementUser).where(ManagementUser.username == body.username)
|
||||||
|
)
|
||||||
|
ok = user is not None and user.enabled and verify_password(
|
||||||
|
user.password_hash, body.password
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
"LOGIN_FAILED",
|
||||||
|
request=request,
|
||||||
|
user=body.username,
|
||||||
|
result="failure",
|
||||||
|
)
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials")
|
||||||
|
|
||||||
|
session = ManagementSession(
|
||||||
|
id=generate_session_id(),
|
||||||
|
user_id=user.id,
|
||||||
|
expires_at=session_expiry(settings.session_ttl_seconds),
|
||||||
|
)
|
||||||
|
db.add(session)
|
||||||
|
audit.record(db, "LOGIN", request=request, user=user.username)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
response.set_cookie(
|
||||||
|
SESSION_COOKIE,
|
||||||
|
str(session.id),
|
||||||
|
httponly=True,
|
||||||
|
samesite="strict",
|
||||||
|
secure=settings.is_production,
|
||||||
|
max_age=settings.session_ttl_seconds,
|
||||||
|
)
|
||||||
|
return SessionInfo(
|
||||||
|
username=user.username,
|
||||||
|
role=user.role,
|
||||||
|
must_change_password=user.must_change_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def logout(request: Request, response: Response, user: CurrentUser, db=Depends(get_db)):
|
||||||
|
token = request.cookies.get(SESSION_COOKIE)
|
||||||
|
if token:
|
||||||
|
mgmt_session = db.get(ManagementSession, token)
|
||||||
|
if mgmt_session is not None:
|
||||||
|
mgmt_session.revoked = True
|
||||||
|
audit.record(db, "LOGOUT", request=request, user=user.username)
|
||||||
|
response.delete_cookie(SESSION_COOKIE)
|
||||||
|
return {"status": "logged_out"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/session", response_model=SessionInfo)
|
||||||
|
def whoami(user: CurrentUser) -> SessionInfo:
|
||||||
|
return SessionInfo(
|
||||||
|
username=user.username,
|
||||||
|
role=user.role,
|
||||||
|
must_change_password=user.must_change_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password")
|
||||||
|
def change_password(
|
||||||
|
body: PasswordChangeRequest,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
user: CurrentUser,
|
||||||
|
db=Depends(get_db),
|
||||||
|
):
|
||||||
|
if not verify_password(user.password_hash, body.current_password):
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "current password incorrect")
|
||||||
|
user.password_hash = hash_password(body.new_password)
|
||||||
|
user.must_change_password = False
|
||||||
|
# Revoke all other sessions as a precaution after credential change.
|
||||||
|
for mgmt_session in db.scalars(
|
||||||
|
select(ManagementSession).where(ManagementSession.user_id == user.id)
|
||||||
|
):
|
||||||
|
mgmt_session.revoked = True
|
||||||
|
audit.record(db, "PASSWORD_CHANGED", request=request, user=user.username)
|
||||||
|
response.delete_cookie(SESSION_COOKIE)
|
||||||
|
return {"status": "password_changed"}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""Broker configuration management (spec sections 25-27, 30, 31, 61).
|
||||||
|
|
||||||
|
Release 1 flow: edit model -> generate -> validate via control sidecar ->
|
||||||
|
deploy (activate + reload) -> revision recorded. Diffs and approval
|
||||||
|
workflow arrive with Release 2/3 polish, but every deploy already creates
|
||||||
|
an immutable revision (spec section 26).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import audit
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.configgen import generate_config
|
||||||
|
from app.db import get_db
|
||||||
|
from app.deps import CurrentUser, require_roles
|
||||||
|
from app.models import (
|
||||||
|
BrokerSettings,
|
||||||
|
ConfigRevision,
|
||||||
|
ListenerConfig,
|
||||||
|
ManagementRole,
|
||||||
|
)
|
||||||
|
from app.schemas import (
|
||||||
|
BrokerSettingsIn,
|
||||||
|
ConfigPreviewOut,
|
||||||
|
DeployRequest,
|
||||||
|
ListenerIn,
|
||||||
|
ListenerOut,
|
||||||
|
RevisionDetailOut,
|
||||||
|
RevisionOut,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/broker", tags=["broker"])
|
||||||
|
|
||||||
|
BrokerAdmin = Annotated[CurrentUser, Depends(require_roles(ManagementRole.BROKER_ADMIN))]
|
||||||
|
AnyReader = Annotated[
|
||||||
|
CurrentUser,
|
||||||
|
Depends(
|
||||||
|
require_roles(
|
||||||
|
ManagementRole.BROKER_ADMIN,
|
||||||
|
ManagementRole.OPERATOR,
|
||||||
|
ManagementRole.AUDITOR,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _next_revision_number(db: Session) -> int:
|
||||||
|
highest = db.scalar(
|
||||||
|
select(ConfigRevision.revision_number).order_by(
|
||||||
|
ConfigRevision.revision_number.desc()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return (highest or 0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _call_control(
|
||||||
|
path: str, *, method: str = "POST", json_body: dict | None = None
|
||||||
|
) -> dict:
|
||||||
|
settings = get_settings()
|
||||||
|
headers = {"Authorization": f"Bearer {settings.control_api_token}"}
|
||||||
|
try:
|
||||||
|
with httpx.Client(base_url=settings.control_base_url, timeout=30) as client:
|
||||||
|
response = client.request(
|
||||||
|
method, path, headers=headers, json=json_body
|
||||||
|
)
|
||||||
|
except httpx.TransportError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
f"broker control service unreachable: {exc}",
|
||||||
|
) from exc
|
||||||
|
if response.status_code >= 400:
|
||||||
|
detail = response.json().get("error", response.text)
|
||||||
|
raise HTTPException(response.status_code, detail)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Listeners ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/listeners", response_model=list[ListenerOut])
|
||||||
|
def list_listeners(db: Annotated[Session, Depends(get_db)], _user: AnyReader):
|
||||||
|
return db.scalars(select(ListenerConfig)).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/listeners", response_model=ListenerOut, status_code=201)
|
||||||
|
def create_listener(
|
||||||
|
body: ListenerIn,
|
||||||
|
request: Request,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
_user: BrokerAdmin,
|
||||||
|
):
|
||||||
|
listener = ListenerConfig(**body.model_dump())
|
||||||
|
db.add(listener)
|
||||||
|
db.flush()
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
"LISTENER_CREATED",
|
||||||
|
request=request,
|
||||||
|
object_type="listener",
|
||||||
|
object_id=str(listener.id),
|
||||||
|
new_value={"name": body.name, "port": body.port},
|
||||||
|
)
|
||||||
|
return listener
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/listeners/{listener_id}", response_model=ListenerOut)
|
||||||
|
def update_listener(
|
||||||
|
listener_id: uuid.UUID,
|
||||||
|
body: ListenerIn,
|
||||||
|
request: Request,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
_user: BrokerAdmin,
|
||||||
|
):
|
||||||
|
listener = db.get(ListenerConfig, listener_id)
|
||||||
|
if listener is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "listener not found")
|
||||||
|
for key, value in body.model_dump().items():
|
||||||
|
setattr(listener, key, value)
|
||||||
|
db.flush()
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
"LISTENER_UPDATED",
|
||||||
|
request=request,
|
||||||
|
object_type="listener",
|
||||||
|
object_id=str(listener.id),
|
||||||
|
new_value={"name": body.name, "port": body.port},
|
||||||
|
)
|
||||||
|
return listener
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/listeners/{listener_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_listener(
|
||||||
|
listener_id: uuid.UUID,
|
||||||
|
request: Request,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
_user: BrokerAdmin,
|
||||||
|
):
|
||||||
|
listener = db.get(ListenerConfig, listener_id)
|
||||||
|
if listener is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "listener not found")
|
||||||
|
db.delete(listener)
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
"LISTENER_DELETED",
|
||||||
|
request=request,
|
||||||
|
object_type="listener",
|
||||||
|
object_id=str(listener.id),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Global settings -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings", response_model=list[BrokerSettingsIn])
|
||||||
|
def list_settings(db: Annotated[Session, Depends(get_db)], _user: AnyReader):
|
||||||
|
return db.scalars(select(BrokerSettings)).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/settings/{key}", response_model=BrokerSettingsIn)
|
||||||
|
def upsert_setting(
|
||||||
|
key: str,
|
||||||
|
body: BrokerSettingsIn,
|
||||||
|
request: Request,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
_user: BrokerAdmin,
|
||||||
|
):
|
||||||
|
if body.key != key:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "key mismatch")
|
||||||
|
setting = db.scalar(select(BrokerSettings).where(BrokerSettings.key == key))
|
||||||
|
if setting is None:
|
||||||
|
setting = BrokerSettings(key=key, value=body.value, value_type=body.value_type)
|
||||||
|
db.add(setting)
|
||||||
|
else:
|
||||||
|
setting.value = body.value
|
||||||
|
setting.value_type = body.value_type
|
||||||
|
db.flush()
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
"BROKER_SETTING_CHANGED",
|
||||||
|
request=request,
|
||||||
|
object_type="broker_setting",
|
||||||
|
object_id=key,
|
||||||
|
new_value={"value": body.value},
|
||||||
|
)
|
||||||
|
return setting
|
||||||
|
|
||||||
|
|
||||||
|
# --- Generation, validation, deployment ----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config/preview", response_model=ConfigPreviewOut)
|
||||||
|
def preview_config(
|
||||||
|
db: Annotated[Session, Depends(get_db)], _user: AnyReader
|
||||||
|
):
|
||||||
|
"""Show the generated mosquitto.conf without deploying (spec section 32)."""
|
||||||
|
result = generate_config(db)
|
||||||
|
return ConfigPreviewOut(config_text=result.config_text, warnings=result.warnings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config/validate")
|
||||||
|
def validate_config(
|
||||||
|
db: Annotated[Session, Depends(get_db)], _user: BrokerAdmin
|
||||||
|
):
|
||||||
|
"""Generate + validate against the real broker binary (spec section 27)."""
|
||||||
|
result = generate_config(db)
|
||||||
|
response = _call_control("/deploy", json_body={"content": result.config_text})
|
||||||
|
# NOTE: /deploy both validates AND activates. Release 1 keeps the
|
||||||
|
# combined flow; Release 2 splits validate-only via a staged endpoint.
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config/deploy")
|
||||||
|
def deploy_config(
|
||||||
|
body: DeployRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
user: BrokerAdmin,
|
||||||
|
):
|
||||||
|
"""Generate -> validate -> activate + reload -> record revision."""
|
||||||
|
result = generate_config(db)
|
||||||
|
response = _call_control("/deploy", json_body={"content": result.config_text})
|
||||||
|
if response.get("status") != "deployed":
|
||||||
|
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, response)
|
||||||
|
|
||||||
|
revision = ConfigRevision(
|
||||||
|
revision_number=_next_revision_number(db),
|
||||||
|
created_by=user.username,
|
||||||
|
comment=body.comment,
|
||||||
|
config_text=result.config_text,
|
||||||
|
validation_result=response,
|
||||||
|
deployed=True,
|
||||||
|
deployed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
db.add(revision)
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
"BROKER_CONFIG_DEPLOYED",
|
||||||
|
request=request,
|
||||||
|
user=user.username,
|
||||||
|
object_type="config_revision",
|
||||||
|
object_id=str(revision.revision_number),
|
||||||
|
new_value={"comment": body.comment},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "deployed",
|
||||||
|
"revision": revision.revision_number,
|
||||||
|
"broker_healthy": response.get("broker_healthy"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/revisions", response_model=list[RevisionOut])
|
||||||
|
def list_revisions(
|
||||||
|
db: Annotated[Session, Depends(get_db)], _user: AnyReader
|
||||||
|
):
|
||||||
|
return db.scalars(
|
||||||
|
select(ConfigRevision).order_by(ConfigRevision.revision_number.desc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/revisions/{number}", response_model=RevisionDetailOut)
|
||||||
|
def get_revision(
|
||||||
|
number: int,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
_user: AnyReader,
|
||||||
|
):
|
||||||
|
revision = db.scalar(
|
||||||
|
select(ConfigRevision).where(ConfigRevision.revision_number == number)
|
||||||
|
)
|
||||||
|
if revision is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "revision not found")
|
||||||
|
return revision
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"""MQTT identity management via Dynamic Security (spec sections 11-14).
|
||||||
|
|
||||||
|
Source-of-truth rules (spec section 40): clients/groups/roles live in the
|
||||||
|
broker; this API proxies to dynsec and keeps supplementary metadata
|
||||||
|
(owner/purpose/tags) in PostgreSQL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import audit
|
||||||
|
from app.db import get_db
|
||||||
|
from app.deps import CurrentUser, require_roles
|
||||||
|
from app.dynsec import DynsecError, dynsec_client
|
||||||
|
from app.models import ManagementRole, MqttClientMetadata
|
||||||
|
from app.schemas import (
|
||||||
|
GroupIn,
|
||||||
|
MqttClientCreate,
|
||||||
|
MqttClientOut,
|
||||||
|
MqttPasswordReset,
|
||||||
|
RoleIn,
|
||||||
|
RoleUpdate,
|
||||||
|
)
|
||||||
|
from app.security import generate_password
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/mqtt", tags=["mqtt-security"])
|
||||||
|
|
||||||
|
BrokerAdmin = Annotated[CurrentUser, Depends(require_roles(ManagementRole.BROKER_ADMIN))]
|
||||||
|
DbDep = Annotated[Session, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata(db: Session, username: str) -> MqttClientMetadata | None:
|
||||||
|
return db.get(MqttClientMetadata, username)
|
||||||
|
|
||||||
|
|
||||||
|
def _client_out(db: Session, client: dict) -> MqttClientOut:
|
||||||
|
meta = _metadata(db, client.get("username", ""))
|
||||||
|
return MqttClientOut(
|
||||||
|
username=client.get("username", ""),
|
||||||
|
clientid=client.get("clientid") or None,
|
||||||
|
text_name=client.get("textname"),
|
||||||
|
text_description=client.get("textdescription"),
|
||||||
|
enabled=client.get("enabled", False),
|
||||||
|
groups=client.get("groups", []),
|
||||||
|
roles=client.get("roles", []),
|
||||||
|
owner=meta.owner if meta else None,
|
||||||
|
purpose=meta.purpose if meta else None,
|
||||||
|
tags=meta.tags if meta else [],
|
||||||
|
last_known_connection=meta.last_known_connection if meta else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Clients ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/clients", response_model=list[MqttClientOut])
|
||||||
|
def list_clients(db: DbDep, _user: BrokerAdmin):
|
||||||
|
return [_client_out(db, c) for c in dynsec_client.list_clients()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clients", response_model=MqttPasswordReset, status_code=201)
|
||||||
|
def create_client(
|
||||||
|
body: MqttClientCreate,
|
||||||
|
request: Request,
|
||||||
|
db: DbDep,
|
||||||
|
_user: BrokerAdmin,
|
||||||
|
):
|
||||||
|
"""Create a dynsec client; the plaintext password is returned ONCE."""
|
||||||
|
password = body.password or generate_password()
|
||||||
|
try:
|
||||||
|
dynsec_client.create_client(
|
||||||
|
username=body.username,
|
||||||
|
password=password,
|
||||||
|
clientid=body.clientid,
|
||||||
|
text_name=body.text_name,
|
||||||
|
text_description=body.text_description,
|
||||||
|
groups=[g.model_dump() for g in body.groups],
|
||||||
|
roles=[r.model_dump() for r in body.roles],
|
||||||
|
)
|
||||||
|
except DynsecError as exc:
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
MqttClientMetadata(
|
||||||
|
username=body.username,
|
||||||
|
owner=body.owner,
|
||||||
|
purpose=body.purpose,
|
||||||
|
tags=body.tags,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
"CLIENT_CREATED",
|
||||||
|
request=request,
|
||||||
|
object_type="mqtt_client",
|
||||||
|
object_id=body.username,
|
||||||
|
new_value={"username": body.username, "groups": len(body.groups)},
|
||||||
|
)
|
||||||
|
return MqttPasswordReset(username=body.username, password=password)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/clients/{username}", response_model=MqttClientOut)
|
||||||
|
def get_client(username: str, db: DbDep, _user: BrokerAdmin):
|
||||||
|
try:
|
||||||
|
client = dynsec_client.get_client(username)
|
||||||
|
except DynsecError as exc:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
||||||
|
return _client_out(db, client)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/clients/{username}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_client(
|
||||||
|
username: str,
|
||||||
|
request: Request,
|
||||||
|
db: DbDep,
|
||||||
|
_user: BrokerAdmin,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
dynsec_client.delete_client(username)
|
||||||
|
except DynsecError as exc:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
||||||
|
meta = _metadata(db, username)
|
||||||
|
if meta is not None:
|
||||||
|
db.delete(meta)
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
"CLIENT_DELETED",
|
||||||
|
request=request,
|
||||||
|
object_type="mqtt_client",
|
||||||
|
object_id=username,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clients/{username}/disable", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def disable_client(
|
||||||
|
username: str, request: Request, db: DbDep, _user: BrokerAdmin
|
||||||
|
):
|
||||||
|
dynsec_client.disable_client(username)
|
||||||
|
audit.record(
|
||||||
|
db, "CLIENT_DISABLED", request=request, object_type="mqtt_client", object_id=username
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clients/{username}/enable", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def enable_client(
|
||||||
|
username: str, request: Request, db: DbDep, _user: BrokerAdmin
|
||||||
|
):
|
||||||
|
dynsec_client.enable_client(username)
|
||||||
|
audit.record(
|
||||||
|
db, "CLIENT_ENABLED", request=request, object_type="mqtt_client", object_id=username
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clients/{username}/password:reset", response_model=MqttPasswordReset)
|
||||||
|
def reset_password(
|
||||||
|
username: str,
|
||||||
|
request: Request,
|
||||||
|
db: DbDep,
|
||||||
|
_user: BrokerAdmin,
|
||||||
|
):
|
||||||
|
"""Generate a new random password; returned once (spec section 11)."""
|
||||||
|
password = generate_password()
|
||||||
|
try:
|
||||||
|
dynsec_client.set_client_password(username, password)
|
||||||
|
except DynsecError as exc:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
"CLIENT_PASSWORD_RESET",
|
||||||
|
request=request,
|
||||||
|
object_type="mqtt_client",
|
||||||
|
object_id=username,
|
||||||
|
)
|
||||||
|
return MqttPasswordReset(username=username, password=password)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Groups ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/groups")
|
||||||
|
def list_groups(_user: BrokerAdmin):
|
||||||
|
return dynsec_client.list_groups()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/groups", status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_group(
|
||||||
|
body: GroupIn, request: Request, db: DbDep, _user: BrokerAdmin
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
dynsec_client.create_group(
|
||||||
|
groupname=body.groupname,
|
||||||
|
roles=[r.model_dump() for r in body.roles],
|
||||||
|
)
|
||||||
|
except DynsecError as exc:
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
"GROUP_CREATED",
|
||||||
|
request=request,
|
||||||
|
object_type="mqtt_group",
|
||||||
|
object_id=body.groupname,
|
||||||
|
)
|
||||||
|
return {"groupname": body.groupname}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/groups/{groupname}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_group(
|
||||||
|
groupname: str, request: Request, db: DbDep, _user: BrokerAdmin
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
dynsec_client.delete_group(groupname)
|
||||||
|
except DynsecError as exc:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
"GROUP_DELETED",
|
||||||
|
request=request,
|
||||||
|
object_type="mqtt_group",
|
||||||
|
object_id=groupname,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/roles")
|
||||||
|
def list_roles(_user: BrokerAdmin):
|
||||||
|
return dynsec_client.list_roles()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/roles/{rolename}")
|
||||||
|
def get_role(rolename: str, _user: BrokerAdmin):
|
||||||
|
"""Full role detail including ACLs — used by the ACL editor."""
|
||||||
|
try:
|
||||||
|
return dynsec_client.get_role(rolename)
|
||||||
|
except DynsecError as exc:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/roles", status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_role(
|
||||||
|
body: RoleIn, request: Request, db: DbDep, _user: BrokerAdmin
|
||||||
|
):
|
||||||
|
"""Create a role with its full ACL set (spec sections 13-14)."""
|
||||||
|
try:
|
||||||
|
dynsec_client.create_role(
|
||||||
|
rolename=body.rolename,
|
||||||
|
acls=[a.model_dump() for a in body.acls],
|
||||||
|
)
|
||||||
|
except DynsecError as exc:
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
"ROLE_CREATED",
|
||||||
|
request=request,
|
||||||
|
object_type="mqtt_role",
|
||||||
|
object_id=body.rolename,
|
||||||
|
new_value={"acl_count": len(body.acls)},
|
||||||
|
)
|
||||||
|
return {"rolename": body.rolename}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/roles/{rolename}")
|
||||||
|
def update_role(
|
||||||
|
rolename: str,
|
||||||
|
body: RoleUpdate,
|
||||||
|
request: Request,
|
||||||
|
db: DbDep,
|
||||||
|
_user: BrokerAdmin,
|
||||||
|
):
|
||||||
|
"""Replace the role's ACL list — the ACL editor's save action."""
|
||||||
|
try:
|
||||||
|
dynsec_client.set_role_acls(rolename, [a.model_dump() for a in body.acls])
|
||||||
|
except DynsecError as exc:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
"ACL_CHANGED",
|
||||||
|
request=request,
|
||||||
|
object_type="mqtt_role",
|
||||||
|
object_id=rolename,
|
||||||
|
new_value={"acl_count": len(body.acls)},
|
||||||
|
)
|
||||||
|
return {"rolename": rolename}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/roles/{rolename}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_role(
|
||||||
|
rolename: str, request: Request, db: DbDep, _user: BrokerAdmin
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
dynsec_client.delete_role(rolename)
|
||||||
|
except DynsecError as exc:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
"ROLE_DELETED",
|
||||||
|
request=request,
|
||||||
|
object_type="mqtt_role",
|
||||||
|
object_id=rolename,
|
||||||
|
)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Management users admin (spec sections 7.1, 9)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app import audit
|
||||||
|
from app.deps import CurrentUser, DbSession, require_roles
|
||||||
|
from app.models import ManagementRole, ManagementUser
|
||||||
|
from app.schemas import (
|
||||||
|
ManagementUserCreate,
|
||||||
|
ManagementUserOut,
|
||||||
|
ManagementUserUpdate,
|
||||||
|
)
|
||||||
|
from app.security import hash_password
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/users", tags=["users"])
|
||||||
|
|
||||||
|
AdminUser = Annotated[CurrentUser, Depends(require_roles(ManagementRole.SUPER_ADMIN))]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ManagementUserOut])
|
||||||
|
def list_users(db: DbSession, _admin: AdminUser):
|
||||||
|
return db.scalars(select(ManagementUser)).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ManagementUserOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_user(
|
||||||
|
body: ManagementUserCreate,
|
||||||
|
request: Request,
|
||||||
|
db: DbSession,
|
||||||
|
_admin: AdminUser,
|
||||||
|
):
|
||||||
|
if db.scalar(select(ManagementUser).where(ManagementUser.username == body.username)):
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT, "username already exists")
|
||||||
|
user = ManagementUser(
|
||||||
|
username=body.username,
|
||||||
|
password_hash=hash_password(body.password),
|
||||||
|
display_name=body.display_name,
|
||||||
|
role=body.role,
|
||||||
|
must_change_password=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
"USER_CREATED",
|
||||||
|
request=request,
|
||||||
|
user=body.username,
|
||||||
|
object_type="management_user",
|
||||||
|
object_id=body.username,
|
||||||
|
new_value={"role": body.role.value},
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{username}", response_model=ManagementUserOut)
|
||||||
|
def update_user(
|
||||||
|
username: str,
|
||||||
|
body: ManagementUserUpdate,
|
||||||
|
request: Request,
|
||||||
|
db: DbSession,
|
||||||
|
_admin: AdminUser,
|
||||||
|
):
|
||||||
|
user = db.scalar(select(ManagementUser).where(ManagementUser.username == username))
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
|
||||||
|
old = {"role": user.role.value, "enabled": user.enabled}
|
||||||
|
if body.display_name is not None:
|
||||||
|
user.display_name = body.display_name
|
||||||
|
if body.role is not None:
|
||||||
|
user.role = body.role
|
||||||
|
if body.enabled is not None:
|
||||||
|
user.enabled = body.enabled
|
||||||
|
db.flush()
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
"USER_UPDATED",
|
||||||
|
request=request,
|
||||||
|
user=username,
|
||||||
|
object_type="management_user",
|
||||||
|
object_id=username,
|
||||||
|
old_value=old,
|
||||||
|
new_value={"role": user.role.value, "enabled": user.enabled},
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{username}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_user(
|
||||||
|
username: str,
|
||||||
|
request: Request,
|
||||||
|
db: DbSession,
|
||||||
|
_admin: AdminUser,
|
||||||
|
):
|
||||||
|
if username == "admin":
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "cannot delete bootstrap admin")
|
||||||
|
user = db.scalar(select(ManagementUser).where(ManagementUser.username == username))
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
|
||||||
|
db.delete(user)
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
"USER_DELETED",
|
||||||
|
request=request,
|
||||||
|
user=username,
|
||||||
|
object_type="management_user",
|
||||||
|
object_id=username,
|
||||||
|
)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""Pydantic request/response schemas shared across routers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, StringConstraints
|
||||||
|
|
||||||
|
from app.models import ManagementRole
|
||||||
|
|
||||||
|
UsernameStr = Annotated[str, StringConstraints(pattern=r"^[a-zA-Z0-9_-]{1,100}$")]
|
||||||
|
TopicFilterStr = Annotated[str, StringConstraints(min_length=1, max_length=200)]
|
||||||
|
|
||||||
|
ACL_TYPES = Literal[
|
||||||
|
"publishClientSend",
|
||||||
|
"publishClientReceive",
|
||||||
|
"subscribeLiteral",
|
||||||
|
"subscribePattern",
|
||||||
|
"unsubscribeLiteral",
|
||||||
|
"unsubscribePattern",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Auth ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class SessionInfo(BaseModel):
|
||||||
|
username: str
|
||||||
|
role: ManagementRole
|
||||||
|
must_change_password: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChangeRequest(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str = Field(min_length=12)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Management users -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ManagementUserOut(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
username: str
|
||||||
|
display_name: str | None
|
||||||
|
role: ManagementRole
|
||||||
|
enabled: bool
|
||||||
|
must_change_password: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ManagementUserCreate(BaseModel):
|
||||||
|
username: UsernameStr
|
||||||
|
password: str = Field(min_length=12)
|
||||||
|
display_name: str | None = None
|
||||||
|
role: ManagementRole = ManagementRole.OPERATOR
|
||||||
|
|
||||||
|
|
||||||
|
class ManagementUserUpdate(BaseModel):
|
||||||
|
display_name: str | None = None
|
||||||
|
role: ManagementRole | None = None
|
||||||
|
enabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# --- MQTT clients ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AclRuleIn(BaseModel):
|
||||||
|
"""One ACL rule as edited in the UI (spec section 14)."""
|
||||||
|
|
||||||
|
acltype: ACL_TYPES
|
||||||
|
topic: TopicFilterStr
|
||||||
|
allow: bool = True
|
||||||
|
priority: int = Field(default=0, ge=0, le=100000)
|
||||||
|
|
||||||
|
|
||||||
|
class RoleIn(BaseModel):
|
||||||
|
rolename: UsernameStr
|
||||||
|
text_name: str | None = None
|
||||||
|
text_description: str | None = None
|
||||||
|
acls: list[AclRuleIn] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class RoleUpdate(BaseModel):
|
||||||
|
"""PUT /roles/{name} payload: rolename comes from the URL path."""
|
||||||
|
|
||||||
|
acls: list[AclRuleIn]
|
||||||
|
|
||||||
|
|
||||||
|
class GroupRoleIn(BaseModel):
|
||||||
|
rolename: str
|
||||||
|
priority: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
class GroupIn(BaseModel):
|
||||||
|
groupname: UsernameStr
|
||||||
|
text_name: str | None = None
|
||||||
|
text_description: str | None = None
|
||||||
|
roles: list[GroupRoleIn] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ClientRoleIn(BaseModel):
|
||||||
|
rolename: str
|
||||||
|
priority: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
class ClientGroupIn(BaseModel):
|
||||||
|
groupname: str
|
||||||
|
priority: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
class MqttClientCreate(BaseModel):
|
||||||
|
username: UsernameStr
|
||||||
|
password: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="If omitted a random password is generated and returned once.",
|
||||||
|
)
|
||||||
|
clientid: str | None = None
|
||||||
|
text_name: str | None = None
|
||||||
|
text_description: str | None = None
|
||||||
|
groups: list[ClientGroupIn] = Field(default_factory=list)
|
||||||
|
roles: list[ClientRoleIn] = Field(default_factory=list)
|
||||||
|
# PostgreSQL-side metadata (spec section 11)
|
||||||
|
owner: str | None = None
|
||||||
|
purpose: str | None = None
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class MqttClientOut(BaseModel):
|
||||||
|
username: str
|
||||||
|
clientid: str | None
|
||||||
|
text_name: str | None
|
||||||
|
text_description: str | None
|
||||||
|
enabled: bool
|
||||||
|
groups: list[dict[str, Any]]
|
||||||
|
roles: list[dict[str, Any]]
|
||||||
|
owner: str | None
|
||||||
|
purpose: str | None
|
||||||
|
tags: list[str]
|
||||||
|
last_known_connection: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class MqttPasswordReset(BaseModel):
|
||||||
|
"""Response for create/reset — plaintext shown exactly once (spec 11)."""
|
||||||
|
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
# --- Broker configuration ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class BrokerSettingsIn(BaseModel):
|
||||||
|
key: str = Field(min_length=1, max_length=100)
|
||||||
|
value: str
|
||||||
|
value_type: Literal["string", "int", "bool"] = "string"
|
||||||
|
|
||||||
|
|
||||||
|
class ListenerIn(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=100)
|
||||||
|
enabled: bool = True
|
||||||
|
port: int = Field(ge=1, le=65535)
|
||||||
|
bind_address: str | None = None
|
||||||
|
protocol: Literal["mqtt", "websockets"] = "mqtt"
|
||||||
|
allow_anonymous: bool = False
|
||||||
|
max_connections: int = -1
|
||||||
|
tls_enabled: bool = False
|
||||||
|
tls_min_version: Literal["tlsv1.2", "tlsv1.3"] | None = None
|
||||||
|
require_certificate: bool = False
|
||||||
|
use_identity_as_username: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ListenerOut(ListenerIn):
|
||||||
|
id: uuid.UUID
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigPreviewOut(BaseModel):
|
||||||
|
config_text: str
|
||||||
|
warnings: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class DeployRequest(BaseModel):
|
||||||
|
comment: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RevisionOut(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
revision_number: int
|
||||||
|
created_by: str | None
|
||||||
|
comment: str | None
|
||||||
|
deployed: bool
|
||||||
|
deployed_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class RevisionDetailOut(RevisionOut):
|
||||||
|
config_text: str
|
||||||
|
validation_result: dict[str, Any]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Password hashing (Argon2id, spec section 43) and session tokens."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import VerifyMismatchError
|
||||||
|
|
||||||
|
_hasher = PasswordHasher() # Argon2id by default
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return _hasher.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password_hash: str, password: str) -> bool:
|
||||||
|
try:
|
||||||
|
return _hasher.verify(password_hash, password)
|
||||||
|
except VerifyMismatchError:
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
# Malformed hash etc. — never crash authentication on it.
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def generate_password(length: int = 24) -> str:
|
||||||
|
"""Cryptographically random password for MQTT clients (spec section 11)."""
|
||||||
|
alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789"
|
||||||
|
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
|
def generate_session_id() -> uuid.UUID:
|
||||||
|
return uuid.uuid4()
|
||||||
|
|
||||||
|
|
||||||
|
def session_expiry(ttl_seconds: int) -> datetime:
|
||||||
|
return datetime.now(UTC) + timedelta(seconds=ttl_seconds)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Alembic environment — derives the URL from app settings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db import Base
|
||||||
|
from app import models # noqa: F401 (register all models with Base)
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
url = get_settings().database_url
|
||||||
|
if url.startswith("postgresql://"):
|
||||||
|
url = url.replace("postgresql://", "postgresql+psycopg://", 1)
|
||||||
|
config.set_main_option("sqlalchemy.url", url)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str] | None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str] | None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Release 1 initial schema.
|
||||||
|
|
||||||
|
Revision ID: r1_0001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-09-15
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "r1_0001"
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str] | None] = None
|
||||||
|
depends_on: Union[str, Sequence[str] | None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||||
|
sa.Column("username", sa.String(100), nullable=False, unique=True, index=True),
|
||||||
|
sa.Column("password_hash", sa.String(255), nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(200), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"role",
|
||||||
|
sa.Enum(
|
||||||
|
"SUPER_ADMIN",
|
||||||
|
"BROKER_ADMIN",
|
||||||
|
"PKI_ADMIN",
|
||||||
|
"OPERATOR",
|
||||||
|
"AUDITOR",
|
||||||
|
name="management_role",
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column(
|
||||||
|
"must_change_password", sa.Boolean(), nullable=False, server_default=sa.true()
|
||||||
|
),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("modified_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sessions",
|
||||||
|
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Uuid(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False, index=True),
|
||||||
|
sa.Column("revoked", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"certificates",
|
||||||
|
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||||
|
sa.Column("common_name", sa.String(200), nullable=False),
|
||||||
|
sa.Column("certificate_pem", sa.Text(), nullable=False),
|
||||||
|
sa.Column("private_key_pem", sa.Text(), nullable=True),
|
||||||
|
sa.Column("expiry_date", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"broker_listeners",
|
||||||
|
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("port", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("bind_address", sa.String(100), nullable=True),
|
||||||
|
sa.Column("protocol", sa.String(20), nullable=False),
|
||||||
|
sa.Column("allow_anonymous", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("max_connections", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("tls_enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("tls_min_version", sa.String(20), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"require_certificate", sa.Boolean(), nullable=False, server_default=sa.false()
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"use_identity_as_username",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.false(),
|
||||||
|
),
|
||||||
|
sa.Column("cert_id", sa.Uuid(), sa.ForeignKey("certificates.id"), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("modified_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"broker_settings",
|
||||||
|
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||||
|
sa.Column("key", sa.String(100), nullable=False, unique=True, index=True),
|
||||||
|
sa.Column("value", sa.Text(), nullable=False),
|
||||||
|
sa.Column("value_type", sa.String(20), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"broker_config_revisions",
|
||||||
|
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||||
|
sa.Column("revision_number", sa.Integer(), nullable=False, unique=True, index=True),
|
||||||
|
sa.Column("created_by", sa.String(100), nullable=True),
|
||||||
|
sa.Column("comment", sa.Text(), nullable=True),
|
||||||
|
sa.Column("config_text", sa.Text(), nullable=False),
|
||||||
|
sa.Column("validation_result", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("deployed", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("deployed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"mqtt_client_metadata",
|
||||||
|
sa.Column("username", sa.String(100), primary_key=True),
|
||||||
|
sa.Column("display_name", sa.String(200), nullable=True),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("owner", sa.String(200), nullable=True),
|
||||||
|
sa.Column("purpose", sa.String(200), nullable=True),
|
||||||
|
sa.Column("tags", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("last_known_connection", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("modified_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"certificate_id",
|
||||||
|
sa.Uuid(),
|
||||||
|
sa.ForeignKey("certificates.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"audit_events",
|
||||||
|
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||||
|
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False, index=True),
|
||||||
|
sa.Column("user", sa.String(100), nullable=True),
|
||||||
|
sa.Column("source_ip", sa.String(64), nullable=True),
|
||||||
|
sa.Column("action", sa.String(100), nullable=False, index=True),
|
||||||
|
sa.Column("object_type", sa.String(100), nullable=True),
|
||||||
|
sa.Column("object_id", sa.String(200), nullable=True),
|
||||||
|
sa.Column("old_value", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("new_value", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("result", sa.String(20), nullable=False),
|
||||||
|
sa.Column("correlation_id", sa.String(64), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("audit_events")
|
||||||
|
op.drop_table("mqtt_client_metadata")
|
||||||
|
op.drop_table("broker_config_revisions")
|
||||||
|
op.drop_table("broker_settings")
|
||||||
|
op.drop_table("broker_listeners")
|
||||||
|
op.drop_table("certificates")
|
||||||
|
op.drop_table("sessions")
|
||||||
|
op.drop_table("users")
|
||||||
|
op.execute("DROP TYPE IF EXISTS management_role")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
[project]
|
||||||
|
name = "mqtt-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "MQTT Management Platform backend (Release 1)"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115",
|
||||||
|
"uvicorn[standard]>=0.32",
|
||||||
|
"sqlalchemy>=2.0",
|
||||||
|
"psycopg[binary]>=3.2",
|
||||||
|
"alembic>=1.14",
|
||||||
|
"pydantic>=2.9",
|
||||||
|
"pydantic-settings>=2.6",
|
||||||
|
"argon2-cffi>=23.1",
|
||||||
|
"paho-mqtt>=2.1",
|
||||||
|
"python-multipart>=0.0.17",
|
||||||
|
"httpx>=0.28",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.3",
|
||||||
|
"httpx>=0.28",
|
||||||
|
"ruff>=0.8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py313"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
"""Tests package."""
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""Shared pytest fixtures: sqlite-backed DB + FastAPI TestClient."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
# Ensure test settings before any app import resolves them.
|
||||||
|
os.environ.setdefault("SESSION_SECRET", "test-session-secret-value-123456")
|
||||||
|
os.environ.setdefault("SECRET_ENCRYPTION_KEY", "test-encryption-key-value-1234")
|
||||||
|
os.environ.setdefault("ADMIN_INITIAL_PASSWORD", "test-admin-password-123")
|
||||||
|
os.environ.setdefault("MOSQUITTO_DYNSEC_PASSWORD", "test-dynsec-password")
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine # noqa: E402
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker # noqa: E402
|
||||||
|
from sqlalchemy.pool import StaticPool # noqa: E402
|
||||||
|
|
||||||
|
from app import db as db_module # noqa: E402
|
||||||
|
from app import main as main_module # noqa: E402
|
||||||
|
from app.models import ManagementRole, ManagementUser # noqa: E402
|
||||||
|
from app.security import hash_password # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def db_session():
|
||||||
|
"""Fresh in-memory database per test with schema + seeded users.
|
||||||
|
|
||||||
|
StaticPool + check_same_thread=False: the TestClient serves requests
|
||||||
|
on a different thread than the fixture, but all access must share ONE
|
||||||
|
sqlite connection (in-memory DBs are per-connection).
|
||||||
|
"""
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
future=True,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
db_module.Base.metadata.create_all(engine)
|
||||||
|
factory = sessionmaker(bind=engine, future=True)
|
||||||
|
session = factory()
|
||||||
|
# Seed one super admin and one auditor for RBAC tests.
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
ManagementUser(
|
||||||
|
username="admin",
|
||||||
|
password_hash=hash_password("admin-password-123"),
|
||||||
|
role=ManagementRole.SUPER_ADMIN,
|
||||||
|
must_change_password=False,
|
||||||
|
),
|
||||||
|
ManagementUser(
|
||||||
|
username="auditor",
|
||||||
|
password_hash=hash_password("auditor-password-123"),
|
||||||
|
role=ManagementRole.AUDITOR,
|
||||||
|
must_change_password=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
yield session
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(db_session, monkeypatch):
|
||||||
|
"""TestClient wired to the fixture DB via dependency_overrides."""
|
||||||
|
|
||||||
|
def _override_get_db():
|
||||||
|
# Yield the SAME session the test asserts against; the request
|
||||||
|
# handler shares its transaction for simplicity in tests.
|
||||||
|
try:
|
||||||
|
yield db_session
|
||||||
|
finally:
|
||||||
|
pass # session lifetime managed by the db_session fixture
|
||||||
|
|
||||||
|
main_module.app.dependency_overrides[db_module.get_db] = _override_get_db
|
||||||
|
|
||||||
|
# Skip the lifespan bootstrap (create_all + admin seed) against a
|
||||||
|
# separate engine: tests manage the schema themselves.
|
||||||
|
from app import main as _m
|
||||||
|
|
||||||
|
async def _noop_lifespan(app):
|
||||||
|
yield
|
||||||
|
|
||||||
|
monkeypatch.setattr(_m, "_bootstrap_admin", lambda: None)
|
||||||
|
|
||||||
|
# Fake dynsec so no broker is needed for API tests.
|
||||||
|
class FakeDynsec:
|
||||||
|
def __init__(self):
|
||||||
|
self.clients: dict[str, dict] = {}
|
||||||
|
self.roles: dict[str, dict] = {}
|
||||||
|
self.groups: dict[str, dict] = {}
|
||||||
|
|
||||||
|
def _command(self, command, **kwargs):
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
def create_client(self, username, password, **kwargs):
|
||||||
|
self.clients[username] = {"username": username, "enabled": True, **kwargs}
|
||||||
|
|
||||||
|
def list_clients(self):
|
||||||
|
return list(self.clients.values())
|
||||||
|
|
||||||
|
def delete_client(self, username):
|
||||||
|
self.clients.pop(username, None)
|
||||||
|
|
||||||
|
def disable_client(self, username):
|
||||||
|
self.clients[username]["enabled"] = False
|
||||||
|
|
||||||
|
def enable_client(self, username):
|
||||||
|
self.clients[username]["enabled"] = True
|
||||||
|
|
||||||
|
def set_client_password(self, username, password):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_client(self, username):
|
||||||
|
return self.clients[username]
|
||||||
|
|
||||||
|
def create_group(self, groupname, roles=None):
|
||||||
|
self.groups[groupname] = {"groupname": groupname, "roles": roles or []}
|
||||||
|
|
||||||
|
def list_groups(self):
|
||||||
|
return list(self.groups.values())
|
||||||
|
|
||||||
|
def delete_group(self, groupname):
|
||||||
|
self.groups.pop(groupname, None)
|
||||||
|
|
||||||
|
def create_role(self, rolename, acls=None):
|
||||||
|
self.roles[rolename] = {"rolename": rolename, "acls": acls or []}
|
||||||
|
|
||||||
|
def list_roles(self):
|
||||||
|
return list(self.roles.values())
|
||||||
|
|
||||||
|
def delete_role(self, rolename):
|
||||||
|
self.roles.pop(rolename, None)
|
||||||
|
|
||||||
|
def set_role_acls(self, rolename, acls):
|
||||||
|
self.roles[rolename]["acls"] = acls
|
||||||
|
|
||||||
|
def get_role(self, rolename):
|
||||||
|
return self.roles[rolename]
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
fake = FakeDynsec()
|
||||||
|
monkeypatch.setattr(main_module, "dynsec_client", fake)
|
||||||
|
|
||||||
|
from app.routers import mqtt as mqtt_router
|
||||||
|
|
||||||
|
monkeypatch.setattr(mqtt_router, "dynsec_client", fake)
|
||||||
|
|
||||||
|
with TestClient(main_module.app) as test_client:
|
||||||
|
yield test_client
|
||||||
|
|
||||||
|
main_module.app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def login(client: TestClient, username: str, password: str):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/auth/login", json={"username": username, "password": password}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def admin_client(client):
|
||||||
|
login(client, "admin", "admin-password-123")
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def auditor_client(client):
|
||||||
|
login(client, "auditor", "auditor-password-123")
|
||||||
|
return client
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""API tests: authentication, RBAC, client + ACL flows (spec section 64)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from tests.conftest import login
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_live(client):
|
||||||
|
response = client.get("/health/live")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_sets_cookie_and_session(client):
|
||||||
|
response = login(client, "admin", "admin-password-123")
|
||||||
|
assert response.json()["role"] == "super_admin"
|
||||||
|
|
||||||
|
session = client.get("/api/v1/auth/session")
|
||||||
|
assert session.status_code == 200
|
||||||
|
assert session.json()["username"] == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_wrong_password_audited(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/auth/login", json={"username": "admin", "password": "wrong"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_unauthenticated_api_rejected(client):
|
||||||
|
response = client.get("/api/v1/mqtt/clients")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_auditor_cannot_create_clients(auditor_client):
|
||||||
|
response = auditor_client.post(
|
||||||
|
"/api/v1/mqtt/clients", json={"username": "ps042"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_client_returns_password_once(admin_client):
|
||||||
|
response = admin_client.post("/api/v1/mqtt/clients", json={"username": "ps042"})
|
||||||
|
assert response.status_code == 201
|
||||||
|
body = response.json()
|
||||||
|
assert body["username"] == "ps042"
|
||||||
|
assert len(body["password"]) >= 20 # random password generated
|
||||||
|
|
||||||
|
listing = admin_client.get("/api/v1/mqtt/clients")
|
||||||
|
assert any(c["username"] == "ps042" for c in listing.json())
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_enable_delete_client(admin_client):
|
||||||
|
admin_client.post("/api/v1/mqtt/clients", json={"username": "sensor-1"})
|
||||||
|
|
||||||
|
response = admin_client.post("/api/v1/mqtt/clients/sensor-1/disable")
|
||||||
|
assert response.status_code == 204
|
||||||
|
clients = {c["username"]: c for c in admin_client.get("/api/v1/mqtt/clients").json()}
|
||||||
|
assert clients["sensor-1"]["enabled"] is False
|
||||||
|
|
||||||
|
assert admin_client.post("/api/v1/mqtt/clients/sensor-1/enable").status_code == 204
|
||||||
|
|
||||||
|
response = admin_client.delete("/api/v1/mqtt/clients/sensor-1")
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_role_acl_lifecycle(admin_client):
|
||||||
|
admin_client.post("/api/v1/mqtt/roles", json={"rolename": "telemetry"})
|
||||||
|
|
||||||
|
acls = [
|
||||||
|
{
|
||||||
|
"acltype": "publishClientSend",
|
||||||
|
"topic": "site/%u/telemetry/#",
|
||||||
|
"allow": True,
|
||||||
|
"priority": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"acltype": "subscribePattern",
|
||||||
|
"topic": "site/%u/config/#",
|
||||||
|
"allow": True,
|
||||||
|
"priority": 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
response = admin_client.put("/api/v1/mqtt/roles/telemetry", json={"acls": acls})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
roles = {r["rolename"]: r for r in admin_client.get("/api/v1/mqtt/roles").json()}
|
||||||
|
assert roles["telemetry"]["acls"] == acls
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_lifecycle(admin_client):
|
||||||
|
response = admin_client.post(
|
||||||
|
"/api/v1/mqtt/groups", json={"groupname": "sensors"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
groups = {g["groupname"]: g for g in admin_client.get("/api/v1/mqtt/groups").json()}
|
||||||
|
assert "sensors" in groups
|
||||||
|
|
||||||
|
assert admin_client.delete("/api/v1/mqtt/groups/sensors").status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_listener_management_and_preview(admin_client):
|
||||||
|
response = admin_client.post(
|
||||||
|
"/api/v1/broker/listeners",
|
||||||
|
json={"name": "Production MQTT", "port": 1883, "protocol": "mqtt"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
listener_id = response.json()["id"]
|
||||||
|
|
||||||
|
preview = admin_client.get("/api/v1/broker/config/preview")
|
||||||
|
assert preview.status_code == 200
|
||||||
|
config_text = preview.json()["config_text"]
|
||||||
|
assert "listener 1883" in config_text
|
||||||
|
# Generated config must not use options deprecated in 2.1 (spec 3.1).
|
||||||
|
assert "password_file" not in config_text
|
||||||
|
assert "acl_file" not in config_text
|
||||||
|
assert "per_listener_settings false" in config_text
|
||||||
|
|
||||||
|
delete = admin_client.delete(f"/api/v1/broker/listeners/{listener_id}")
|
||||||
|
assert delete.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_events_recorded(admin_client, db_session):
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models import AuditEvent
|
||||||
|
|
||||||
|
admin_client.post("/api/v1/mqtt/clients", json={"username": "audited-1"})
|
||||||
|
actions = db_session.scalars(select(AuditEvent.action)).all()
|
||||||
|
assert "CLIENT_CREATED" in actions
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Config generator unit tests (spec section 64: configuration generation)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.configgen import generate_config
|
||||||
|
from app.models import BrokerSettings, ListenerConfig
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def session(db_session):
|
||||||
|
yield db_session
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_with_one_listener(session):
|
||||||
|
session.add(
|
||||||
|
ListenerConfig(
|
||||||
|
name="Production MQTT",
|
||||||
|
port=1883,
|
||||||
|
protocol="mqtt",
|
||||||
|
allow_anonymous=False,
|
||||||
|
max_connections=-1,
|
||||||
|
tls_enabled=False,
|
||||||
|
require_certificate=False,
|
||||||
|
use_identity_as_username=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
result = generate_config(session)
|
||||||
|
assert "listener 1883" in result.config_text
|
||||||
|
assert "per_listener_settings false" in result.config_text
|
||||||
|
assert "global_plugin /usr/lib/mosquitto_dynamic_security.so" in result.config_text
|
||||||
|
assert "allow_anonymous false" in result.config_text
|
||||||
|
assert result.warnings == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_includes_global_settings(session):
|
||||||
|
session.add(ListenerConfig(name="l", port=1883, protocol="mqtt"))
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_refuses_without_listeners(session):
|
||||||
|
with pytest.raises(ValueError, match="no enabled listeners"):
|
||||||
|
generate_config(session)
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_listeners_are_omitted(session):
|
||||||
|
session.add(ListenerConfig(name="off", port=1884, protocol="mqtt", enabled=False))
|
||||||
|
session.add(ListenerConfig(name="on", port=1883, protocol="mqtt"))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
result = generate_config(session)
|
||||||
|
assert "listener 1883" in result.config_text
|
||||||
|
assert "listener 1884" not in result.config_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_tls_listener_without_pki_warns(session):
|
||||||
|
session.add(
|
||||||
|
ListenerConfig(
|
||||||
|
name="tls-later",
|
||||||
|
port=8883,
|
||||||
|
protocol="mqtt",
|
||||||
|
tls_enabled=True,
|
||||||
|
tls_min_version="tlsv1.2",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
result = generate_config(session)
|
||||||
|
assert any("PKI is not available" in w for w in result.warnings)
|
||||||
|
assert "tls_version tlsv1.2" in result.config_text
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# broker-control — internal control sidecar (spec §29)
|
||||||
|
#
|
||||||
|
# Runs in the mosquitto container's PID namespace (compose `pid: "service:mosquitto"`)
|
||||||
|
# so it can signal the broker process (PID 1) without docker.sock access.
|
||||||
|
# The backend calls its HTTP API over the backend-net network.
|
||||||
|
#
|
||||||
|
# Responsibilities (deliberately minimal, spec §29):
|
||||||
|
# - validate staged configuration (mosquitto -c <staged> --test-config)
|
||||||
|
# - activate a configuration revision (atomic rename into place)
|
||||||
|
# - signal config reload (mosquitto_signal -p 1 config-reload == SIGHUP)
|
||||||
|
# - 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
|
||||||
|
|
||||||
|
# Same image as the broker → --test-config validates with the exact
|
||||||
|
# broker binary that will load the config.
|
||||||
|
|
||||||
|
# The base image ships NO Python. control.py is stdlib-only, so the
|
||||||
|
# distro package is sufficient (no pip, no venv needed).
|
||||||
|
RUN apk add --no-cache python3
|
||||||
|
|
||||||
|
COPY control.py /app/control.py
|
||||||
|
|
||||||
|
# The base image has no USER directive, so this sidecar runs as root.
|
||||||
|
# Deliberate: it writes /mosquitto/config (owned by uid 1883 in the fresh
|
||||||
|
# volume) and signals the broker via the shared PID namespace. The API
|
||||||
|
# is token-guarded, internal-network-only, and no docker.sock is mounted
|
||||||
|
# (spec §29).
|
||||||
|
|
||||||
|
# Clear the CMD inherited from the base image — its default CMD
|
||||||
|
# (`mosquitto -c ...`) would otherwise be appended to our entrypoint.
|
||||||
|
CMD []
|
||||||
|
|
||||||
|
EXPOSE 8090
|
||||||
|
|
||||||
|
ENTRYPOINT ["python3", "/app/control.py"]
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"""broker-control: minimal internal control API for Mosquitto.
|
||||||
|
|
||||||
|
Scope (spec section 29):
|
||||||
|
POST /deploy { "content": "<mosquitto.conf text>" } -> validate + atomic activate + reload
|
||||||
|
POST /reload -> signal config reload
|
||||||
|
GET /status -> broker process state
|
||||||
|
GET /health/live -> liveness
|
||||||
|
|
||||||
|
Security model:
|
||||||
|
* Listens on 0.0.0.0:8090 but is ONLY reachable on the backend-net
|
||||||
|
Docker network; the port is not published to the host.
|
||||||
|
* Requires a bearer token (CONTROL_API_TOKEN) shared with the backend.
|
||||||
|
* Never mounts docker.sock; signals the broker over the shared PID
|
||||||
|
namespace (broker is PID 1 in this container's view).
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
* Atomic activation writes content to a temp file in the SAME
|
||||||
|
directory as the target, then os.replace() renames it over the old
|
||||||
|
file. os.replace is atomic and is NOT affected by Mosquitto 2.1's
|
||||||
|
symlink-read restrictions (which we deliberately do not relax via
|
||||||
|
MOSQUITTO_UNSAFE_ALLOW_SYMLINKS).
|
||||||
|
* The broker runs as uid/gid 1883 in the official image, and so does
|
||||||
|
this sidecar (inherited from the base image), so file ownership is
|
||||||
|
consistent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
TOKEN = os.environ.get("CONTROL_API_TOKEN", "")
|
||||||
|
CONFIG_DIR = os.environ.get("MOSQUITTO_CONFIG_DIR", "/mosquitto/config")
|
||||||
|
ACTIVE_CONF = os.path.join(CONFIG_DIR, "mosquitto.conf")
|
||||||
|
STAGE_DIR = os.path.join(CONFIG_DIR, "staged")
|
||||||
|
LISTEN_PORT = int(os.environ.get("CONTROL_LISTEN_PORT", "8090"))
|
||||||
|
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
class _Error(Exception):
|
||||||
|
def __init__(self, status: int, message: str) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.status = status
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
def _validate(content: str) -> dict[str, object]:
|
||||||
|
"""Run `mosquitto -c <staged> --test-config` against staged content."""
|
||||||
|
os.makedirs(STAGE_DIR, exist_ok=True)
|
||||||
|
# Temp file inside STAGE_DIR so --test-config sees the same filesystem
|
||||||
|
# (relative includes, cert paths etc. behave as in production).
|
||||||
|
fd, path = tempfile.mkstemp(prefix="validate-", suffix=".conf", dir=STAGE_DIR)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
proc = subprocess.run(
|
||||||
|
["/usr/sbin/mosquitto", "-c", path, "--test-config"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"valid": proc.returncode == 0,
|
||||||
|
"stdout": proc.stdout,
|
||||||
|
"stderr": proc.stderr,
|
||||||
|
}
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
return {"valid": False, "stdout": "", "stderr": f"validation timed out: {exc}"}
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _activate(content: str) -> None:
|
||||||
|
"""Atomically replace the active mosquitto.conf with validated content."""
|
||||||
|
os.makedirs(os.path.dirname(ACTIVE_CONF), exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(prefix=".mosquitto.conf.", dir=CONFIG_DIR)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
# 0644: broker (uid 1883) must be able to read; same-uid sidecar writes it.
|
||||||
|
os.chmod(tmp, 0o644)
|
||||||
|
os.replace(tmp, ACTIVE_CONF)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _signal_reload() -> None:
|
||||||
|
"""Signal the broker (PID 1 in the shared PID namespace) to reload."""
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["mosquitto_signal", "-p", "1", "config-reload"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
# Fallback if the helper app is unavailable for any reason.
|
||||||
|
os.kill(1, signal.SIGHUP)
|
||||||
|
return
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise _Error(500, f"mosquitto_signal failed: {proc.stderr.strip()}")
|
||||||
|
|
||||||
|
|
||||||
|
def _broker_pid_alive() -> bool:
|
||||||
|
try:
|
||||||
|
# Broker is PID 1 in the shared PID namespace.
|
||||||
|
with open("/proc/1/comm", encoding="utf-8") as f:
|
||||||
|
return f.read().strip() == "mosquitto"
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _post_broker_health() -> bool:
|
||||||
|
"""Cheap post-signal health probe: subscribe to $SYS/broker/version."""
|
||||||
|
username = os.environ.get("MOSQUITTO_DYNSEC_USERNAME", "admin")
|
||||||
|
token_pw = os.environ.get("MOSQUITTO_DYNSEC_PASSWORD", "")
|
||||||
|
cmd = [
|
||||||
|
"mosquitto_sub",
|
||||||
|
"-h",
|
||||||
|
"127.0.0.1",
|
||||||
|
"-u",
|
||||||
|
username,
|
||||||
|
"-P",
|
||||||
|
token_pw,
|
||||||
|
"-t",
|
||||||
|
"$SYS/broker/version",
|
||||||
|
"-C",
|
||||||
|
"1",
|
||||||
|
"-W",
|
||||||
|
"3",
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||||
|
return proc.returncode == 0
|
||||||
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "mosquitto-control/0.1"
|
||||||
|
|
||||||
|
# -- plumbing -----------------------------------------------------------
|
||||||
|
def _send(self, status: int, payload: dict[str, object]) -> None:
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _authorized(self) -> bool:
|
||||||
|
if not TOKEN:
|
||||||
|
return False # refuse to run unauthenticated
|
||||||
|
header = self.headers.get("Authorization", "")
|
||||||
|
return header == f"Bearer {TOKEN}"
|
||||||
|
|
||||||
|
def _read_json(self) -> dict[str, object]:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
if length <= 0 or length > 2_000_000:
|
||||||
|
raise _Error(400, "missing or oversized body")
|
||||||
|
try:
|
||||||
|
data = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise _Error(400, f"invalid JSON body: {exc}") from exc
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise _Error(400, "JSON body must be an object")
|
||||||
|
return data
|
||||||
|
|
||||||
|
def log_message(self, fmt: str, *args: object) -> None:
|
||||||
|
sys.stderr.write("[control] " + (fmt % args) + "\n")
|
||||||
|
|
||||||
|
# -- routes -------------------------------------------------------------
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
if self.path == "/health/live":
|
||||||
|
self._send(200, {"status": "ok"})
|
||||||
|
return
|
||||||
|
if not self._authorized():
|
||||||
|
self._send(401, {"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
if self.path == "/status":
|
||||||
|
self._send(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"broker_pid_alive": _broker_pid_alive(),
|
||||||
|
"config_file": ACTIVE_CONF,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self._send(404, {"error": "not found"})
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
if not self._authorized():
|
||||||
|
self._send(401, {"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if self.path == "/deploy":
|
||||||
|
self._handle_deploy()
|
||||||
|
elif self.path == "/reload":
|
||||||
|
_signal_reload()
|
||||||
|
self._send(200, {"status": "reloading", "broker_healthy": _post_broker_health()})
|
||||||
|
else:
|
||||||
|
self._send(404, {"error": "not found"})
|
||||||
|
except _Error as exc:
|
||||||
|
self._send(exc.status, {"error": exc.message})
|
||||||
|
except OSError as exc:
|
||||||
|
self._send(500, {"error": f"os error: {exc}"})
|
||||||
|
|
||||||
|
def _handle_deploy(self) -> None:
|
||||||
|
body = self._read_json()
|
||||||
|
content = body.get("content")
|
||||||
|
if not isinstance(content, str) or not content.strip():
|
||||||
|
raise _Error(400, "field 'content' (mosquitto.conf text) is required")
|
||||||
|
with _LOCK:
|
||||||
|
result = _validate(content)
|
||||||
|
if not result["valid"]:
|
||||||
|
self._send(422, {"status": "validation_failed", **result})
|
||||||
|
return
|
||||||
|
_activate(content)
|
||||||
|
_signal_reload()
|
||||||
|
self._send(
|
||||||
|
200,
|
||||||
|
{"status": "deployed", "broker_healthy": _post_broker_health()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if not TOKEN:
|
||||||
|
sys.stderr.write("[control] FATAL: CONTROL_API_TOKEN is not set\n")
|
||||||
|
sys.exit(1)
|
||||||
|
server = ThreadingHTTPServer(("0.0.0.0", LISTEN_PORT), Handler)
|
||||||
|
sys.stderr.write(f"[control] listening on :{LISTEN_PORT}\n")
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
name: mqtt-platform
|
||||||
|
|
||||||
|
# MQTT Management Platform — Release 1
|
||||||
|
# See specification.md §4, §5, §29, §60 for architecture decisions.
|
||||||
|
|
||||||
|
x-backend-env: &backend-env
|
||||||
|
APP_ENV: ${APP_ENV:-development}
|
||||||
|
DATABASE_URL: postgresql+psycopg://${DATABASE_USER:-mqttadmin}:${DATABASE_PASSWORD:-changeme}@${DATABASE_HOST:-mqtt-db}:${DATABASE_PORT:-5432}/${DATABASE_NAME:-mqttadmin}
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
||||||
|
SECRET_ENCRYPTION_KEY: ${SECRET_ENCRYPTION_KEY:?SECRET_ENCRYPTION_KEY must be set}
|
||||||
|
ADMIN_INITIAL_USERNAME: ${ADMIN_INITIAL_USERNAME:-admin}
|
||||||
|
ADMIN_INITIAL_PASSWORD: ${ADMIN_INITIAL_PASSWORD:?ADMIN_INITIAL_PASSWORD must be set}
|
||||||
|
MOSQUITTO_HOST: ${MOSQUITTO_HOST:-mosquitto}
|
||||||
|
MOSQUITTO_MGMT_PORT: ${MOSQUITTO_MGMT_PORT:-1883}
|
||||||
|
MOSQUITTO_DYNSEC_USERNAME: ${MOSQUITTO_DYNSEC_USERNAME:-admin}
|
||||||
|
MOSQUITTO_DYNSEC_PASSWORD: ${MOSQUITTO_DYNSEC_PASSWORD:?MOSQUITTO_DYNSEC_PASSWORD must be set}
|
||||||
|
DEFAULT_TLS_MIN_VERSION: ${DEFAULT_TLS_MIN_VERSION:-tlsv1.2}
|
||||||
|
DEFAULT_CERT_VALIDITY_DAYS: ${DEFAULT_CERT_VALIDITY_DAYS:-365}
|
||||||
|
DEFAULT_CA_VALIDITY_DAYS: ${DEFAULT_CA_VALIDITY_DAYS:-3650}
|
||||||
|
|
||||||
|
services:
|
||||||
|
mqtt-db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${DATABASE_USER:-mqttadmin}
|
||||||
|
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-changeme}
|
||||||
|
POSTGRES_DB: ${DATABASE_NAME:-mqttadmin}
|
||||||
|
volumes:
|
||||||
|
- mqtt_db_data:/var/lib/postgresql/data
|
||||||
|
# Port deliberately NOT published to the host (spec §5.2).
|
||||||
|
networks:
|
||||||
|
- backend-net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USER:-mqttadmin} -d ${DATABASE_NAME:-mqttadmin}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
|
||||||
|
mosquitto:
|
||||||
|
image: eclipse-mosquitto:2.1.2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
# Spec §29: no docker.sock. Control sidecar shares the broker's PID namespace
|
||||||
|
# and the mosquitto_config volume; it signals PID 1 (the broker) directly.
|
||||||
|
environment:
|
||||||
|
# Used by the dynsec plugin on first start to seed the 'admin' client
|
||||||
|
# (verified: Mosquitto 2.1 dynamic security docs).
|
||||||
|
MOSQUITTO_DYNSEC_PASSWORD: ${MOSQUITTO_DYNSEC_PASSWORD:?MOSQUITTO_DYNSEC_PASSWORD must be set}
|
||||||
|
volumes:
|
||||||
|
- mosquitto_config:/mosquitto/config
|
||||||
|
- mosquitto_data:/mosquitto/data
|
||||||
|
- mosquitto_log:/mosquitto/log
|
||||||
|
networks:
|
||||||
|
- backend-net
|
||||||
|
# Release 1 ships an internal-only, plaintext listener on the backend
|
||||||
|
# network for the management channel; production listeners are added
|
||||||
|
# through the platform (spec §60). Nothing is published to the host.
|
||||||
|
# Healthcheck: dynsec denies anonymous clients, so we must connect as
|
||||||
|
# the seeded 'admin' (password from env, $$ = escaped for compose).
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "mosquitto_sub -h 127.0.0.1 -u admin -P \"$$MOSQUITTO_DYNSEC_PASSWORD\" -t '$$SYS/broker/version' -C 1 -W 3 >/dev/null 2>&1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
mosquitto-control:
|
||||||
|
build:
|
||||||
|
context: ./broker-control
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
mosquitto:
|
||||||
|
condition: service_started
|
||||||
|
# Shares PID namespace with the broker so `mosquitto_signal -p 1 ...`
|
||||||
|
# reaches the broker process without mounting docker.sock.
|
||||||
|
pid: "service:mosquitto"
|
||||||
|
environment:
|
||||||
|
# Minimal default token for the internal control API; in production
|
||||||
|
# this MUST be replaced (it only guards a backend-net-only endpoint).
|
||||||
|
CONTROL_API_TOKEN: ${CONTROL_API_TOKEN:?CONTROL_API_TOKEN must be set}
|
||||||
|
# Dynsec admin credentials for the post-deploy broker health probe
|
||||||
|
# (mosquitto_sub against $SYS/broker/version on loopback).
|
||||||
|
MOSQUITTO_DYNSEC_USERNAME: ${MOSQUITTO_DYNSEC_USERNAME:-admin}
|
||||||
|
MOSQUITTO_DYNSEC_PASSWORD: ${MOSQUITTO_DYNSEC_PASSWORD:?MOSQUITTO_DYNSEC_PASSWORD must be set}
|
||||||
|
MOSQUITTO_CONFIG_DIR: /mosquitto/config
|
||||||
|
volumes:
|
||||||
|
- mosquitto_config:/mosquitto/config
|
||||||
|
networks:
|
||||||
|
- backend-net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8090/health/live"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
mqtt-backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
mqtt-db:
|
||||||
|
condition: service_healthy
|
||||||
|
mosquitto:
|
||||||
|
condition: service_healthy
|
||||||
|
mosquitto-control:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
<<: *backend-env
|
||||||
|
volumes:
|
||||||
|
- backend_data:/data
|
||||||
|
networks:
|
||||||
|
- frontend-net
|
||||||
|
- backend-net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/live', timeout=3)\""]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
mqtt-frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
args:
|
||||||
|
# Only non-secret build args cross the frontend boundary (spec §7.2).
|
||||||
|
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api/v1}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
mqtt-backend:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- frontend-net
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT:-8080}:80"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:80/ || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mqtt_db_data:
|
||||||
|
mosquitto_config:
|
||||||
|
mosquitto_data:
|
||||||
|
mosquitto_log:
|
||||||
|
backend_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
frontend-net:
|
||||||
|
backend-net:
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Build stage (spec section 3.3: no privileged credentials in the frontend;
|
||||||
|
# VITE_API_BASE_URL is the ONLY build arg and contains no secret).
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
ARG VITE_API_BASE_URL=/api/v1
|
||||||
|
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Serve stage: nginx proxies /api to the backend over frontend-net.
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>MQTT Management</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# SPA routing: serve index.html for any non-API, non-asset path.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# REST API proxy (spec section 4: frontend -> backend over frontend-net).
|
||||||
|
location /api/ {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "mqtt-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "~5.7.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { NavLink, Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import { useAuth } from "./auth";
|
||||||
|
import { LoginPage } from "./pages/Login";
|
||||||
|
import { ClientsPage } from "./pages/Clients";
|
||||||
|
import { RolesPage } from "./pages/Roles";
|
||||||
|
import { GroupsPage } from "./pages/Groups";
|
||||||
|
import { BrokerConfigPage } from "./pages/BrokerConfig";
|
||||||
|
|
||||||
|
function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
const { session, logout } = useAuth();
|
||||||
|
return (
|
||||||
|
<div className="layout">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<h1>MQTT Management</h1>
|
||||||
|
<nav>
|
||||||
|
<NavLink to="/clients">Clients</NavLink>
|
||||||
|
<NavLink to="/groups">Groups</NavLink>
|
||||||
|
<NavLink to="/roles">Roles & ACLs</NavLink>
|
||||||
|
<NavLink to="/broker">Broker configuration</NavLink>
|
||||||
|
</nav>
|
||||||
|
<p style={{ marginTop: "2rem", fontSize: "0.8rem", opacity: 0.7 }}>
|
||||||
|
{session?.username} ({session?.role})
|
||||||
|
</p>
|
||||||
|
<button onClick={() => void logout()}>Sign out</button>
|
||||||
|
</aside>
|
||||||
|
<main className="content">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Protected({ children }: { children: React.ReactNode }) {
|
||||||
|
const { session, loading } = useAuth();
|
||||||
|
if (loading) return <p>Loading…</p>;
|
||||||
|
if (!session) return <Navigate to="/login" replace />;
|
||||||
|
return <Layout>{children}</Layout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route
|
||||||
|
path="/clients"
|
||||||
|
element={
|
||||||
|
<Protected>
|
||||||
|
<ClientsPage />
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/groups"
|
||||||
|
element={
|
||||||
|
<Protected>
|
||||||
|
<GroupsPage />
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/roles"
|
||||||
|
element={
|
||||||
|
<Protected>
|
||||||
|
<RolesPage />
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/broker"
|
||||||
|
element={
|
||||||
|
<Protected>
|
||||||
|
<BrokerConfigPage />
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/" element={<Navigate to="/clients" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Thin API client around fetch with session cookies (spec section 8:
|
||||||
|
// HttpOnly cookies, never tokens in localStorage).
|
||||||
|
|
||||||
|
const BASE = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(`${BASE}${path}`, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: init?.body ? { "Content-Type": "application/json" } : undefined,
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = response.statusText;
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
message = body.detail ?? body.error ?? message;
|
||||||
|
} catch {
|
||||||
|
/* non-JSON error body */
|
||||||
|
}
|
||||||
|
throw new ApiError(response.status, message);
|
||||||
|
}
|
||||||
|
if (response.status === 204) return undefined as T;
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <T>(path: string) => request<T>(path),
|
||||||
|
post: <T>(path: string, body?: unknown) =>
|
||||||
|
request<T>(path, { method: "POST", body: body === undefined ? undefined : JSON.stringify(body) }),
|
||||||
|
put: <T>(path: string, body?: unknown) =>
|
||||||
|
request<T>(path, { method: "PUT", body: body === undefined ? undefined : JSON.stringify(body) }),
|
||||||
|
patch: <T>(path: string, body?: unknown) =>
|
||||||
|
request<T>(path, { method: "PATCH", body: body === undefined ? undefined : JSON.stringify(body) }),
|
||||||
|
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Types (mirrors backend schemas) ---------------------------------------
|
||||||
|
|
||||||
|
export interface SessionInfo {
|
||||||
|
username: string;
|
||||||
|
role: "super_admin" | "broker_admin" | "pki_admin" | "operator" | "auditor";
|
||||||
|
must_change_password: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AclRule {
|
||||||
|
acltype: string;
|
||||||
|
topic: string;
|
||||||
|
allow: boolean;
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Role {
|
||||||
|
rolename: string;
|
||||||
|
text_name?: string | null;
|
||||||
|
text_description?: string | null;
|
||||||
|
acls: AclRule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Group {
|
||||||
|
groupname: string;
|
||||||
|
text_name?: string | null;
|
||||||
|
roles: { rolename: string; priority: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MqttClient {
|
||||||
|
username: string;
|
||||||
|
clientid: string | null;
|
||||||
|
text_name: string | null;
|
||||||
|
text_description: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
groups: { groupname: string; priority: number }[];
|
||||||
|
roles: { rolename: string; priority: number }[];
|
||||||
|
owner: string | null;
|
||||||
|
purpose: string | null;
|
||||||
|
tags: string[];
|
||||||
|
last_known_connection: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordReset {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Listener {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
port: number;
|
||||||
|
bind_address: string | null;
|
||||||
|
protocol: "mqtt" | "websockets";
|
||||||
|
allow_anonymous: boolean;
|
||||||
|
max_connections: number;
|
||||||
|
tls_enabled: boolean;
|
||||||
|
tls_min_version: string | null;
|
||||||
|
require_certificate: boolean;
|
||||||
|
use_identity_as_username: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Revision {
|
||||||
|
id: string;
|
||||||
|
revision_number: number;
|
||||||
|
created_by: string | null;
|
||||||
|
comment: string | null;
|
||||||
|
deployed: boolean;
|
||||||
|
deployed_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { api, type SessionInfo } from "./api";
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
session: SessionInfo | null;
|
||||||
|
loading: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [session, setSession] = useState<SessionInfo | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.get<SessionInfo>("/auth/session")
|
||||||
|
.then(setSession)
|
||||||
|
.catch(() => setSession(null))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
|
setSession(await api.post<SessionInfo>("/auth/login", { username, password }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
await api.post("/auth/logout");
|
||||||
|
setSession(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ session, loading, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { AuthProvider } from "./auth";
|
||||||
|
import { App } from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { api, type Listener, type Revision } from "../api";
|
||||||
|
|
||||||
|
interface ConfigPreview {
|
||||||
|
config_text: string;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrokerConfigPage() {
|
||||||
|
const listeners = useQuery({
|
||||||
|
queryKey: ["listeners"],
|
||||||
|
queryFn: () => api.get<Listener[]>("/broker/listeners"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const preview = useQuery({
|
||||||
|
queryKey: ["config-preview"],
|
||||||
|
queryFn: () => api.get<ConfigPreview>("/broker/config/preview"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const revisions = useQuery({
|
||||||
|
queryKey: ["revisions"],
|
||||||
|
queryFn: () => api.get<Revision[]>("/broker/revisions"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deploy = useMutation({
|
||||||
|
mutationFn: (comment: string | null) =>
|
||||||
|
api.post<{ status: string; revision: number; broker_healthy: boolean }>(
|
||||||
|
"/broker/config/deploy",
|
||||||
|
{ comment },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
preview.refetch();
|
||||||
|
revisions.refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Broker configuration</h2>
|
||||||
|
<p>
|
||||||
|
The configuration is generated from the platform model, validated with
|
||||||
|
<code> mosquitto --test-config</code>, deployed atomically, and every
|
||||||
|
deployment creates a revision.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Listeners</h3>
|
||||||
|
{listeners.isLoading ? (
|
||||||
|
<p>Loading…</p>
|
||||||
|
) : (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Port</th>
|
||||||
|
<th>Bind</th>
|
||||||
|
<th>Protocol</th>
|
||||||
|
<th>Anonymous</th>
|
||||||
|
<th>Enabled</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(listeners.data ?? []).map((l) => (
|
||||||
|
<tr key={l.id}>
|
||||||
|
<td>{l.name}</td>
|
||||||
|
<td className="mono">{l.port}</td>
|
||||||
|
<td className="mono">{l.bind_address ?? "0.0.0.0"}</td>
|
||||||
|
<td>{l.protocol}</td>
|
||||||
|
<td>{l.allow_anonymous ? "yes" : "no"}</td>
|
||||||
|
<td>{l.enabled ? "yes" : "no"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3 style={{ marginTop: "2rem" }}>Generated configuration</h3>
|
||||||
|
{preview.data?.warnings.map((w) => (
|
||||||
|
<div key={w} className="banner warn">
|
||||||
|
{w}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<pre className="config mono">{preview.data?.config_text ?? "Generating…"}</pre>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="primary"
|
||||||
|
disabled={deploy.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
const comment = prompt("Deployment comment (optional)");
|
||||||
|
deploy.mutate(comment);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deploy.isPending ? "Deploying…" : "Validate & deploy configuration"}
|
||||||
|
</button>
|
||||||
|
{deploy.data && (
|
||||||
|
<div className="banner ok">
|
||||||
|
Deployed revision {deploy.data.revision}. Broker health:{" "}
|
||||||
|
{deploy.data.broker_healthy ? "OK" : "unknown — check logs"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{deploy.error && (
|
||||||
|
<div className="banner error">{(deploy.error as Error).message}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3 style={{ marginTop: "2rem" }}>Revisions</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Deployed</th>
|
||||||
|
<th>By</th>
|
||||||
|
<th>Comment</th>
|
||||||
|
<th>Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(revisions.data ?? []).map((r) => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td className="mono">{r.revision_number}</td>
|
||||||
|
<td>{r.deployed ? new Date(r.deployed_at!).toLocaleString() : "—"}</td>
|
||||||
|
<td>{r.created_by ?? "—"}</td>
|
||||||
|
<td>{r.comment ?? "—"}</td>
|
||||||
|
<td>{new Date(r.created_at).toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api, type MqttClient, type PasswordReset } from "../api";
|
||||||
|
|
||||||
|
export function ClientsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [newUsername, setNewUsername] = useState("");
|
||||||
|
const [oncePassword, setOncePassword] = useState<PasswordReset | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const clients = useQuery({
|
||||||
|
queryKey: ["clients"],
|
||||||
|
queryFn: () => api.get<MqttClient[]>("/mqtt/clients"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: (username: string) =>
|
||||||
|
api.post<PasswordReset>("/mqtt/clients", { username }),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setOncePassword(result);
|
||||||
|
setNewUsername("");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const disable = useMutation({
|
||||||
|
mutationFn: (username: string) => api.post(`/mqtt/clients/${username}/disable`),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clients"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const enable = useMutation({
|
||||||
|
mutationFn: (username: string) => api.post(`/mqtt/clients/${username}/enable`),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clients"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (username: string) => api.delete(`/mqtt/clients/${username}`),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clients"] }),
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (newUsername) create.mutate(newUsername);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Clients</h2>
|
||||||
|
<p>
|
||||||
|
MQTT identities managed by the broker's dynamic security plugin. Passwords
|
||||||
|
are shown exactly once on creation or reset.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{oncePassword && (
|
||||||
|
<div className="banner warn">
|
||||||
|
<strong>Password for {oncePassword.username} — copy it now, it is shown
|
||||||
|
only once:</strong>
|
||||||
|
<div className="password-once mono">{oncePassword.password}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <div className="banner error">{error}</div>}
|
||||||
|
|
||||||
|
<form className="stack" onSubmit={submit}>
|
||||||
|
<label htmlFor="new-client">New client username</label>
|
||||||
|
<input
|
||||||
|
id="new-client"
|
||||||
|
value={newUsername}
|
||||||
|
onChange={(e) => setNewUsername(e.target.value)}
|
||||||
|
placeholder="e.g. ps042"
|
||||||
|
/>
|
||||||
|
<p />
|
||||||
|
<button className="primary" disabled={!newUsername || create.isPending}>
|
||||||
|
{create.isPending ? "Creating…" : "Create client (random password)"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{clients.isLoading ? (
|
||||||
|
<p>Loading clients…</p>
|
||||||
|
) : (
|
||||||
|
<table style={{ marginTop: "1.5rem" }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Client ID</th>
|
||||||
|
<th>Enabled</th>
|
||||||
|
<th>Groups</th>
|
||||||
|
<th>Roles</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(clients.data ?? []).map((c) => (
|
||||||
|
<tr key={c.username}>
|
||||||
|
<td className="mono">{c.username}</td>
|
||||||
|
<td className="mono">{c.clientid ?? "—"}</td>
|
||||||
|
<td>{c.enabled ? "yes" : "no"}</td>
|
||||||
|
<td>{c.groups.map((g) => g.groupname).join(", ") || "—"}</td>
|
||||||
|
<td>{c.roles.map((r) => r.rolename).join(", ") || "—"}</td>
|
||||||
|
<td>
|
||||||
|
{c.enabled ? (
|
||||||
|
<button onClick={() => disable.mutate(c.username)}>Disable</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => enable.mutate(c.username)}>Enable</button>
|
||||||
|
)}{" "}
|
||||||
|
<button
|
||||||
|
className="danger"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete client ${c.username}?`))
|
||||||
|
remove.mutate(c.username);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api, type Group } from "../api";
|
||||||
|
|
||||||
|
export function GroupsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const groups = useQuery({
|
||||||
|
queryKey: ["groups"],
|
||||||
|
queryFn: () => api.get<Group[]>("/mqtt/groups"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: (groupname: string) => api.post("/mqtt/groups", { groupname }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setName("");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["groups"] });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (groupname: string) => api.delete(`/mqtt/groups/${groupname}`),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["groups"] }),
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (name) create.mutate(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Groups</h2>
|
||||||
|
<p>Assign roles to collections of clients (dynamic security groups).</p>
|
||||||
|
{error && <div className="banner error">{error}</div>}
|
||||||
|
|
||||||
|
<form className="stack" onSubmit={submit}>
|
||||||
|
<label htmlFor="group-name">New group name</label>
|
||||||
|
<input
|
||||||
|
id="group-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g. sensors"
|
||||||
|
/>
|
||||||
|
<p />
|
||||||
|
<button className="primary" disabled={!name || create.isPending}>
|
||||||
|
Create group
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<table style={{ marginTop: "1.5rem" }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Group</th>
|
||||||
|
<th>Roles</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(groups.data ?? []).map((g) => (
|
||||||
|
<tr key={g.groupname}>
|
||||||
|
<td className="mono">{g.groupname}</td>
|
||||||
|
<td>{(g.roles ?? []).map((r) => r.rolename).join(", ") || "—"}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="danger"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete group ${g.groupname}?`)) remove.mutate(g.groupname);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { useAuth } from "../auth";
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
} catch {
|
||||||
|
setError("Invalid username or password.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="login-card" onSubmit={(e) => void submit(e)}>
|
||||||
|
<h2>MQTT Management</h2>
|
||||||
|
{error && <div className="banner error">{error}</div>}
|
||||||
|
<label htmlFor="username">Username</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<label htmlFor="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<p />
|
||||||
|
<button className="primary" disabled={busy || !username || !password}>
|
||||||
|
{busy ? "Signing in…" : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api, type Role, type AclRule } from "../api";
|
||||||
|
|
||||||
|
const ACL_TYPES = [
|
||||||
|
"publishClientSend",
|
||||||
|
"publishClientReceive",
|
||||||
|
"subscribeLiteral",
|
||||||
|
"subscribePattern",
|
||||||
|
"unsubscribeLiteral",
|
||||||
|
"unsubscribePattern",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function RolesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const roles = useQuery({
|
||||||
|
queryKey: ["roles"],
|
||||||
|
queryFn: () => api.get<Role[]>("/mqtt/roles"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const detail = useQuery({
|
||||||
|
queryKey: ["role", selected],
|
||||||
|
queryFn: () => api.get<Role>(`/mqtt/roles/${selected}`),
|
||||||
|
enabled: selected !== null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: (rolename: string) => api.post("/mqtt/roles", { rolename, acls: [] }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setName("");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["roles"] });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: (role: Role) => api.put(`/mqtt/roles/${role.rolename}`, role),
|
||||||
|
onSuccess: () => {
|
||||||
|
setError(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["role", selected] });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (name) create.mutate(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const role = detail.data;
|
||||||
|
|
||||||
|
const updateAcl = (index: number, patch: Partial<AclRule>) => {
|
||||||
|
if (!role) return;
|
||||||
|
const acls = role.acls.map((a, i) => (i === index ? { ...a, ...patch } : a));
|
||||||
|
save.mutate({ ...role, acls });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addAcl = () => {
|
||||||
|
if (!role) return;
|
||||||
|
save.mutate({
|
||||||
|
...role,
|
||||||
|
acls: [
|
||||||
|
...role.acls,
|
||||||
|
{ acltype: "publishClientSend", topic: "", allow: true, priority: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAcl = (index: number) => {
|
||||||
|
if (!role) return;
|
||||||
|
save.mutate({ ...role, acls: role.acls.filter((_, i) => i !== index) });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Roles & ACLs</h2>
|
||||||
|
<p>
|
||||||
|
ACL types map to dynamic security: publishClientSend (client publishes),
|
||||||
|
publishClientReceive (client receives), subscribe/unsubscribe in literal
|
||||||
|
and pattern variants. <code>%u</code> and <code>%c</code> substitute the
|
||||||
|
username and client id.
|
||||||
|
</p>
|
||||||
|
{error && <div className="banner error">{error}</div>}
|
||||||
|
|
||||||
|
<form className="stack" onSubmit={submit}>
|
||||||
|
<label htmlFor="role-name">New role name</label>
|
||||||
|
<input
|
||||||
|
id="role-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g. site-a-sensors"
|
||||||
|
/>
|
||||||
|
<p />
|
||||||
|
<button className="primary" disabled={!name || create.isPending}>
|
||||||
|
Create role
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<table style={{ marginTop: "1.5rem" }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>ACLs</th>
|
||||||
|
<th>Open</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(roles.data ?? []).map((r) => (
|
||||||
|
<tr key={r.rolename}>
|
||||||
|
<td className="mono">{r.rolename}</td>
|
||||||
|
<td>{(r.acls ?? []).length}</td>
|
||||||
|
<td>
|
||||||
|
<button onClick={() => setSelected(r.rolename)}>Edit ACLs</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{role && (
|
||||||
|
<section style={{ marginTop: "2rem" }}>
|
||||||
|
<h3>
|
||||||
|
Role: <span className="mono">{role.rolename}</span>
|
||||||
|
</h3>
|
||||||
|
<button onClick={addAcl} disabled={save.isPending}>
|
||||||
|
+ Add ACL rule
|
||||||
|
</button>
|
||||||
|
<table style={{ marginTop: "1rem" }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Topic filter</th>
|
||||||
|
<th>Effect</th>
|
||||||
|
<th>Priority</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{role.acls.map((acl, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
value={acl.acltype}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAcl(index, { acltype: e.target.value as (typeof ACL_TYPES)[number] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ACL_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
className="mono"
|
||||||
|
value={acl.topic}
|
||||||
|
placeholder="site/a/%u/telemetry/#"
|
||||||
|
onChange={(e) => updateAcl(index, { topic: e.target.value })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
value={acl.allow ? "allow" : "deny"}
|
||||||
|
onChange={(e) => updateAcl(index, { allow: e.target.value === "allow" })}
|
||||||
|
>
|
||||||
|
<option value="allow">allow</option>
|
||||||
|
<option value="deny">deny</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100000}
|
||||||
|
value={acl.priority}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAcl(index, { priority: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button className="danger" onClick={() => removeAcl(index)}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{save.isPending && <p>Saving…</p>}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
:root {
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #f4f5f7;
|
||||||
|
color: #1f2933;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: #1f2933;
|
||||||
|
color: #f0f4f8;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar h1 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar nav a {
|
||||||
|
display: block;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
margin: 0.1rem 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #cbd2d9;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar nav a.active,
|
||||||
|
.sidebar nav a:hover {
|
||||||
|
background: #323f4b;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
max-width: 1100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
|
border-bottom: 1px solid #e4e7eb;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: #f0f4f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #cbd2d9;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: #1f6feb;
|
||||||
|
border-color: #1f6feb;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: #fff;
|
||||||
|
border-color: #e5534b;
|
||||||
|
color: #e5534b;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.stack label,
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin: 0.6rem 0 0.2rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #52606d;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
border: 1px solid #cbd2d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
width: 100%;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
max-width: 340px;
|
||||||
|
margin: 12vh auto;
|
||||||
|
background: #fff;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 16px rgba(31, 41, 51, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 0.6rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner.ok {
|
||||||
|
background: #e6f4ea;
|
||||||
|
color: #1e6b3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner.error {
|
||||||
|
background: #fdecea;
|
||||||
|
color: #a4342d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner.warn {
|
||||||
|
background: #fff6e0;
|
||||||
|
color: #8a5a00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre.config {
|
||||||
|
background: #1f2933;
|
||||||
|
color: #d5e3f0;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-once {
|
||||||
|
background: #fff6e0;
|
||||||
|
border: 1px solid #e3b341;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
// Typed access to the single non-secret frontend build arg (spec §7.2).
|
||||||
|
// Browser-visible env must NEVER contain passwords or secrets.
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
// Local dev: Vite on :5173 proxies API calls to the backend container.
|
||||||
|
"/api": "http://localhost:8000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# mosquitto.conf — initial bootstrap configuration (Release 1)
|
||||||
|
#
|
||||||
|
# This file ships with the stack and starts the broker on the internal
|
||||||
|
# backend network only. It is the ONE file that is legitimately part of
|
||||||
|
# the deployment; from here on, all configuration changes go through the
|
||||||
|
# platform's revision pipeline (spec §25, §61).
|
||||||
|
#
|
||||||
|
# The generated platform configuration will replace this file through
|
||||||
|
# mosquitto-control, never by direct editing.
|
||||||
|
#
|
||||||
|
# NOTE (2.1 deprecations, spec §3.1): we use `plugin`/`global_plugin`,
|
||||||
|
# NOT the deprecated password_file/acl_file/per_listener_settings options.
|
||||||
|
|
||||||
|
per_listener_settings false
|
||||||
|
|
||||||
|
# --- Internal management listener (backend-net only) ---
|
||||||
|
# Plaintext is acceptable here because the network is isolated and the
|
||||||
|
# listener is not published to the host. Production TLS listeners are
|
||||||
|
# added via the platform (spec §60).
|
||||||
|
listener 1883 0.0.0.0
|
||||||
|
allow_anonymous false
|
||||||
|
|
||||||
|
# --- Dynamic Security plugin ---
|
||||||
|
plugin /usr/lib/mosquitto_dynamic_security.so
|
||||||
|
plugin_opt_config_file /mosquitto/config/dynamic-security.json
|
||||||
|
# Admin client password is seeded from MOSQUITTO_DYNSEC_PASSWORD on first
|
||||||
|
# start (2.1 feature); the .pw file fallback is not used because the env
|
||||||
|
# var is always provided by compose.
|
||||||
|
|
||||||
|
# --- Persistence (plugin-based, recommended over built-in in 2.1) ---
|
||||||
|
persistence true
|
||||||
|
persistence_location /mosquitto/data/
|
||||||
|
autosave_interval 900
|
||||||
|
|
||||||
|
# --- Telemetry for the future dashboard (Release 3) ---
|
||||||
|
sys_interval 10
|
||||||
|
|
||||||
|
# --- Logging ---
|
||||||
|
log_dest stdout
|
||||||
|
log_type error
|
||||||
|
log_type warning
|
||||||
|
log_type notice
|
||||||
|
log_type information
|
||||||
|
log_timestamp true
|
||||||
|
log_timestamp_format %Y-%m-%dT%H:%M:%S
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Minimal health-check client config used by `mosquitto_sub` in the
|
||||||
|
# compose healthcheck. It connects to the loopback listener anonymously
|
||||||
|
# only far enough to receive $SYS/broker/version — dynsec denies
|
||||||
|
# everything else for anonymous clients, which is fine: we only need the
|
||||||
|
# TCP + MQTT CONNECT handshake to succeed for the check.
|
||||||
|
#
|
||||||
|
# Kept separate from bootstrap/mosquitto.conf so the healthcheck does not
|
||||||
|
# depend on the generated/managed configuration at all.
|
||||||
+79
-41
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Service Specification
|
## Service Specification
|
||||||
|
|
||||||
**Status:** Initial specification
|
**Status:** Initial specification, revised 2026-09-15 (MVP split into three releases)
|
||||||
**Target deployment:** Docker Compose / Portainer Stack
|
**Target deployment:** Docker Compose / Portainer Stack
|
||||||
**Primary broker:** Eclipse Mosquitto 2.1.x
|
**Primary broker:** Eclipse Mosquitto 2.1.x
|
||||||
**Architecture:** Mosquitto + custom backend + custom frontend + PostgreSQL
|
**Architecture:** Mosquitto + custom backend + custom frontend + PostgreSQL
|
||||||
@@ -2134,14 +2134,18 @@ Generated configuration and secrets shall not be stored in Git.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 68. MVP scope
|
# 68. MVP scope and release plan
|
||||||
|
|
||||||
The first functional release should include:
|
The original single "first release" list contained roughly 25 sub-systems and is not achievable as one release. The MVP is therefore split into three releases. Each release is independently deployable and useful, and each builds on the previous one.
|
||||||
|
|
||||||
|
## 68.1 Release 1 — Core broker administration
|
||||||
|
|
||||||
|
Goal: deliver the original core requirement — a web-managed Mosquitto broker with client authentication and authorization, including ACLs, without touching files on the host.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Docker Compose stack
|
Docker Compose stack
|
||||||
|
|
||||||
Mosquitto 2.1.x
|
Mosquitto 2.1.x (pinned)
|
||||||
|
|
||||||
PostgreSQL
|
PostgreSQL
|
||||||
|
|
||||||
@@ -2149,60 +2153,94 @@ FastAPI backend
|
|||||||
|
|
||||||
React frontend
|
React frontend
|
||||||
|
|
||||||
Management authentication
|
Management authentication (sessions, Argon2id, ADMIN_INITIAL_* bootstrap)
|
||||||
|
|
||||||
Management RBAC
|
Management RBAC (core roles)
|
||||||
|
|
||||||
Dynamic Security integration
|
Dynamic Security integration (backend as MQTT admin client)
|
||||||
|
|
||||||
MQTT clients
|
MQTT clients (create/disable/enable/delete, password reset, random passwords)
|
||||||
|
|
||||||
Groups
|
Groups
|
||||||
|
|
||||||
Roles
|
Roles
|
||||||
|
|
||||||
ACL editor
|
ACL editor (all six ACL types, %u/%c patterns, priorities)
|
||||||
|
|
||||||
ACL simulator
|
Broker configuration editor (basic + advanced mode)
|
||||||
|
|
||||||
CA creation
|
|
||||||
|
|
||||||
Server certificates
|
|
||||||
|
|
||||||
Client certificates
|
|
||||||
|
|
||||||
CSR signing
|
|
||||||
|
|
||||||
Certificate revocation
|
|
||||||
|
|
||||||
CRL
|
|
||||||
|
|
||||||
Broker configuration editor
|
|
||||||
|
|
||||||
Listener configuration
|
Listener configuration
|
||||||
|
|
||||||
Configuration validation
|
Configuration validation (--test-config)
|
||||||
|
|
||||||
Configuration revisioning
|
Configuration generation from the PostgreSQL model
|
||||||
|
|
||||||
Configuration deployment
|
|
||||||
|
|
||||||
Broker reload
|
|
||||||
|
|
||||||
Dashboard
|
|
||||||
|
|
||||||
$SYS monitoring
|
|
||||||
|
|
||||||
Audit logging
|
|
||||||
|
|
||||||
Backup/restore
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Release 1 delivers a secure default broker configuration and removes the need to edit
|
||||||
|
`mosquitto.conf`, the Dynamic Security JSON, or password files manually.
|
||||||
|
|
||||||
|
## 68.2 Release 2 — PKI, versioning, and controlled change
|
||||||
|
|
||||||
|
Goal: make TLS/mTLS and broker configuration changes safe and auditable.
|
||||||
|
|
||||||
|
```text
|
||||||
|
CA creation (internal CA, import CA)
|
||||||
|
|
||||||
|
Server certificate generation (CN, SANs, key algorithm/length)
|
||||||
|
|
||||||
|
Client certificates (managed-key mode, one-time download)
|
||||||
|
|
||||||
|
CSR signing
|
||||||
|
|
||||||
|
Certificate revocation and CRL generation/publishing
|
||||||
|
|
||||||
|
mTLS listeners (require_certificate, use_identity_as_username)
|
||||||
|
|
||||||
|
Configuration revisions (history, diff)
|
||||||
|
|
||||||
|
Configuration deployment with health check
|
||||||
|
|
||||||
|
Broker reload (mosquitto_signal / SIGHUP), reload-vs-restart classification
|
||||||
|
|
||||||
|
Configuration rollback (rollback creates a new revision)
|
||||||
|
|
||||||
|
mosquitto-control service (validate, activate, signal, health check)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 68.3 Release 3 — Operations, safety, and visibility
|
||||||
|
|
||||||
|
Goal: make the platform usable for daily operation and secure change management.
|
||||||
|
|
||||||
|
```text
|
||||||
|
ACL simulator (with parity tests against real broker decisions)
|
||||||
|
|
||||||
|
Dashboard ($SYS monitoring, broker status)
|
||||||
|
|
||||||
|
Certificate expiry monitoring and dashboard warnings
|
||||||
|
|
||||||
|
Audit logging (all privileged actions)
|
||||||
|
|
||||||
|
Backup and restore (logical backup, encrypted)
|
||||||
|
|
||||||
|
Configuration drift detection
|
||||||
|
|
||||||
|
Client metadata/tags (owner, purpose, last known connection)
|
||||||
|
|
||||||
|
Connected-client view (where available)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 68.4 Post-MVP
|
||||||
|
|
||||||
|
Topic explorer, MQTT test client, connection tester, client provisioning wizard,
|
||||||
|
bridge configuration, retained-message administration, Prometheus metrics, external
|
||||||
|
notifications, and the approval workflow remain post-MVP features as previously
|
||||||
|
planned (see sections 69 and 70).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 69. Phase 2
|
# 69. Post-MVP: Phase 2
|
||||||
|
|
||||||
The second phase should add:
|
After the three MVP releases (section 68) are complete, the next phase should add:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Topic explorer
|
Topic explorer
|
||||||
@@ -2221,7 +2259,7 @@ External notification mechanisms
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 70. Phase 3
|
# 70. Post-MVP: Phase 3
|
||||||
|
|
||||||
Potential advanced functionality:
|
Potential advanced functionality:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user