config/default.yaml reads its camera's host, username and password from
${ENV}. Unset placeholders parse as YAML null, and CameraConfig had no
_normalize_blanks - the guard ApiSection and EmailSection have had all
along - so loading it raised three pydantic errors and the engine would
not start at all. On a developer's checkout .env is right there, which is
why this survived: the failing machine is every machine the product is
actually installed on, and installer/build.ps1 runs this suite, so the
Windows build would have failed on a fresh clone.
The normalisation is field-by-field, never a blanket None -> "": `webcam`
is an Optional[int] whose None means "this is not a webcam", and `tuning`
is a nested model. Sweeping either trades one validation error for
another - which it did, on the first attempt.
Second bug behind the same line: that camera entry would then have been
SEEDED into a fresh install, giving a shop a camera called cam1 that
nobody added, retrying a connection to "" forever, with the first task on
a new PC being to work out what it was. CameraConfig.addressed() says
what a camera entry needs to be one, and seed() drops the rest.
Found by cloning the repository into a temp directory and running the
tests there. Nothing in a working tree can find this class of bug.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
204 lines
7.9 KiB
Python
204 lines
7.9 KiB
Python
"""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
|