Skip to main content

netget/server/wsdiscovery/
wire.rs

1//! WS-Discovery messages (SOAP 1.2 over UDP): the April 2005 version that Windows, ONVIF and
2//! wsdd speak, and the OASIS 2009 version, told apart by the discovery namespace a message
3//! uses. Shared by NetGet's target service (server) and discovery client.
4//!
5//! Types are qualified names. The model sees and writes them in Clark notation,
6//! `{namespace}LocalName`, which needs no prefix bookkeeping; a few well-known prefixes
7//! (`wsdp:`, `pub:`, `dn:`, `tds:`) are accepted on input. On the wire each Types element
8//! declares the namespaces its values use, so every value resolves where it stands.
9use anyhow::{bail, ensure, Context, Result};
10use quick_xml::events::Event;
11use quick_xml::Reader;
12use std::collections::HashMap;
13use std::net::{Ipv4Addr, SocketAddr};
14
15pub const PORT: u16 = 3702;
16pub const GROUP_V4: Ipv4Addr = Ipv4Addr::new(239, 255, 255, 250);
17/// A SOAP-over-UDP message must fit one datagram; NetGet reads at most this much.
18pub const MAX_DATAGRAM: usize = 65_507;
19/// Element nesting a message may reach; real ones nest about six deep.
20pub const MAX_DEPTH: usize = 32;
21/// Matches in one ProbeMatches, and types, scopes or addresses in one list.
22pub const MAX_ITEMS: usize = 64;
23
24pub fn group() -> SocketAddr {
25    SocketAddr::from((GROUP_V4, PORT))
26}
27
28const NS_SOAP: &str = "http://www.w3.org/2003/05/soap-envelope";
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Version {
32    /// WS-Discovery April 2005 (wsdd, Windows, ONVIF).
33    V2005,
34    /// OASIS WS-Discovery 1.1 (2009).
35    V2009,
36}
37
38impl Version {
39    pub fn discovery_ns(self) -> &'static str {
40        match self {
41            Version::V2005 => "http://schemas.xmlsoap.org/ws/2005/04/discovery",
42            Version::V2009 => "http://docs.oasis-open.org/ws-dd/ns/discovery/2009/01",
43        }
44    }
45    pub fn addressing_ns(self) -> &'static str {
46        match self {
47            Version::V2005 => "http://schemas.xmlsoap.org/ws/2004/08/addressing",
48            Version::V2009 => "http://www.w3.org/2005/08/addressing",
49        }
50    }
51    /// The `To` of a multicast message.
52    pub fn to_all(self) -> &'static str {
53        match self {
54            Version::V2005 => "urn:schemas-xmlsoap-org:ws:2005:04:discovery",
55            Version::V2009 => "urn:docs-oasis-open-org:ws-dd:ns:discovery:2009:01",
56        }
57    }
58    /// The `To` of a reply.
59    pub fn anonymous(self) -> &'static str {
60        match self {
61            Version::V2005 => "http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous",
62            Version::V2009 => "http://www.w3.org/2005/08/addressing/anonymous",
63        }
64    }
65    pub fn name(self) -> &'static str {
66        match self {
67            Version::V2005 => "2005/04",
68            Version::V2009 => "2009/01",
69        }
70    }
71    pub fn parse(s: &str) -> Result<Self> {
72        match s {
73            "2005/04" | "2005" => Ok(Version::V2005),
74            "2009/01" | "2009" | "1.1" => Ok(Version::V2009),
75            other => bail!("WS-Discovery version {other:?} is not 2005/04 or 2009/01"),
76        }
77    }
78    fn of(ns: &str) -> Option<Self> {
79        [Version::V2005, Version::V2009]
80            .into_iter()
81            .find(|v| v.discovery_ns() == ns)
82    }
83}
84
85/// The prefixes these namespaces conventionally carry. Types are written with them on the wire
86/// because some implementations compare the Types text literally rather than resolving the
87/// QName: wsdd answers `wsdp:Device` and nothing else, whatever the prefix is bound to.
88pub const WELL_KNOWN: &[(&str, &str)] = &[
89    ("wsdp", "http://schemas.xmlsoap.org/ws/2006/02/devprof"),
90    ("pub", "http://schemas.microsoft.com/windows/pub/2005/07"),
91    ("dn", "http://www.onvif.org/ver10/network/wsdl"),
92    ("tds", "http://www.onvif.org/ver10/device/wsdl"),
93];
94
95#[derive(Debug, Clone, PartialEq, Eq, Hash)]
96pub struct QName {
97    pub ns: String,
98    pub local: String,
99}
100
101impl QName {
102    /// `{namespace}Local`, or one of the well-known prefixed forms.
103    pub fn parse(s: &str) -> Result<Self> {
104        if let Some(rest) = s.strip_prefix('{') {
105            let (ns, local) = rest
106                .split_once('}')
107                .context("a type in Clark notation is {namespace}LocalName")?;
108            ensure!(
109                !ns.is_empty() && valid_ncname(local),
110                "{s:?} is not {{namespace}}LocalName"
111            );
112            return Ok(QName {
113                ns: ns.into(),
114                local: local.into(),
115            });
116        }
117        let (prefix, local) = s
118            .split_once(':')
119            .with_context(|| format!("{s:?} is not a type; write {{namespace}}LocalName"))?;
120        let ns = match WELL_KNOWN.iter().find(|(p, _)| *p == prefix) {
121            Some((_, ns)) => *ns,
122            None => bail!("prefix {prefix:?} is not one NetGet knows (wsdp, pub, dn, tds); write {{namespace}}{local}"),
123        };
124        ensure!(valid_ncname(local), "{local:?} is not an XML local name");
125        Ok(QName {
126            ns: ns.into(),
127            local: local.into(),
128        })
129    }
130    pub fn clark(&self) -> String {
131        format!("{{{}}}{}", self.ns, self.local)
132    }
133}
134
135fn valid_ncname(s: &str) -> bool {
136    let mut chars = s.chars();
137    matches!(chars.next(), Some(c) if c.is_alphabetic() || c == '_')
138        && chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
139}
140
141/// One ProbeMatch, ResolveMatch, Hello or Bye's description of a target service.
142#[derive(Debug, Clone, Default, PartialEq)]
143pub struct Target {
144    pub endpoint: String,
145    pub types: Vec<QName>,
146    pub scopes: Vec<String>,
147    pub xaddrs: Vec<String>,
148    pub metadata_version: Option<u64>,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum Kind {
153    Probe,
154    ProbeMatches,
155    Resolve,
156    ResolveMatches,
157    Hello,
158    Bye,
159}
160
161impl Kind {
162    pub fn name(self) -> &'static str {
163        match self {
164            Kind::Probe => "Probe",
165            Kind::ProbeMatches => "ProbeMatches",
166            Kind::Resolve => "Resolve",
167            Kind::ResolveMatches => "ResolveMatches",
168            Kind::Hello => "Hello",
169            Kind::Bye => "Bye",
170        }
171    }
172    fn of(local: &str) -> Option<Self> {
173        [
174            Kind::Probe,
175            Kind::ProbeMatches,
176            Kind::Resolve,
177            Kind::ResolveMatches,
178            Kind::Hello,
179            Kind::Bye,
180        ]
181        .into_iter()
182        .find(|k| k.name() == local)
183    }
184}
185
186#[derive(Debug, Clone, PartialEq)]
187pub struct Message {
188    pub version: Version,
189    pub kind: Kind,
190    pub message_id: String,
191    pub relates_to: Option<String>,
192    /// Probe: the types and scopes asked for, and the scope matching rule.
193    pub types: Vec<QName>,
194    pub scopes: Vec<String>,
195    pub match_by: Option<String>,
196    /// Resolve and Bye: the endpoint concerned.
197    pub endpoint: Option<String>,
198    /// ProbeMatches and ResolveMatches: the matches; Hello: the one service announcing itself.
199    pub targets: Vec<Target>,
200}
201
202fn local_and_prefix(name: &[u8]) -> (String, Option<String>) {
203    let s = String::from_utf8_lossy(name);
204    match s.split_once(':') {
205        Some((p, l)) => (l.to_string(), Some(p.to_string())),
206        None => (s.to_string(), None),
207    }
208}
209
210/// Parse one datagram. Refuses anything not a SOAP envelope carrying one of the six
211/// WS-Discovery messages, nesting past [`MAX_DEPTH`], or lists past [`MAX_ITEMS`].
212pub fn parse(datagram: &[u8]) -> Result<Message> {
213    ensure!(
214        datagram.len() <= MAX_DATAGRAM,
215        "a {}-byte datagram exceeds {MAX_DATAGRAM}",
216        datagram.len()
217    );
218    let text = std::str::from_utf8(datagram).context("the message is not UTF-8")?;
219    let mut reader = Reader::from_str(text);
220    reader.config_mut().trim_text(true);
221    // One namespace frame per open element; a stack of (element ns, local name).
222    let mut frames: Vec<HashMap<String, String>> = vec![HashMap::new()];
223    let mut path: Vec<(String, String)> = Vec::new();
224    let mut message_id = None;
225    let mut relates_to = None;
226    let mut action_body: Option<(Version, Kind)> = None;
227    let mut types = Vec::new();
228    let mut scopes = Vec::new();
229    let mut match_by = None;
230    let mut endpoint = None;
231    let mut targets: Vec<Target> = Vec::new();
232    let mut current: Option<Target> = None;
233    let resolve = |frames: &[HashMap<String, String>], prefix: Option<&str>| -> Option<String> {
234        let key = prefix.unwrap_or("");
235        frames.iter().rev().find_map(|f| f.get(key).cloned())
236    };
237    loop {
238        let event = reader
239            .read_event()
240            .context("the message is not well-formed XML")?;
241        match event {
242            Event::Start(ref e) | Event::Empty(ref e) => {
243                ensure!(
244                    path.len() < MAX_DEPTH,
245                    "elements nest more than {MAX_DEPTH} deep"
246                );
247                let mut frame = HashMap::new();
248                let mut attrs = HashMap::new();
249                for a in e.attributes() {
250                    let a = a.context("a malformed attribute")?;
251                    let key = String::from_utf8_lossy(a.key.as_ref()).to_string();
252                    let value = a
253                        .decode_and_unescape_value(reader.decoder())
254                        .context("a malformed attribute value")?
255                        .to_string();
256                    if key == "xmlns" {
257                        frame.insert(String::new(), value);
258                    } else if let Some(p) = key.strip_prefix("xmlns:") {
259                        frame.insert(p.to_string(), value);
260                    } else {
261                        attrs.insert(key, value);
262                    }
263                }
264                frames.push(frame);
265                let (local, prefix) = local_and_prefix(e.name().as_ref());
266                let ns = resolve(&frames, prefix.as_deref()).unwrap_or_default();
267                let empty = matches!(event, Event::Empty(_));
268                let in_body =
269                    path.len() == 2 && path[1] == (NS_SOAP.to_string(), "Body".to_string());
270                if in_body && action_body.is_none() {
271                    if let (Some(v), Some(k)) = (Version::of(&ns), Kind::of(&local)) {
272                        action_body = Some((v, k));
273                    }
274                }
275                if let Some((v, k)) = action_body {
276                    if ns == v.discovery_ns() {
277                        let opens_target = matches!(
278                            (k, local.as_str()),
279                            (Kind::ProbeMatches, "ProbeMatch")
280                                | (Kind::ResolveMatches, "ResolveMatch")
281                        ) || (path.len() == 2
282                            && matches!(k, Kind::Hello | Kind::Bye));
283                        if opens_target {
284                            ensure!(targets.len() < MAX_ITEMS, "more than {MAX_ITEMS} matches");
285                            current = Some(Target::default());
286                        }
287                        if local == "Scopes" {
288                            match_by = attrs.get("MatchBy").cloned();
289                        }
290                    }
291                }
292                path.push((ns, local));
293                if empty {
294                    close(
295                        &mut path,
296                        &mut frames,
297                        action_body,
298                        &mut current,
299                        &mut targets,
300                    );
301                }
302            }
303            Event::Text(t) => {
304                let value = t.unescape().context("malformed text")?.trim().to_string();
305                let Some((ns, local)) = path.last().cloned() else {
306                    continue;
307                };
308                let parent = path
309                    .len()
310                    .checked_sub(2)
311                    .map(|i| path[i].1.clone())
312                    .unwrap_or_default();
313                let items = || -> Result<Vec<String>> {
314                    let v: Vec<String> = value.split_whitespace().map(str::to_string).collect();
315                    ensure!(
316                        v.len() <= MAX_ITEMS,
317                        "a list of more than {MAX_ITEMS} items"
318                    );
319                    Ok(v)
320                };
321                // The header comes before the body, so its version is not known yet: either
322                // addressing namespace is accepted there.
323                let Some((version, kind)) = action_body else {
324                    let addressing = ns == Version::V2005.addressing_ns()
325                        || ns == Version::V2009.addressing_ns();
326                    if addressing && local == "MessageID" {
327                        message_id = Some(value);
328                    } else if addressing && local == "RelatesTo" {
329                        relates_to = Some(value);
330                    }
331                    continue;
332                };
333                if ns == version.discovery_ns() {
334                    let slot = current.as_mut();
335                    match (local.as_str(), slot) {
336                        ("Types", Some(t)) => t.types = qnames(&items()?, &frames)?,
337                        ("Types", None) => types = qnames(&items()?, &frames)?,
338                        ("Scopes", Some(t)) => t.scopes = items()?,
339                        ("Scopes", None) => scopes = items()?,
340                        ("XAddrs", Some(t)) => t.xaddrs = items()?,
341                        ("MetadataVersion", Some(t)) => t.metadata_version = value.parse().ok(),
342                        _ => {}
343                    }
344                } else if local == "Address"
345                    && ns == version.addressing_ns()
346                    && parent == "EndpointReference"
347                {
348                    match current.as_mut() {
349                        Some(t) => t.endpoint = value,
350                        None if kind == Kind::Resolve => endpoint = Some(value),
351                        None => {}
352                    }
353                }
354            }
355            Event::End(_) => close(
356                &mut path,
357                &mut frames,
358                action_body,
359                &mut current,
360                &mut targets,
361            ),
362            Event::Eof => break,
363            _ => {}
364        }
365    }
366    let (version, kind) = action_body.context(
367        "not a WS-Discovery message (no Probe, Resolve, Hello, Bye or matches in the body)",
368    )?;
369    if kind == Kind::Bye {
370        endpoint = targets.first().map(|t| t.endpoint.clone());
371    }
372    Ok(Message {
373        version,
374        kind,
375        message_id: message_id.context("the message has no MessageID")?,
376        relates_to,
377        types,
378        scopes,
379        match_by,
380        endpoint,
381        targets,
382    })
383}
384
385fn close(
386    path: &mut Vec<(String, String)>,
387    frames: &mut Vec<HashMap<String, String>>,
388    action_body: Option<(Version, Kind)>,
389    current: &mut Option<Target>,
390    targets: &mut Vec<Target>,
391) {
392    let closed = path.pop();
393    frames.pop();
394    if let (Some((ns, local)), Some((v, k))) = (closed, action_body) {
395        let closes_target = ns == v.discovery_ns()
396            && (matches!(
397                (k, local.as_str()),
398                (Kind::ProbeMatches, "ProbeMatch") | (Kind::ResolveMatches, "ResolveMatch")
399            ) || (path.len() == 2
400                && matches!(k, Kind::Hello | Kind::Bye)
401                && local == k.name()));
402        if closes_target {
403            if let Some(t) = current.take() {
404                targets.push(t);
405            }
406        }
407    }
408}
409
410fn qnames(items: &[String], frames: &[HashMap<String, String>]) -> Result<Vec<QName>> {
411    items
412        .iter()
413        .map(|item| {
414            let (prefix, local) = item.split_once(':').unwrap_or(("", item.as_str()));
415            let ns = frames
416                .iter()
417                .rev()
418                .find_map(|f| f.get(prefix).cloned())
419                .with_context(|| format!("type {item:?} uses an undeclared prefix"))?;
420            Ok(QName {
421                ns,
422                local: local.to_string(),
423            })
424        })
425        .collect()
426}
427
428fn esc(s: &str) -> String {
429    quick_xml::escape::escape(s).into_owned()
430}
431
432/// An envelope with the header every message carries.
433fn envelope(
434    v: Version,
435    action: &str,
436    message_id: &str,
437    to: &str,
438    extra_header: &str,
439    body: &str,
440) -> String {
441    format!(
442        "<?xml version=\"1.0\" encoding=\"utf-8\"?>\
443<s:Envelope xmlns:s=\"{NS_SOAP}\" xmlns:a=\"{a}\" xmlns:d=\"{d}\">\
444<s:Header><a:Action>{d}/{action}</a:Action><a:MessageID>{id}</a:MessageID><a:To>{to}</a:To>{extra_header}</s:Header>\
445<s:Body>{body}</s:Body></s:Envelope>",
446        a = v.addressing_ns(),
447        d = v.discovery_ns(),
448        id = esc(message_id),
449        to = esc(to),
450    )
451}
452
453fn types_xml(types: &[QName]) -> String {
454    if types.is_empty() {
455        return String::new();
456    }
457    let mut declared: Vec<(String, &str)> = Vec::new();
458    let mut values = Vec::new();
459    for t in types {
460        let prefix = match declared.iter().find(|(_, ns)| *ns == t.ns) {
461            Some((p, _)) => p.clone(),
462            None => {
463                let p = WELL_KNOWN
464                    .iter()
465                    .find(|(_, ns)| *ns == t.ns)
466                    .map(|(p, _)| p.to_string())
467                    .unwrap_or_else(|| format!("n{}", declared.len()));
468                declared.push((p.clone(), &t.ns));
469                p
470            }
471        };
472        values.push(format!("{prefix}:{}", t.local));
473    }
474    let decls: String = declared
475        .iter()
476        .map(|(p, ns)| format!(" xmlns:{p}=\"{}\"", esc(ns)))
477        .collect();
478    format!("<d:Types{decls}>{}</d:Types>", values.join(" "))
479}
480
481fn list_xml(element: &str, items: &[String], attrs: &str) -> String {
482    if items.is_empty() {
483        return String::new();
484    }
485    format!(
486        "<d:{element}{attrs}>{}</d:{element}>",
487        esc(&items.join(" "))
488    )
489}
490
491fn target_xml(t: &Target) -> String {
492    format!(
493        "<a:EndpointReference><a:Address>{}</a:Address></a:EndpointReference>{}{}{}<d:MetadataVersion>{}</d:MetadataVersion>",
494        esc(&t.endpoint),
495        types_xml(&t.types),
496        list_xml("Scopes", &t.scopes, ""),
497        list_xml("XAddrs", &t.xaddrs, ""),
498        t.metadata_version.unwrap_or(1)
499    )
500}
501
502fn app_sequence(instance_id: u64, number: u64) -> String {
503    format!("<d:AppSequence InstanceId=\"{instance_id}\" MessageNumber=\"{number}\"/>")
504}
505
506pub fn new_message_id() -> String {
507    format!("urn:uuid:{}", uuid::Uuid::new_v4())
508}
509
510/// ProbeMatches or ResolveMatches answering `relates_to`.
511pub fn matches(
512    v: Version,
513    kind: Kind,
514    relates_to: &str,
515    targets: &[Target],
516    instance_id: u64,
517    number: u64,
518) -> Result<String> {
519    let (wrapper, each) = match kind {
520        Kind::ProbeMatches => ("ProbeMatches", "ProbeMatch"),
521        Kind::ResolveMatches => ("ResolveMatches", "ResolveMatch"),
522        other => bail!("{} is not a matches message", other.name()),
523    };
524    ensure!(targets.len() <= MAX_ITEMS, "at most {MAX_ITEMS} matches");
525    let body: String = targets
526        .iter()
527        .map(|t| format!("<d:{each}>{}</d:{each}>", target_xml(t)))
528        .collect();
529    let header = format!(
530        "<a:RelatesTo>{}</a:RelatesTo>{}",
531        esc(relates_to),
532        app_sequence(instance_id, number)
533    );
534    Ok(envelope(
535        v,
536        wrapper,
537        &new_message_id(),
538        v.anonymous(),
539        &header,
540        &format!("<d:{wrapper}>{body}</d:{wrapper}>"),
541    ))
542}
543
544pub fn probe(
545    v: Version,
546    message_id: &str,
547    types: &[QName],
548    scopes: &[String],
549    match_by: Option<&str>,
550) -> String {
551    let attrs = match_by
552        .map(|m| format!(" MatchBy=\"{}\"", esc(m)))
553        .unwrap_or_default();
554    envelope(
555        v,
556        "Probe",
557        message_id,
558        v.to_all(),
559        "",
560        &format!(
561            "<d:Probe>{}{}</d:Probe>",
562            types_xml(types),
563            list_xml("Scopes", scopes, &attrs)
564        ),
565    )
566}
567
568pub fn resolve(v: Version, message_id: &str, endpoint: &str) -> String {
569    envelope(
570        v,
571        "Resolve",
572        message_id,
573        v.to_all(),
574        "",
575        &format!("<d:Resolve><a:EndpointReference><a:Address>{}</a:Address></a:EndpointReference></d:Resolve>", esc(endpoint)),
576    )
577}
578
579pub fn hello(v: Version, t: &Target, instance_id: u64, number: u64) -> String {
580    envelope(
581        v,
582        "Hello",
583        &new_message_id(),
584        v.to_all(),
585        &app_sequence(instance_id, number),
586        &format!("<d:Hello>{}</d:Hello>", target_xml(t)),
587    )
588}
589
590pub fn bye(v: Version, endpoint: &str, instance_id: u64, number: u64) -> String {
591    envelope(
592        v,
593        "Bye",
594        &new_message_id(),
595        v.to_all(),
596        &app_sequence(instance_id, number),
597        &format!(
598            "<d:Bye><a:EndpointReference><a:Address>{}</a:Address></a:EndpointReference></d:Bye>",
599            esc(endpoint)
600        ),
601    )
602}
603
604/// A target as the model writes it.
605pub fn target_from_json(v: &serde_json::Value) -> Result<Target> {
606    let strings = |key: &str| -> Result<Vec<String>> {
607        match v.get(key) {
608            None | Some(serde_json::Value::Null) => Ok(vec![]),
609            Some(serde_json::Value::Array(a)) => {
610                ensure!(
611                    a.len() <= MAX_ITEMS,
612                    "{key} has more than {MAX_ITEMS} entries"
613                );
614                a.iter()
615                    .map(|x| {
616                        let s = x.as_str().with_context(|| format!("{key} holds strings"))?;
617                        ensure!(
618                            !s.is_empty() && !s.chars().any(char::is_whitespace),
619                            "{key} entries are single URIs without spaces: {s:?}"
620                        );
621                        Ok(s.to_string())
622                    })
623                    .collect()
624            }
625            Some(_) => bail!("{key} must be an array of strings"),
626        }
627    };
628    let endpoint = v["endpoint_reference"]
629        .as_str()
630        .context("endpoint_reference is required, e.g. urn:uuid:...")?;
631    ensure!(
632        !endpoint.is_empty() && !endpoint.chars().any(char::is_whitespace),
633        "endpoint_reference is one URI"
634    );
635    Ok(Target {
636        endpoint: endpoint.into(),
637        types: strings("types")?
638            .iter()
639            .map(|s| QName::parse(s))
640            .collect::<Result<_>>()?,
641        scopes: strings("scopes")?,
642        xaddrs: strings("xaddrs")?,
643        metadata_version: match v.get("metadata_version") {
644            None | Some(serde_json::Value::Null) => None,
645            Some(x) => Some(x.as_u64().context("metadata_version is a whole number")?),
646        },
647    })
648}
649
650pub fn target_json(t: &Target) -> serde_json::Value {
651    serde_json::json!({
652        "endpoint_reference": crate::utils::sanitize::line_field(&t.endpoint),
653        "types": t.types.iter().map(|q| crate::utils::sanitize::line_field(&q.clark())).collect::<Vec<_>>(),
654        "scopes": t.scopes.iter().map(|s| crate::utils::sanitize::line_field(s)).collect::<Vec<_>>(),
655        "xaddrs": t.xaddrs.iter().map(|s| crate::utils::sanitize::line_field(s)).collect::<Vec<_>>(),
656        "metadata_version": t.metadata_version,
657    })
658}