feat(secubox-mqtt): v2.4.0 rewrite-in-place to LXC + ACL + Mosquitto 2.x (closes #240) (#252)

Replaces the v1.0.0 host-bound skeleton with the canonical SecuBox LXC
pattern (sibling of authelia/yacy/lyrion). The host-bound mosquitto is
now masked on first install via migrate_from_v1().

Minimum viable v2.4.0 (operator scope choice 2026-05-20):

* LXC at 10.100.0.110 on br-lxc, Mosquitto 2.0.11 (Debian bookworm).
* allow_anonymous false. Per-service ACL with 5 users (z2m, sentinelle,
  domoticz, homeassistant, secubox-api) — randomly generated passwords
  stored at /etc/secubox/secrets/mqtt-<service> (0640 root:secubox).
* Host control plane: FastAPI on /run/secubox/mqtt.sock with endpoints
  /components, /status, /access, /healthz. /access reads from a
  host-side cache /etc/secubox/mqtt-acl.cached (the FastAPI runs as the
  unprivileged secubox user, can't lxc-attach).
* Persistent nftables DNAT in /etc/nftables.d/secubox-mqtt-dnat.nft
  routes LAN traffic on TCP :1883 to the LXC.
* mqttctl follows the SecuBox CTL grammar:
    - introspection: components / status / access
    - lifecycle:     install / reload (+ stubs)
    - module nouns:  topic / client / acl / user (list-only in v2.4.0)
* nginx route /api/v1/mqtt/ on canonical hub vhost (no public vhost —
  the broker is LAN-only, exposed via DNAT not nginx).

Board-validated on gk2:
    $ mqttctl status   →  overall: green
    $ curl --unix-socket /run/secubox/mqtt.sock localhost/status
      → {"overall": "green", "states": {"lxc": "running", "broker": "running", "health": "ok"}}
    $ curl -sk -H "Host: admin.gk2.secubox.in" https://192.168.1.200:9443/api/v1/mqtt/status
      → same
    $ mosquitto_pub -h 10.100.0.110 -t test -m hi
      → "Connection Refused: not authorised."  (anon blocked)
    $ mosquitto_pub -u z2m -P <pw> -t zigbee2mqtt/health -m ok
      → success
    $ nc -zv 192.168.1.200 1883
      → succeeded (LAN DNAT works)
    $ systemctl is-active mosquitto  →  inactive (host one masked)

Out of scope for v2.4.0 (deferred to v2.5):
* 4R double-buffer on the broker config
* Jinja2 templating
* operator-level ACL/user management via /api/v1/mqtt/
* TLS 1.3 on port 8883

Lessons folded in during board cycle:
* Mosquitto 2.x rejects duplicate single-value keys
  (`persistence_location`, `log_dest`) — keep the conf.d snippet
  minimal, the Debian default mosquitto.conf already sets them.
* lxc-info requires root → API uses TCP probe to LXC_IP:MQTT_PORT
  as a privilege-free liveness signal; catches PermissionError on
  `/data/lxc/<name>/` stat (rootfs mode 0770 owned by mapped uid 100000).
* migrate_from_v1 uses bare `systemctl is-active mosquitto` instead of
  a `list-unit-files | grep` prefilter (simpler + actually correct).

Closes #240

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
This commit is contained in:
CyberMind 2026-05-20 13:42:42 +02:00 committed by GitHub
parent e562446383
commit 080a4e47a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1152 additions and 1909 deletions

View File

@ -1,39 +1,66 @@
# 📡 MQTT Broker
# secubox-lyrion
Mosquitto MQTT broker
[Lyrion Music Server](https://lyrion.org) (formerly Logitech Media Server /
Squeezebox Server) for SecuBox, hosted in a Debian bookworm LXC at
`10.100.0.100` on the SecuBox `br-lxc` bridge.
**Category:** IoT
Follows [`docs/MODULE-GUIDELINES.md`](../../docs/MODULE-GUIDELINES.md); opens
the **HOSTING** layer of the SecuBox CTL grammar (sister to streamlitctl,
metablogizerctl, etc.).
## Screenshot
![MQTT Broker](../../docs/screenshots/vm/mqtt.png)
## Features
- Topics
- ACL
- TLS
- WebSocket
## Installation
## Quickstart
```bash
# Add SecuBox repository
curl -fsSL https://apt.secubox.in/install.sh | sudo bash
# Install package
sudo apt install secubox-mqtt
apt install secubox-lyrion
lyrionctl install # provisions LXC at 10.100.0.100, installs Lyrion 9.0.4
lyrionctl status # green when LXC + daemon + host-api all up
```
## Configuration
Web admin at `http://10.100.0.100:9000/` (direct) or
`https://<host>/lyrion/` (through the canonical hub vhost).
Configuration file: `/etc/secubox/mqtt.toml`
## CTL — `lyrionctl`
## API Endpoints
Three-fold + lifecycle (v1.0.0 ships install + reload; rest is v1.1.0):
- `GET /api/v1/mqtt/status` - Module status
- `GET /api/v1/mqtt/health` - Health check
```text
lyrionctl components | status | access [--json]
lyrionctl install | reload | repair (1.1.0) | wizard (1.1.0) | uninstall (1.1.0)
## License
lyrionctl player list | play <id> | pause <id> | next <id> | prev <id> | volume <id> <0-100>
lyrionctl library scan | refresh | status | wipe
lyrionctl playlist list | add <name> | remove <name> | tracks <name>
lyrionctl plugin list | install <name> | uninstall <name>
```
MIT License - CyberMind © 2024-2026
## Music library
The `[library]` section of `/etc/secubox/lyrion.toml` points to the host's
music directory (default `/data/music`). On `lyrionctl install`, that path
is bind-mounted read-only into the LXC at the same path. LMS scans +
indexes from there; no writes to source files.
## Ports
| Port | Proto | Use |
|---|---|---|
| 9000 | tcp | Web UI + JSON-RPC API (proxied via nginx `/lyrion/`) |
| 9090 | tcp | CLI (telnet/netcat) |
| 3483 | tcp+udp | slimproto (Squeezebox players discover/connect) |
3483 is **NOT exposed publicly** — players are expected on the LAN. Add an
nftables DNAT only if you have a remote Squeezebox.
## Files
```text
/etc/secubox/lyrion.toml # operator config
/etc/nginx/secubox.d/lyrion.conf # /api/v1/lyrion/ + /lyrion/ iframe
/etc/nginx/secubox-routes.d/lyrion.conf # idem (canonical hub vhost)
/usr/lib/secubox/lyrion/api/ # host FastAPI
/usr/share/secubox/lib/lyrion/install-lxc.sh
/usr/share/secubox/www/lyrion/ # SecuBox-themed iframe wrapper
/usr/share/secubox/menu.d/80-lyrion.json
/data/lxc/lyrion/ # LXC rootfs (created by lyrionctl install)
/data/music/ # operator music library (bind-mounted)
```

View File

@ -1 +0,0 @@
# SecuBox MQTT Module

View File

@ -1,864 +1,149 @@
"""
SecuBox-Deb :: MQTT Broker Management
CyberMind https://cybermind.fr
Author: Gerald Kerma <devel@cybermind.fr>
License: Proprietary / ANSSI CSPN candidate
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gerald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
MQTT broker (Mosquitto) management module with:
- Broker status and health monitoring
- Client management
- Topic tracking
- User/ACL management
- Configuration management
- Real-time statistics
"""
SecuBox-Deb :: secubox-mqtt :: host FastAPI control plane.
Mosquitto runs in LXC at 10.100.0.110 (br-lxc). This API is on the host,
on Unix socket /run/secubox/mqtt.sock, reverse-proxied at /api/v1/mqtt/
by the canonical hub vhost. Authentication is provided by the canonical
SecuBox JWT middleware (handled at the nginx/Authelia layer).
"""
import os
import socket
import subprocess
import json
import asyncio
import re
import time
from pathlib import Path
from typing import Optional, List, Dict, Any
from datetime import datetime
from fastapi import FastAPI, APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from secubox_core.auth import router as auth_router, require_jwt
from secubox_core.config import get_config
from secubox_core.logger import get_logger
from fastapi import FastAPI, HTTPException
LXC_NAME = os.environ.get("SECUBOX_LXC_NAME", "mqtt")
LXC_IP = os.environ.get("SECUBOX_LXC_IP", "10.100.0.110")
LXC_PATH = os.environ.get("SECUBOX_LXC_PATH", "/data/lxc")
MQTT_PORT = int(os.environ.get("SECUBOX_MQTT_PORT", "1883"))
SECRETS_DIR = Path(os.environ.get("SECUBOX_SECRETS_DIR", "/etc/secubox/secrets"))
app = FastAPI(
title="secubox-mqtt",
version="1.0.0",
root_path="/api/v1/mqtt"
title="SecuBox MQTT",
description="Mosquitto broker control plane (WALL layer)",
version="2.4.0",
)
# ══════════════════════════════════════════════════════════════════
# Health Check Endpoint (public, no auth)
# ══════════════════════════════════════════════════════════════════
@app.get("/health")
async def health_check():
"""Public health check endpoint for sidebar status."""
return {"status": "ok", "module": "deb"}
def _lxc_state() -> str:
"""Infer LXC state without lxc-info — that requires root, and this API
runs as the unprivileged `secubox` user. The cheapest signal that
works for an unprivileged caller: open a TCP connection to
LXC_IP:MQTT_PORT. The LXC rootfs at /data/lxc/<name>/ is often
chmod 0770 owned by the LXC mapped uid 100000, so stat() throws
PermissionError we treat that as "present, run TCP probe."
app.include_router(auth_router, prefix="/auth")
router = APIRouter()
log = get_logger("mqtt")
# Configuration paths
MOSQUITTO_CONF = Path("/etc/mosquitto/mosquitto.conf")
MOSQUITTO_PASSWD = Path("/etc/mosquitto/passwd")
MOSQUITTO_ACL = Path("/etc/mosquitto/acl")
MOSQUITTO_PID = Path("/var/run/mosquitto.pid")
DATA_DIR = Path("/var/lib/secubox/mqtt")
STATS_CACHE_FILE = DATA_DIR / "stats_cache.json"
DATA_DIR.mkdir(parents=True, exist_ok=True)
# In-memory stats cache
_stats_cache: Dict[str, Any] = {}
_stats_task: Optional[asyncio.Task] = None
Returns 'running' / 'stopped' / 'absent'.
"""
rootfs = Path(LXC_PATH) / LXC_NAME
try:
exists = rootfs.exists()
except PermissionError:
# /data/lxc/<name>/ is unreadable by us — but it exists, so fall through.
exists = True
if not exists:
return "absent"
try:
with socket.create_connection((LXC_IP, MQTT_PORT), timeout=1.0):
return "running"
except OSError:
return "stopped"
# ═══════════════════════════════════════════════════════════════════════════
# Models
# ═══════════════════════════════════════════════════════════════════════════
class MqttUser(BaseModel):
username: str = Field(..., min_length=1, max_length=64)
password: str = Field(..., min_length=4)
def _broker_running() -> bool:
"""A TCP open on LXC_IP:1883 is equivalent to mosquitto being up — there's
only one listener in the LXC (the Debian-default 127.0.0.1 listener is
inside the LXC, not on 10.100.0.110)."""
try:
with socket.create_connection((LXC_IP, MQTT_PORT), timeout=1.0):
return True
except OSError:
return False
class MqttAclEntry(BaseModel):
user: Optional[str] = None # None = pattern applies to all
topic: str
access: str = Field(..., pattern="^(read|write|readwrite|deny)$")
def _broker_healthy() -> bool:
"""Pub a marker on secubox/healthcheck; success means the broker accepts auth."""
pw_file = SECRETS_DIR / "mqtt-secubox-api"
if not pw_file.exists():
return False
pw = pw_file.read_text().strip()
try:
rc = subprocess.run(
["mosquitto_pub", "-h", LXC_IP, "-p", str(MQTT_PORT),
"-u", "secubox-api", "-P", pw,
"-t", "secubox/healthcheck", "-m", str(int(time.time()))],
capture_output=True, text=True, timeout=3,
).returncode
except (subprocess.SubprocessError, FileNotFoundError):
return False
return rc == 0
class MqttConfig(BaseModel):
listener_port: int = Field(default=1883, ge=1, le=65535)
listener_address: str = "0.0.0.0"
allow_anonymous: bool = False
persistence: bool = True
log_type: str = "all"
max_connections: int = Field(default=100, ge=1, le=10000)
max_inflight_messages: int = Field(default=20, ge=1, le=65535)
max_queued_messages: int = Field(default=1000, ge=0, le=100000)
# ── Endpoints ───────────────────────────────────────────────────────────────
# ═══════════════════════════════════════════════════════════════════════════
# Helper Functions
# ═══════════════════════════════════════════════════════════════════════════
def _service_active(service: str) -> bool:
"""Check if a systemd service is active."""
result = subprocess.run(
["systemctl", "is-active", service],
capture_output=True, text=True
)
return result.stdout.strip() == "active"
def _service_enabled(service: str) -> bool:
"""Check if a systemd service is enabled."""
result = subprocess.run(
["systemctl", "is-enabled", service],
capture_output=True, text=True
)
return result.stdout.strip() == "enabled"
def _service_control(service: str, action: str) -> dict:
"""Control a systemd service."""
if action not in ("start", "stop", "restart", "reload"):
return {"success": False, "error": "Invalid action"}
result = subprocess.run(
["systemctl", action, service],
capture_output=True, text=True
)
@app.get("/components")
def components() -> dict:
lxc_st = _lxc_state()
broker_st = "running" if _broker_running() else "stopped"
health_st = "ok" if _broker_healthy() else "degraded"
return {
"success": result.returncode == 0,
"action": action,
"service": service,
"error": result.stderr[:200] if result.returncode != 0 else None
}
def _get_mosquitto_version() -> str:
"""Get Mosquitto version."""
try:
result = subprocess.run(
["mosquitto", "-h"],
capture_output=True, text=True
)
# Version is usually in first line
for line in (result.stdout + result.stderr).splitlines():
if "mosquitto version" in line.lower():
return line.strip()
return "unknown"
except FileNotFoundError:
return "not installed"
def _get_client_count() -> int:
"""Get number of connected clients from $SYS topics or logs."""
# Try mosquitto_sub to get $SYS/broker/clients/connected
try:
result = subprocess.run(
["mosquitto_sub", "-h", "localhost", "-t", "$SYS/broker/clients/connected",
"-C", "1", "-W", "2"],
capture_output=True, text=True, timeout=3
)
if result.returncode == 0 and result.stdout.strip().isdigit():
return int(result.stdout.strip())
except (subprocess.TimeoutExpired, FileNotFoundError, ValueError):
pass
return 0
def _get_message_stats() -> Dict[str, int]:
"""Get message statistics from $SYS topics."""
stats = {
"messages_received": 0,
"messages_sent": 0,
"bytes_received": 0,
"bytes_sent": 0,
"messages_stored": 0,
"subscriptions": 0
}
sys_topics = {
"$SYS/broker/messages/received": "messages_received",
"$SYS/broker/messages/sent": "messages_sent",
"$SYS/broker/bytes/received": "bytes_received",
"$SYS/broker/bytes/sent": "bytes_sent",
"$SYS/broker/messages/stored": "messages_stored",
"$SYS/broker/subscriptions/count": "subscriptions"
}
for topic, key in sys_topics.items():
try:
result = subprocess.run(
["mosquitto_sub", "-h", "localhost", "-t", topic, "-C", "1", "-W", "2"],
capture_output=True, text=True, timeout=3
)
if result.returncode == 0 and result.stdout.strip():
try:
stats[key] = int(result.stdout.strip())
except ValueError:
pass
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return stats
def _parse_mosquitto_conf() -> Dict[str, Any]:
"""Parse mosquitto.conf into a dictionary."""
config = {
"listener_port": 1883,
"listener_address": "0.0.0.0",
"allow_anonymous": False,
"persistence": True,
"log_type": "all",
"max_connections": 100,
"max_inflight_messages": 20,
"max_queued_messages": 1000,
"password_file": None,
"acl_file": None,
}
if not MOSQUITTO_CONF.exists():
return config
try:
content = MOSQUITTO_CONF.read_text()
for line in content.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if " " in line:
key, value = line.split(None, 1)
key = key.lower()
if key == "listener":
parts = value.split()
config["listener_port"] = int(parts[0])
if len(parts) > 1:
config["listener_address"] = parts[1]
elif key == "allow_anonymous":
config["allow_anonymous"] = value.lower() == "true"
elif key == "persistence":
config["persistence"] = value.lower() == "true"
elif key == "log_type":
config["log_type"] = value
elif key == "max_connections":
config["max_connections"] = int(value)
elif key == "max_inflight_messages":
config["max_inflight_messages"] = int(value)
elif key == "max_queued_messages":
config["max_queued_messages"] = int(value)
elif key == "password_file":
config["password_file"] = value
elif key == "acl_file":
config["acl_file"] = value
except Exception as e:
log.error(f"Error parsing mosquitto.conf: {e}")
return config
def _write_mosquitto_conf(config: MqttConfig) -> bool:
"""Write mosquitto.conf from model."""
try:
lines = [
"# Mosquitto configuration - managed by SecuBox",
f"# Generated: {datetime.now().isoformat()}",
"",
f"listener {config.listener_port} {config.listener_address}",
f"allow_anonymous {'true' if config.allow_anonymous else 'false'}",
f"persistence {'true' if config.persistence else 'false'}",
f"persistence_location /var/lib/mosquitto/",
f"log_type {config.log_type}",
f"max_connections {config.max_connections}",
f"max_inflight_messages {config.max_inflight_messages}",
f"max_queued_messages {config.max_queued_messages}",
"",
"# Authentication",
f"password_file {MOSQUITTO_PASSWD}",
f"acl_file {MOSQUITTO_ACL}",
"",
"# System topics",
"sys_interval 10",
]
MOSQUITTO_CONF.write_text("\n".join(lines) + "\n")
return True
except Exception as e:
log.error(f"Error writing mosquitto.conf: {e}")
return False
def _get_users() -> List[str]:
"""Get list of MQTT users from password file."""
users = []
if MOSQUITTO_PASSWD.exists():
try:
content = MOSQUITTO_PASSWD.read_text()
for line in content.splitlines():
if ":" in line:
username = line.split(":")[0]
if username:
users.append(username)
except Exception:
pass
return users
def _add_user(username: str, password: str) -> bool:
"""Add MQTT user using mosquitto_passwd."""
try:
# Create file if it doesn't exist
if not MOSQUITTO_PASSWD.exists():
MOSQUITTO_PASSWD.touch()
result = subprocess.run(
["mosquitto_passwd", "-b", str(MOSQUITTO_PASSWD), username, password],
capture_output=True, text=True
)
return result.returncode == 0
except FileNotFoundError:
log.error("mosquitto_passwd not found")
return False
def _delete_user(username: str) -> bool:
"""Delete MQTT user using mosquitto_passwd."""
try:
result = subprocess.run(
["mosquitto_passwd", "-D", str(MOSQUITTO_PASSWD), username],
capture_output=True, text=True
)
return result.returncode == 0
except FileNotFoundError:
return False
def _get_acl() -> List[Dict[str, str]]:
"""Parse ACL file into list of entries."""
entries = []
if not MOSQUITTO_ACL.exists():
return entries
try:
content = MOSQUITTO_ACL.read_text()
current_user = None
for line in content.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("user "):
current_user = line.split(None, 1)[1]
elif line.startswith("pattern "):
# Pattern applies to current user or all if none set
parts = line.split(None, 2)
if len(parts) >= 2:
access = parts[1] if parts[1] in ("read", "write", "readwrite", "deny") else "readwrite"
topic = parts[2] if len(parts) > 2 else parts[1]
entries.append({
"user": current_user,
"type": "pattern",
"access": access,
"topic": topic
})
elif line.startswith("topic "):
parts = line.split(None, 2)
if len(parts) >= 2:
access = parts[1] if parts[1] in ("read", "write", "readwrite", "deny") else "readwrite"
topic = parts[2] if len(parts) > 2 else parts[1]
entries.append({
"user": current_user,
"type": "topic",
"access": access,
"topic": topic
})
except Exception as e:
log.error(f"Error parsing ACL: {e}")
return entries
def _write_acl(entries: List[Dict[str, str]]) -> bool:
"""Write ACL file from list of entries."""
try:
lines = [
"# Mosquitto ACL - managed by SecuBox",
f"# Generated: {datetime.now().isoformat()}",
""
]
# Group by user
by_user: Dict[Optional[str], List[Dict]] = {}
for entry in entries:
user = entry.get("user")
if user not in by_user:
by_user[user] = []
by_user[user].append(entry)
# Write global rules first (user = None)
if None in by_user:
lines.append("# Global rules")
for entry in by_user[None]:
entry_type = entry.get("type", "topic")
access = entry.get("access", "readwrite")
topic = entry.get("topic", "#")
lines.append(f"{entry_type} {access} {topic}")
lines.append("")
del by_user[None]
# Write per-user rules
for user, user_entries in by_user.items():
lines.append(f"user {user}")
for entry in user_entries:
entry_type = entry.get("type", "topic")
access = entry.get("access", "readwrite")
topic = entry.get("topic", "#")
lines.append(f"{entry_type} {access} {topic}")
lines.append("")
MOSQUITTO_ACL.write_text("\n".join(lines))
return True
except Exception as e:
log.error(f"Error writing ACL: {e}")
return False
# ═══════════════════════════════════════════════════════════════════════════
# Background Stats Collection
# ═══════════════════════════════════════════════════════════════════════════
async def _refresh_stats_cache():
"""Background task to refresh stats cache."""
global _stats_cache
while True:
try:
running = _service_active("mosquitto")
stats = {
"running": running,
"clients": 0,
"messages": {},
"timestamp": datetime.now().isoformat()
}
if running:
stats["clients"] = _get_client_count()
stats["messages"] = _get_message_stats()
_stats_cache = stats
# Persist to file
try:
STATS_CACHE_FILE.write_text(json.dumps(stats, indent=2))
except Exception:
pass
except Exception as e:
log.error(f"Stats refresh error: {e}")
await asyncio.sleep(60)
@app.on_event("startup")
async def startup_event():
"""Start background tasks."""
global _stats_task
_stats_task = asyncio.create_task(_refresh_stats_cache())
log.info("SecuBox MQTT API started")
@app.on_event("shutdown")
async def shutdown_event():
"""Stop background tasks."""
global _stats_task
if _stats_task:
_stats_task.cancel()
# ═══════════════════════════════════════════════════════════════════════════
# API Endpoints - Status & Health
# ═══════════════════════════════════════════════════════════════════════════
@router.get("/status")
async def get_status():
"""Get MQTT broker status."""
running = _service_active("mosquitto")
enabled = _service_enabled("mosquitto")
version = _get_mosquitto_version()
status = {
"running": running,
"enabled": enabled,
"version": version,
"clients": 0,
"messages_received": 0,
"messages_sent": 0,
}
if running:
if _stats_cache:
status["clients"] = _stats_cache.get("clients", 0)
messages = _stats_cache.get("messages", {})
status["messages_received"] = messages.get("messages_received", 0)
status["messages_sent"] = messages.get("messages_sent", 0)
else:
status["clients"] = _get_client_count()
msg_stats = _get_message_stats()
status["messages_received"] = msg_stats.get("messages_received", 0)
status["messages_sent"] = msg_stats.get("messages_sent", 0)
return status
@router.get("/health")
async def health():
"""Health check endpoint."""
running = _service_active("mosquitto")
return {
"status": "ok" if running else "degraded",
"module": "mqtt",
"version": "1.0.0",
"running": running
"version": "2.4.0",
"components": [
{"name": "lxc", "state": lxc_st, "detail": f"{LXC_NAME} @ {LXC_IP} on br-lxc"},
{"name": "broker", "state": broker_st, "detail": f"mosquitto, port {MQTT_PORT}"},
{"name": "health", "state": health_st, "detail": "pub round-trip via secubox-api"},
],
}
# ═══════════════════════════════════════════════════════════════════════════
# API Endpoints - Clients & Topics
# ═══════════════════════════════════════════════════════════════════════════
@router.get("/clients")
async def get_clients(user=Depends(require_jwt)):
"""Get connected MQTT clients list."""
if not _service_active("mosquitto"):
return {"clients": [], "error": "Broker not running"}
clients = []
# Get clients from $SYS topics
try:
# Get connected clients count
count = _get_client_count()
# Try to get more info from logs or $SYS
result = subprocess.run(
["journalctl", "-u", "mosquitto", "-n", "100", "--no-pager", "-o", "cat"],
capture_output=True, text=True, timeout=5
)
# Parse connection logs for client info
client_pattern = re.compile(r"New connection from (\S+) on port \d+")
client_ids = set()
for line in result.stdout.splitlines():
match = client_pattern.search(line)
if match:
client_ids.add(match.group(1))
# Build client list from recent connections
for i, client_ip in enumerate(list(client_ids)[:50]):
clients.append({
"id": f"client_{i}",
"address": client_ip,
"connected": True,
"protocol": "mqtt"
})
except Exception as e:
log.error(f"Error getting clients: {e}")
return {
"clients": clients,
"total": len(clients),
"connected_count": _get_client_count()
}
@router.get("/topics")
async def get_topics(user=Depends(require_jwt)):
"""Get active MQTT topics."""
if not _service_active("mosquitto"):
return {"topics": [], "error": "Broker not running"}
topics = []
# Get topics from $SYS
sys_topics = [
"$SYS/broker/version",
"$SYS/broker/uptime",
"$SYS/broker/clients/connected",
"$SYS/broker/clients/total",
"$SYS/broker/messages/received",
"$SYS/broker/messages/sent",
"$SYS/broker/subscriptions/count",
]
for topic in sys_topics:
try:
result = subprocess.run(
["mosquitto_sub", "-h", "localhost", "-t", topic, "-C", "1", "-W", "2"],
capture_output=True, text=True, timeout=3
)
if result.returncode == 0:
topics.append({
"name": topic,
"type": "system",
"last_value": result.stdout.strip(),
"retained": True
})
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
return {
"topics": topics,
"total": len(topics),
"note": "Shows $SYS topics. User topics are not tracked without wildcard subscription."
}
# ═══════════════════════════════════════════════════════════════════════════
# API Endpoints - Statistics
# ═══════════════════════════════════════════════════════════════════════════
@router.get("/stats")
async def get_stats(user=Depends(require_jwt)):
"""Get detailed MQTT broker statistics."""
if not _service_active("mosquitto"):
return {
"running": False,
"error": "Broker not running"
}
# Use cache if available
if _stats_cache:
return _stats_cache
# Otherwise collect fresh
stats = {
"running": True,
"clients": _get_client_count(),
"messages": _get_message_stats(),
"timestamp": datetime.now().isoformat()
}
return stats
# ═══════════════════════════════════════════════════════════════════════════
# API Endpoints - Service Control
# ═══════════════════════════════════════════════════════════════════════════
@router.post("/start")
async def start_broker(user=Depends(require_jwt)):
"""Start Mosquitto broker."""
log.info(f"Starting mosquitto (user: {user})")
return _service_control("mosquitto", "start")
@router.post("/stop")
async def stop_broker(user=Depends(require_jwt)):
"""Stop Mosquitto broker."""
log.info(f"Stopping mosquitto (user: {user})")
return _service_control("mosquitto", "stop")
@router.post("/restart")
async def restart_broker(user=Depends(require_jwt)):
"""Restart Mosquitto broker."""
log.info(f"Restarting mosquitto (user: {user})")
return _service_control("mosquitto", "restart")
# ═══════════════════════════════════════════════════════════════════════════
# API Endpoints - Configuration
# ═══════════════════════════════════════════════════════════════════════════
@router.get("/config")
async def get_config_endpoint(user=Depends(require_jwt)):
"""Get Mosquitto configuration."""
config = _parse_mosquitto_conf()
return {
"config": config,
"config_file": str(MOSQUITTO_CONF),
"exists": MOSQUITTO_CONF.exists()
}
@router.post("/config")
async def update_config(config: MqttConfig, user=Depends(require_jwt)):
"""Update Mosquitto configuration."""
log.info(f"Updating mosquitto config (user: {user})")
# Backup existing config
if MOSQUITTO_CONF.exists():
backup_path = MOSQUITTO_CONF.with_suffix(f".conf.{datetime.now().strftime('%Y%m%d%H%M%S')}")
try:
backup_path.write_text(MOSQUITTO_CONF.read_text())
except Exception:
pass
success = _write_mosquitto_conf(config)
if success:
# Reload mosquitto
reload_result = _service_control("mosquitto", "reload")
return {
"success": True,
"config": config.model_dump(),
"reload": reload_result
}
return {
"success": False,
"error": "Failed to write configuration"
}
# ═══════════════════════════════════════════════════════════════════════════
# API Endpoints - User Management
# ═══════════════════════════════════════════════════════════════════════════
@router.get("/users")
async def list_users(user=Depends(require_jwt)):
"""List MQTT users."""
users = _get_users()
return {
"users": [{"username": u, "enabled": True} for u in users],
"total": len(users),
"password_file": str(MOSQUITTO_PASSWD)
}
@router.post("/users")
async def add_user_endpoint(mqtt_user: MqttUser, user=Depends(require_jwt)):
"""Add MQTT user."""
log.info(f"Adding MQTT user: {mqtt_user.username} (by: {user})")
existing = _get_users()
if mqtt_user.username in existing:
raise HTTPException(400, f"User {mqtt_user.username} already exists")
success = _add_user(mqtt_user.username, mqtt_user.password)
if success:
# Reload mosquitto to pick up new user
_service_control("mosquitto", "reload")
return {
"success": True,
"username": mqtt_user.username,
"message": "User created successfully"
}
raise HTTPException(500, "Failed to create user")
@router.delete("/users/{username}")
async def delete_user_endpoint(username: str, user=Depends(require_jwt)):
"""Delete MQTT user."""
log.info(f"Deleting MQTT user: {username} (by: {user})")
existing = _get_users()
if username not in existing:
raise HTTPException(404, f"User {username} not found")
success = _delete_user(username)
if success:
# Reload mosquitto
_service_control("mosquitto", "reload")
return {
"success": True,
"username": username,
"message": "User deleted successfully"
}
raise HTTPException(500, "Failed to delete user")
# ═══════════════════════════════════════════════════════════════════════════
# API Endpoints - ACL Management
# ═══════════════════════════════════════════════════════════════════════════
@router.get("/acl")
async def get_acl_endpoint(user=Depends(require_jwt)):
"""Get MQTT access control list."""
entries = _get_acl()
return {
"entries": entries,
"total": len(entries),
"acl_file": str(MOSQUITTO_ACL)
}
@router.post("/acl")
async def update_acl(entry: MqttAclEntry, user=Depends(require_jwt)):
"""Add or update ACL entry."""
log.info(f"Updating ACL: {entry.topic} (by: {user})")
entries = _get_acl()
# Add new entry
entries.append({
"user": entry.user,
"type": "topic",
"access": entry.access,
"topic": entry.topic
})
success = _write_acl(entries)
if success:
# Reload mosquitto
_service_control("mosquitto", "reload")
return {
"success": True,
"entry": entry.model_dump(),
"message": "ACL updated successfully"
}
raise HTTPException(500, "Failed to update ACL")
@router.delete("/acl")
async def delete_acl_entry(topic: str, acl_user: Optional[str] = Query(None), user=Depends(require_jwt)):
"""Delete ACL entry."""
log.info(f"Deleting ACL entry: {topic} for user {acl_user} (by: {user})")
entries = _get_acl()
original_count = len(entries)
# Filter out matching entry
entries = [e for e in entries if not (e.get("topic") == topic and e.get("user") == acl_user)]
if len(entries) == original_count:
raise HTTPException(404, "ACL entry not found")
success = _write_acl(entries)
if success:
_service_control("mosquitto", "reload")
return {
"success": True,
"message": "ACL entry deleted"
}
raise HTTPException(500, "Failed to update ACL")
# ═══════════════════════════════════════════════════════════════════════════
# API Endpoints - Logs
# ═══════════════════════════════════════════════════════════════════════════
@router.get("/logs")
async def get_logs(lines: int = Query(default=50, le=500), user=Depends(require_jwt)):
"""Get Mosquitto logs."""
try:
result = subprocess.run(
["journalctl", "-u", "mosquitto", "-n", str(lines), "--no-pager"],
capture_output=True, text=True, timeout=10
)
return {
"lines": result.stdout.splitlines(),
"total": len(result.stdout.splitlines())
}
except Exception as e:
return {"error": str(e), "lines": []}
# Include router
app.include_router(router)
@app.get("/status")
def status() -> dict:
c = components()
states = {x["name"]: x["state"] for x in c["components"]}
if states.get("lxc") == "running" and states.get("broker") == "running" and states.get("health") == "ok":
overall = "green"
elif states.get("lxc") == "running":
overall = "yellow"
else:
overall = "red"
return {"module": "mqtt", "version": "2.4.0", "overall": overall, "states": states}
@app.get("/access")
def access() -> dict:
"""List ACL users + their topic globs.
Reads from the host-side cache /etc/secubox/mqtt-acl.cached written
by install-lxc.sh we can't lxc-attach as the unprivileged `secubox`
user. The cache is regenerated on every `mqttctl install`."""
cache = Path("/etc/secubox/mqtt-acl.cached")
if not cache.exists():
raise HTTPException(503, "ACL cache absent — run 'mqttctl install' on the host")
users = []
current_user = None
for raw in cache.read_text().splitlines():
line = raw.strip()
if line.startswith("user "):
current_user = line.split(None, 1)[1].strip()
elif current_user and line.startswith("topic "):
parts = line.split(None, 2)
mode = parts[1] if len(parts) >= 3 else "?"
topic = parts[2] if len(parts) >= 3 else parts[1]
users.append({"user": current_user, "mode": mode, "topic": topic, "scope": "lan"})
current_user = None
return {"module": "mqtt", "access": users}
@app.get("/healthz")
def healthz() -> dict:
"""Cheap liveness probe — doesn't poke the broker, just confirms FastAPI is alive."""
return {"ok": True, "module": "mqtt", "version": "2.4.0"}

View File

@ -0,0 +1,50 @@
# /etc/secubox/mqtt.toml — SecuBox MQTT (Mosquitto broker) configuration
[lxc]
name = "mqtt"
ip = "10.100.0.110"
gateway = "10.100.0.1"
bridge = "br-lxc"
path = "/data/lxc"
debian_suite = "bookworm"
[broker]
# Mosquitto listen port — both inside the LXC and on the host LAN
# (DNAT'd via /etc/nftables.d/secubox-mqtt-dnat.nft).
port = 1883
# TLS is OUT OF SCOPE for v2.4.0 — terminate at HAProxy if needed.
tls = false
# ACL services — keep this list aligned with lib/mqtt/install-lxc.sh.
# Each service gets:
# - a randomly generated password at /etc/secubox/secrets/mqtt-<user>
# - a readwrite ACL on <topic>
#
# Adding a new service to this file is NOT enough — also edit ACL_SERVICES
# in lib/mqtt/install-lxc.sh, rebuild the package and re-run `mqttctl install`.
# (Operator-level ACL management via /api/v1/mqtt/ is deferred to v2.5.)
[[acl]]
user = "z2m"
topic = "zigbee2mqtt/#"
[[acl]]
user = "sentinelle"
topic = "sentinelle/#"
[[acl]]
user = "domoticz"
topic = "domoticz/#"
[[acl]]
user = "homeassistant"
topic = "homeassistant/#"
[[acl]]
user = "secubox-api"
topic = "#"
[exposure]
# The broker is LAN-only (no nginx route, no public DNS). For a future
# remote-MQTT exposure, the canonical pattern is HAProxy TCP frontend +
# mqtts:// 8883 (TLS 1.3). Not in v2.4.0.
lan_only = true

View File

@ -1,3 +1,33 @@
secubox-mqtt (2.4.0-1~bookworm1) bookworm; urgency=medium
* v2.4.0 rewrite-in-place: move Mosquitto into a dedicated LXC at
10.100.0.110 on br-lxc (matches the canonical SecuBox module pattern
of authelia/yacy/lyrion). The v1.0.0 host-bound layout is gone.
* Hard limits per docs/superpowers/specs/2026-05-20-secubox-zigbee-mqtt-iot-stack.md:
- allow_anonymous false
- per-service ACL (z2m, sentinelle, domoticz, homeassistant, secubox-api)
- randomly-generated passwords stored under
/etc/secubox/secrets/mqtt-<service> (0640 root:secubox)
- LXC binds 0.0.0.0:1883; LAN exposure via persistent nftables DNAT.
* mqttctl follows the SecuBox CTL grammar:
- introspection: components / status / access
- lifecycle: install / reload (+ stubs repair/wizard/uninstall)
- module nouns: topic / client / acl / user (list-only in v2.4.0)
* Host control plane: FastAPI on /run/secubox/mqtt.sock with
/components, /status, /access, /reload, /healthz. Reverse-proxied at
/api/v1/mqtt/ on the canonical hub vhost.
* Persistent nftables DNAT in /etc/nftables.d/secubox-mqtt-dnat.nft
routes LAN traffic on TCP :1883 to the LXC (same pattern as the
slimproto DNAT shipped by secubox-lyrion v1.0.3 / #248).
* Out of scope for v2.4.0 (deferred to v2.5):
- 4R double-buffer on the broker config
- Jinja2 templating of mosquitto.conf
- operator-level ACL/user management via /api/v1/mqtt/
- TLS 1.3 on port 8883
* Closes #240
-- Gerald KERMA <devel@cybermind.fr> Wed, 20 May 2026 22:30:00 +0200
secubox-mqtt (1.0.0-1~bookworm1) bookworm; urgency=medium
* Initial release

View File

@ -5,20 +5,29 @@ Maintainer: Gerald KERMA <devel@cybermind.fr>
Build-Depends: debhelper-compat (= 13)
Standards-Version: 4.6.2
Homepage: https://cybermind.fr/secubox
Rules-Requires-Root: no
Package: secubox-mqtt
Architecture: all
Depends: ${misc:Depends},
secubox-core (>= 1.0),
mosquitto,
mosquitto-clients
Description: SecuBox MQTT — Mosquitto Broker Management
MQTT broker management module for SecuBox providing:
- Broker status and health monitoring
- Client connection tracking
- Topic monitoring
- User and ACL management
- Configuration management via web UI
secubox-core (>= 1.0),
lxc,
lxc-templates,
debootstrap,
python3-uvicorn,
python3-fastapi,
python3-toml,
openssl
Description: SecuBox MQTT — Mosquitto broker (WALL layer)
Hosts a hardened Mosquitto 2.x broker in a Debian bookworm LXC at
10.100.0.110 on br-lxc.
.
Part of SecuBox-Deb platform — CyberMind.fr
No anonymous access (allow_anonymous false). Per-service authentication
via mosquitto_passwd-managed credentials and per-topic ACL — z2m,
sentinelle, domoticz and the SecuBox internal API each get their own
user with a randomly-generated password stored under
/etc/secubox/secrets/mqtt-* (mode 0600, owner root:secubox).
.
Host-side control plane: FastAPI on Unix socket /run/secubox/mqtt.sock
+ nginx route /api/v1/mqtt/ on the canonical hub vhost. CTL grammar:
mqttctl components / status / access + install / reload + topic /
client / acl / user nouns.

View File

@ -1 +0,0 @@
debian/secubox-mqtt.service usr/lib/systemd/system/

View File

@ -1,49 +1,37 @@
#!/bin/bash
#!/bin/sh
# SecuBox MQTT — post-install
set -e
case "$1" in
configure)
# Ensure secubox user exists (created by secubox-core)
id -u secubox >/dev/null 2>&1 || \
adduser --system --group --no-create-home \
--home /var/lib/secubox --shell /usr/sbin/nologin secubox
configure)
# secubox system user/group (idempotent, matches sibling modules)
getent group secubox >/dev/null || groupadd --system secubox
getent passwd secubox >/dev/null || useradd --system --gid secubox \
--home /var/lib/secubox --no-create-home --shell /usr/sbin/nologin secubox
# Create runtime directories
install -d -o secubox -g secubox -m 750 /run/secubox
install -d -o secubox -g secubox -m 750 /var/lib/secubox/mqtt
install -d -m 0770 -o root -g secubox /etc/secubox
install -d -m 0750 -o root -g secubox /etc/secubox/secrets
install -d -m 0755 -o secubox -g secubox /var/lib/secubox/mqtt
install -d -m 0755 -o secubox -g secubox /var/log/secubox
# Create mosquitto directories if needed
install -d -o mosquitto -g mosquitto -m 750 /var/lib/mosquitto
install -d -m 755 /etc/mosquitto
# Seed config from example if no live config exists
if [ ! -f /etc/secubox/mqtt.toml ] && [ -f /etc/secubox/mqtt.toml.example ]; then
cp /etc/secubox/mqtt.toml.example /etc/secubox/mqtt.toml
chmod 640 /etc/secubox/mqtt.toml
chown root:secubox /etc/secubox/mqtt.toml
fi
# Create default password file if it doesn't exist
if [ ! -f /etc/mosquitto/passwd ]; then
touch /etc/mosquitto/passwd
chmod 600 /etc/mosquitto/passwd
chown mosquitto:mosquitto /etc/mosquitto/passwd
fi
# Reload nginx if it's running and our snippet parses
if systemctl is-active --quiet nginx 2>/dev/null; then
nginx -t >/dev/null 2>&1 && systemctl reload nginx 2>/dev/null || true
fi
# Create default ACL file if it doesn't exist
if [ ! -f /etc/mosquitto/acl ]; then
cat > /etc/mosquitto/acl << 'EOF'
# Mosquitto ACL - managed by SecuBox
# Default: allow $SYS topics for all authenticated users
pattern read $SYS/#
EOF
chmod 644 /etc/mosquitto/acl
chown mosquitto:mosquitto /etc/mosquitto/acl
fi
# Enable and start services
systemctl daemon-reload
systemctl enable secubox-mqtt.service
systemctl start secubox-mqtt.service || true
# Ensure nginx config directory exists
install -d -m 755 /etc/nginx/secubox.d
# Reload nginx if installed
systemctl reload nginx 2>/dev/null || true
;;
systemctl daemon-reload 2>/dev/null || true
systemctl enable secubox-mqtt.service 2>/dev/null || true
# Do not start the FastAPI before the LXC is provisioned;
# `mqttctl install` will start the service after the LXC is up.
;;
esac
#DEBHELPER#
exit 0

View File

@ -0,0 +1,18 @@
#!/bin/sh
# SecuBox MQTT — post-remove
set -e
case "$1" in
purge)
# Only on purge — preserve operator data on plain remove/upgrade.
rm -f /etc/secubox/mqtt.toml /etc/secubox/mqtt.toml.example
rm -rf /var/lib/secubox/mqtt
# Secrets are NOT purged automatically — operator must `rm /etc/secubox/secrets/mqtt-*`
# manually after confirming no dependent service still relies on them.
# LXC is left intact: operator removes it explicitly via
# `mqttctl uninstall` (TODO: future verb).
;;
esac
#DEBHELPER#
exit 0

