Skip to main content

heldar_kernel/
config.rs

1use std::path::PathBuf;
2
3use crate::env::{parse_bool, parse_or, var, var_or};
4
5/// Runtime configuration, loaded from environment (see `.env.example`).
6#[derive(Clone, Debug)]
7pub struct Config {
8    pub database_url: String,
9    pub data_dir: PathBuf,
10    pub recordings_dir: PathBuf,
11    pub clips_dir: PathBuf,
12    pub snapshots_dir: PathBuf,
13    pub frames_dir: PathBuf,
14    /// Directory where segment-spanning HLS playback sessions are generated (one subdir per session).
15    pub playback_dir: PathBuf,
16    pub ffmpeg_bin: String,
17    pub ffprobe_bin: String,
18    pub mediamtx_api_url: String,
19    pub mediamtx_hls_base: String,
20    pub mediamtx_rtsp_base: String,
21    pub mediamtx_webrtc_base: String,
22    /// Max SQLite pool connections. Tunable per deployment: more absorbs bursts of concurrent
23    /// requests (WAL serves reads concurrently; writes still serialize), at the cost of memory.
24    pub db_max_connections: u32,
25    pub recorder_enabled: bool,
26    /// Optional second recordings root for dual/mirror recording. When set, cameras with
27    /// `mirror_enabled` get a SECOND ffmpeg pipeline writing byte-identical segments here (a redundant
28    /// DVR copy on a separate volume). Empty/unset disables mirror recording entirely.
29    pub mirror_recordings_dir: Option<PathBuf>,
30    /// Master switch for ANR (Automatic Network Replenishment) edge re-fill: re-fetch missed footage
31    /// from a camera's onboard storage to fill recording gaps. Cameras still need `anr_enabled`.
32    pub anr_enabled: bool,
33    /// How often the ANR loop scans for pending gaps to fill (seconds).
34    pub anr_interval_s: u64,
35    /// Ignore gaps older than this many hours (most cameras only retain recent onboard footage).
36    pub anr_max_gap_hours: i64,
37    /// Give up on a gap after this many fill attempts (marked `failed`).
38    pub anr_max_attempts: i64,
39    pub default_segment_seconds: i64,
40    pub default_retention_hours: i64,
41    /// Default per-camera storage quota (bytes) applied when a camera is created without an explicit
42    /// `storage_quota_bytes`. 0 means no default quota (the camera's quota is stored as NULL).
43    pub default_camera_quota_bytes: u64,
44    /// Default audio-recording toggle applied when a camera is created without an explicit
45    /// `record_audio`. When false (default) the recorder drops audio (video only).
46    pub default_record_audio: bool,
47    /// Default pre-roll seconds applied when a camera is created without an explicit
48    /// `pre_roll_seconds` (event / scheduled_event recording). Clamped to 0..300 in handlers.
49    pub default_pre_roll_seconds: i64,
50    /// Default post-roll seconds (the trigger recording window) applied when a camera is created
51    /// without an explicit `post_roll_seconds`. Clamped to 0..3600 in handlers.
52    pub default_post_roll_seconds: i64,
53    pub indexer_interval_s: u64,
54    pub health_interval_s: u64,
55    pub retention_interval_s: u64,
56    pub api_host: String,
57    pub api_port: u16,
58    pub cors_origins: Vec<String>,
59    /// Soft cap on total recording footprint; oldest unlocked segments are pruned above this.
60    pub max_recordings_bytes: u64,
61    /// Hard floor on free disk space; when free space drops below this, oldest unlocked segments
62    /// are pruned regardless of age/size policy (protects the host from a full disk).
63    pub min_free_disk_bytes: u64,
64    /// How often the alert notifier polls for new events to deliver.
65    pub notifier_interval_s: u64,
66    /// Master switch for AI frame sampling (Stage 2). Cameras still need an enabled AI task.
67    pub ai_enabled: bool,
68    /// Global frame-sampling budget (frames/sec summed across all cameras); per-camera fps is
69    /// reduced proportionally above this so adding AI cameras degrades fps instead of overloading.
70    pub ai_max_total_fps: f64,
71    pub default_ai_fps: f64,
72    pub default_ai_width: i64,
73    /// How long detection rows are kept before the retention sweeper prunes them.
74    pub detection_retention_hours: i64,
75    // ---- Scheduled interval snapshots ----
76    /// Master switch for the background snapshot scheduler (interval live-frame captures).
77    pub snapshot_scheduler_enabled: bool,
78    /// How often the scheduler ticks to look for due schedules (seconds).
79    pub snapshot_scheduler_interval_s: u64,
80    /// How long captured snapshots are kept before the retention sweeper prunes them. 0 = no pruning.
81    pub snapshot_retention_hours: i64,
82    // ---- Per-camera recording schedule (time-of-day windows) ----
83    /// How often the schedule watcher ticks to open/close recording windows for `scheduled` /
84    /// `scheduled_event` cameras (seconds). Windows are evaluated against the SERVER's LOCAL timezone.
85    pub schedule_check_interval_s: u64,
86    // ---- Segment-spanning HLS playback sessions (kernel platform feature) ----
87    /// How long a generated playback session (its HLS dir + the segment read-locks it holds) is
88    /// retained before the cleanup sweeper removes the dir and releases its locks. Server time.
89    pub playback_session_ttl_minutes: i64,
90    /// Maximum playback session span (seconds); a longer requested range is rejected (HTTP 400).
91    pub max_playback_seconds: f64,
92    // ---- Auth / RBAC (kernel platform feature) ----
93    /// Master switch for authentication + RBAC. When false, the API is open (dev/single-tenant
94    /// LAN appliance default) and a synthetic admin principal is used. When true, the auth/admin
95    /// surface requires a valid bearer token (session or API key) and enforces roles.
96    pub auth_enabled: bool,
97    /// Lifetime of an issued login session token.
98    pub session_ttl_hours: i64,
99    /// Idle timeout (minutes): a session unused for longer than this is rejected even before its
100    /// absolute TTL. 0 (default) disables it. Recommended for internet-exposed remote-dashboard
101    /// access (bounds a stolen token's window), paired with a shorter `session_ttl_hours`.
102    pub session_idle_timeout_minutes: i64,
103    /// Add `Secure` to the session cookie (require HTTPS). Default false for HTTP LAN/overlay
104    /// appliances; set true when the deployment is served over TLS.
105    pub auth_cookie_secure: bool,
106    /// Per-account brute-force lockout: lock an account after this many CONSECUTIVE failed logins
107    /// (the per-IP Worker rate limit is complementary). 0 disables account lockout.
108    pub login_max_failures: i64,
109    /// How long a locked account stays locked (minutes); auto-unlocks after the window. 0 disables.
110    pub login_lockout_min: i64,
111    /// Base64-encoded 32-byte master key for encryption-at-rest of sensitive fields (camera
112    /// credentials). Unset = plaintext at rest (LAN appliance). Installed via `services::secrets`.
113    pub secret_key_b64: Option<String>,
114    /// Turn the production guardrails (see `enforce_production_guardrails`) into hard boot failures
115    /// instead of warnings, for an internet-exposed deployment.
116    pub strict_prod: bool,
117    /// Optional first-run admin bootstrap (only used when no users exist yet).
118    pub bootstrap_admin_user: Option<String>,
119    pub bootstrap_admin_password: Option<String>,
120    /// How long kernel audit-log + generic-event rows are kept before retention prunes them.
121    pub audit_retention_days: i64,
122    // ---- Remote-access overlay (kernel platform feature; see docs/REMOTE-ACCESS.md) ----
123    /// Whether this deployment is reached through a WireGuard overlay (Tailscale / NetBird /
124    /// wireguard) running as an external daemon on the host. The kernel does not manage the
125    /// overlay; it only reports whether the configured interface is present + up so the dashboard
126    /// can surface remote-access health. When false, the deployment is LAN-only.
127    pub overlay_enabled: bool,
128    /// Label for the overlay in use: `tailscale` | `netbird` | `wireguard` | `none`.
129    pub overlay_kind: String,
130    /// The overlay's network interface to probe (e.g. `tailscale0`, `wt0`, `wg0`).
131    pub overlay_iface: Option<String>,
132    // ---- Backup subsystem (kernel platform feature) ----
133    /// Path to the `rclone` binary used for sftp/ftp/s3 remote backups. Local/NAS-mount backups use
134    /// std fs copy and never need it; remote backups degrade to a clear job error when it is missing.
135    pub rclone_bin: String,
136    /// Master switch for the background backup scheduler (scheduled policy jobs). On-demand archive
137    /// export still works when this is false.
138    pub backup_enabled: bool,
139    /// How often the backup scheduler ticks to look for due policies (seconds).
140    pub backup_scheduler_interval_s: u64,
141    /// Hard timeout for a single backup job's transfer (seconds); a job exceeding it is marked error.
142    pub backup_job_timeout_s: u64,
143    /// Maximum number of backup jobs running concurrently (a tokio Semaphore bounds the scheduler +
144    /// manual triggers).
145    pub backup_max_concurrent_jobs: usize,
146    /// Where on-demand archive (.zip) exports are written; also served at `/media/archives`.
147    pub archive_dir: PathBuf,
148    /// Maximum total source footprint (sum of segment sizes) for a single archive export; a larger
149    /// selection is rejected (HTTP 400).
150    pub archive_max_bytes: u64,
151    /// How long archive exports + finished backup-job rows are kept before retention prunes them.
152    pub archive_retention_hours: i64,
153    // ---- ONVIF (kernel platform feature; Profile S MVP) ----
154    /// How long the WS-Discovery probe listens for ProbeMatch replies (milliseconds).
155    pub onvif_discovery_timeout_ms: u64,
156    /// Per-request timeout for an ONVIF SOAP call (GetDeviceInformation, PTZ, etc.) in milliseconds.
157    pub onvif_request_timeout_ms: u64,
158    /// Per-request timeout for a HikVision ISAPI camera-config call (HTTP Digest) in milliseconds.
159    pub isapi_request_timeout_ms: u64,
160    // ---- Disk / array health (HA ops; see docs/HA.md) ----
161    /// Run periodic SMART self-assessment checks (`smartctl -H`) inside the health loop. Off by
162    /// default; needs `smartmontools` on PATH. Missing binary degrades to a one-time log + skip.
163    pub smart_check_enabled: bool,
164    /// Block devices to query when SMART checks are enabled (e.g. `/dev/sda,/dev/sdb`).
165    pub smart_devices: Vec<String>,
166    /// Watch `/proc/mdstat` (Linux md/RAID) and emit `raid_degraded` when an array shows a down member.
167    pub mdstat_check_enabled: bool,
168    /// Cadence of the disk-health (SMART/RAID) check inside the health loop (seconds).
169    pub smart_check_interval_s: u64,
170    // ---- Readiness HA probe (see docs/HA.md) ----
171    /// When > 0, `/readyz` also requires at least this percent of enabled cameras to be actively
172    /// recording (503 `insufficient_recorders` otherwise). 0 (default) keeps DB-connectivity-only.
173    pub readyz_min_recording_percent: f64,
174    // ---- Live preview transcode (HEVC->H.264) hardware acceleration ----
175    /// Encoder engine for the live preview transcode path: `software` (libx264, default), `vaapi`,
176    /// or `nvenc`. Unknown values warn and fall back to software.
177    pub live_transcode_engine: String,
178    /// VAAPI render node used when `live_transcode_engine = vaapi`.
179    pub vaapi_device: String,
180    // ---- Fleet / multi-site identity ----
181    /// Optional site identifier stamped onto outbox rows and surfaced at `GET /api/v1/site` for the
182    /// edge->cloud fleet uplink. Empty/unset = a single unnamed site.
183    pub site_id: Option<String>,
184    /// Control-plane base URL for edge-side self-registration (`HELDAR_CP_URL`). Unset (default) = this
185    /// node never phones home; the fleet is opt-in. When set together with `site_id` and
186    /// `public_base_url`, the node POSTs its identity to the control plane on boot + on a heartbeat, so
187    /// the control plane drains it without any static config or restart.
188    pub cp_url: Option<String>,
189    /// This node's externally reachable base URL, as the control plane should address it
190    /// (`HELDAR_PUBLIC_BASE_URL`, e.g. its overlay/WireGuard address). Required for self-registration —
191    /// the node cannot infer it (it binds `0.0.0.0`). Unset → self-registration parks.
192    pub public_base_url: Option<String>,
193    /// Bearer credential the control plane presents when draining this node's outbox
194    /// (`HELDAR_CP_TOKEN`). Empty (default) when this node runs with auth disabled (the LAN default);
195    /// when auth is enabled, set it to a valid API key the control plane may use.
196    pub cp_token: String,
197    /// Heartbeat cadence (seconds) for re-registration with the control plane
198    /// (`HELDAR_CP_REGISTER_INTERVAL_S`). Re-registration is idempotent, so the heartbeat also
199    /// re-teaches a control plane that restarted or lost its registry.
200    pub cp_register_interval_s: u64,
201    /// Optional mTLS material for talking to the control plane: this node's client cert + key (to
202    /// present when registering) and the CA that signed the control plane's server cert (to verify
203    /// it). Required as a set when the control plane enforces mTLS; unset = plain HTTP to the control
204    /// plane (the LAN/overlay default).
205    pub cp_tls: Option<CpTlsCfg>,
206    /// Public WebRTC rendezvous URL (`HELDAR_REMOTE_RENDEZVOUS_URL`) the box dials OUT to for universal
207    /// remote viewing (ADR 0003, P2). Unset (default) → the rendezvous client parks; remote access is
208    /// opt-in. Reuses `site_id` for identity, `cp_token` as bearer, and `cp_tls` for mTLS.
209    pub rendezvous_url: Option<String>,
210    /// WebRTC ICE servers (`HELDAR_WEBRTC_ICE_SERVERS`) the kernel programs into MediaMTX so it gathers
211    /// reachable candidates for remote viewing — **bring your own** STUN/TURN. A JSON array in MediaMTX
212    /// `webrtcICEServers2` shape, e.g. `[{"url":"turn:turn.example.com:3478","username":"u","password":"p"}]`.
213    /// When unset but a `rendezvous_url` is configured, the kernel fetches short-lived TURN credentials
214    /// from the rendezvous (the Heldar-hosted option) and refreshes them; when neither is set, MediaMTX
215    /// is left as-is (LAN-only).
216    pub webrtc_ice_servers: Option<String>,
217    // ---- Plugin registry / store (Phase C) ----
218    /// Master switch for the plugin store's remote-registry fetching. When false, the store shows only
219    /// the bundled open catalog + locally installed plugins (fully offline). The bundled catalog is
220    /// always available regardless.
221    pub registry_enabled: bool,
222    /// Remote signed-catalog URLs to fetch (comma-separated). Default EMPTY — no phone-home; an
223    /// operator (or the proprietary build) sets the official Straits-AI registry here to populate the
224    /// proprietary/community shelves.
225    pub registry_urls: Vec<String>,
226    /// How often the background loop refreshes remote registries (seconds).
227    pub registry_refresh_s: u64,
228    /// Per-fetch timeout for a remote catalog (seconds).
229    pub registry_fetch_timeout_s: u64,
230    /// Operator-pinned extra trust anchors, `key_id:base64pubkey` comma-separated, added to the
231    /// compile-time pinned keys (for private registries).
232    pub registry_trusted_keys: Vec<(String, String)>,
233    /// When true, surface a remote registry's entries even if its signature does not verify (badged
234    /// unverified). Default false — fail closed.
235    pub registry_allow_unverified: bool,
236    /// When true, allow remote registry URLs that resolve to private/link-local addresses (default
237    /// false; SSRF guard for the admin-configured fetch).
238    pub registry_allow_private: bool,
239    // ---- Embedded dashboard (single-binary SPA serving) ----
240    /// Directory holding the built React dashboard (`apps/web/dist`), served as a static SPA
241    /// fallback so the whole product is one binary at one URL. Resolved from `HELDAR_WEB_DIR`; when
242    /// unset it falls back to `apps/web/dist` relative to the binary CWD. `None` when neither path
243    /// exists — the server then runs API-only (no dashboard).
244    pub web_dir: Option<PathBuf>,
245    // ---- Email / SMTP notifier (the off-by-default `smtp` feature) ----
246    /// SMTP relay host. Unset = email notifications disabled (the notifier parks).
247    pub smtp_host: Option<String>,
248    pub smtp_port: u16,
249    pub smtp_username: Option<String>,
250    pub smtp_password: Option<String>,
251    /// Envelope/From address (e.g. `heldar@site.example`). Required to send.
252    pub smtp_from: Option<String>,
253    /// `starttls` (587, default) | `implicit` (465) | `none`.
254    pub smtp_tls: String,
255    /// Recipient addresses that receive matching-event emails.
256    pub smtp_recipients: Vec<String>,
257    /// Severity floor for emailed events: `info` | `warning` (default) | `critical`.
258    pub smtp_min_severity: String,
259    /// How often the notifier polls for new events to email (seconds).
260    pub smtp_interval_s: u64,
261}
262
263/// mTLS material the edge presents to / uses to verify the control plane.
264#[derive(Clone, Debug)]
265pub struct CpTlsCfg {
266    /// PEM path: this node's client certificate (CN must equal `site_id`).
267    pub client_cert: PathBuf,
268    /// PEM path: the private key for the client certificate.
269    pub client_key: PathBuf,
270    /// PEM path: the CA that signed the control plane's server certificate.
271    pub server_ca: PathBuf,
272}
273
274/// Read the control-plane mTLS material from the environment. All-or-none: a partial set is a
275/// misconfiguration, so warn and disable mTLS (the heartbeat will then fail loudly against an https
276/// control plane, which is the visible signal to fix the config).
277fn cp_tls_from_env() -> Option<CpTlsCfg> {
278    match (
279        var("HELDAR_CP_TLS_CLIENT_CERT"),
280        var("HELDAR_CP_TLS_CLIENT_KEY"),
281        var("HELDAR_CP_TLS_CA"),
282    ) {
283        (None, None, None) => None,
284        (Some(client_cert), Some(client_key), Some(server_ca)) => Some(CpTlsCfg {
285            client_cert: client_cert.into(),
286            client_key: client_key.into(),
287            server_ca: server_ca.into(),
288        }),
289        _ => {
290            tracing::warn!(
291                "control-plane mTLS needs all of HELDAR_CP_TLS_CLIENT_CERT, HELDAR_CP_TLS_CLIENT_KEY, HELDAR_CP_TLS_CA; ignoring partial config"
292            );
293            None
294        }
295    }
296}
297
298impl Config {
299    /// Whether per-account brute-force lockout is active (both knobs must be > 0).
300    pub fn login_lockout_enabled(&self) -> bool {
301        self.login_max_failures > 0 && self.login_lockout_min > 0
302    }
303
304    /// Fail-loud guardrails for an internet-exposed deployment. A configured remote rendezvous
305    /// (`HELDAR_REMOTE_RENDEZVOUS_URL`) means the box is reachable from the internet, so an unsafe auth
306    /// posture is a misconfiguration: with auth disabled we **refuse to boot** (the open API must never
307    /// be exposed); otherwise we WARN — or refuse, under `HELDAR_STRICT_PROD=true` — on a non-`Secure`
308    /// cookie, no idle timeout, an over-long session TTL, a localhost CORS allowlist, or plaintext
309    /// camera credentials. A LAN/overlay appliance (no rendezvous) keeps its intentional defaults.
310    pub fn enforce_production_guardrails(&self) -> anyhow::Result<()> {
311        if self.rendezvous_url.is_none() {
312            return Ok(());
313        }
314        if !self.auth_enabled {
315            anyhow::bail!(
316                "remote access is configured (HELDAR_REMOTE_RENDEZVOUS_URL) but HELDAR_AUTH_ENABLED=false \
317                 — refusing to expose the open API to the internet. Set HELDAR_AUTH_ENABLED=true."
318            );
319        }
320        let mut warnings: Vec<String> = Vec::new();
321        if !self.auth_cookie_secure {
322            warnings.push(
323                "HELDAR_AUTH_COOKIE_SECURE=false — set true so the session cookie requires HTTPS"
324                    .into(),
325            );
326        }
327        if self.session_idle_timeout_minutes == 0 {
328            warnings.push(
329                "HELDAR_SESSION_IDLE_TIMEOUT_MIN=0 — set e.g. 30 to expire idle remote sessions"
330                    .into(),
331            );
332        }
333        if self.session_ttl_hours > 12 {
334            warnings.push(format!(
335                "HELDAR_SESSION_TTL_HOURS={} is long for remote access — consider 4 or less",
336                self.session_ttl_hours
337            ));
338        }
339        if self
340            .cors_origins
341            .iter()
342            .any(|o| o.contains("localhost") || o.contains("127.0.0.1"))
343        {
344            warnings.push(
345                "HELDAR_CORS_ORIGINS still allows localhost — lock it to the dashboard origin"
346                    .into(),
347            );
348        }
349        if self.secret_key_b64.is_none() {
350            warnings.push(
351                "HELDAR_SECRET_KEY is unset — camera credentials are stored in plaintext at rest"
352                    .into(),
353            );
354        }
355        if warnings.is_empty() {
356            return Ok(());
357        }
358        for w in &warnings {
359            tracing::warn!("production guardrail: {w}");
360        }
361        if self.strict_prod {
362            anyhow::bail!(
363                "HELDAR_STRICT_PROD=true and {} production guardrail(s) failed (see warnings above)",
364                warnings.len()
365            );
366        }
367        Ok(())
368    }
369
370    pub fn from_env() -> Self {
371        let data_dir = PathBuf::from(var_or("HELDAR_DATA_DIR", "./data"));
372        let recordings_dir = var("HELDAR_RECORDINGS_DIR")
373            .map(PathBuf::from)
374            .unwrap_or_else(|| data_dir.join("recordings"));
375        let clips_dir = var("HELDAR_CLIPS_DIR")
376            .map(PathBuf::from)
377            .unwrap_or_else(|| data_dir.join("clips"));
378        let snapshots_dir = var("HELDAR_SNAPSHOTS_DIR")
379            .map(PathBuf::from)
380            .unwrap_or_else(|| data_dir.join("snapshots"));
381        let frames_dir = var("HELDAR_FRAMES_DIR")
382            .map(PathBuf::from)
383            .unwrap_or_else(|| data_dir.join("frames"));
384        let playback_dir = var("HELDAR_PLAYBACK_DIR")
385            .map(PathBuf::from)
386            .unwrap_or_else(|| data_dir.join("playback"));
387        let archive_dir = var("HELDAR_ARCHIVE_DIR")
388            .map(PathBuf::from)
389            .unwrap_or_else(|| data_dir.join("archives"));
390
391        let cors_origins = var_or("HELDAR_CORS_ORIGINS", "http://localhost:5173")
392            .split(',')
393            .map(|s| s.trim().to_string())
394            .filter(|s| !s.is_empty())
395            .collect();
396
397        // Embedded dashboard: explicit HELDAR_WEB_DIR wins; otherwise try `apps/web/dist` relative
398        // to the binary CWD. Only `Some` when the directory actually exists (else API-only).
399        let web_dir = var("HELDAR_WEB_DIR")
400            .map(PathBuf::from)
401            .unwrap_or_else(|| PathBuf::from("apps/web/dist"));
402        let web_dir = if web_dir.is_dir() {
403            Some(web_dir)
404        } else {
405            None
406        };
407
408        let max_recordings_gb: f64 = parse_or("HELDAR_MAX_RECORDINGS_GB", 20.0);
409        let min_free_disk_gb: f64 = parse_or("HELDAR_MIN_FREE_DISK_GB", 5.0);
410        let default_camera_quota_gb: f64 = parse_or("HELDAR_DEFAULT_CAMERA_QUOTA_GB", 0.0);
411
412        Config {
413            database_url: var_or("HELDAR_DATABASE_URL", "sqlite://./data/heldar.db"),
414            data_dir,
415            recordings_dir,
416            clips_dir,
417            snapshots_dir,
418            frames_dir,
419            playback_dir,
420            ffmpeg_bin: var_or("HELDAR_FFMPEG_BIN", "ffmpeg"),
421            ffprobe_bin: var_or("HELDAR_FFPROBE_BIN", "ffprobe"),
422            mediamtx_api_url: var_or("HELDAR_MEDIAMTX_API_URL", "http://127.0.0.1:9997"),
423            mediamtx_hls_base: var_or("HELDAR_MEDIAMTX_HLS_BASE", "http://127.0.0.1:8888"),
424            mediamtx_rtsp_base: var_or("HELDAR_MEDIAMTX_RTSP_BASE", "rtsp://127.0.0.1:8554"),
425            mediamtx_webrtc_base: var_or("HELDAR_MEDIAMTX_WEBRTC_BASE", "http://127.0.0.1:8889"),
426            db_max_connections: parse_or::<u32>("HELDAR_DB_MAX_CONNECTIONS", 16).clamp(2, 256),
427            recorder_enabled: parse_bool("HELDAR_RECORDER_ENABLED", true),
428            mirror_recordings_dir: var("HELDAR_MIRROR_RECORDINGS_DIR").map(PathBuf::from),
429            anr_enabled: parse_bool("HELDAR_ANR_ENABLED", false),
430            anr_interval_s: parse_or("HELDAR_ANR_INTERVAL_S", 300),
431            anr_max_gap_hours: parse_or("HELDAR_ANR_MAX_GAP_HOURS", 24),
432            anr_max_attempts: parse_or("HELDAR_ANR_MAX_ATTEMPTS", 3),
433            default_segment_seconds: parse_or("HELDAR_DEFAULT_SEGMENT_SECONDS", 60),
434            default_retention_hours: parse_or("HELDAR_DEFAULT_RETENTION_HOURS", 24),
435            default_camera_quota_bytes: (default_camera_quota_gb * 1024.0 * 1024.0 * 1024.0) as u64,
436            default_record_audio: parse_bool("HELDAR_DEFAULT_RECORD_AUDIO", false),
437            default_pre_roll_seconds: parse_or("HELDAR_DEFAULT_PRE_ROLL_SECONDS", 10),
438            default_post_roll_seconds: parse_or("HELDAR_DEFAULT_POST_ROLL_SECONDS", 30),
439            indexer_interval_s: parse_or("HELDAR_INDEXER_INTERVAL_S", 10),
440            health_interval_s: parse_or("HELDAR_HEALTH_INTERVAL_S", 15),
441            retention_interval_s: parse_or("HELDAR_RETENTION_INTERVAL_S", 300),
442            api_host: var_or("HELDAR_API_HOST", "0.0.0.0"),
443            api_port: parse_or("HELDAR_API_PORT", 8000),
444            cors_origins,
445            max_recordings_bytes: (max_recordings_gb * 1024.0 * 1024.0 * 1024.0) as u64,
446            min_free_disk_bytes: (min_free_disk_gb * 1024.0 * 1024.0 * 1024.0) as u64,
447            notifier_interval_s: parse_or("HELDAR_NOTIFIER_INTERVAL_S", 15),
448            ai_enabled: parse_bool("HELDAR_AI_ENABLED", true),
449            ai_max_total_fps: parse_or("HELDAR_AI_MAX_TOTAL_FPS", 40.0),
450            default_ai_fps: parse_or("HELDAR_DEFAULT_AI_FPS", 5.0),
451            default_ai_width: parse_or("HELDAR_DEFAULT_AI_WIDTH", 1280),
452            detection_retention_hours: parse_or("HELDAR_DETECTION_RETENTION_HOURS", 168),
453            snapshot_scheduler_enabled: parse_bool("HELDAR_SNAPSHOT_SCHEDULER_ENABLED", true),
454            snapshot_scheduler_interval_s: parse_or("HELDAR_SNAPSHOT_SCHEDULER_INTERVAL_S", 60),
455            snapshot_retention_hours: parse_or("HELDAR_SNAPSHOT_RETENTION_HOURS", 168),
456            schedule_check_interval_s: parse_or("HELDAR_SCHEDULE_CHECK_INTERVAL_S", 30),
457            playback_session_ttl_minutes: parse_or("HELDAR_PLAYBACK_SESSION_TTL_MINUTES", 60),
458            max_playback_seconds: parse_or("HELDAR_MAX_PLAYBACK_SECONDS", 7200.0),
459            auth_enabled: parse_bool("HELDAR_AUTH_ENABLED", false),
460            session_ttl_hours: parse_or("HELDAR_SESSION_TTL_HOURS", 12),
461            session_idle_timeout_minutes: parse_or("HELDAR_SESSION_IDLE_TIMEOUT_MIN", 0),
462            auth_cookie_secure: parse_bool("HELDAR_AUTH_COOKIE_SECURE", false),
463            login_max_failures: parse_or("HELDAR_LOGIN_MAX_FAILURES", 5),
464            login_lockout_min: parse_or("HELDAR_LOGIN_LOCKOUT_MIN", 15),
465            secret_key_b64: var("HELDAR_SECRET_KEY"),
466            strict_prod: parse_bool("HELDAR_STRICT_PROD", false),
467            bootstrap_admin_user: var("HELDAR_BOOTSTRAP_ADMIN_USER"),
468            bootstrap_admin_password: var("HELDAR_BOOTSTRAP_ADMIN_PASSWORD"),
469            audit_retention_days: parse_or("HELDAR_AUDIT_RETENTION_DAYS", 365),
470            overlay_enabled: parse_bool("HELDAR_OVERLAY_ENABLED", false),
471            overlay_kind: var_or("HELDAR_OVERLAY_KIND", "none"),
472            overlay_iface: var("HELDAR_OVERLAY_IFACE"),
473            rclone_bin: var_or("HELDAR_RCLONE_BIN", "rclone"),
474            backup_enabled: parse_bool("HELDAR_BACKUP_ENABLED", true),
475            backup_scheduler_interval_s: parse_or("HELDAR_BACKUP_SCHEDULER_INTERVAL_S", 60),
476            backup_job_timeout_s: parse_or("HELDAR_BACKUP_JOB_TIMEOUT_S", 3600),
477            backup_max_concurrent_jobs: parse_or::<usize>("HELDAR_BACKUP_MAX_CONCURRENT_JOBS", 2)
478                .max(1),
479            archive_dir,
480            archive_max_bytes: parse_or("HELDAR_ARCHIVE_MAX_BYTES", 10_737_418_240u64),
481            archive_retention_hours: parse_or("HELDAR_ARCHIVE_RETENTION_HOURS", 48),
482            onvif_discovery_timeout_ms: parse_or("HELDAR_ONVIF_DISCOVERY_TIMEOUT_MS", 2000),
483            onvif_request_timeout_ms: parse_or("HELDAR_ONVIF_REQUEST_TIMEOUT_MS", 5000),
484            isapi_request_timeout_ms: parse_or("HELDAR_ISAPI_REQUEST_TIMEOUT_MS", 8000),
485            smart_check_enabled: parse_bool("HELDAR_SMART_CHECK_ENABLED", false),
486            smart_devices: var("HELDAR_SMART_DEVICES")
487                .map(|v| {
488                    v.split(',')
489                        .map(|s| s.trim().to_string())
490                        .filter(|s| !s.is_empty())
491                        .collect()
492                })
493                .unwrap_or_default(),
494            mdstat_check_enabled: parse_bool("HELDAR_MDSTAT_CHECK_ENABLED", false),
495            smart_check_interval_s: parse_or("HELDAR_SMART_CHECK_INTERVAL_S", 300),
496            readyz_min_recording_percent: parse_or("HELDAR_READYZ_MIN_RECORDING_PERCENT", 0.0),
497            live_transcode_engine: var_or("HELDAR_LIVE_TRANSCODE_ENGINE", "software"),
498            vaapi_device: var_or("HELDAR_VAAPI_DEVICE", "/dev/dri/renderD128"),
499            site_id: var("HELDAR_SITE_ID"),
500            cp_url: var("HELDAR_CP_URL"),
501            public_base_url: var("HELDAR_PUBLIC_BASE_URL"),
502            cp_token: var_or("HELDAR_CP_TOKEN", ""),
503            cp_register_interval_s: parse_or("HELDAR_CP_REGISTER_INTERVAL_S", 300),
504            cp_tls: cp_tls_from_env(),
505            rendezvous_url: var("HELDAR_REMOTE_RENDEZVOUS_URL"),
506            webrtc_ice_servers: var("HELDAR_WEBRTC_ICE_SERVERS"),
507            registry_enabled: parse_bool("HELDAR_REGISTRY_ENABLED", true),
508            registry_urls: var_or("HELDAR_REGISTRY_URLS", "")
509                .split(',')
510                .map(|s| s.trim().to_string())
511                .filter(|s| !s.is_empty())
512                .collect(),
513            registry_refresh_s: parse_or("HELDAR_REGISTRY_REFRESH_S", 900),
514            registry_fetch_timeout_s: parse_or("HELDAR_REGISTRY_FETCH_TIMEOUT_S", 10),
515            registry_trusted_keys: var_or("HELDAR_REGISTRY_TRUSTED_KEYS", "")
516                .split(',')
517                .filter_map(|s| {
518                    let s = s.trim();
519                    s.split_once(':')
520                        .map(|(id, key)| (id.trim().to_string(), key.trim().to_string()))
521                        .filter(|(id, key)| !id.is_empty() && !key.is_empty())
522                })
523                .collect(),
524            registry_allow_unverified: parse_bool("HELDAR_REGISTRY_ALLOW_UNVERIFIED", false),
525            registry_allow_private: parse_bool("HELDAR_REGISTRY_ALLOW_PRIVATE", false),
526            web_dir,
527            smtp_host: var("HELDAR_SMTP_HOST"),
528            smtp_port: parse_or("HELDAR_SMTP_PORT", 587u16),
529            smtp_username: var("HELDAR_SMTP_USERNAME"),
530            smtp_password: var("HELDAR_SMTP_PASSWORD"),
531            smtp_from: var("HELDAR_SMTP_FROM"),
532            smtp_tls: var_or("HELDAR_SMTP_TLS", "starttls"),
533            smtp_recipients: var_or("HELDAR_SMTP_RECIPIENTS", "")
534                .split(',')
535                .map(|s| s.trim().to_string())
536                .filter(|s| !s.is_empty())
537                .collect(),
538            smtp_min_severity: var_or("HELDAR_SMTP_MIN_SEVERITY", "warning"),
539            smtp_interval_s: parse_or("HELDAR_SMTP_INTERVAL_S", 30),
540        }
541    }
542
543    /// Directory where a camera's segments are stored.
544    pub fn camera_recordings_dir(&self, camera_id: &str) -> PathBuf {
545        self.recordings_dir.join(camera_id)
546    }
547
548    /// Directory where a camera's sampled AI frames are written.
549    pub fn camera_frames_dir(&self, camera_id: &str) -> PathBuf {
550        self.frames_dir.join(camera_id)
551    }
552}