feat: Phase 3 manufacturing + firmware management
This commit is contained in:
126
backend/mqtt/auth.py
Normal file
126
backend/mqtt/auth.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
MQTT authentication and ACL endpoints for mosquitto-go-auth HTTP backend.
|
||||
|
||||
Mosquitto calls these on every CONNECT, SUBSCRIBE, and PUBLISH.
|
||||
- POST /mqtt/auth/user → validate device credentials
|
||||
- POST /mqtt/auth/acl → enforce per-device topic isolation
|
||||
|
||||
Password strategy: HMAC-SHA256(MQTT_SECRET, username)[:32]
|
||||
- Deterministic: no storage needed, re-derive on every auth check
|
||||
- Rotating MQTT_SECRET invalidates all passwords at once if needed
|
||||
|
||||
Transition support: during rollout, the legacy password "vesper" is also
|
||||
accepted so that devices still on old firmware stay connected.
|
||||
|
||||
User types handled:
|
||||
- Device users (e.g. "PV25L22BP01R01", "PV-26A18-BC02R-X7KQA"):
|
||||
Authenticated via HMAC. ACL restricted to their own vesper/{sn}/... topics.
|
||||
- Kiosk users (e.g. "PV25L22BP01R01-kiosk"):
|
||||
Same HMAC auth derived from the full kiosk username.
|
||||
ACL: allowed to access topics of their base device (suffix stripped).
|
||||
- bonamin, NodeRED, and other non-device users:
|
||||
These connect via the passwd file backend (go-auth file backend).
|
||||
They never reach this HTTP backend — go-auth resolves them first.
|
||||
The ACL endpoint below handles them defensively anyway (superuser list).
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import hashlib
|
||||
from fastapi import APIRouter, Form, Response
|
||||
from config import settings
|
||||
|
||||
router = APIRouter(prefix="/mqtt/auth", tags=["mqtt-auth"])
|
||||
|
||||
LEGACY_PASSWORD = "vesper"
|
||||
|
||||
# Users authenticated via passwd file (go-auth file backend).
|
||||
# If they somehow reach the HTTP ACL endpoint, grant full access.
|
||||
SUPERUSERS = {"bonamin", "NodeRED"}
|
||||
|
||||
|
||||
def _derive_password(username: str) -> str:
|
||||
"""Derive the expected MQTT password for a given username."""
|
||||
return hmac.new(
|
||||
settings.mqtt_secret.encode(),
|
||||
username.encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()[:32]
|
||||
|
||||
|
||||
def _is_valid_password(username: str, password: str) -> bool:
|
||||
"""
|
||||
Accept the password if it matches either:
|
||||
- The HMAC-derived password (new firmware)
|
||||
- The legacy hardcoded "vesper" password (old firmware, transition period)
|
||||
|
||||
Remove the legacy check in Stage 7 once all devices are on new firmware.
|
||||
"""
|
||||
expected = _derive_password(username)
|
||||
hmac_ok = hmac.compare_digest(expected, password)
|
||||
legacy_ok = hmac.compare_digest(LEGACY_PASSWORD, password)
|
||||
return hmac_ok or legacy_ok
|
||||
|
||||
|
||||
def _base_sn(username: str) -> str:
|
||||
"""
|
||||
Strip the -kiosk suffix if present, returning the base serial number.
|
||||
e.g. "PV25L22BP01R01-kiosk" -> "PV25L22BP01R01"
|
||||
"PV25L22BP01R01" -> "PV25L22BP01R01"
|
||||
"""
|
||||
if username.endswith("-kiosk"):
|
||||
return username[: -len("-kiosk")]
|
||||
return username
|
||||
|
||||
|
||||
@router.post("/user")
|
||||
async def mqtt_auth_user(
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
clientid: str = Form(default=""),
|
||||
):
|
||||
"""
|
||||
Called by Mosquitto on every CONNECT.
|
||||
Returns 200 to allow, 403 to deny.
|
||||
|
||||
Username = device SN (new format: "PV-26A18-BC02R-X7KQA", old format: "PV25L22BP01R01")
|
||||
or kiosk variant: "PV25L22BP01R01-kiosk"
|
||||
Password = HMAC-derived (new firmware) or "vesper" (legacy firmware)
|
||||
|
||||
Note: bonamin and NodeRED authenticate via the go-auth passwd file backend
|
||||
and never reach this endpoint.
|
||||
"""
|
||||
if _is_valid_password(username, password):
|
||||
return Response(status_code=200)
|
||||
|
||||
return Response(status_code=403)
|
||||
|
||||
|
||||
@router.post("/acl")
|
||||
async def mqtt_auth_acl(
|
||||
username: str = Form(...),
|
||||
topic: str = Form(...),
|
||||
clientid: str = Form(default=""),
|
||||
acc: int = Form(...), # 1 = subscribe, 2 = publish, 3 = subscribe+publish
|
||||
):
|
||||
"""
|
||||
Called by Mosquitto on every SUBSCRIBE and PUBLISH.
|
||||
Returns 200 to allow, 403 to deny.
|
||||
|
||||
Topic pattern: vesper/{sn}/...
|
||||
- Device users: may only access their own SN segment
|
||||
- Kiosk users: stripped of -kiosk suffix, then same rule applies
|
||||
- Superusers (bonamin, NodeRED): full access
|
||||
"""
|
||||
# Superusers get full access (shouldn't reach here but handled defensively)
|
||||
if username in SUPERUSERS:
|
||||
return Response(status_code=200)
|
||||
|
||||
# Derive the base SN (handles -kiosk suffix)
|
||||
base = _base_sn(username)
|
||||
|
||||
# Topic must be vesper/{base_sn}/...
|
||||
parts = topic.split("/")
|
||||
if len(parts) >= 2 and parts[0] == "vesper" and parts[1] == base:
|
||||
return Response(status_code=200)
|
||||
|
||||
return Response(status_code=403)
|
||||
@@ -1,52 +1,17 @@
|
||||
import subprocess
|
||||
import os
|
||||
from config import settings
|
||||
"""
|
||||
mqtt/mosquitto.py — no-ops since Stage 5.
|
||||
|
||||
Auth is now HMAC-based via the go-auth HTTP plugin.
|
||||
These functions are kept as no-ops so existing call sites don't break.
|
||||
They can be removed entirely in Phase 6 cleanup.
|
||||
"""
|
||||
|
||||
|
||||
def register_device_password(serial_number: str, password: str) -> bool:
|
||||
"""Register a device in the Mosquitto password file.
|
||||
|
||||
Uses mosquitto_passwd to add/update the device credentials.
|
||||
The serial number is used as the MQTT username.
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
passwd_file = settings.mosquitto_password_file
|
||||
|
||||
# Ensure the password file exists
|
||||
if not os.path.exists(passwd_file):
|
||||
# Create the file if it doesn't exist
|
||||
os.makedirs(os.path.dirname(passwd_file), exist_ok=True)
|
||||
open(passwd_file, "a").close()
|
||||
|
||||
try:
|
||||
# Use mosquitto_passwd with -b flag (batch mode) to set password
|
||||
result = subprocess.run(
|
||||
["mosquitto_passwd", "-b", passwd_file, serial_number, password],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
|
||||
print(f"[WARNING] Mosquitto password registration failed: {e}")
|
||||
return False
|
||||
"""No-op. HMAC auth is derived on demand — no registration needed."""
|
||||
return True
|
||||
|
||||
|
||||
def remove_device_password(serial_number: str) -> bool:
|
||||
"""Remove a device from the Mosquitto password file."""
|
||||
passwd_file = settings.mosquitto_password_file
|
||||
|
||||
if not os.path.exists(passwd_file):
|
||||
return True
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["mosquitto_passwd", "-D", passwd_file, serial_number],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
|
||||
print(f"[WARNING] Mosquitto password removal failed: {e}")
|
||||
return False
|
||||
"""No-op. HMAC auth is derived on demand — no removal needed."""
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user