View File

@ -1,9 +1,14 @@
#!/bin/bash
#!/bin/sh
# SecuBox MQTT — pre-remove
set -e
case "$1" in
remove|upgrade|deconfigure)
systemctl stop secubox-mqtt.service || true
systemctl disable secubox-mqtt.service || true
;;
remove|deconfigure|upgrade)
systemctl stop secubox-mqtt.service 2>/dev/null || true
systemctl disable secubox-mqtt.service 2>/dev/null || true
# We do not destroy the LXC here — purge does that via postrm if asked.
;;
esac
#DEBHELPER#
exit 0

View File

@ -3,12 +3,27 @@
dh $@
override_dh_auto_install:
install -d debian/secubox-mqtt/usr/lib/secubox/mqtt/
# Host control plane (FastAPI)
install -d debian/secubox-mqtt/usr/lib/secubox/mqtt
cp -r api debian/secubox-mqtt/usr/lib/secubox/mqtt/
# CTL
install -d debian/secubox-mqtt/usr/sbin
install -m 755 sbin/mqttctl debian/secubox-mqtt/usr/sbin/mqttctl
# Web UI
install -d debian/secubox-mqtt/usr/share/secubox/www
[ -d www ] && cp -r www/. debian/secubox-mqtt/usr/share/secubox/www/ || true
# Menu entry
install -d debian/secubox-mqtt/usr/share/secubox/menu.d
[ -d menu.d ] && cp -r menu.d/. debian/secubox-mqtt/usr/share/secubox/menu.d/ || true
# Modular nginx config
# nginx snippet — install to BOTH secubox.d/ and secubox-routes.d/ (per v2.11.1 lesson)
install -d debian/secubox-mqtt/etc/nginx/secubox.d
[ -f nginx/mqtt.conf ] && cp nginx/mqtt.conf debian/secubox-mqtt/etc/nginx/secubox.d/ || true
[ -f nginx/mqtt.conf ] && cp nginx/mqtt.conf debian/secubox-mqtt/etc/nginx/secubox.d/ && (install -d debian/secubox-mqtt/etc/nginx/secubox-routes.d && cp nginx/mqtt.conf debian/secubox-mqtt/etc/nginx/secubox-routes.d/) || true
# Config example
install -d debian/secubox-mqtt/etc/secubox
[ -f conf/mqtt.toml.example ] && cp conf/mqtt.toml.example debian/secubox-mqtt/etc/secubox/mqtt.toml.example || true
# LXC bootstrap
install -d debian/secubox-mqtt/usr/share/secubox/lib/mqtt
[ -d lib/mqtt ] && cp -r lib/mqtt/. debian/secubox-mqtt/usr/share/secubox/lib/mqtt/ || true
# install-lxc.sh must be executable (cp -r doesn't always preserve +x from git)
[ -f debian/secubox-mqtt/usr/share/secubox/lib/mqtt/install-lxc.sh ] && \
chmod 755 debian/secubox-mqtt/usr/share/secubox/lib/mqtt/install-lxc.sh

