b99450db2f
Mirror the Proxmox auto-sync pattern for the Zigbee2MQTT and Z-Wave JS UI mesh imports. Connection config + MQTT credentials live in .env only, are never persisted to scan_config.json, and are never returned by any API or shown in the UI (single source of truth). - config: ZIGBEE_* / ZWAVE_* env settings; only sync_enabled+interval are persisted, connection/credentials stay env-only - routes: GET/POST /config, POST /sync-now; auto-sync reuses the exact manual _background_*_import + _persist_pending_import path (fresh import when empty, update-in-place when nodes exist, ScanRun trace) - scheduler: zigbee_sync / zwave_sync jobs with live enable + reschedule - frontend: reusable MeshAutoSync section in Settings (Zigbee, Z-Wave) - .env.example: documented both blocks - tests: scheduler jobs, router config/sync-now/auth, credential-never- persisted, SettingsModal sections Manual Zigbee/Z-Wave import behaviour is unchanged. ha-relevant: no
198 lines
8.9 KiB
Python
198 lines
8.9 KiB
Python
import json
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from pydantic import model_validator
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
def _read_version() -> str:
|
|
for candidate in [
|
|
Path(__file__).parent.parent.parent.parent / "VERSION", # repo root (dev)
|
|
Path("/app/VERSION"), # Docker image
|
|
]:
|
|
if candidate.exists():
|
|
return candidate.read_text().strip()
|
|
return "unknown"
|
|
|
|
APP_VERSION = _read_version()
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
|
|
|
secret_key: str # Required — set SECRET_KEY in .env
|
|
sqlite_path: str = "./data/homelab.db"
|
|
# Uploaded media (floor plans, and future raw image uploads) live on disk,
|
|
# not in the DB. Defaults to a `uploads/` folder next to the SQLite DB so it
|
|
# sits on the same persistent Docker volume. Override with UPLOAD_DIR.
|
|
upload_dir: str = ""
|
|
cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
|
|
|
# JWT
|
|
algorithm: str = "HS256"
|
|
access_token_expire_minutes: int = 1440 # 24h
|
|
|
|
# Auth — set AUTH_USERNAME and AUTH_PASSWORD_HASH in .env
|
|
auth_username: str = "admin"
|
|
auth_password_hash: str = ""
|
|
|
|
@model_validator(mode="after")
|
|
def check_password_hash(self) -> "Settings":
|
|
h = self.auth_password_hash
|
|
if h and not h.startswith("$2"):
|
|
logger.error(
|
|
"AUTH_PASSWORD_HASH looks invalid (does not start with '$2b$'). "
|
|
"bcrypt hashes contain '$' signs — wrap the value in single quotes "
|
|
"in your .env file: AUTH_PASSWORD_HASH='$2b$12$...'"
|
|
)
|
|
return self
|
|
|
|
# Scanner
|
|
scanner_ranges: list[str] = ["192.168.1.0/24"]
|
|
|
|
# Deep scan — persisted defaults (overridable per-scan from the scan dialog).
|
|
# http_ranges: extra nmap port ranges, opt-in, no default. Probe + TLS off by default.
|
|
scanner_http_ranges: list[str] = []
|
|
scanner_http_probe_enabled: bool = False
|
|
scanner_http_verify_tls: bool = False
|
|
|
|
# Status checker
|
|
status_checker_interval: int = 60
|
|
|
|
# Per-service status checker (independent of node checks). Off by default.
|
|
service_check_enabled: bool = False
|
|
service_check_interval: int = 300
|
|
|
|
# MCP service key — set MCP_SERVICE_KEY in .env
|
|
# Used by the MCP server to authenticate against the backend without a user password.
|
|
# Leave empty to disable MCP service key auth.
|
|
mcp_service_key: str = ""
|
|
|
|
# Live view — optional read-only public canvas endpoint.
|
|
# Set to a random secret string to enable /api/v1/liveview?key=<value>.
|
|
# Leave unset (or empty) to keep the feature disabled (default).
|
|
liveview_key: str | None = None
|
|
|
|
# Homepage widget — optional read-only stats endpoint for gethomepage.
|
|
# Set to a random secret to enable /api/v1/stats/summary (X-API-Key header).
|
|
# Leave empty to keep the feature disabled (default).
|
|
homepage_api_key: str = ""
|
|
|
|
# Proxmox VE import.
|
|
# Token = a real credential → env/.env ONLY, never persisted by the app to
|
|
# scan_config.json and never returned by the API. token_id is
|
|
# 'user@realm!tokenname'; use a read-only PVEAuditor role.
|
|
proxmox_token_id: str = ""
|
|
proxmox_token_secret: str = ""
|
|
# Non-secret connection + auto-sync config (persisted via save_overrides).
|
|
proxmox_host: str = ""
|
|
proxmox_port: int = 8006
|
|
proxmox_verify_tls: bool = True
|
|
proxmox_sync_enabled: bool = False
|
|
proxmox_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
|
|
|
|
# Zigbee2MQTT auto-sync import.
|
|
# MQTT credentials are secrets → env/.env ONLY, never persisted by the app to
|
|
# scan_config.json and never returned by the API. Only the auto-sync
|
|
# activation (enabled + interval) is persisted; connection config is env-only.
|
|
zigbee_mqtt_host: str = ""
|
|
zigbee_mqtt_port: int = 1883
|
|
zigbee_mqtt_username: str = ""
|
|
zigbee_mqtt_password: str = ""
|
|
zigbee_base_topic: str = "zigbee2mqtt"
|
|
zigbee_mqtt_tls: bool = False
|
|
zigbee_mqtt_tls_insecure: bool = False
|
|
zigbee_sync_enabled: bool = False
|
|
zigbee_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
|
|
|
|
# Z-Wave JS UI (zwavejs2mqtt) auto-sync import. Same secret/env rules.
|
|
zwave_mqtt_host: str = ""
|
|
zwave_mqtt_port: int = 1883
|
|
zwave_mqtt_username: str = ""
|
|
zwave_mqtt_password: str = ""
|
|
zwave_prefix: str = "zwave"
|
|
zwave_gateway_name: str = "zwavejs2mqtt"
|
|
zwave_mqtt_tls: bool = False
|
|
zwave_mqtt_tls_insecure: bool = False
|
|
zwave_sync_enabled: bool = False
|
|
zwave_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
|
|
|
|
def _override_path(self) -> Path:
|
|
return Path(self.sqlite_path).parent / "scan_config.json"
|
|
|
|
def media_dir(self) -> Path:
|
|
"""On-disk folder for uploaded media. Sits on the same persistent
|
|
volume as the SQLite DB unless UPLOAD_DIR is set."""
|
|
if self.upload_dir:
|
|
return Path(self.upload_dir)
|
|
return Path(self.sqlite_path).parent / "uploads"
|
|
|
|
def load_overrides(self) -> None:
|
|
"""Load runtime scan config overrides written by the API."""
|
|
try:
|
|
data = json.loads(self._override_path().read_text())
|
|
if "scanner_ranges" in data:
|
|
self.scanner_ranges = data["scanner_ranges"]
|
|
if "status_checker_interval" in data:
|
|
self.status_checker_interval = int(data["status_checker_interval"])
|
|
if "service_check_enabled" in data:
|
|
self.service_check_enabled = bool(data["service_check_enabled"])
|
|
if "service_check_interval" in data:
|
|
self.service_check_interval = int(data["service_check_interval"])
|
|
if "scanner_http_ranges" in data:
|
|
self.scanner_http_ranges = list(data["scanner_http_ranges"])
|
|
if "scanner_http_probe_enabled" in data:
|
|
self.scanner_http_probe_enabled = bool(data["scanner_http_probe_enabled"])
|
|
if "scanner_http_verify_tls" in data:
|
|
self.scanner_http_verify_tls = bool(data["scanner_http_verify_tls"])
|
|
# Proxmox auto-sync activation only. Connection config (host, port,
|
|
# token, verify_tls) is env-only by design — never read from or
|
|
# written to this file. Persisting host here previously created a
|
|
# dual source of truth that silently clobbered PROXMOX_HOST.
|
|
if "proxmox_sync_enabled" in data:
|
|
self.proxmox_sync_enabled = bool(data["proxmox_sync_enabled"])
|
|
if "proxmox_sync_interval" in data:
|
|
self.proxmox_sync_interval = int(data["proxmox_sync_interval"])
|
|
# Zigbee/Z-Wave: only the auto-sync activation is persisted. MQTT
|
|
# connection config (host, port, credentials, topic, tls) is env-only
|
|
# by design — never read from or written to this file.
|
|
if "zigbee_sync_enabled" in data:
|
|
self.zigbee_sync_enabled = bool(data["zigbee_sync_enabled"])
|
|
if "zigbee_sync_interval" in data:
|
|
self.zigbee_sync_interval = int(data["zigbee_sync_interval"])
|
|
if "zwave_sync_enabled" in data:
|
|
self.zwave_sync_enabled = bool(data["zwave_sync_enabled"])
|
|
if "zwave_sync_interval" in data:
|
|
self.zwave_sync_interval = int(data["zwave_sync_interval"])
|
|
except Exception:
|
|
pass
|
|
|
|
def save_overrides(self) -> None:
|
|
"""Persist scan config so it survives container restarts."""
|
|
self._override_path().parent.mkdir(parents=True, exist_ok=True)
|
|
self._override_path().write_text(json.dumps({
|
|
"scanner_ranges": self.scanner_ranges,
|
|
"status_checker_interval": self.status_checker_interval,
|
|
"service_check_enabled": self.service_check_enabled,
|
|
"service_check_interval": self.service_check_interval,
|
|
"scanner_http_ranges": self.scanner_http_ranges,
|
|
"scanner_http_probe_enabled": self.scanner_http_probe_enabled,
|
|
"scanner_http_verify_tls": self.scanner_http_verify_tls,
|
|
# Proxmox: only the auto-sync activation is persisted. Connection
|
|
# config (host, port, token, verify_tls) is env-only and must never
|
|
# be written to disk — that is the single source of truth.
|
|
"proxmox_sync_enabled": self.proxmox_sync_enabled,
|
|
"proxmox_sync_interval": self.proxmox_sync_interval,
|
|
# Zigbee/Z-Wave: only the auto-sync activation is persisted. MQTT
|
|
# connection config (host/port/credentials/topic/tls) is env-only.
|
|
"zigbee_sync_enabled": self.zigbee_sync_enabled,
|
|
"zigbee_sync_interval": self.zigbee_sync_interval,
|
|
"zwave_sync_enabled": self.zwave_sync_enabled,
|
|
"zwave_sync_interval": self.zwave_sync_interval,
|
|
}))
|
|
|
|
|
|
settings = Settings() # type: ignore[call-arg]
|