"""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. Entries with no address are dropped rather than imported. The bundled config declares one whose host comes from the environment, which is right for a checkout with a .env and resolves to nothing on every machine the product is actually installed on. Seeding that would put a camera the shop never added into a brand new install, permanently failing to connect to "" — and the first thing they would have to do is work out what it was and delete it. """ with self._lock: usable = [c for c in cameras if c.addressed()] if self.path.exists() or not usable: return False for cam in usable: self._cameras[cam.id] = cam self._save() log.info("seeded %d camera(s) from YAML into %s", len(usable), self.path) return True