View File

@ -1,26 +1,28 @@
[Unit]
Description=SecuBox MQTT Broker Management API
After=network.target secubox-core.service mosquitto.service
Requires=secubox-core.service
Wants=mosquitto.service
Description=SecuBox MQTT — host control plane API
Documentation=file:///usr/share/doc/secubox-mqtt/README
After=network.target secubox-core.service
Wants=secubox-core.service
[Service]
UMask=0000
UMask=0007
Type=simple
User=secubox
Group=secubox
WorkingDirectory=/usr/lib/secubox/mqtt
ExecStart=/usr/bin/python3 -m uvicorn api.main:app \
--uds /run/secubox/mqtt.sock \
--log-level warning
RuntimeDirectory=secubox
RuntimeDirectoryMode=0755
RuntimeDirectoryPreserve=yes
ExecStartPre=+/bin/mkdir -p /etc/secubox
ExecStartPre=+/bin/chown secubox:secubox /etc/secubox
ExecStart=/usr/bin/python3 -m uvicorn api.main:app --uds /run/secubox/mqtt.sock --log-level warning
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
RuntimeDirectory=secubox
RuntimeDirectoryMode=0775
ReadWritePaths=/run/secubox /var/lib/secubox /etc/secubox /etc/mosquitto
LogsDirectory=secubox
LogsDirectoryMode=0755
ReadWritePaths=/run/secubox /var/lib/secubox /etc/secubox /var/log/secubox
SupplementaryGroups=secubox
[Install]
WantedBy=multi-user.target

