Skip to content

Architecture

Provider/adapter boundary

Camera-specific behavior is isolated behind two small interfaces in reovault/providers/base.py:

  • CameraProvider: camera I/O: list recordings in a time window (vod search), download one (vod download), report SD card storage status. Talks to the camera through the reolink-gateway sidecar.
  • CameraRegistry: camera management: discover cameras on the LAN, add/list registered cameras, fetch device info (model/firmware/name). Works from local config files plus a LAN broadcast; never needs the gateway except for device_info.

Today's implementation of both (ReolinkCliProvider, ReolinkCliRegistry) shells out to the official reolink-cli binary rather than reimplementing the Reolink protocol. Exit codes map onto typed exceptions (InputError/NetworkError/AuthError/DeviceError/ProtocolError), so the archiver's retry/backoff logic never has to guess what kind of failure it's looking at. A second camera integration would implement the same two interfaces without touching the archiver, scheduler, storage, or dashboard.

ReolinkCliProvider searches one camera-local day per vod search call, never a whole window at once. Search time grows with the number of clips on the card, and one multi-day search on a busy camera outruns reolink-cli's fixed 60 s gateway read budget. A vod download that reports the recording as not found on the card (loop recording overwrote it after it was listed) is quarantined on the first failure rather than retried every run. reolink-cli 0.19.0 reports that case as a retryable NETWORK_ERROR, so the provider recognizes it by its message text. Both behaviors were verified against a D340W.

reolink-cli itself manages camera credentials in its own encrypted aliases.toml (AES-GCM, decrypted by a credentials.key beside it, file mode 0600). ReoVault never stores the camera password: it passes through the process once, on stdin, on its way into that registry.

Archiving state machine

Archiver.run() (reovault/archiver.py) drives each recording through: discover → stage a download → verify size/hash → encrypt into the vault → finalize (atomic rename) → mark archived in the database. A crash at any step leaves the recording in a well-defined intermediate state; reconcile (run manually or automatically) sweeps orphaned staging files and adopts/deletes unreferenced vault files left behind. A recording is never marked archived before verification and finalization both succeed, and a successfully archived recording is never re-downloaded.

Retention (reovault/retention.py, see [retention] in the configuration docs) is the only path that deletes archived footage, and only when the operator has set an age or size limit. It deletes the oldest vault files first and marks their rows pruned instead of removing them, because dedup lives entirely in the database: a deleted row would let the next scan re-download the clip from the SD card.

Storage: the vault

EncryptedFsVault (reovault/storage/vault.py) lays out ciphertext on disk as:

<device-alias>/<YYYY>/<MM>/<DD>/<start>_<remote-name>.enc
                                <start>_<remote-name>.enc.json

The .json sidecar (plaintext SHA-256, sizes, algorithm, format version, created-at) is redundant with the SQLite database on purpose. The vault stays self-describing even if the database is ever lost. Files are only ever written by encrypt-then-atomic-rename, so nothing observing the directory (including an external backup tool) can ever capture a partially-written file.

Encryption and key management

Each recording is encrypted with AES-GCM. A single master key, generated by reovault key init, is itself wrapped by a user-supplied passphrase ($REOVAULT_MASTER_PASSPHRASE or a Docker secret file). The passphrase is never stored, only the wrapped key file (crypto/keyring.py, mode 0600). Losing both the key file and the passphrase makes every archived recording permanently unrecoverable; there is no recovery path by design. The key file must live outside storage.vault_dir, enforced by a startup check; otherwise a backup/retention tool that treats the vault as its source would replicate the key alongside the ciphertext it unlocks, collapsing the security boundary.

Integrity is checked two ways: every read verifies the AES-GCM tag, and a scheduled integrity_scan re-reads a rotating sample of archived files (default 5%/week) to catch bit rot before it's the only copy left.

Scheduler

reovault daemon runs an APScheduler instance with per-device jobs (reovault/scheduler.py): a scheduled archive run, a periodic deep backfill, a reconcile pass, and the integrity scan, each independently configurable per device or globally via [schedule]/REOVAULT_SCHEDULE__*. Cron expressions evaluate in each device's own configured timezone unless a global override is set.

After every archive run the scheduler samples the camera's SD card (capacity, free space, mounted, and the oldest clip still on it). reovault/health.py's card_status turns that sample plus the recordings table into one verdict per card, which the dashboard, the coverage alert and /healthz?verbose=1 all share:

  • Waiting: clips on the card that aren't archived yet.
  • Lost: unarchived clips older than the card's oldest clip, so the card has already overwritten them. Counted, never alarmed on: nothing can bring them back.
  • At risk: the card is nearly full (under 10% free, so it's looping) and the oldest waiting clip will be overwritten within 20% of the card's window. Time left is oldest waiting clip + window - now, where the window is how much footage the card holds (now - oldest clip on the card, or capacity divided by the recent daily recording rate when the oldest clip isn't known).
  • Not mounted: the camera reports no mounted card.

Web dashboard

Two halves, one process, one origin:

  • The API (reovault/web/): a JSON API under /api/v1, one FastAPI router per area in reovault/web/api/, with typed Pydantic models. Its OpenAPI schema is exported to dashboard/openapi.json and turned into TypeScript types, so the dashboard compiles against the real API.
  • The dashboard (dashboard/): a SvelteKit single-page app (Svelte 5, Tailwind v4, shadcn-svelte components), built to static files in the image's Node build stage. FastAPI serves those files and returns index.html for every client-side route. There is no Node at runtime.

The home page (Overview, GET /api/v1/overview) is fleet-wide: the archive's disk and one SD card tile per camera, built only from stored samples so loading it never calls a camera. Every other page (Health, Footage, Runs, Problems) is scoped to the camera picked in the header with ?device=.

Password-protected single-user login: Argon2id-hashed password, a signed session cookie that logout revokes server-side, and a CSRF token the dashboard sends as X-CSRF-Token on every mutating request. Installable as a PWA; the service worker caches only the app shell, never /api.

Live updates come from one Server-Sent Events stream, GET /api/v1/events. Scheduler jobs and actions publish small hints ("camera 3's activity changed") onto an in-process bus; the dashboard refetches whatever the hint concerns, and refetches everything after a reconnect, so a dropped event is never fatal. Nothing polls.

Alerts (reovault/notify.py) are evaluated once a minute on their own scheduler executor, so a long archive run never delays them. Evaluation is state-based: what is wrong right now is diffed against the alert_state table, which opens, re-notifies and resolves alerts and survives restarts without losing or repeating one.

Export streams a zip of decrypted clips straight from the vault, with no temp files; recordings play the same way over HTTP Range requests. Only the bytes actually being watched, seeked to or exported are ever decrypted.

Behind a reverse proxy, bind host = "0.0.0.0" (loopback inside a container is unreachable from the proxy) and set forwarded_allow_ips to the proxy's own address. See Configuration and Security.

Data model

SQLite (reovault/db/), single-writer, embedded, no extra infrastructure. A small migration runner applies numbered .sql files from db/migrations/ in order. reconcile and verify operate directly against the vault and the database's recordings table.