Skip to main content

heldar_kernel/
camera_url.rs

1//! RTSP URL construction from vendor templates, plus credential masking.
2
3use crate::models::Camera;
4
5/// Map a logical stream name to a HikVision channel id (101 = main, 102 = sub).
6fn hik_channel(stream: &str) -> &'static str {
7    if stream == "sub" {
8        "102"
9    } else {
10        "101"
11    }
12}
13
14/// Percent-encode the reserved characters that would break the `user:pass@host` userinfo section.
15pub(crate) fn encode_userinfo(s: &str) -> String {
16    let mut out = String::with_capacity(s.len());
17    for b in s.bytes() {
18        match b {
19            // RFC 3986 unreserved + a few safe chars
20            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
21                out.push(b as char)
22            }
23            _ => out.push_str(&format!("%{:02X}", b)),
24        }
25    }
26    out
27}
28
29/// Build the RTSP URL (with credentials) for the given stream ("main" | "sub").
30/// Honors an explicit per-stream URL override; otherwise builds from the vendor template.
31pub fn stream_url(cam: &Camera, stream: &str) -> Option<String> {
32    let explicit = if stream == "sub" {
33        cam.sub_stream_url.as_deref()
34    } else {
35        cam.main_stream_url.as_deref()
36    };
37    if let Some(u) = explicit {
38        if !u.trim().is_empty() {
39            return Some(u.trim().to_string());
40        }
41    }
42
43    let host = cam.address.as_deref()?.trim();
44    if host.is_empty() {
45        return None;
46    }
47    let port = cam.rtsp_port;
48
49    let creds = match (cam.username.as_deref(), cam.password.as_deref()) {
50        (Some(u), Some(p)) if !u.is_empty() => {
51            format!("{}:{}@", encode_userinfo(u), encode_userinfo(p))
52        }
53        (Some(u), _) if !u.is_empty() => format!("{}@", encode_userinfo(u)),
54        _ => String::new(),
55    };
56
57    let path = match cam.vendor.as_str() {
58        "hikvision" => format!("/Streaming/Channels/{}", hik_channel(stream)),
59        "dahua" => format!(
60            "/cam/realmonitor?channel=1&subtype={}",
61            if stream == "sub" { "1" } else { "0" }
62        ),
63        // generic/onvif: without an explicit URL we cannot guess a path.
64        _ => return None,
65    };
66
67    Some(format!("rtsp://{creds}{host}:{port}{path}"))
68}
69
70/// The RTSP URL for the stream this camera records.
71pub fn record_url(cam: &Camera) -> Option<String> {
72    stream_url(cam, &cam.record_stream)
73}
74
75/// Schemes permitted for explicit camera stream URLs. Excludes `file:`, `gopher:`, etc., which
76/// would let ffmpeg/ffprobe/MediaMTX read local files or reach unintended protocols (SSRF/LFI).
77const ALLOWED_SCHEMES: &[&str] = &["rtsp", "rtsps", "http", "https"];
78
79/// Validate an operator-supplied stream URL: must parse and use an allowed scheme.
80pub fn validate_stream_url(url: &str) -> Result<(), String> {
81    let url = url.trim();
82    let Some((scheme, _)) = url.split_once("://") else {
83        return Err(format!(
84            "invalid stream URL `{}` (no scheme://)",
85            mask_url(url)
86        ));
87    };
88    let scheme = scheme.to_ascii_lowercase();
89    if !ALLOWED_SCHEMES.contains(&scheme.as_str()) {
90        return Err(format!(
91            "stream URL scheme `{scheme}` not allowed; use one of {ALLOWED_SCHEMES:?}"
92        ));
93    }
94    Ok(())
95}
96
97/// Replace `user:pass@` (or `user@`) credentials in an RTSP/HTTP URL with `***` for safe logging/display.
98pub fn mask_url(url: &str) -> String {
99    let Some(scheme_end) = url.find("://") else {
100        return url.to_string();
101    };
102    let after = scheme_end + 3;
103    // The userinfo/host boundary is the LAST '@' before the first '/' of the authority; using the
104    // last '@' ensures a literal '@' inside the password (from an explicit URL) is fully masked.
105    let authority_end = url[after..]
106        .find('/')
107        .map(|i| after + i)
108        .unwrap_or(url.len());
109    if let Some(at_rel) = url[after..authority_end].rfind('@') {
110        let at = after + at_rel;
111        format!("{}***@{}", &url[..after], &url[at + 1..])
112    } else {
113        url.to_string()
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::models::Camera;
121    use chrono::Utc;
122    use serde_json::json;
123    use sqlx::types::Json;
124
125    fn base() -> Camera {
126        Camera {
127            id: "cam1".into(),
128            site_id: None,
129            name: "Cam 1".into(),
130            vendor: "hikvision".into(),
131            model: None,
132            address: Some("192.168.0.2".into()),
133            rtsp_port: 554,
134            username: Some("admin".into()),
135            password: Some("p@ss/w:rd".into()),
136            main_stream_url: None,
137            sub_stream_url: None,
138            record_stream: "main".into(),
139            codec: None,
140            resolution_main: None,
141            resolution_sub: None,
142            fps_main: None,
143            fps_sub: None,
144            capabilities: Json(json!({})),
145            record_enabled: true,
146            segment_seconds: 60,
147            retention_hours: 24,
148            enabled: true,
149            created_at: Utc::now(),
150            updated_at: Utc::now(),
151        }
152    }
153
154    #[test]
155    fn hikvision_main_url_percent_encodes_credentials() {
156        let c = base();
157        assert_eq!(
158            stream_url(&c, "main").unwrap(),
159            "rtsp://admin:p%40ss%2Fw%3Ard@192.168.0.2:554/Streaming/Channels/101"
160        );
161    }
162
163    #[test]
164    fn hikvision_sub_uses_channel_102() {
165        assert!(stream_url(&base(), "sub")
166            .unwrap()
167            .ends_with("/Streaming/Channels/102"));
168    }
169
170    #[test]
171    fn explicit_override_takes_precedence() {
172        let mut c = base();
173        c.main_stream_url = Some("rtsp://example/stream".into());
174        assert_eq!(stream_url(&c, "main").unwrap(), "rtsp://example/stream");
175    }
176
177    #[test]
178    fn generic_vendor_without_url_is_none() {
179        let mut c = base();
180        c.vendor = "generic".into();
181        c.main_stream_url = None;
182        assert!(stream_url(&c, "main").is_none());
183    }
184
185    #[test]
186    fn mask_url_hides_credentials() {
187        assert_eq!(
188            mask_url("rtsp://admin:secret@10.0.0.1:554/Streaming/Channels/101"),
189            "rtsp://***@10.0.0.1:554/Streaming/Channels/101"
190        );
191        assert_eq!(mask_url("rtsp://10.0.0.1:554/x"), "rtsp://10.0.0.1:554/x");
192    }
193
194    #[test]
195    fn mask_url_handles_at_in_password() {
196        // An explicit URL with a literal '@' in the password must be fully masked (use last '@').
197        assert_eq!(
198            mask_url("rtsp://user:p@ss@10.0.0.1:554/x"),
199            "rtsp://***@10.0.0.1:554/x"
200        );
201    }
202
203    #[test]
204    fn validate_stream_url_rejects_dangerous_schemes() {
205        assert!(validate_stream_url("rtsp://10.0.0.1:554/x").is_ok());
206        assert!(validate_stream_url("https://cam/stream").is_ok());
207        assert!(validate_stream_url("file:///etc/passwd").is_err());
208        assert!(validate_stream_url("gopher://x").is_err());
209        assert!(validate_stream_url("not-a-url").is_err());
210    }
211}