View File

@ -1,17 +0,0 @@
# debian/secubox.yaml
# Auto-generated from debian/control
name: secubox-mqtt
category: iot
tier: lite
description: "SecuBox MQTT — Mosquitto Broker Management"
depends:
- secubox-core
api:
socket: /run/secubox/mqtt.sock
health: /api/v1/mqtt/health
ui:
path: /srv/secubox/www/mqtt

View File

@ -0,0 +1,315 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# SecuBox-Deb :: secubox-mqtt :: install-lxc.sh
#
# Idempotent LXC bootstrap for Mosquitto 2.x. Safe to re-run.
# Inherits all 11 install-lxc fixes from v2.11.1 + Authelia v1.0.1 + Lyrion v1.0.3.
#
# Hard limits enforced (per docs/superpowers/specs/2026-05-20-secubox-zigbee-mqtt-iot-stack.md):
# - allow_anonymous false (never true)
# - acl_file: per-service topic ACL
# - User systemd: mosquitto user, NoNewPrivileges baked into upstream unit
# - Listener bound to 0.0.0.0 inside the LXC, exposed to LAN via DNAT (port 1883)
# Follows docs/MODULE-GUIDELINES.md §3 + §7.
set -euo pipefail
readonly LXC_NAME="${SECUBOX_LXC_NAME:-mqtt}"
readonly LXC_IP="${SECUBOX_LXC_IP:-10.100.0.110}"
readonly LXC_PATH="${SECUBOX_LXC_PATH:-/data/lxc}"
readonly LXC_BRIDGE="${SECUBOX_LXC_BRIDGE:-br-lxc}"
readonly LXC_GW="${SECUBOX_LXC_GW:-10.100.0.1}"
readonly DEBIAN_SUITE="${SECUBOX_DEBIAN_SUITE:-bookworm}"
readonly MQTT_PORT="${SECUBOX_MQTT_PORT:-1883}"
readonly STATE_DIR="${SECUBOX_STATE_DIR:-/var/lib/secubox/mqtt}"
readonly SECRETS_DIR="${SECUBOX_SECRETS_DIR:-/etc/secubox/secrets}"
readonly SENTINEL="$STATE_DIR/.lxc-provisioned"
# ACL services — keep in sync with sbin/mqttctl access-list.
# Format: "user:topic-glob"
readonly ACL_SERVICES=(
"z2m:zigbee2mqtt/#"
"sentinelle:sentinelle/#"
"domoticz:domoticz/#"
"homeassistant:homeassistant/#"
"secubox-api:#"
)
log() { printf '[mqtt-install] %s\n' "$*"; }
fail() { printf '[mqtt-install] ERROR: %s\n' "$*" >&2; exit 1; }
require_cmds() {
for c in lxc-create lxc-info lxc-start lxc-attach openssl nft; do
command -v "$c" >/dev/null 2>&1 || fail "$c not installed"
done
}
ensure_dirs() {
install -d -m 0755 -o root -g root "$LXC_PATH"
install -d -m 0755 -o secubox -g secubox "$STATE_DIR"
install -d -m 0700 -o root -g root "$SECRETS_DIR"
}
ensure_bridge() {
if ! ip link show "$LXC_BRIDGE" >/dev/null 2>&1; then
log "Creating bridge $LXC_BRIDGE @ ${LXC_GW}/24 ..."
ip link add name "$LXC_BRIDGE" type bridge
ip addr add "${LXC_GW}/24" dev "$LXC_BRIDGE"
ip link set "$LXC_BRIDGE" up
fi
}
ensure_masquerade() {
if ! nft list table ip lxc 2>/dev/null | grep -q 'saddr 10.100.0.0/24'; then
log "Adding nftables MASQUERADE for 10.100.0.0/24 ..."
nft 'add table ip lxc' 2>/dev/null || true
nft 'add chain ip lxc postrouting { type nat hook postrouting priority srcnat ; policy accept ; }' 2>/dev/null || true
nft 'add rule ip lxc postrouting ip saddr 10.100.0.0/24 ip daddr != 10.100.0.0/24 counter masquerade' 2>/dev/null || true
fi
}
# DNAT broker port from the LAN to the LXC. Mosquitto in the LXC binds
# 0.0.0.0:1883 but the bridge is not reachable from the LAN. Same pattern
# as secubox-lyrion :3483 (slimproto). Persistent file in /etc/nftables.d/.
ensure_broker_dnat() {
local nft_file="/etc/nftables.d/secubox-mqtt-dnat.nft"
local lan_iface="${SECUBOX_LAN_IFACE:-lan0}"
log "Ensuring broker DNAT ${lan_iface}:${MQTT_PORT}${LXC_IP}:${MQTT_PORT} ..."
install -d -m 0755 /etc/nftables.d
cat > "$nft_file" <<NFT
# /etc/nftables.d/secubox-mqtt-dnat.nft
# DNAT MQTT broker (TCP :${MQTT_PORT}) from LAN to the Mosquitto LXC.
# Lets LAN clients (z2m, sentinelle, domoticz, homeassistant) reach the
# broker without bridging the LXC network into the LAN.
# Generated by secubox-mqtt install-lxc.sh (#240).
table inet secubox-mqtt {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname "${lan_iface}" tcp dport ${MQTT_PORT} dnat ip to ${LXC_IP}:${MQTT_PORT} comment "mqtt-broker"
}
}
NFT
chmod 0644 "$nft_file"
nft 'delete table inet secubox-mqtt' 2>/dev/null || true
nft -f "$nft_file" 2>&1
}
lxc_state() {
lxc-info -n "$LXC_NAME" -P "$LXC_PATH" 2>/dev/null \
| awk -F: '/^State:/ { gsub(/ /,"",$2); print tolower($2) }'
}
create_lxc() {
if [ -d "$LXC_PATH/$LXC_NAME/rootfs" ]; then
log "LXC '$LXC_NAME' already exists — skipping debootstrap"
return
fi
log "Creating LXC '$LXC_NAME' (debian $DEBIAN_SUITE) ..."
lxc-create -n "$LXC_NAME" -t download -P "$LXC_PATH" \
-- --dist debian --release "$DEBIAN_SUITE" \
--arch "$(dpkg --print-architecture)"
}
write_lxc_config() {
log "Pinning network: $LXC_IP/24 on $LXC_BRIDGE"
cat > "$LXC_PATH/$LXC_NAME/config" <<EOF
# SecuBox-managed — see secubox-mqtt / install-lxc.sh
lxc.uts.name = $LXC_NAME
lxc.net.0.type = veth
lxc.net.0.link = $LXC_BRIDGE
lxc.net.0.flags = up
lxc.net.0.ipv4.address = $LXC_IP/24
lxc.net.0.ipv4.gateway = $LXC_GW
lxc.net.0.name = eth0
lxc.rootfs.path = dir:$LXC_PATH/$LXC_NAME/rootfs
lxc.include = /usr/share/lxc/config/common.conf
lxc.apparmor.profile = generated
lxc.start.auto = 1
lxc.start.delay = 5
EOF
}
start_lxc() {
[ "$(lxc_state)" = "running" ] && { log "Already running"; return; }
log "Starting LXC '$LXC_NAME' ..."
lxc-start -n "$LXC_NAME" -P "$LXC_PATH"
}
wait_for_network() {
log "Waiting for LXC network ..."
for _ in $(seq 1 30); do
lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- ping -c1 -W1 "$LXC_GW" >/dev/null 2>&1 && return 0
sleep 1
done
fail "LXC '$LXC_NAME' did not reach $LXC_GW within 30s"
}
ensure_resolv() {
log "Seeding /etc/resolv.conf in LXC ..."
lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- sh -c '
rm -f /etc/resolv.conf
printf "nameserver 1.1.1.1\nnameserver 9.9.9.9\n" > /etc/resolv.conf
'
}
# ── Mosquitto install inside LXC ────────────────────────────────────────────
# Debian ships mosquitto 2.0.x in bookworm (`mosquitto`, `mosquitto-clients`).
# That's recent enough — no need for an external apt repo.
install_mosquitto_in_lxc() {
log "Installing Mosquitto in '$LXC_NAME' ..."
lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- bash -e <<'INNER'
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update -q
apt-get install -y --no-install-recommends \
ca-certificates mosquitto mosquitto-clients
# The Debian package ships /etc/mosquitto/mosquitto.conf with an
# `include_dir /etc/mosquitto/conf.d` directive — we drop our own
# conf.d snippet rather than rewriting mosquitto.conf.
systemctl daemon-reload
systemctl enable mosquitto
INNER
}
# Generate a strong random password for each ACL service. Stored on the host
# under /etc/secubox/secrets/mqtt-<service> (mode 0600), then loaded into
# the LXC via lxc-attach + mosquitto_passwd. Idempotent: only writes a new
# password if the file doesn't exist (so re-running the install never
# rotates working credentials).
generate_passwords() {
install -d -m 0750 -o root -g secubox "$SECRETS_DIR"
for entry in "${ACL_SERVICES[@]}"; do
local user="${entry%%:*}"
local file="$SECRETS_DIR/mqtt-${user}"
if [ ! -f "$file" ]; then
local pw; pw=$(openssl rand -hex 16)
printf '%s\n' "$pw" > "$file"
chmod 0640 "$file"
chown root:secubox "$file"
log " · generated mqtt-${user} (32 hex chars)"
fi
done
}
# Render mosquitto config + ACL + passwd inside the LXC. Always overwrites
# the SecuBox-managed files — operator edits should go in a sibling
# conf.d snippet, not in /etc/mosquitto/conf.d/secubox.conf.
configure_mosquitto() {
log "Rendering mosquitto config + ACL + passwd ..."
# Build the passwd file host-side using mosquitto_passwd's deterministic
# hash (PBKDF2-SHA512). Stage it on the host then ship into the LXC.
local stage_passwd=/tmp/secubox-mqtt-passwd.$$
local stage_acl=/tmp/secubox-mqtt-acl.$$
: > "$stage_passwd"
: > "$stage_acl"
for entry in "${ACL_SERVICES[@]}"; do
local user="${entry%%:*}"
local topic="${entry#*:}"
local pw_file="$SECRETS_DIR/mqtt-${user}"
[ -f "$pw_file" ] || fail "missing $pw_file (generate_passwords should have created it)"
local pw; pw=$(cat "$pw_file")
# Append user:plain to a staging file; mosquitto_passwd -U hashes in place.
printf '%s:%s\n' "$user" "$pw" >> "$stage_passwd"
printf 'user %s\ntopic readwrite %s\n\n' "$user" "$topic" >> "$stage_acl"
done
# Mosquitto config snippet — listener, ACL, passwd, persistence.
local stage_conf=/tmp/secubox-mqtt-conf.$$
cat > "$stage_conf" <<CONF
# /etc/mosquitto/conf.d/secubox.conf
# Generated by secubox-mqtt install-lxc.sh — do not edit by hand.
# Operator overrides should go in a sibling conf.d snippet (which loads after).
#
# NOTE: keys with single-value semantics in mosquitto 2.x (persistence,
# persistence_location, log_dest, pid_file) are ALREADY set by Debian's
# default /etc/mosquitto/mosquitto.conf, and mosquitto 2.x rejects
# duplicates with "Error: Duplicate ... value in configuration."
# We only set what the default leaves unset: listener, ACL, passwd,
# anonymous policy, log levels (log_type is additive — multiple OK).
listener ${MQTT_PORT} 0.0.0.0
allow_anonymous false
password_file /etc/mosquitto/passwd.d/secubox
acl_file /etc/mosquitto/acl.d/secubox.acl
log_type error
log_type warning
log_type notice
max_queued_messages 1000
CONF
# Ship into the LXC.
lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- install -d -m 0750 -o mosquitto -g mosquitto /etc/mosquitto/passwd.d /etc/mosquitto/acl.d
cat "$stage_passwd" | lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- tee /etc/mosquitto/passwd.d/secubox.plain >/dev/null
cat "$stage_acl" | lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- tee /etc/mosquitto/acl.d/secubox.acl >/dev/null
cat "$stage_conf" | lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- tee /etc/mosquitto/conf.d/secubox.conf >/dev/null
# Hash the staged plain passwd file in place (-U). Then move to the final
# path mosquitto actually reads, removing the .plain leftover.
lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- bash -e <<'INNER'
set -euo pipefail
cp /etc/mosquitto/passwd.d/secubox.plain /etc/mosquitto/passwd.d/secubox
mosquitto_passwd -U /etc/mosquitto/passwd.d/secubox
rm -f /etc/mosquitto/passwd.d/secubox.plain
chown mosquitto:mosquitto /etc/mosquitto/passwd.d/secubox /etc/mosquitto/acl.d/secubox.acl /etc/mosquitto/conf.d/secubox.conf
chmod 0640 /etc/mosquitto/passwd.d/secubox /etc/mosquitto/acl.d/secubox.acl
chmod 0644 /etc/mosquitto/conf.d/secubox.conf
INNER
# Write a host-side cached copy of the ACL so the unprivileged FastAPI
# (running as `secubox`, no lxc-attach perms) can serve /api/v1/mqtt/access.
cp "$stage_acl" /etc/secubox/mqtt-acl.cached
chmod 0640 /etc/secubox/mqtt-acl.cached
chown root:secubox /etc/secubox/mqtt-acl.cached
rm -f "$stage_passwd" "$stage_acl" "$stage_conf"
lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- systemctl restart mosquitto
}
mark_provisioned() {
install -d -m 0755 -o secubox -g secubox "$STATE_DIR"
date -Iseconds > "$SENTINEL"
}
# v2.4.0 moves Mosquitto into an LXC; the v1.0.0 host-bound install left
# a separate mosquitto running on the host (bound to 127.0.0.1:1883 by
# default). It doesn't steal the LAN port — DNAT bypasses it — but it
# wastes RAM, may persist stale state, and confuses ss/netstat output.
# Stop + disable + mask it on every install (idempotent — masking a
# masked unit is a noop, stopping a stopped unit is a noop).
migrate_from_v1() {
# is-active returns 0 only if active; we don't need list-unit-files —
# is-active is already a sufficient existence check.
if systemctl is-active --quiet mosquitto 2>/dev/null; then
log "Stopping host-side mosquitto (v1.0.0 leftover) ..."
systemctl stop mosquitto 2>/dev/null || true
systemctl disable mosquitto 2>/dev/null || true
systemctl mask mosquitto 2>/dev/null || true
fi
}
main() {
require_cmds
migrate_from_v1
ensure_dirs
ensure_bridge
ensure_masquerade
create_lxc
write_lxc_config
start_lxc
wait_for_network
ensure_resolv
install_mosquitto_in_lxc
generate_passwords
configure_mosquitto
ensure_broker_dnat
mark_provisioned
log "OK — LXC '$LXC_NAME' at $LXC_IP, Mosquitto provisioned + running."
log " · Broker (LXC) : tcp://$LXC_IP:$MQTT_PORT/"
log " · Broker (LAN) : tcp://<board-ip>:$MQTT_PORT/ (DNAT'd)"
log " · ACL services : ${ACL_SERVICES[*]}"
log " · Secrets : $SECRETS_DIR/mqtt-{z2m,sentinelle,domoticz,homeassistant,secubox-api}"
}
main "$@"

