mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 13:59:40 +00:00
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:
parent
e562446383
commit
080a4e47a3
|
|
@ -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
|
||||
|
||||

|
||||
|
||||
## 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)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
# SecuBox MQTT Module
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
50
packages/secubox-mqtt/conf/mqtt.toml.example
Normal file
50
packages/secubox-mqtt/conf/mqtt.toml.example
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
debian/secubox-mqtt.service usr/lib/systemd/system/
|
||||
|
|
@ -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
|
||||
|
|
|
|||
18
packages/secubox-mqtt/debian/postrm
Executable file
18
packages/secubox-mqtt/debian/postrm
Executable 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
315
packages/secubox-mqtt/lib/mqtt/install-lxc.sh
Executable file
315
packages/secubox-mqtt/lib/mqtt/install-lxc.sh
Executable 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 "$@"
|
||||
9
packages/secubox-mqtt/menu.d/80-mqtt.json
Normal file
9
packages/secubox-mqtt/menu.d/80-mqtt.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"title": "Lyrion",
|
||||
"subtitle": "Music server",
|
||||
"icon": "fa-music",
|
||||
"url": "/lyrion/",
|
||||
"section": "hosting",
|
||||
"order": 80,
|
||||
"module": "lyrion"
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"id": "mqtt",
|
||||
"name": "MQTT Broker",
|
||||
"category": "services",
|
||||
"icon": "📡",
|
||||
"path": "/mqtt/",
|
||||
"order": 916,
|
||||
"description": "Mosquitto MQTT broker management"
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
312
packages/secubox-mqtt/sbin/mqttctl
Executable file
312
packages/secubox-mqtt/sbin/mqttctl
Executable 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
Loading…
Reference in New Issue
Block a user