feat: Phase 3 manufacturing + firmware management

This commit is contained in:
2026-02-27 02:47:08 +02:00
parent 2f610633c4
commit 32a2634739
25 changed files with 2266 additions and 52 deletions

126
backend/mqtt/auth.py Normal file
View 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)