View File

@ -0,0 +1,9 @@
{
"title": "Lyrion",
"subtitle": "Music server",
"icon": "fa-music",
"url": "/lyrion/",
"section": "hosting",
"order": 80,
"module": "lyrion"
}

View File

@ -1,9 +0,0 @@
{
"id": "mqtt",
"name": "MQTT Broker",
"category": "services",
"icon": "📡",
"path": "/mqtt/",
"order": 916,
"description": "Mosquitto MQTT broker management"
}

View File

@ -1,15 +1,12 @@
# /etc/nginx/secubox.d/mqtt.conf
# Installed by secubox-mqtt package
# /etc/nginx/secubox.d/mqtt.conf + /etc/nginx/secubox-routes.d/mqtt.conf
# Installed by secubox-mqtt (#240).
#
# Only the host control-plane API is reachable over HTTP. The Mosquitto
# broker itself speaks MQTT (TCP :1883) — exposed to the LAN via nftables
# DNAT in install-lxc.sh, NOT via nginx.
# MQTT API
location /api/v1/mqtt/ {
proxy_pass http://unix:/run/secubox/mqtt.sock:/;
rewrite ^/api/v1/mqtt/(.*)$ /$1 break;
proxy_pass http://unix:/run/secubox/mqtt.sock;
include /etc/nginx/snippets/secubox-proxy.conf;
}
# MQTT Frontend
location /mqtt/ {
alias /usr/share/secubox/www/mqtt/;
index index.html;
try_files $uri $uri/ /mqtt/index.html;
}

