🚧 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
|
||||
|
||||
**Status:** Initial specification
|
||||
**Status:** Initial specification, revised 2026-09-15 (MVP split into three releases)
|
||||
**Target deployment:** Docker Compose / Portainer Stack
|
||||
**Primary broker:** Eclipse Mosquitto 2.1.x
|
||||
**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
|
||||
Docker Compose stack
|
||||
|
||||
Mosquitto 2.1.x
|
||||
Mosquitto 2.1.x (pinned)
|
||||
|
||||
PostgreSQL
|
||||
|
||||
@@ -2149,60 +2153,94 @@ FastAPI backend
|
||||
|
||||
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
|
||||
|
||||
Roles
|
||||
|
||||
ACL editor
|
||||
ACL editor (all six ACL types, %u/%c patterns, priorities)
|
||||
|
||||
ACL simulator
|
||||
|
||||
CA creation
|
||||
|
||||
Server certificates
|
||||
|
||||
Client certificates
|
||||
|
||||
CSR signing
|
||||
|
||||
Certificate revocation
|
||||
|
||||
CRL
|
||||
|
||||
Broker configuration editor
|
||||
Broker configuration editor (basic + advanced mode)
|
||||
|
||||
Listener configuration
|
||||
|
||||
Configuration validation
|
||||
Configuration validation (--test-config)
|
||||
|
||||
Configuration revisioning
|
||||
|
||||
Configuration deployment
|
||||
|
||||
Broker reload
|
||||
|
||||
Dashboard
|
||||
|
||||
$SYS monitoring
|
||||
|
||||
Audit logging
|
||||
|
||||
Backup/restore
|
||||
Configuration generation from the PostgreSQL model
|
||||
```
|
||||
|
||||
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
|
||||
Topic explorer
|
||||
@@ -2221,7 +2259,7 @@ External notification mechanisms
|
||||
|
||||
---
|
||||
|
||||
# 70. Phase 3
|
||||
# 70. Post-MVP: Phase 3
|
||||
|
||||
Potential advanced functionality:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user