Skip to main content

heldar_kernel/
config.rs

1use std::env;
2use std::path::PathBuf;
3
4/// Runtime configuration, loaded from environment (see `.env.example`).
5#[derive(Clone, Debug)]
6pub struct Config {
7    pub database_url: String,
8    pub data_dir: PathBuf,
9    pub recordings_dir: PathBuf,
10    pub clips_dir: PathBuf,
11    pub snapshots_dir: PathBuf,
12    pub frames_dir: PathBuf,
13    /// Directory where segment-spanning HLS playback sessions are generated (one subdir per session).
14    pub playback_dir: PathBuf,
15    pub ffmpeg_bin: String,
16    pub ffprobe_bin: String,
17    pub mediamtx_api_url: String,
18    pub mediamtx_hls_base: String,
19    pub mediamtx_rtsp_base: String,
20    pub mediamtx_webrtc_base: String,
21    /// Max SQLite pool connections. Tunable per deployment: more absorbs bursts of concurrent
22    /// requests (WAL serves reads concurrently; writes still serialize), at the cost of memory.
23    pub db_max_connections: u32,
24    pub recorder_enabled: bool,
25    /// Optional second recordings root for dual/mirror recording. When set, cameras with
26    /// `mirror_enabled` get a SECOND ffmpeg pipeline writing byte-identical segments here (a redundant
27    /// DVR copy on a separate volume). Empty/unset disables mirror recording entirely.
28    pub mirror_recordings_dir: Option<PathBuf>,
29    /// Master switch for ANR (Automatic Network Replenishment) edge re-fill: re-fetch missed footage
30    /// from a camera's onboard storage to fill recording gaps. Cameras still need `anr_enabled`.
31    pub anr_enabled: bool,
32    /// How often the ANR loop scans for pending gaps to fill (seconds).
33    pub anr_interval_s: u64,
34    /// Ignore gaps older than this many hours (most cameras only retain recent onboard footage).
35    pub anr_max_gap_hours: i64,
36    /// Give up on a gap after this many fill attempts (marked `failed`).
37    pub anr_max_attempts: i64,
38    pub default_segment_seconds: i64,
39    pub default_retention_hours: i64,
40    /// Default per-camera storage quota (bytes) applied when a camera is created without an explicit
41    /// `storage_quota_bytes`. 0 means no default quota (the camera's quota is stored as NULL).
42    pub default_camera_quota_bytes: u64,
43    /// Default audio-recording toggle applied when a camera is created without an explicit
44    /// `record_audio`. When false (default) the recorder drops audio (video only).
45    pub default_record_audio: bool,
46    /// Default pre-roll seconds applied when a camera is created without an explicit
47    /// `pre_roll_seconds` (event / scheduled_event recording). Clamped to 0..300 in handlers.
48    pub default_pre_roll_seconds: i64,
49    /// Default post-roll seconds (the trigger recording window) applied when a camera is created
50    /// without an explicit `post_roll_seconds`. Clamped to 0..3600 in handlers.
51    pub default_post_roll_seconds: i64,
52    pub indexer_interval_s: u64,
53    pub health_interval_s: u64,
54    pub retention_interval_s: u64,
55    pub api_host: String,
56    pub api_port: u16,
57    pub cors_origins: Vec<String>,
58    /// Soft cap on total recording footprint; oldest unlocked segments are pruned above this.
59    pub max_recordings_bytes: u64,
60    /// Hard floor on free disk space; when free space drops below this, oldest unlocked segments
61    /// are pruned regardless of age/size policy (protects the host from a full disk).
62    pub min_free_disk_bytes: u64,
63    /// Optional webhook URL that receives warning/critical events as JSON (alerting).
64    pub alert_webhook_url: Option<String>,
65    /// How often the alert notifier polls for new events to deliver.
66    pub notifier_interval_s: u64,
67    /// Master switch for AI frame sampling (Stage 2). Cameras still need an enabled AI task.
68    pub ai_enabled: bool,
69    /// Global frame-sampling budget (frames/sec summed across all cameras); per-camera fps is
70    /// reduced proportionally above this so adding AI cameras degrades fps instead of overloading.
71    pub ai_max_total_fps: f64,
72    pub default_ai_fps: f64,
73    pub default_ai_width: i64,
74    /// How long detection rows are kept before the retention sweeper prunes them.
75    pub detection_retention_hours: i64,
76    // ---- Scheduled interval snapshots ----
77    /// Master switch for the background snapshot scheduler (interval live-frame captures).
78    pub snapshot_scheduler_enabled: bool,
79    /// How often the scheduler ticks to look for due schedules (seconds).
80    pub snapshot_scheduler_interval_s: u64,
81    /// How long captured snapshots are kept before the retention sweeper prunes them. 0 = no pruning.
82    pub snapshot_retention_hours: i64,
83    // ---- Per-camera recording schedule (time-of-day windows) ----
84    /// How often the schedule watcher ticks to open/close recording windows for `scheduled` /
85    /// `scheduled_event` cameras (seconds). Windows are evaluated against the SERVER's LOCAL timezone.
86    pub schedule_check_interval_s: u64,
87    // ---- Segment-spanning HLS playback sessions (kernel platform feature) ----
88    /// How long a generated playback session (its HLS dir + the segment read-locks it holds) is
89    /// retained before the cleanup sweeper removes the dir and releases its locks. Server time.
90    pub playback_session_ttl_minutes: i64,
91    /// Maximum playback session span (seconds); a longer requested range is rejected (HTTP 400).
92    pub max_playback_seconds: f64,
93    // ---- Auth / RBAC (kernel platform feature) ----
94    /// Master switch for authentication + RBAC. When false, the API is open (dev/single-tenant
95    /// LAN appliance default) and a synthetic admin principal is used. When true, the auth/admin
96    /// surface requires a valid bearer token (session or API key) and enforces roles.
97    pub auth_enabled: bool,
98    /// Lifetime of an issued login session token.
99    pub session_ttl_hours: i64,
100    /// Add `Secure` to the session cookie (require HTTPS). Default false for HTTP LAN/overlay
101    /// appliances; set true when the deployment is served over TLS.
102    pub auth_cookie_secure: bool,
103    /// Optional first-run admin bootstrap (only used when no users exist yet).
104    pub bootstrap_admin_user: Option<String>,
105    pub bootstrap_admin_password: Option<String>,
106    /// How long kernel audit-log + generic-event rows are kept before retention prunes them.
107    pub audit_retention_days: i64,
108    // ---- Remote-access overlay (kernel platform feature; see docs/REMOTE-ACCESS.md) ----
109    /// Whether this deployment is reached through a WireGuard overlay (Tailscale / NetBird /
110    /// wireguard) running as an external daemon on the host. The kernel does not manage the
111    /// overlay; it only reports whether the configured interface is present + up so the dashboard
112    /// can surface remote-access health. When false, the deployment is LAN-only.
113    pub overlay_enabled: bool,
114    /// Label for the overlay in use: `tailscale` | `netbird` | `wireguard` | `none`.
115    pub overlay_kind: String,
116    /// The overlay's network interface to probe (e.g. `tailscale0`, `wt0`, `wg0`).
117    pub overlay_iface: Option<String>,
118    // ---- Backup subsystem (kernel platform feature) ----
119    /// Path to the `rclone` binary used for sftp/ftp/s3 remote backups. Local/NAS-mount backups use
120    /// std fs copy and never need it; remote backups degrade to a clear job error when it is missing.
121    pub rclone_bin: String,
122    /// Master switch for the background backup scheduler (scheduled policy jobs). On-demand archive
123    /// export still works when this is false.
124    pub backup_enabled: bool,
125    /// How often the backup scheduler ticks to look for due policies (seconds).
126    pub backup_scheduler_interval_s: u64,
127    /// Hard timeout for a single backup job's transfer (seconds); a job exceeding it is marked error.
128    pub backup_job_timeout_s: u64,
129    /// Maximum number of backup jobs running concurrently (a tokio Semaphore bounds the scheduler +
130    /// manual triggers).
131    pub backup_max_concurrent_jobs: usize,
132    /// Where on-demand archive (.zip) exports are written; also served at `/media/archives`.
133    pub archive_dir: PathBuf,
134    /// Maximum total source footprint (sum of segment sizes) for a single archive export; a larger
135    /// selection is rejected (HTTP 400).
136    pub archive_max_bytes: u64,
137    /// How long archive exports + finished backup-job rows are kept before retention prunes them.
138    pub archive_retention_hours: i64,
139    // ---- ONVIF (kernel platform feature; Profile S MVP) ----
140    /// How long the WS-Discovery probe listens for ProbeMatch replies (milliseconds).
141    pub onvif_discovery_timeout_ms: u64,
142    /// Per-request timeout for an ONVIF SOAP call (GetDeviceInformation, PTZ, etc.) in milliseconds.
143    pub onvif_request_timeout_ms: u64,
144    /// Per-request timeout for a HikVision ISAPI camera-config call (HTTP Digest) in milliseconds.
145    pub isapi_request_timeout_ms: u64,
146    // ---- Disk / array health (HA ops; see docs/HA.md) ----
147    /// Run periodic SMART self-assessment checks (`smartctl -H`) inside the health loop. Off by
148    /// default; needs `smartmontools` on PATH. Missing binary degrades to a one-time log + skip.
149    pub smart_check_enabled: bool,
150    /// Block devices to query when SMART checks are enabled (e.g. `/dev/sda,/dev/sdb`).
151    pub smart_devices: Vec<String>,
152    /// Watch `/proc/mdstat` (Linux md/RAID) and emit `raid_degraded` when an array shows a down member.
153    pub mdstat_check_enabled: bool,
154    /// Cadence of the disk-health (SMART/RAID) check inside the health loop (seconds).
155    pub smart_check_interval_s: u64,
156    // ---- Readiness HA probe (see docs/HA.md) ----
157    /// When > 0, `/readyz` also requires at least this percent of enabled cameras to be actively
158    /// recording (503 `insufficient_recorders` otherwise). 0 (default) keeps DB-connectivity-only.
159    pub readyz_min_recording_percent: f64,
160    // ---- Live preview transcode (HEVC->H.264) hardware acceleration ----
161    /// Encoder engine for the live preview transcode path: `software` (libx264, default), `vaapi`,
162    /// or `nvenc`. Unknown values warn and fall back to software.
163    pub live_transcode_engine: String,
164    /// VAAPI render node used when `live_transcode_engine = vaapi`.
165    pub vaapi_device: String,
166    // ---- Fleet / multi-site identity ----
167    /// Optional site identifier stamped onto outbox rows and surfaced at `GET /api/v1/site` for the
168    /// edge->cloud fleet uplink. Empty/unset = a single unnamed site.
169    pub site_id: Option<String>,
170    // ---- Embedded dashboard (single-binary SPA serving) ----
171    /// Directory holding the built React dashboard (`apps/web/dist`), served as a static SPA
172    /// fallback so the whole product is one binary at one URL. Resolved from `HELDAR_WEB_DIR`; when
173    /// unset it falls back to `apps/web/dist` relative to the binary CWD. `None` when neither path
174    /// exists — the server then runs API-only (no dashboard).
175    pub web_dir: Option<PathBuf>,
176}
177
178fn var(key: &str) -> Option<String> {
179    env::var(key).ok().filter(|s| !s.trim().is_empty())
180}
181
182fn var_or(key: &str, default: &str) -> String {
183    var(key).unwrap_or_else(|| default.to_string())
184}
185
186fn parse_or<T: std::str::FromStr>(key: &str, default: T) -> T {
187    var(key).and_then(|v| v.parse().ok()).unwrap_or(default)
188}
189
190fn parse_bool(key: &str, default: bool) -> bool {
191    match var(key) {
192        Some(v) => matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"),
193        None => default,
194    }
195}
196
197impl Config {
198    pub fn from_env() -> Self {
199        let data_dir = PathBuf::from(var_or("HELDAR_DATA_DIR", "./data"));
200        let recordings_dir = var("HELDAR_RECORDINGS_DIR")
201            .map(PathBuf::from)
202            .unwrap_or_else(|| data_dir.join("recordings"));
203        let clips_dir = var("HELDAR_CLIPS_DIR")
204            .map(PathBuf::from)
205            .unwrap_or_else(|| data_dir.join("clips"));
206        let snapshots_dir = var("HELDAR_SNAPSHOTS_DIR")
207            .map(PathBuf::from)
208            .unwrap_or_else(|| data_dir.join("snapshots"));
209        let frames_dir = var("HELDAR_FRAMES_DIR")
210            .map(PathBuf::from)
211            .unwrap_or_else(|| data_dir.join("frames"));
212        let playback_dir = var("HELDAR_PLAYBACK_DIR")
213            .map(PathBuf::from)
214            .unwrap_or_else(|| data_dir.join("playback"));
215        let archive_dir = var("HELDAR_ARCHIVE_DIR")
216            .map(PathBuf::from)
217            .unwrap_or_else(|| data_dir.join("archives"));
218
219        let cors_origins = var_or("HELDAR_CORS_ORIGINS", "http://localhost:5173")
220            .split(',')
221            .map(|s| s.trim().to_string())
222            .filter(|s| !s.is_empty())
223            .collect();
224
225        // Embedded dashboard: explicit HELDAR_WEB_DIR wins; otherwise try `apps/web/dist` relative
226        // to the binary CWD. Only `Some` when the directory actually exists (else API-only).
227        let web_dir = var("HELDAR_WEB_DIR")
228            .map(PathBuf::from)
229            .unwrap_or_else(|| PathBuf::from("apps/web/dist"));
230        let web_dir = if web_dir.is_dir() {
231            Some(web_dir)
232        } else {
233            None
234        };
235
236        let max_recordings_gb: f64 = parse_or("HELDAR_MAX_RECORDINGS_GB", 20.0);
237        let min_free_disk_gb: f64 = parse_or("HELDAR_MIN_FREE_DISK_GB", 5.0);
238        let default_camera_quota_gb: f64 = parse_or("HELDAR_DEFAULT_CAMERA_QUOTA_GB", 0.0);
239
240        Config {
241            database_url: var_or("HELDAR_DATABASE_URL", "sqlite://./data/heldar.db"),
242            data_dir,
243            recordings_dir,
244            clips_dir,
245            snapshots_dir,
246            frames_dir,
247            playback_dir,
248            ffmpeg_bin: var_or("HELDAR_FFMPEG_BIN", "ffmpeg"),
249            ffprobe_bin: var_or("HELDAR_FFPROBE_BIN", "ffprobe"),
250            mediamtx_api_url: var_or("HELDAR_MEDIAMTX_API_URL", "http://127.0.0.1:9997"),
251            mediamtx_hls_base: var_or("HELDAR_MEDIAMTX_HLS_BASE", "http://127.0.0.1:8888"),
252            mediamtx_rtsp_base: var_or("HELDAR_MEDIAMTX_RTSP_BASE", "rtsp://127.0.0.1:8554"),
253            mediamtx_webrtc_base: var_or("HELDAR_MEDIAMTX_WEBRTC_BASE", "http://127.0.0.1:8889"),
254            db_max_connections: parse_or::<u32>("HELDAR_DB_MAX_CONNECTIONS", 16).clamp(2, 256),
255            recorder_enabled: parse_bool("HELDAR_RECORDER_ENABLED", true),
256            mirror_recordings_dir: var("HELDAR_MIRROR_RECORDINGS_DIR").map(PathBuf::from),
257            anr_enabled: parse_bool("HELDAR_ANR_ENABLED", false),
258            anr_interval_s: parse_or("HELDAR_ANR_INTERVAL_S", 300),
259            anr_max_gap_hours: parse_or("HELDAR_ANR_MAX_GAP_HOURS", 24),
260            anr_max_attempts: parse_or("HELDAR_ANR_MAX_ATTEMPTS", 3),
261            default_segment_seconds: parse_or("HELDAR_DEFAULT_SEGMENT_SECONDS", 60),
262            default_retention_hours: parse_or("HELDAR_DEFAULT_RETENTION_HOURS", 24),
263            default_camera_quota_bytes: (default_camera_quota_gb * 1024.0 * 1024.0 * 1024.0) as u64,
264            default_record_audio: parse_bool("HELDAR_DEFAULT_RECORD_AUDIO", false),
265            default_pre_roll_seconds: parse_or("HELDAR_DEFAULT_PRE_ROLL_SECONDS", 10),
266            default_post_roll_seconds: parse_or("HELDAR_DEFAULT_POST_ROLL_SECONDS", 30),
267            indexer_interval_s: parse_or("HELDAR_INDEXER_INTERVAL_S", 10),
268            health_interval_s: parse_or("HELDAR_HEALTH_INTERVAL_S", 15),
269            retention_interval_s: parse_or("HELDAR_RETENTION_INTERVAL_S", 300),
270            api_host: var_or("HELDAR_API_HOST", "0.0.0.0"),
271            api_port: parse_or("HELDAR_API_PORT", 8000),
272            cors_origins,
273            max_recordings_bytes: (max_recordings_gb * 1024.0 * 1024.0 * 1024.0) as u64,
274            min_free_disk_bytes: (min_free_disk_gb * 1024.0 * 1024.0 * 1024.0) as u64,
275            alert_webhook_url: var("HELDAR_ALERT_WEBHOOK_URL"),
276            notifier_interval_s: parse_or("HELDAR_NOTIFIER_INTERVAL_S", 15),
277            ai_enabled: parse_bool("HELDAR_AI_ENABLED", true),
278            ai_max_total_fps: parse_or("HELDAR_AI_MAX_TOTAL_FPS", 40.0),
279            default_ai_fps: parse_or("HELDAR_DEFAULT_AI_FPS", 5.0),
280            default_ai_width: parse_or("HELDAR_DEFAULT_AI_WIDTH", 1280),
281            detection_retention_hours: parse_or("HELDAR_DETECTION_RETENTION_HOURS", 168),
282            snapshot_scheduler_enabled: parse_bool("HELDAR_SNAPSHOT_SCHEDULER_ENABLED", true),
283            snapshot_scheduler_interval_s: parse_or("HELDAR_SNAPSHOT_SCHEDULER_INTERVAL_S", 60),
284            snapshot_retention_hours: parse_or("HELDAR_SNAPSHOT_RETENTION_HOURS", 168),
285            schedule_check_interval_s: parse_or("HELDAR_SCHEDULE_CHECK_INTERVAL_S", 30),
286            playback_session_ttl_minutes: parse_or("HELDAR_PLAYBACK_SESSION_TTL_MINUTES", 60),
287            max_playback_seconds: parse_or("HELDAR_MAX_PLAYBACK_SECONDS", 7200.0),
288            auth_enabled: parse_bool("HELDAR_AUTH_ENABLED", false),
289            session_ttl_hours: parse_or("HELDAR_SESSION_TTL_HOURS", 12),
290            auth_cookie_secure: parse_bool("HELDAR_AUTH_COOKIE_SECURE", false),
291            bootstrap_admin_user: var("HELDAR_BOOTSTRAP_ADMIN_USER"),
292            bootstrap_admin_password: var("HELDAR_BOOTSTRAP_ADMIN_PASSWORD"),
293            audit_retention_days: parse_or("HELDAR_AUDIT_RETENTION_DAYS", 365),
294            overlay_enabled: parse_bool("HELDAR_OVERLAY_ENABLED", false),
295            overlay_kind: var_or("HELDAR_OVERLAY_KIND", "none"),
296            overlay_iface: var("HELDAR_OVERLAY_IFACE"),
297            rclone_bin: var_or("HELDAR_RCLONE_BIN", "rclone"),
298            backup_enabled: parse_bool("HELDAR_BACKUP_ENABLED", true),
299            backup_scheduler_interval_s: parse_or("HELDAR_BACKUP_SCHEDULER_INTERVAL_S", 60),
300            backup_job_timeout_s: parse_or("HELDAR_BACKUP_JOB_TIMEOUT_S", 3600),
301            backup_max_concurrent_jobs: parse_or::<usize>("HELDAR_BACKUP_MAX_CONCURRENT_JOBS", 2)
302                .max(1),
303            archive_dir,
304            archive_max_bytes: parse_or("HELDAR_ARCHIVE_MAX_BYTES", 10_737_418_240u64),
305            archive_retention_hours: parse_or("HELDAR_ARCHIVE_RETENTION_HOURS", 48),
306            onvif_discovery_timeout_ms: parse_or("HELDAR_ONVIF_DISCOVERY_TIMEOUT_MS", 2000),
307            onvif_request_timeout_ms: parse_or("HELDAR_ONVIF_REQUEST_TIMEOUT_MS", 5000),
308            isapi_request_timeout_ms: parse_or("HELDAR_ISAPI_REQUEST_TIMEOUT_MS", 8000),
309            smart_check_enabled: parse_bool("HELDAR_SMART_CHECK_ENABLED", false),
310            smart_devices: var("HELDAR_SMART_DEVICES")
311                .map(|v| {
312                    v.split(',')
313                        .map(|s| s.trim().to_string())
314                        .filter(|s| !s.is_empty())
315                        .collect()
316                })
317                .unwrap_or_default(),
318            mdstat_check_enabled: parse_bool("HELDAR_MDSTAT_CHECK_ENABLED", false),
319            smart_check_interval_s: parse_or("HELDAR_SMART_CHECK_INTERVAL_S", 300),
320            readyz_min_recording_percent: parse_or("HELDAR_READYZ_MIN_RECORDING_PERCENT", 0.0),
321            live_transcode_engine: var_or("HELDAR_LIVE_TRANSCODE_ENGINE", "software"),
322            vaapi_device: var_or("HELDAR_VAAPI_DEVICE", "/dev/dri/renderD128"),
323            site_id: var("HELDAR_SITE_ID"),
324            web_dir,
325        }
326    }
327
328    /// Directory where a camera's segments are stored.
329    pub fn camera_recordings_dir(&self, camera_id: &str) -> PathBuf {
330        self.recordings_dir.join(camera_id)
331    }
332
333    /// Directory where a camera's sampled AI frames are written.
334    pub fn camera_frames_dir(&self, camera_id: &str) -> PathBuf {
335        self.frames_dir.join(camera_id)
336    }
337}