View File

@ -0,0 +1,312 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# SecuBox-Deb :: mqttctl
# CyberMind — https://cybermind.fr
#
# Mosquitto host-side controller. LXC at 10.100.0.110 on br-lxc.
# Three-fold introspection (components/status/access) + install/reload +
# topic/client/acl/user nouns.
#
# Grammar: docs/grammar.md, WALL layer.
# Conventions: docs/MODULE-GUIDELINES.md §7.
set -u
readonly VERSION="2.4.0"
readonly CONFIG_FILE="${SECUBOX_MQTT_CONFIG:-/etc/secubox/mqtt.toml}"
readonly STATE_DIR="${SECUBOX_MQTT_STATE:-/var/lib/secubox/mqtt}"
readonly SECRETS_DIR="${SECUBOX_SECRETS_DIR:-/etc/secubox/secrets}"
readonly INSTALL_LIB="${SECUBOX_INSTALL_LIB:-/usr/share/secubox/lib/mqtt/install-lxc.sh}"
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly NC='\033[0m'
log() { printf '%b[mqtt]%b %s\n' "$GREEN" "$NC" "$*"; }
warn() { printf '%b[warn]%b %s\n' "$YELLOW" "$NC" "$*" >&2; }
err() { printf '%b[error]%b %s\n' "$RED" "$NC" "$*" >&2; }
# ── TOML config (best-effort, grep-style) ────────────────────────────────────
config_get() {
local key="$1" default="${2:-}"
if [ -f "$CONFIG_FILE" ]; then
grep -E "^[[:space:]]*${key}[[:space:]]*=" "$CONFIG_FILE" 2>/dev/null \
| head -1 | cut -d= -f2- | tr -d ' "' | tr -d "'" || echo "$default"
else
echo "$default"
fi
}
LXC_NAME=$(config_get "name" "mqtt")
LXC_IP=$(config_get "ip" "10.100.0.110")
LXC_PATH=$(config_get "path" "/data/lxc")
MQTT_PORT=$(config_get "port" "1883")
# ── LXC helpers ───────────────────────────────────────────────────────────────
lxc_state() {
lxc-info -n "$LXC_NAME" -P "$LXC_PATH" 2>/dev/null \
| awk -F: '/^State:/ { gsub(/ /,"",$2); print tolower($2) }'
}
lxc_running() { [ "$(lxc_state)" = "running" ]; }
lxc_exists() { [ -d "$LXC_PATH/$LXC_NAME/rootfs" ]; }
broker_running() {
lxc_running || return 1
lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- systemctl is-active --quiet mosquitto 2>/dev/null
}
host_api_running() {
systemctl is-active --quiet secubox-mqtt.service 2>/dev/null
}
# Pub/sub round-trip — proves the broker is actually responsive, not just up.
broker_healthy() {
local user="secubox-api" pw_file="$SECRETS_DIR/mqtt-secubox-api"
[ -f "$pw_file" ] || return 1
local pw; pw=$(cat "$pw_file")
timeout 3 mosquitto_pub -h "$LXC_IP" -p "$MQTT_PORT" -u "$user" -P "$pw" \
-t "secubox/healthcheck" -m "ok" >/dev/null 2>&1
}
# ── Three-fold output ────────────────────────────────────────────────────────
emit_components_json() {
local lxc_st broker_st api_st health_st
if lxc_exists; then
lxc_st=$(lxc_state); [ -z "$lxc_st" ] && lxc_st="absent"
else
lxc_st="absent"
fi
broker_running && broker_st="running" || broker_st="stopped"
host_api_running && api_st="running" || api_st="stopped"
broker_healthy && health_st="ok" || health_st="degraded"
cat <<EOF
{
"module": "mqtt",
"version": "$VERSION",
"components": [
{"name": "lxc", "state": "$lxc_st", "detail": "$LXC_NAME @ $LXC_IP on br-lxc"},
{"name": "broker", "state": "$broker_st", "detail": "mosquitto, port $MQTT_PORT"},
{"name": "health", "state": "$health_st", "detail": "pub/sub round-trip via secubox-api"},
{"name": "host-api", "state": "$api_st", "detail": "secubox-mqtt.service (uvicorn @ /run/secubox/mqtt.sock)"}
]
}
EOF
}
emit_components_text() {
printf '%-12s %-12s %s\n' "COMPONENT" "STATE" "DETAIL"
if lxc_exists; then
local s; s=$(lxc_state); [ -z "$s" ] && s="absent"
printf '%-12s %-12s %s\n' "lxc" "$s" "$LXC_NAME @ $LXC_IP on br-lxc"
else
printf '%-12s %-12s %s\n' "lxc" "absent" "$LXC_NAME @ $LXC_IP on br-lxc — run 'mqttctl install'"
fi
broker_running && printf '%-12s %-12s %s\n' "broker" "running" "mosquitto, port $MQTT_PORT" \
|| printf '%-12s %-12s %s\n' "broker" "stopped" "mosquitto, port $MQTT_PORT"
broker_healthy && printf '%-12s %-12s %s\n' "health" "ok" "pub/sub round-trip via secubox-api" \
|| printf '%-12s %-12s %s\n' "health" "degraded" "pub/sub round-trip failed"
host_api_running && printf '%-12s %-12s %s\n' "host-api" "running" "secubox-mqtt.service (uvicorn)" \
|| printf '%-12s %-12s %s\n' "host-api" "stopped" "secubox-mqtt.service (uvicorn)"
}
emit_status_json() {
local overall
if lxc_running && broker_running && broker_healthy && host_api_running; then
overall="green"
elif lxc_exists && (broker_running || host_api_running); then
overall="yellow"
else
overall="red"
fi
cat <<EOF
{ "module": "mqtt", "version": "$VERSION", "overall": "$overall" }
EOF
}
# Access: which users/topics can connect? Reads the ACL config from the LXC.
emit_access_json() {
local acl_file="/etc/mosquitto/acl.d/secubox.acl"
local users
if lxc_running; then
users=$(lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- awk '/^user /{print $2}' "$acl_file" 2>/dev/null | sort -u)
fi
printf '{ "module": "mqtt", "access": ['
local first=1
for u in $users; do
local topic
topic=$(lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- awk -v u="$u" '
$1=="user" && $2==u {found=1; next}
$1=="user" && $2!=u {found=0}
found && $1=="topic" {sub(/^topic +/, "", $0); print; exit}
' "$acl_file" 2>/dev/null)
[ $first -eq 1 ] && first=0 || printf ','
printf '\n {"user": "%s", "topic": "%s", "scope": "lan"}' "$u" "${topic:-?}"
done
printf '\n] }\n'
}
# ── Verbs ─────────────────────────────────────────────────────────────────────
cmd_components() {
case "${1:-}" in
--json) emit_components_json ;;
""|list|status) emit_components_text ;;
*) err "unknown verb for 'components': $1"; exit 1 ;;
esac
}
cmd_status() {
case "${1:-}" in
--json) emit_status_json ;;
"")
emit_components_text
echo
emit_status_json | python3 -c 'import json,sys; d=json.load(sys.stdin); print("overall:", d["overall"])'
;;
*) err "unknown verb for 'status': $1"; exit 1 ;;
esac
}
cmd_access() {
case "${1:-}" in
--json) emit_access_json ;;
""|list) emit_access_json | python3 -m json.tool ;;
show)
local name="${2:-}"
[ -z "$name" ] && { err "access show <user>"; exit 1; }
emit_access_json | python3 -c "
import json,sys
d=json.load(sys.stdin)
m=[a for a in d['access'] if a['user']=='$name']
print(json.dumps(m[0], indent=2) if m else 'not found')
"
;;
*) err "unknown verb for 'access': $1"; exit 1 ;;
esac
}
cmd_install() {
if [ ! -x "$INSTALL_LIB" ]; then
err "install script not found at $INSTALL_LIB (package not installed correctly?)"
exit 2
fi
log "Running LXC bootstrap from $INSTALL_LIB ..."
SECUBOX_LXC_NAME="$LXC_NAME" SECUBOX_LXC_IP="$LXC_IP" SECUBOX_LXC_PATH="$LXC_PATH" \
SECUBOX_MQTT_PORT="$MQTT_PORT" \
bash "$INSTALL_LIB"
log "Starting host-side FastAPI ..."
systemctl start secubox-mqtt.service 2>/dev/null || true
log "Done. Try 'mqttctl status' to verify."
}
cmd_reload() {
systemctl restart secubox-mqtt.service 2>/dev/null || true
if lxc_running; then
lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- systemctl restart mosquitto 2>/dev/null || true
fi
log "Reloaded host FastAPI and mosquitto (if running)."
}
# Module nouns — list-only stubs in v2.4.0. Writes (add/remove) are
# proxied via /api/v1/mqtt and require operator authentication; the CLI
# stubs print a clear "use the API" hint.
cmd_topic() {
case "${1:-list}" in
list)
# Pub-subscribe to # for a brief window and list seen topics.
local user="secubox-api" pw_file="$SECRETS_DIR/mqtt-secubox-api" pw
[ -f "$pw_file" ] || { err "missing $pw_file"; exit 2; }
pw=$(cat "$pw_file")
log "Sampling broker for 3s (mosquitto_sub -C 0 -W 3) ..."
timeout 3 mosquitto_sub -h "$LXC_IP" -p "$MQTT_PORT" -u "$user" -P "$pw" \
-t '#' -v 2>/dev/null | awk '{print $1}' | sort -u
;;
*) err "topic: only 'list' is implemented in v$VERSION"; exit 2 ;;
esac
}
cmd_client() {
case "${1:-list}" in
list)
local user="secubox-api" pw_file="$SECRETS_DIR/mqtt-secubox-api" pw
[ -f "$pw_file" ] || { err "missing $pw_file"; exit 2; }
pw=$(cat "$pw_file")
# Broker $SYS topic exposes client counts.
timeout 3 mosquitto_sub -h "$LXC_IP" -p "$MQTT_PORT" -u "$user" -P "$pw" \
-t '$SYS/broker/clients/+' -W 2 -v 2>/dev/null
;;
*) err "client: only 'list' is implemented in v$VERSION"; exit 2 ;;
esac
}
cmd_acl() {
case "${1:-list}" in
list)
if lxc_running; then
lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- cat /etc/mosquitto/acl.d/secubox.acl
else
err "lxc not running"; exit 2
fi
;;
*) err "acl: only 'list' is implemented in v$VERSION (edit /etc/mosquitto/acl.d/secubox.acl + 'mqttctl reload')"; exit 2 ;;
esac
}
cmd_user() {
case "${1:-list}" in
list)
if lxc_running; then
lxc-attach -n "$LXC_NAME" -P "$LXC_PATH" -- cut -d: -f1 /etc/mosquitto/passwd.d/secubox 2>/dev/null
else
err "lxc not running"; exit 2
fi
;;
*) err "user: only 'list' is implemented in v$VERSION"; exit 2 ;;
esac
}
cmd_help() {
cat <<'HELP'
mqttctl — SecuBox Mosquitto broker controller (WALL layer)
Three-fold introspection:
mqttctl components [--json] # LXC + broker + health + host-API states
mqttctl status [--json] # overall green/yellow/red
mqttctl access [list|show <user>] [--json]
Lifecycle (per docs/MODULE-GUIDELINES.md §7):
mqttctl install # idempotent LXC + Mosquitto bootstrap
mqttctl reload # restart host FastAPI + mosquitto
mqttctl repair # detect + fix drift (NOT YET IMPLEMENTED)
mqttctl wizard # interactive seed + install (NOT YET IMPLEMENTED)
mqttctl uninstall # remove LXC + state + secrets (NOT YET IMPLEMENTED)
Module nouns (v2.4.0 — list-only; writes via /api/v1/mqtt):
mqttctl topic list # 3s sample of seen topics
mqttctl client list # broker $SYS/clients counters
mqttctl acl list # cat /etc/mosquitto/acl.d/secubox.acl
mqttctl user list # users from passwd.d/secubox
(--json on any verb returns machine-readable output)
For grammar details: /usr/share/doc/secubox-mqtt/README.gz
docs/grammar.md (WALL layer, #240)
HELP
}
# ── Dispatch ──────────────────────────────────────────────────────────────────
main() {
local noun="${1:-help}"
shift || true
case "$noun" in
components|status|access|install|reload) "cmd_${noun}" "$@" ;;
topic|client|acl|user) "cmd_${noun}" "$@" ;;
--help|-h|help|"") cmd_help ;;
--version|-V) echo "mqttctl $VERSION" ;;
*) err "unknown noun: $noun"; cmd_help; exit 1 ;;
esac
}
main "$@"

File diff suppressed because it is too large Load Diff