Skip to main content

heldar_kernel/
camera_url.rs

1//! RTSP URL construction from vendor templates, plus credential masking.
2
3use chrono::{DateTime, Utc};
4
5use crate::models::Camera;
6
7/// Map a logical stream name to a HikVision channel id (101 = main, 102 = sub).
8fn hik_channel(stream: &str) -> &'static str {
9    if stream == "sub" {
10        "102"
11    } else {
12        "101"
13    }
14}
15
16/// Percent-encode the reserved characters that would break the `user:pass@host` userinfo section.
17pub(crate) fn encode_userinfo(s: &str) -> String {
18    let mut out = String::with_capacity(s.len());
19    for b in s.bytes() {
20        match b {
21            // RFC 3986 unreserved + a few safe chars
22            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
23                out.push(b as char)
24            }
25            _ => out.push_str(&format!("%{:02X}", b)),
26        }
27    }
28    out
29}
30
31/// The camera password, decrypted via the process key (see [`crate::services::secrets`]). When no key
32/// is configured this is the stored plaintext unchanged. On a decrypt failure (a sealed value with a
33/// wrong/missing key) it logs and returns `None`, so the URL is built without a password — the camera
34/// fails to authenticate rather than being handed ciphertext.
35fn decrypted_password(cam: &Camera) -> Option<String> {
36    let stored = cam.password.as_deref()?;
37    match crate::services::secrets::decrypt_stored(stored) {
38        Ok(p) => Some(p),
39        Err(e) => {
40            tracing::error!(camera = %cam.id, "camera credential decrypt failed: {e}");
41            None
42        }
43    }
44}
45
46/// Build the RTSP URL (with credentials) for the given stream ("main" | "sub").
47/// Honors an explicit per-stream URL override; otherwise builds from the vendor template.
48pub fn stream_url(cam: &Camera, stream: &str) -> Option<String> {
49    let explicit = if stream == "sub" {
50        cam.sub_stream_url.as_deref()
51    } else {
52        cam.main_stream_url.as_deref()
53    };
54    if let Some(u) = explicit {
55        if !u.trim().is_empty() {
56            return Some(u.trim().to_string());
57        }
58    }
59
60    let host = cam.address.as_deref()?.trim();
61    if host.is_empty() {
62        return None;
63    }
64    let port = cam.rtsp_port;
65
66    let password = decrypted_password(cam);
67    let creds = match (cam.username.as_deref(), password.as_deref()) {
68        (Some(u), Some(p)) if !u.is_empty() => {
69            format!("{}:{}@", encode_userinfo(u), encode_userinfo(p))
70        }
71        (Some(u), _) if !u.is_empty() => format!("{}@", encode_userinfo(u)),
72        _ => String::new(),
73    };
74
75    let path = match cam.vendor.as_str() {
76        "hikvision" => format!("/Streaming/Channels/{}", hik_channel(stream)),
77        "dahua" => format!(
78            "/cam/realmonitor?channel=1&subtype={}",
79            if stream == "sub" { "1" } else { "0" }
80        ),
81        // generic/onvif: without an explicit URL we cannot guess a path.
82        _ => return None,
83    };
84
85    Some(format!("rtsp://{creds}{host}:{port}{path}"))
86}
87
88/// The RTSP URL for the stream this camera records.
89pub fn record_url(cam: &Camera) -> Option<String> {
90    stream_url(cam, &cam.record_stream)
91}
92
93/// A Hikvision-style replay timestamp: `20260613T120500Z` (UTC, used by ISAPI/RTSP playback).
94fn hik_replay_time(t: DateTime<Utc>) -> String {
95    t.format("%Y%m%dT%H%M%SZ").to_string()
96}
97
98/// Build the replay (playback) URL used by ANR to pull a `[start, end]` window from the camera's
99/// ONBOARD storage. Honors a per-camera `anr_replay_url_template` (with `{start}` / `{end}`
100/// placeholders, filled with Hikvision-format timestamps); otherwise defaults to the Hikvision RTSP
101/// playback endpoint (`/Streaming/tracks/{channel}?starttime=..&endtime=..`) built from the camera's
102/// address + credentials. Returns `None` when there is no template and no host/credentials to build
103/// one from. Best-effort and camera-dependent — see `services/anr.rs`.
104pub fn anr_replay_url(cam: &Camera, start: DateTime<Utc>, end: DateTime<Utc>) -> Option<String> {
105    let s = hik_replay_time(start);
106    let e = hik_replay_time(end);
107    if let Some(tpl) = cam.anr_replay_url_template.as_deref() {
108        let tpl = tpl.trim();
109        if !tpl.is_empty() {
110            return Some(tpl.replace("{start}", &s).replace("{end}", &e));
111        }
112    }
113    let host = cam.address.as_deref()?.trim();
114    if host.is_empty() {
115        return None;
116    }
117    let port = cam.rtsp_port;
118    let password = decrypted_password(cam);
119    let creds = match (cam.username.as_deref(), password.as_deref()) {
120        (Some(u), Some(p)) if !u.is_empty() => {
121            format!("{}:{}@", encode_userinfo(u), encode_userinfo(p))
122        }
123        (Some(u), _) if !u.is_empty() => format!("{}@", encode_userinfo(u)),
124        _ => String::new(),
125    };
126    let channel = hik_channel(&cam.record_stream);
127    Some(format!(
128        "rtsp://{creds}{host}:{port}/Streaming/tracks/{channel}?starttime={s}&endtime={e}"
129    ))
130}
131
132/// Schemes permitted for explicit camera stream URLs. Excludes `file:`, `gopher:`, etc., which
133/// would let ffmpeg/ffprobe/MediaMTX read local files or reach unintended protocols (SSRF/LFI).
134const ALLOWED_SCHEMES: &[&str] = &["rtsp", "rtsps", "http", "https"];
135
136/// Validate an operator-supplied stream URL: must parse and use an allowed scheme.
137pub fn validate_stream_url(url: &str) -> Result<(), String> {
138    let url = url.trim();
139    let Some((scheme, _)) = url.split_once("://") else {
140        return Err(format!(
141            "invalid stream URL `{}` (no scheme://)",
142            mask_url(url)
143        ));
144    };
145    let scheme = scheme.to_ascii_lowercase();
146    if !ALLOWED_SCHEMES.contains(&scheme.as_str()) {
147        return Err(format!(
148            "stream URL scheme `{scheme}` not allowed; use one of {ALLOWED_SCHEMES:?}"
149        ));
150    }
151    Ok(())
152}
153
154/// Replace `user:pass@` (or `user@`) credentials in an RTSP/HTTP URL with `***` for safe logging/display.
155pub fn mask_url(url: &str) -> String {
156    let Some(scheme_end) = url.find("://") else {
157        return url.to_string();
158    };
159    let after = scheme_end + 3;
160    // The userinfo/host boundary is the LAST '@' before the first '/' of the authority; using the
161    // last '@' ensures a literal '@' inside the password (from an explicit URL) is fully masked.
162    let authority_end = url[after..]
163        .find('/')
164        .map(|i| after + i)
165        .unwrap_or(url.len());
166    if let Some(at_rel) = url[after..authority_end].rfind('@') {
167        let at = after + at_rel;
168        format!("{}***@{}", &url[..after], &url[at + 1..])
169    } else {
170        url.to_string()
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::models::Camera;
178    use chrono::Utc;
179    use serde_json::json;
180    use sqlx::types::Json;
181
182    fn base() -> Camera {
183        Camera {
184            id: "cam1".into(),
185            site_id: None,
186            name: "Cam 1".into(),
187            vendor: "hikvision".into(),
188            model: None,
189            address: Some("192.168.0.2".into()),
190            rtsp_port: 554,
191            username: Some("admin".into()),
192            password: Some("p@ss/w:rd".into()),
193            main_stream_url: None,
194            sub_stream_url: None,
195            record_stream: "main".into(),
196            codec: None,
197            resolution_main: None,
198            resolution_sub: None,
199            fps_main: None,
200            fps_sub: None,
201            capabilities: Json(json!({})),
202            record_enabled: true,
203            segment_seconds: 60,
204            retention_hours: 24,
205            storage_quota_bytes: None,
206            record_audio: false,
207            record_mode: "continuous".into(),
208            pre_roll_seconds: 10,
209            post_roll_seconds: 30,
210            mirror_enabled: false,
211            anr_enabled: false,
212            anr_replay_url_template: None,
213            enabled: true,
214            priority: 100,
215            created_at: Utc::now(),
216            updated_at: Utc::now(),
217        }
218    }
219
220    #[test]
221    fn hikvision_main_url_percent_encodes_credentials() {
222        let c = base();
223        assert_eq!(
224            stream_url(&c, "main").unwrap(),
225            "rtsp://admin:p%40ss%2Fw%3Ard@192.168.0.2:554/Streaming/Channels/101"
226        );
227    }
228
229    #[test]
230    fn hikvision_sub_uses_channel_102() {
231        assert!(stream_url(&base(), "sub")
232            .unwrap()
233            .ends_with("/Streaming/Channels/102"));
234    }
235
236    #[test]
237    fn explicit_override_takes_precedence() {
238        let mut c = base();
239        c.main_stream_url = Some("rtsp://example/stream".into());
240        assert_eq!(stream_url(&c, "main").unwrap(), "rtsp://example/stream");
241    }
242
243    #[test]
244    fn generic_vendor_without_url_is_none() {
245        let mut c = base();
246        c.vendor = "generic".into();
247        c.main_stream_url = None;
248        assert!(stream_url(&c, "main").is_none());
249    }
250
251    #[test]
252    fn mask_url_hides_credentials() {
253        assert_eq!(
254            mask_url("rtsp://admin:secret@10.0.0.1:554/Streaming/Channels/101"),
255            "rtsp://***@10.0.0.1:554/Streaming/Channels/101"
256        );
257        assert_eq!(mask_url("rtsp://10.0.0.1:554/x"), "rtsp://10.0.0.1:554/x");
258    }
259
260    #[test]
261    fn mask_url_handles_at_in_password() {
262        // An explicit URL with a literal '@' in the password must be fully masked (use last '@').
263        assert_eq!(
264            mask_url("rtsp://user:p@ss@10.0.0.1:554/x"),
265            "rtsp://***@10.0.0.1:554/x"
266        );
267    }
268
269    #[test]
270    fn anr_replay_url_default_hikvision_playback() {
271        let c = base();
272        let start = parse_t("2026-06-13T12:00:00Z");
273        let end = parse_t("2026-06-13T12:01:30Z");
274        assert_eq!(
275            anr_replay_url(&c, start, end).unwrap(),
276            "rtsp://admin:p%40ss%2Fw%3Ard@192.168.0.2:554/Streaming/tracks/101?\
277             starttime=20260613T120000Z&endtime=20260613T120130Z"
278        );
279    }
280
281    #[test]
282    fn anr_replay_url_honors_template_placeholders() {
283        let mut c = base();
284        c.anr_replay_url_template = Some("rtsp://cam/replay?s={start}&e={end}".into());
285        assert_eq!(
286            anr_replay_url(
287                &c,
288                parse_t("2026-06-13T12:00:00Z"),
289                parse_t("2026-06-13T12:00:05Z")
290            )
291            .unwrap(),
292            "rtsp://cam/replay?s=20260613T120000Z&e=20260613T120005Z"
293        );
294    }
295
296    #[test]
297    fn anr_replay_url_none_without_host_or_template() {
298        let mut c = base();
299        c.vendor = "generic".into();
300        c.address = None;
301        c.anr_replay_url_template = None;
302        assert!(anr_replay_url(&c, Utc::now(), Utc::now()).is_none());
303    }
304
305    fn parse_t(s: &str) -> DateTime<Utc> {
306        DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
307    }
308
309    #[test]
310    fn validate_stream_url_rejects_dangerous_schemes() {
311        assert!(validate_stream_url("rtsp://10.0.0.1:554/x").is_ok());
312        assert!(validate_stream_url("https://cam/stream").is_ok());
313        assert!(validate_stream_url("file:///etc/passwd").is_err());
314        assert!(validate_stream_url("gopher://x").is_err());
315        assert!(validate_stream_url("not-a-url").is_err());
316    }
317}