"""Writable camera list — the store behind "user connects their camera". Cameras used to live in `config/default.yaml` with credentials in `.env`, which means adding one is an edit-and-restart. A product needs them added at runtime from a UI, so they move here: a small JSON file the API can rewrite safely while the engine is running. Deliberately NOT stored in `behavision.db`. That file is a biometric database with its own handling and erasure obligations; folding user-editable config into it makes both harder to reason about, to back up, and to hand to support. Passwords are protected at rest with Windows DPAPI. This is not theatre: the file sits on the same disk as the face gallery, and an RTSP credential is a live path into the camera itself. """ from __future__ import annotations import base64 import json import logging import os import threading from pathlib import Path from typing import Optional from .config import CameraConfig log = logging.getLogger(__name__) _PLAIN = "plain:" _DPAPI = "dpapi:" # Machine scope, not user scope. The service (LocalSystem) and an admin running # the CLI are different accounts, and a user-scoped blob written by one cannot # be read by the other — a failure that only shows up after install, on the # customer's machine. Machine scope still defends the actual threat here: # someone copying cameras.json off the box. _CRYPTPROTECT_LOCAL_MACHINE = 0x04 def _win32crypt(): try: import win32crypt # type: ignore return win32crypt except ImportError: return None def protect(value: str) -> str: """Encrypt a secret for storage. Tagged so the format can change later.""" if not value: return "" crypt = _win32crypt() if crypt is None: return _PLAIN + value try: blob = crypt.CryptProtectData(value.encode("utf-8"), "behavision", None, None, None, _CRYPTPROTECT_LOCAL_MACHINE) return _DPAPI + base64.b64encode(blob).decode("ascii") except Exception: log.warning("DPAPI unavailable - storing camera password unencrypted", exc_info=True) return _PLAIN + value def unprotect(stored: str) -> str: """Inverse of `protect`. Never raises: a credential that cannot be read is an empty credential, so one unreadable camera does not stop the engine.""" if not stored: return "" if stored.startswith(_PLAIN): return stored[len(_PLAIN):] if stored.startswith(_DPAPI): crypt = _win32crypt() if crypt is None: log.error("camera password is DPAPI-encrypted but win32crypt is " "unavailable - re-enter it on this machine") return "" try: return crypt.CryptUnprotectData( base64.b64decode(stored[len(_DPAPI):]), None, None, None, 0)[1].decode("utf-8") except Exception: log.error("camera password could not be decrypted (config copied " "from another machine?) - re-enter it", exc_info=True) return "" return stored # pre-tag file written before this module existed class CameraStore: """Cameras as JSON, safe to rewrite while the engine is running.""" def __init__(self, path: "Path | str"): self.path = Path(path) self._lock = threading.RLock() self._cameras: "dict[str, CameraConfig]" = {} self._load() # -- persistence ---------------------------------------------------- def _load(self) -> None: if not self.path.exists(): return try: raw = json.loads(self.path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): log.exception("%s is unreadable - starting with no cameras " "(the file is left in place, not overwritten)", self.path) return for entry in raw.get("cameras", []): try: entry = dict(entry) entry["password"] = unprotect(entry.get("password", "")) cam = CameraConfig.model_validate(entry) except Exception: log.exception("skipping malformed camera entry %r", entry) continue self._cameras[cam.id] = cam def _save(self) -> None: """Atomic: a crash mid-write must not leave a truncated camera list.""" payload = {"version": 1, "cameras": []} for cam in self._cameras.values(): entry = cam.model_dump(mode="json") entry["password"] = protect(cam.password) payload["cameras"].append(entry) self.path.parent.mkdir(parents=True, exist_ok=True) tmp = self.path.with_suffix(".json.tmp") tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8") try: os.chmod(tmp, 0o600) except OSError: # best effort (Windows) pass os.replace(tmp, self.path) # atomic on POSIX and NTFS # -- CRUD ----------------------------------------------------------- def list(self) -> "list[CameraConfig]": with self._lock: return list(self._cameras.values()) def get(self, camera_id: str) -> Optional[CameraConfig]: with self._lock: return self._cameras.get(camera_id) def add(self, camera: CameraConfig) -> CameraConfig: with self._lock: if camera.id in self._cameras: raise ValueError(f"camera '{camera.id}' already exists") camera.source() # validate now, not at connect time self._cameras[camera.id] = camera self._save() return camera def update(self, camera_id: str, fields: dict) -> Optional[CameraConfig]: with self._lock: existing = self._cameras.get(camera_id) if existing is None: return None # id is the engine's key for the worker; renaming would orphan it fields = {k: v for k, v in fields.items() if k != "id" and v is not None} # Re-validated rather than model_copy(update=...): copy does not # coerce, so a nested `tuning` arriving as a plain dict from JSON # would be stored as a dict and blow up the first time a camera # asked it for its thresholds. updated = CameraConfig.model_validate( {**existing.model_dump(), **fields}) updated.source() self._cameras[camera_id] = updated self._save() return updated def delete(self, camera_id: str) -> bool: with self._lock: if self._cameras.pop(camera_id, None) is None: return False self._save() return True def seed(self, cameras: "list[CameraConfig]") -> bool: """Import YAML-declared cameras on first run only. After that the store is authoritative — otherwise a camera the user deleted in the UI would reappear on every restart. """ with self._lock: if self.path.exists() or not cameras: return False for cam in cameras: self._cameras[cam.id] = cam self._save() log.info("seeded %d camera(s) from YAML into %s", len(cameras), self.path) return True