Skip to main content

netget/server/onvif/
wire.rs

1//! ONVIF on the wire, for both roles: SOAP 1.2 envelopes read into a bounded,
2//! namespace-resolved element tree; WS-Security UsernameToken password digests; and the
3//! envelopes NetGet writes, with every value XML-escaped. Nothing the model says is spliced into
4//! an envelope raw.
5use anyhow::{bail, ensure, Context, Result};
6use base64::Engine;
7use quick_xml::escape::escape;
8use quick_xml::events::Event as XmlEvent;
9use quick_xml::name::ResolveResult;
10use quick_xml::NsReader;
11use serde_json::{json, Map, Value};
12use sha1::{Digest, Sha1};
13
14/// A request envelope at most (the device side). ONVIF requests are a few kilobytes.
15pub const MAX_REQUEST: usize = 256 * 1024;
16/// A response envelope at most (the client side): a device with many profiles answers in
17/// tens of kilobytes.
18pub const MAX_RESPONSE: usize = 1024 * 1024;
19/// Element nesting at most, in either direction.
20pub const MAX_DEPTH: usize = 32;
21/// Elements in one envelope at most.
22pub const MAX_ELEMENTS: usize = 20_000;
23/// Items (profiles, scopes, interfaces, sources, services) one answer may carry.
24pub const MAX_ITEMS: usize = 64;
25/// A decoded WS-Security nonce at most.
26pub const MAX_NONCE: usize = 128;
27
28pub const ENV: &str = "http://www.w3.org/2003/05/soap-envelope";
29pub const ENV11: &str = "http://schemas.xmlsoap.org/soap/envelope/";
30pub const TDS: &str = "http://www.onvif.org/ver10/device/wsdl";
31pub const TRT: &str = "http://www.onvif.org/ver10/media/wsdl";
32pub const TT: &str = "http://www.onvif.org/ver10/schema";
33pub const TER: &str = "http://www.onvif.org/ver10/error";
34pub const WSSE: &str =
35    "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd";
36pub const WSU: &str =
37    "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd";
38pub const PASSWORD_DIGEST: &str =
39    "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest";
40pub const BASE64_BINARY: &str =
41    "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary";
42
43/// Where the two services live on a NetGet device, and where a client looks first.
44pub const DEVICE_PATH: &str = "/onvif/device_service";
45pub const MEDIA_PATH: &str = "/onvif/media_service";
46
47#[derive(Debug, Default, Clone)]
48pub struct Element {
49    /// The resolved namespace URI ("" when unqualified).
50    pub ns: String,
51    pub name: String,
52    /// Attributes by local name.
53    pub attrs: Vec<(String, String)>,
54    pub children: Vec<Element>,
55    pub text: String,
56}
57
58impl Element {
59    pub fn child(&self, name: &str) -> Option<&Element> {
60        self.children.iter().find(|c| c.name == name)
61    }
62    pub fn children_named<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a Element> {
63        self.children.iter().filter(move |c| c.name == name)
64    }
65    pub fn text_of(&self, name: &str) -> Option<String> {
66        self.child(name).map(|c| c.text.trim().to_string())
67    }
68    pub fn attr(&self, name: &str) -> Option<&str> {
69        self.attrs
70            .iter()
71            .find(|(k, _)| k == name)
72            .map(|(_, v)| v.as_str())
73    }
74    /// The text at a path of child names.
75    pub fn path(&self, names: &[&str]) -> Option<String> {
76        let mut e = self;
77        for n in names {
78            e = e.child(n)?;
79        }
80        Some(e.text.trim().to_string())
81    }
82}
83
84fn local(name: &[u8]) -> String {
85    let s = String::from_utf8_lossy(name);
86    s.rsplit(':').next().unwrap_or_default().to_string()
87}
88
89/// The element tree of an XML document, bounded in size, depth and element count, with every
90/// element's namespace resolved. A DOCTYPE is refused, so there is no entity expansion.
91pub fn tree(xml: &[u8], max: usize) -> Result<Element> {
92    ensure!(xml.len() <= max, "document larger than {max} bytes");
93    let mut reader = NsReader::from_reader(xml);
94    reader.config_mut().trim_text(false);
95    let mut stack: Vec<Element> = vec![Element::default()];
96    let mut count = 0usize;
97    let mut buf = Vec::new();
98    loop {
99        let (ns, ev) = reader
100            .read_resolved_event_into(&mut buf)
101            .context("the document is not well-formed XML")?;
102        let ns = match ns {
103            ResolveResult::Bound(n) => String::from_utf8_lossy(n.as_ref()).into_owned(),
104            ResolveResult::Unbound => String::new(),
105            ResolveResult::Unknown(p) => {
106                bail!(
107                    "undeclared namespace prefix {}",
108                    String::from_utf8_lossy(&p)
109                )
110            }
111        };
112        let empty = matches!(ev, XmlEvent::Empty(_));
113        match ev {
114            XmlEvent::Start(e) | XmlEvent::Empty(e) => {
115                count += 1;
116                ensure!(count <= MAX_ELEMENTS, "more than {MAX_ELEMENTS} elements");
117                ensure!(stack.len() <= MAX_DEPTH, "nested deeper than {MAX_DEPTH}");
118                let mut el = Element {
119                    ns,
120                    name: local(e.local_name().as_ref()),
121                    ..Default::default()
122                };
123                for a in e.attributes() {
124                    let a = a.context("a malformed attribute")?;
125                    let v = a
126                        .decode_and_unescape_value(reader.decoder())
127                        .context("a malformed attribute value")?
128                        .into_owned();
129                    el.attrs.push((local(a.key.local_name().as_ref()), v));
130                }
131                if empty {
132                    if let Some(parent) = stack.last_mut() {
133                        parent.children.push(el);
134                    }
135                } else {
136                    stack.push(el);
137                }
138            }
139            XmlEvent::End(_) => {
140                ensure!(stack.len() > 1, "an end tag with nothing open");
141                let el = stack.pop().unwrap_or_default();
142                if let Some(parent) = stack.last_mut() {
143                    parent.children.push(el);
144                }
145            }
146            XmlEvent::Text(t) => {
147                if let Some(top) = stack.last_mut() {
148                    top.text
149                        .push_str(&t.unescape().context("a bad character reference")?);
150                }
151            }
152            XmlEvent::CData(t) => {
153                if let Some(top) = stack.last_mut() {
154                    top.text.push_str(&String::from_utf8_lossy(&t));
155                }
156            }
157            XmlEvent::DocType(_) => bail!("a DOCTYPE is not allowed in a SOAP envelope"),
158            XmlEvent::Eof => break,
159            _ => {}
160        }
161        buf.clear();
162    }
163    ensure!(stack.len() == 1, "the document ends inside an element");
164    stack
165        .pop()
166        .unwrap_or_default()
167        .children
168        .into_iter()
169        .next()
170        .context("an empty document")
171}
172
173/// A WS-Security UsernameToken as it arrived.
174#[derive(Debug, Default, Clone)]
175pub struct UsernameToken {
176    pub username: String,
177    pub password: String,
178    pub password_type: Option<String>,
179    pub nonce: Option<String>,
180    pub nonce_encoding: Option<String>,
181    pub created: Option<String>,
182}
183
184/// One SOAP request: its envelope version, its UsernameToken (if any) and the operation
185/// element in the Body.
186#[derive(Debug)]
187pub struct Request {
188    /// The envelope's namespace was SOAP 1.2's.
189    pub soap12: bool,
190    pub token: Option<UsernameToken>,
191    /// The operation's namespace (`TDS`, `TRT`, or anything else) and local name.
192    pub ns: String,
193    pub operation: String,
194    pub body: Element,
195}
196
197pub fn parse_request(xml: &[u8]) -> Result<Request> {
198    let env = tree(xml, MAX_REQUEST)?;
199    ensure!(
200        env.name == "Envelope" && (env.ns == ENV || env.ns == ENV11),
201        "not a SOAP envelope"
202    );
203    let token = env
204        .child("Header")
205        .and_then(|h| {
206            h.children
207                .iter()
208                .find(|c| c.name == "Security" && c.ns == WSSE)
209        })
210        .and_then(|s| s.child("UsernameToken"))
211        .map(|t| UsernameToken {
212            username: t.text_of("Username").unwrap_or_default(),
213            password: t.text_of("Password").unwrap_or_default(),
214            password_type: t
215                .child("Password")
216                .and_then(|p| p.attr("Type"))
217                .map(str::to_string),
218            nonce: t.text_of("Nonce"),
219            nonce_encoding: t
220                .child("Nonce")
221                .and_then(|n| n.attr("EncodingType"))
222                .map(str::to_string),
223            created: t.text_of("Created"),
224        });
225    let body = env.child("Body").context("the envelope has no Body")?;
226    let op = body.children.first().context("the Body is empty")?.clone();
227    Ok(Request {
228        soap12: env.ns == ENV,
229        token,
230        ns: op.ns.clone(),
231        operation: op.name.clone(),
232        body: op,
233    })
234}
235
236/// `Base64(SHA-1(nonce ‖ created ‖ password))`, the UsernameToken Profile 1.0 digest ONVIF
237/// requires.
238pub fn password_digest(nonce: &[u8], created: &str, password: &str) -> String {
239    let mut h = Sha1::new();
240    h.update(nonce);
241    h.update(created.as_bytes());
242    h.update(password.as_bytes());
243    base64::engine::general_purpose::STANDARD.encode(h.finalize())
244}
245
246fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
247    if a.len() != b.len() {
248        return false;
249    }
250    a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
251}
252
253/// Why a UsernameToken was refused, for the log (the peer only ever hears NotAuthorized).
254pub type Refusal = &'static str;
255
256/// Check a UsernameToken against the configured credentials: a PasswordDigest whose nonce is
257/// base64, whose Created is within `max_skew` of `now`, for the configured user. Returns the
258/// decoded nonce, which the caller remembers so it cannot be replayed.
259pub fn verify_token(
260    token: Option<&UsernameToken>,
261    username: &str,
262    password: &str,
263    now: chrono::DateTime<chrono::Utc>,
264    max_skew: chrono::Duration,
265) -> std::result::Result<Vec<u8>, Refusal> {
266    let t = token.ok_or("no_username_token")?;
267    match t.password_type.as_deref() {
268        Some(PASSWORD_DIGEST) => {}
269        Some(other) if other.ends_with("#PasswordDigest") => {}
270        _ => return Err("password_not_digest"),
271    }
272    // The token profile's default nonce encoding is Base64Binary; anything else is refused.
273    if !t
274        .nonce_encoding
275        .as_deref()
276        .is_none_or(|e| e.ends_with("#Base64Binary"))
277    {
278        return Err("nonce_not_base64");
279    }
280    let nonce = base64::engine::general_purpose::STANDARD
281        .decode(t.nonce.as_deref().ok_or("no_nonce")?.trim())
282        .map_err(|_| "nonce_not_base64")?;
283    if nonce.is_empty() || nonce.len() > MAX_NONCE {
284        return Err("bad_nonce_length");
285    }
286    let created = t.created.as_deref().ok_or("no_created")?;
287    if created.len() > 64 {
288        return Err("bad_created");
289    }
290    let when = chrono::DateTime::parse_from_rfc3339(created)
291        .map_err(|_| "bad_created")?
292        .with_timezone(&chrono::Utc);
293    if (now - when).abs() > max_skew {
294        return Err("stale_created");
295    }
296    let expected = password_digest(&nonce, created, password);
297    let user_ok = constant_time_eq(t.username.as_bytes(), username.as_bytes());
298    let digest_ok = constant_time_eq(t.password.trim().as_bytes(), expected.as_bytes());
299    if !user_ok {
300        return Err("unknown_user");
301    }
302    if !digest_ok {
303        return Err("bad_digest");
304    }
305    Ok(nonce)
306}
307
308pub fn esc(s: &str) -> String {
309    escape(s).into_owned()
310}
311
312/// A SOAP 1.2 response envelope around a Body's content.
313pub fn envelope(body: &str) -> String {
314    format!(
315        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<env:Envelope xmlns:env=\"{ENV}\" xmlns:tds=\"{TDS}\" xmlns:trt=\"{TRT}\" xmlns:tt=\"{TT}\" xmlns:ter=\"{TER}\"><env:Body>{body}</env:Body></env:Envelope>"
316    )
317}
318
319#[derive(Debug, Clone, Copy, PartialEq, Eq)]
320pub enum FaultCode {
321    Sender,
322    Receiver,
323    VersionMismatch,
324}
325
326impl FaultCode {
327    pub fn name(self) -> &'static str {
328        match self {
329            FaultCode::Sender => "Sender",
330            FaultCode::Receiver => "Receiver",
331            FaultCode::VersionMismatch => "VersionMismatch",
332        }
333    }
334}
335
336/// A SOAP 1.2 Fault: `env:<code>`, then each `ter:<subcode>` nested inside the last.
337pub fn fault(code: FaultCode, subcodes: &[&str], reason: &str) -> String {
338    let mut sub = String::new();
339    for s in subcodes.iter().rev() {
340        sub = format!(
341            "<env:Subcode><env:Value>ter:{}</env:Value>{sub}</env:Subcode>",
342            esc(s)
343        );
344    }
345    format!(
346        "<env:Fault><env:Code><env:Value>env:{}</env:Value>{sub}</env:Code><env:Reason><env:Text xml:lang=\"en\">{}</env:Text></env:Reason></env:Fault>",
347        code.name(),
348        esc(reason)
349    )
350}
351
352fn s<'a>(v: &'a Value, k: &str) -> &'a str {
353    v[k].as_str().unwrap_or_default()
354}
355
356fn n(v: &Value, k: &str, default: u64) -> u64 {
357    v[k].as_u64().unwrap_or(default)
358}
359
360fn b(v: &Value, k: &str, default: bool) -> bool {
361    v[k].as_bool().unwrap_or(default)
362}
363
364fn items<'a>(v: &'a Value, k: &str) -> impl Iterator<Item = &'a Value> {
365    v[k].as_array().into_iter().flatten().take(MAX_ITEMS)
366}
367
368pub fn device_information(v: &Value) -> String {
369    format!(
370        "<tds:GetDeviceInformationResponse><tds:Manufacturer>{}</tds:Manufacturer><tds:Model>{}</tds:Model><tds:FirmwareVersion>{}</tds:FirmwareVersion><tds:SerialNumber>{}</tds:SerialNumber><tds:HardwareId>{}</tds:HardwareId></tds:GetDeviceInformationResponse>",
371        esc(s(v, "manufacturer")),
372        esc(s(v, "model")),
373        esc(s(v, "firmware_version")),
374        esc(s(v, "serial_number")),
375        esc(s(v, "hardware_id"))
376    )
377}
378
379pub fn scopes(v: &Value) -> String {
380    let mut out = String::from("<tds:GetScopesResponse>");
381    for scope in items(v, "scopes") {
382        out.push_str(&format!(
383            "<tds:Scopes><tt:ScopeDef>Fixed</tt:ScopeDef><tt:ScopeItem>{}</tt:ScopeItem></tds:Scopes>",
384            esc(scope.as_str().unwrap_or_default())
385        ));
386    }
387    out.push_str("</tds:GetScopesResponse>");
388    out
389}
390
391pub fn network_interfaces(v: &Value) -> String {
392    let mut out = String::from("<tds:GetNetworkInterfacesResponse>");
393    for i in items(v, "interfaces") {
394        let dhcp = b(i, "dhcp", false);
395        let address = format!(
396            "<tt:Address>{}</tt:Address><tt:PrefixLength>{}</tt:PrefixLength>",
397            esc(s(i, "ipv4_address")),
398            n(i, "prefix_length", 24)
399        );
400        out.push_str(&format!(
401            "<tds:NetworkInterfaces token=\"{}\"><tt:Enabled>{}</tt:Enabled><tt:Info><tt:Name>{}</tt:Name><tt:HwAddress>{}</tt:HwAddress><tt:MTU>{}</tt:MTU></tt:Info><tt:IPv4><tt:Enabled>true</tt:Enabled><tt:Config>{}<tt:DHCP>{dhcp}</tt:DHCP></tt:Config></tt:IPv4></tds:NetworkInterfaces>",
402            esc(s(i, "token")),
403            b(i, "enabled", true),
404            esc(s(i, "name")),
405            esc(s(i, "mac")),
406            n(i, "mtu", 1500),
407            if dhcp {
408                format!("<tt:FromDHCP>{address}</tt:FromDHCP>")
409            } else {
410                format!("<tt:Manual>{address}</tt:Manual>")
411            }
412        ));
413    }
414    out.push_str("</tds:GetNetworkInterfacesResponse>");
415    out
416}
417
418fn resolution(w: u64, h: u64) -> String {
419    format!("<tt:Resolution><tt:Width>{w}</tt:Width><tt:Height>{h}</tt:Height></tt:Resolution>")
420}
421
422pub fn profiles(v: &Value) -> String {
423    let mut out = String::from("<trt:GetProfilesResponse>");
424    for p in items(v, "profiles") {
425        let token = esc(s(p, "token"));
426        let source = match s(p, "video_source_token") {
427            "" => "video_source".to_string(),
428            t => esc(t),
429        };
430        let (w, h) = (n(p, "width", 1920), n(p, "height", 1080));
431        let fps = n(p, "framerate", 25);
432        let encoding = match s(p, "encoding") {
433            "" => "H264",
434            e => e,
435        };
436        let h264 = if encoding == "H264" {
437            format!("<tt:H264><tt:GovLength>{fps}</tt:GovLength><tt:H264Profile>Main</tt:H264Profile></tt:H264>")
438        } else {
439            String::new()
440        };
441        out.push_str(&format!(
442            "<trt:Profiles token=\"{token}\" fixed=\"true\"><tt:Name>{}</tt:Name><tt:VideoSourceConfiguration token=\"vsc_{source}\"><tt:Name>{source}</tt:Name><tt:UseCount>1</tt:UseCount><tt:SourceToken>{source}</tt:SourceToken><tt:Bounds x=\"0\" y=\"0\" width=\"{w}\" height=\"{h}\"/></tt:VideoSourceConfiguration><tt:VideoEncoderConfiguration token=\"vec_{token}\"><tt:Name>{token}</tt:Name><tt:UseCount>1</tt:UseCount><tt:Encoding>{}</tt:Encoding>{}<tt:Quality>{}</tt:Quality><tt:RateControl><tt:FrameRateLimit>{fps}</tt:FrameRateLimit><tt:EncodingInterval>1</tt:EncodingInterval><tt:BitrateLimit>{}</tt:BitrateLimit></tt:RateControl>{h264}<tt:Multicast><tt:Address><tt:Type>IPv4</tt:Type><tt:IPv4Address>0.0.0.0</tt:IPv4Address></tt:Address><tt:Port>0</tt:Port><tt:TTL>1</tt:TTL><tt:AutoStart>false</tt:AutoStart></tt:Multicast><tt:SessionTimeout>PT60S</tt:SessionTimeout></tt:VideoEncoderConfiguration></trt:Profiles>",
443            esc(match s(p, "name") { "" => s(p, "token"), name => name }),
444            esc(encoding),
445            resolution(w, h),
446            n(p, "quality", 5),
447            n(p, "bitrate", 4096),
448        ));
449    }
450    out.push_str("</trt:GetProfilesResponse>");
451    out
452}
453
454pub fn video_sources(v: &Value) -> String {
455    let mut out = String::from("<trt:GetVideoSourcesResponse>");
456    for src in items(v, "sources") {
457        out.push_str(&format!(
458            "<trt:VideoSources token=\"{}\"><tt:Framerate>{}</tt:Framerate>{}</trt:VideoSources>",
459            esc(s(src, "token")),
460            n(src, "framerate", 25),
461            resolution(n(src, "width", 1920), n(src, "height", 1080))
462        ));
463    }
464    out.push_str("</trt:GetVideoSourcesResponse>");
465    out
466}
467
468/// GetStreamUriResponse or GetSnapshotUriResponse.
469pub fn media_uri(operation: &str, v: &Value) -> String {
470    format!(
471        "<trt:{operation}Response><trt:MediaUri><tt:Uri>{}</tt:Uri><tt:InvalidAfterConnect>{}</tt:InvalidAfterConnect><tt:InvalidAfterReboot>{}</tt:InvalidAfterReboot><tt:Timeout>PT{}S</tt:Timeout></trt:MediaUri></trt:{operation}Response>",
472        esc(s(v, "uri")),
473        b(v, "invalid_after_connect", false),
474        b(v, "invalid_after_reboot", false),
475        n(v, "timeout_secs", 60)
476    )
477}
478
479/// GetCapabilities for the two services NetGet serves, at `base` (`http://host:port`).
480pub fn capabilities(base: &str, device: bool, media: bool) -> String {
481    let mut out = String::from("<tds:GetCapabilitiesResponse><tds:Capabilities>");
482    if device {
483        out.push_str(&format!(
484            "<tt:Device><tt:XAddr>{}{DEVICE_PATH}</tt:XAddr></tt:Device>",
485            esc(base)
486        ));
487    }
488    if media {
489        out.push_str(&format!(
490            "<tt:Media><tt:XAddr>{}{MEDIA_PATH}</tt:XAddr><tt:StreamingCapabilities><tt:RTPMulticast>false</tt:RTPMulticast><tt:RTP_TCP>true</tt:RTP_TCP><tt:RTP_RTSP_TCP>true</tt:RTP_RTSP_TCP></tt:StreamingCapabilities></tt:Media>",
491            esc(base)
492        ));
493    }
494    out.push_str("</tds:Capabilities></tds:GetCapabilitiesResponse>");
495    out
496}
497
498pub fn services(base: &str) -> String {
499    let mut out = String::from("<tds:GetServicesResponse>");
500    for (ns, path) in [(TDS, DEVICE_PATH), (TRT, MEDIA_PATH)] {
501        out.push_str(&format!(
502            "<tds:Service><tds:Namespace>{ns}</tds:Namespace><tds:XAddr>{}{path}</tds:XAddr><tds:Version><tt:Major>2</tt:Major><tt:Minor>40</tt:Minor></tds:Version></tds:Service>",
503            esc(base)
504        ));
505    }
506    out.push_str("</tds:GetServicesResponse>");
507    out
508}
509
510pub fn system_date_and_time(now: chrono::DateTime<chrono::Utc>) -> String {
511    use chrono::{Datelike, Timelike};
512    let dt = format!(
513        "<tt:Time><tt:Hour>{}</tt:Hour><tt:Minute>{}</tt:Minute><tt:Second>{}</tt:Second></tt:Time><tt:Date><tt:Year>{}</tt:Year><tt:Month>{}</tt:Month><tt:Day>{}</tt:Day></tt:Date>",
514        now.hour(),
515        now.minute(),
516        now.second(),
517        now.year(),
518        now.month(),
519        now.day()
520    );
521    format!(
522        "<tds:GetSystemDateAndTimeResponse><tds:SystemDateAndTime><tt:DateTimeType>Manual</tt:DateTimeType><tt:DaylightSavings>false</tt:DaylightSavings><tt:TimeZone><tt:TZ>UTC0</tt:TZ></tt:TimeZone><tt:UTCDateTime>{dt}</tt:UTCDateTime><tt:LocalDateTime>{dt}</tt:LocalDateTime></tds:SystemDateAndTime></tds:GetSystemDateAndTimeResponse>"
523    )
524}
525
526// ---- the client side ------------------------------------------------------------------------
527
528/// A WS-Security header carrying a PasswordDigest UsernameToken.
529pub fn security_header(username: &str, password: &str, nonce: &[u8], created: &str) -> String {
530    format!(
531        "<wsse:Security xmlns:wsse=\"{WSSE}\" xmlns:wsu=\"{WSU}\" env:mustUnderstand=\"true\"><wsse:UsernameToken><wsse:Username>{}</wsse:Username><wsse:Password Type=\"{PASSWORD_DIGEST}\">{}</wsse:Password><wsse:Nonce EncodingType=\"{BASE64_BINARY}\">{}</wsse:Nonce><wsu:Created>{}</wsu:Created></wsse:UsernameToken></wsse:Security>",
532        esc(username),
533        password_digest(nonce, created, password),
534        base64::engine::general_purpose::STANDARD.encode(nonce),
535        esc(created)
536    )
537}
538
539/// A SOAP 1.2 request envelope, with a header when `security` is given.
540pub fn request_envelope(security: Option<&str>, body: &str) -> String {
541    let header = security
542        .map(|h| format!("<env:Header>{h}</env:Header>"))
543        .unwrap_or_default();
544    format!(
545        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<env:Envelope xmlns:env=\"{ENV}\" xmlns:tds=\"{TDS}\" xmlns:trt=\"{TRT}\" xmlns:tt=\"{TT}\">{header}<env:Body>{body}</env:Body></env:Envelope>"
546    )
547}
548
549/// A device's reply: the Body's first element, or the fault it carried as `{code, subcodes,
550/// reason}`.
551pub enum Reply {
552    Ok(Element),
553    Fault(Value),
554}
555
556fn qname_local(s: &str) -> String {
557    s.trim().rsplit(':').next().unwrap_or_default().to_string()
558}
559
560pub fn parse_reply(xml: &[u8]) -> Result<Reply> {
561    let env = tree(xml, MAX_RESPONSE)?;
562    ensure!(
563        env.name == "Envelope" && (env.ns == ENV || env.ns == ENV11),
564        "the reply is not a SOAP envelope"
565    );
566    let body = env.child("Body").context("the reply has no Body")?;
567    let el = body.children.first().context("the reply's Body is empty")?;
568    if el.name != "Fault" {
569        return Ok(Reply::Ok(el.clone()));
570    }
571    // SOAP 1.2: Code/Value with nested Subcode/Value; SOAP 1.1: faultcode and faultstring.
572    if let Some(code) = el.child("Code") {
573        let mut subcodes = Vec::new();
574        let mut at = code.child("Subcode");
575        while let Some(sc) = at {
576            if subcodes.len() >= 8 {
577                break;
578            }
579            subcodes.push(qname_local(&sc.text_of("Value").unwrap_or_default()));
580            at = sc.child("Subcode");
581        }
582        let reason = el
583            .child("Reason")
584            .and_then(|r| r.text_of("Text"))
585            .unwrap_or_default();
586        return Ok(Reply::Fault(json!({
587            "code": qname_local(&code.text_of("Value").unwrap_or_default()),
588            "subcodes": subcodes,
589            "reason": reason,
590        })));
591    }
592    Ok(Reply::Fault(json!({
593        "code": qname_local(&el.text_of("faultcode").unwrap_or_default()),
594        "subcodes": [],
595        "reason": el.text_of("faultstring").unwrap_or_default(),
596    })))
597}
598
599/// The path (and query) of an XAddr URL; `None` when it is not an http(s) URL.
600pub fn xaddr_path(xaddr: &str) -> Option<String> {
601    let rest = xaddr
602        .trim()
603        .strip_prefix("http://")
604        .or_else(|| xaddr.trim().strip_prefix("https://"))?;
605    Some(match rest.find('/') {
606        Some(i) => rest[i..].to_string(),
607        None => "/".to_string(),
608    })
609}
610
611fn num(e: &Element, path: &[&str]) -> Value {
612    match e.path(path) {
613        Some(t) => t
614            .parse::<u64>()
615            .map(Value::from)
616            .or_else(|_| t.parse::<f64>().map(Value::from))
617            .unwrap_or(Value::Null),
618        None => Value::Null,
619    }
620}
621
622fn opt(e: &Element, path: &[&str]) -> Value {
623    e.path(path).map(Value::from).unwrap_or(Value::Null)
624}
625
626fn boolean(e: &Element, path: &[&str]) -> Value {
627    match e.path(path).as_deref() {
628        Some("true" | "1") => json!(true),
629        Some("false" | "0") => json!(false),
630        _ => Value::Null,
631    }
632}
633
634/// A device's answer to `operation` as the JSON the model reads.
635pub fn result_json(operation: &str, el: &Element) -> Value {
636    match operation {
637        "GetDeviceInformation" => json!({
638            "manufacturer": opt(el, &["Manufacturer"]),
639            "model": opt(el, &["Model"]),
640            "firmware_version": opt(el, &["FirmwareVersion"]),
641            "serial_number": opt(el, &["SerialNumber"]),
642            "hardware_id": opt(el, &["HardwareId"]),
643        }),
644        "GetScopes" => json!({"scopes": el.children_named("Scopes").take(MAX_ITEMS).map(|s| json!({
645            "uri": opt(s, &["ScopeItem"]), "definition": opt(s, &["ScopeDef"])})).collect::<Vec<_>>()}),
646        "GetNetworkInterfaces" => {
647            json!({"interfaces": el.children_named("NetworkInterfaces").take(MAX_ITEMS).map(|i| {
648            let config = i.child("IPv4").and_then(|v| v.child("Config"));
649            let addrs: Vec<Value> = config.map(|c| c.children.iter()
650                .filter(|a| matches!(a.name.as_str(), "Manual" | "FromDHCP" | "LinkLocal"))
651                .map(|a| json!({"source": a.name, "address": opt(a, &["Address"]), "prefix_length": num(a, &["PrefixLength"])}))
652                .collect()).unwrap_or_default();
653            json!({"token": i.attr("token"), "enabled": boolean(i, &["Enabled"]), "name": opt(i, &["Info", "Name"]),
654                   "mac": opt(i, &["Info", "HwAddress"]), "mtu": num(i, &["Info", "MTU"]),
655                   "dhcp": config.map(|c| boolean(c, &["DHCP"])).unwrap_or(Value::Null), "ipv4": addrs})
656        }).collect::<Vec<_>>()})
657        }
658        "GetCapabilities" => {
659            let mut services = Map::new();
660            if let Some(c) = el.child("Capabilities") {
661                for cap in &c.children {
662                    if let Some(x) = cap.text_of("XAddr") {
663                        services.insert(cap.name.clone(), json!(x));
664                    }
665                }
666            }
667            json!({"services": services})
668        }
669        "GetServices" => {
670            json!({"services": el.children_named("Service").take(MAX_ITEMS).map(|s| json!({
671            "namespace": opt(s, &["Namespace"]), "xaddr": opt(s, &["XAddr"]),
672            "version": format!("{}.{}", s.path(&["Version", "Major"]).unwrap_or_default(), s.path(&["Version", "Minor"]).unwrap_or_default())})).collect::<Vec<_>>()})
673        }
674        "GetSystemDateAndTime" => {
675            let t = el.child("SystemDateAndTime");
676            json!({
677                "date_time_type": t.map(|t| opt(t, &["DateTimeType"])).unwrap_or(Value::Null),
678                "time_zone": t.map(|t| opt(t, &["TimeZone", "TZ"])).unwrap_or(Value::Null),
679                "utc": t.and_then(utc_of).map(|d| json!(d.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))).unwrap_or(Value::Null),
680            })
681        }
682        "GetProfiles" => json!({"profiles": el.children_named("Profiles").take(MAX_ITEMS).map(|p| {
683            let enc = p.child("VideoEncoderConfiguration");
684            json!({"token": p.attr("token"), "name": opt(p, &["Name"]),
685                   "video_source_token": opt(p, &["VideoSourceConfiguration", "SourceToken"]),
686                   "encoding": enc.map(|e| opt(e, &["Encoding"])).unwrap_or(Value::Null),
687                   "width": enc.map(|e| num(e, &["Resolution", "Width"])).unwrap_or(Value::Null),
688                   "height": enc.map(|e| num(e, &["Resolution", "Height"])).unwrap_or(Value::Null),
689                   "framerate": enc.map(|e| num(e, &["RateControl", "FrameRateLimit"])).unwrap_or(Value::Null),
690                   "bitrate": enc.map(|e| num(e, &["RateControl", "BitrateLimit"])).unwrap_or(Value::Null)})
691        }).collect::<Vec<_>>()}),
692        "GetVideoSources" => {
693            json!({"sources": el.children_named("VideoSources").take(MAX_ITEMS).map(|v| json!({
694            "token": v.attr("token"), "framerate": num(v, &["Framerate"]),
695            "width": num(v, &["Resolution", "Width"]), "height": num(v, &["Resolution", "Height"])})).collect::<Vec<_>>()})
696        }
697        "GetStreamUri" | "GetSnapshotUri" => json!({
698            "uri": opt(el, &["MediaUri", "Uri"]),
699            "timeout": opt(el, &["MediaUri", "Timeout"]),
700            "invalid_after_connect": boolean(el, &["MediaUri", "InvalidAfterConnect"]),
701        }),
702        _ => json!({}),
703    }
704}
705
706/// The UTC date and time in a SystemDateAndTime element.
707pub fn utc_of(t: &Element) -> Option<chrono::DateTime<chrono::Utc>> {
708    use chrono::TimeZone;
709    let u = t.child("UTCDateTime")?;
710    let g = |p: &[&str]| u.path(p).and_then(|v| v.parse::<u32>().ok());
711    let date = chrono::NaiveDate::from_ymd_opt(
712        g(&["Date", "Year"])? as i32,
713        g(&["Date", "Month"])?,
714        g(&["Date", "Day"])?,
715    )?;
716    let time = chrono::NaiveTime::from_hms_opt(
717        g(&["Time", "Hour"])?,
718        g(&["Time", "Minute"])?,
719        g(&["Time", "Second"])?,
720    )?;
721    chrono::Utc
722        .from_local_datetime(&date.and_time(time))
723        .single()
724}
725
726/// The request body a client sends for one operation.
727pub fn request_body(operation: &str, args: &Value) -> Result<String> {
728    let token = || -> String { esc(s(args, "profile_token")) };
729    Ok(match operation {
730        "GetSystemDateAndTime" | "GetDeviceInformation" | "GetScopes" | "GetNetworkInterfaces" => {
731            format!("<tds:{operation}/>")
732        }
733        "GetCapabilities" => {
734            "<tds:GetCapabilities><tds:Category>All</tds:Category></tds:GetCapabilities>".into()
735        }
736        "GetServices" => {
737            "<tds:GetServices><tds:IncludeCapability>false</tds:IncludeCapability></tds:GetServices>"
738                .into()
739        }
740        "GetProfiles" | "GetVideoSources" => format!("<trt:{operation}/>"),
741        "GetStreamUri" => format!(
742            "<trt:GetStreamUri><trt:StreamSetup><tt:Stream>{}</tt:Stream><tt:Transport><tt:Protocol>{}</tt:Protocol></tt:Transport></trt:StreamSetup><trt:ProfileToken>{}</trt:ProfileToken></trt:GetStreamUri>",
743            esc(match s(args, "stream") { "" => "RTP-Unicast", x => x }),
744            esc(match s(args, "protocol") { "" => "RTSP", x => x }),
745            token()
746        ),
747        "GetSnapshotUri" => format!(
748            "<trt:GetSnapshotUri><trt:ProfileToken>{}</trt:ProfileToken></trt:GetSnapshotUri>",
749            token()
750        ),
751        other => bail!("no request body for {other}"),
752    })
753}