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 thereolink-gatewaysidecar.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 fordevice_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 inreovault/web/api/, with typed Pydantic models. Its OpenAPI schema is exported todashboard/openapi.jsonand 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 returnsindex.htmlfor 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.