Skip to main content

netget/server/ssdp/
message.rs

1//! HTTPU (HTTP over UDP) message codec for SSDP.
2//!
3//! Pure functions only — no sockets, no LLM, no state. Everything here is deterministic
4//! except [`response_delay_ms`], which draws the MX jitter and is separated out precisely so
5//! the *range* can be tested without a clock.
6//!
7//! SSDP (UPnP Device Architecture 1.1, §1) is HTTP/1.1 syntax carried in a single UDP
8//! datagram. There is no body, no chunking and no content length: a message is a start line,
9//! a run of `NAME: value` header lines, and a blank line. That is the whole grammar, which
10//! is why this is hand-rolled rather than reaching for an HTTP parser — a real HTTP parser
11//! wants a body framing that SSDP does not have.
12
13use std::fmt::Write as _;
14
15/// The IPv4 SSDP multicast group (UDA 1.1 §1.1).
16pub const SSDP_GROUP_V4: std::net::Ipv4Addr = std::net::Ipv4Addr::new(239, 255, 255, 250);
17
18/// The IPv6 link-local SSDP multicast group, `FF02::C`.
19pub const SSDP_GROUP_V6: std::net::Ipv6Addr =
20    std::net::Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 0xc);
21
22/// The well-known SSDP port.
23pub const SSDP_PORT: u16 = 1900;
24
25/// UDA 1.1 §1.3.2: a device MUST NOT wait longer than 5 seconds, and MUST treat an MX
26/// larger than 5 as 5.
27pub const MAX_MX_SECONDS: u32 = 5;
28
29/// Largest datagram we will parse. A datagram longer than this is dropped rather than
30/// truncated: a half-message parses into plausible-looking headers, which is worse than
31/// nothing.
32pub const MAX_MESSAGE_LEN: usize = 8192;
33
34/// A parsed HTTPU message.
35///
36/// Header order is preserved as received, because a model asked to reason about a device's
37/// announcement should see what the device actually sent, not a normalised rewrite of it.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct HttpuMessage {
40    /// The start line verbatim, e.g. `M-SEARCH * HTTP/1.1` or `HTTP/1.1 200 OK`.
41    pub start_line: String,
42    /// The request method, uppercased — `M-SEARCH` or `NOTIFY`. `None` for a status line
43    /// (`HTTP/1.1 200 OK`), which is a *response* and must never be answered.
44    pub method: Option<String>,
45    /// Headers in receive order, as `(name, value)` with the name uppercased and the value
46    /// trimmed.
47    pub headers: Vec<(String, String)>,
48}
49
50/// Why a datagram was not a usable SSDP message. Every variant is a reason to drop it.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum ParseError {
53    /// Zero bytes, or nothing but blank lines.
54    Empty,
55    /// Longer than [`MAX_MESSAGE_LEN`].
56    TooLong(usize),
57    /// Not valid UTF-8. SSDP is a text protocol; binary here is not an SSDP message.
58    NotUtf8,
59    /// A header line with no `:` separator.
60    MalformedHeader(String),
61    /// A header line before any start line, or a start line that is not a request line or a
62    /// status line.
63    MalformedStartLine(String),
64}
65
66impl std::fmt::Display for ParseError {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        match self {
69            ParseError::Empty => write!(f, "datagram is empty"),
70            ParseError::TooLong(n) => {
71                write!(
72                    f,
73                    "datagram is {n} bytes, more than the {MAX_MESSAGE_LEN} accepted"
74                )
75            }
76            ParseError::NotUtf8 => {
77                write!(f, "datagram is not valid UTF-8; SSDP is a text protocol")
78            }
79            ParseError::MalformedHeader(line) => {
80                write!(f, "header line has no ':' separator: {line:?}")
81            }
82            ParseError::MalformedStartLine(line) => write!(f, "unusable start line: {line:?}"),
83        }
84    }
85}
86
87impl std::error::Error for ParseError {}
88
89impl HttpuMessage {
90    /// Case-insensitive header lookup, first occurrence wins.
91    pub fn header(&self, name: &str) -> Option<&str> {
92        self.headers
93            .iter()
94            .find(|(k, _)| k.eq_ignore_ascii_case(name))
95            .map(|(_, v)| v.as_str())
96    }
97
98    /// Headers as a JSON object, for the event payload.
99    ///
100    /// A map, never a pre-rendered blob: the root `CLAUDE.md` rule about structured fields
101    /// applies to this exactly. A duplicated header keeps its first value here and is still
102    /// visible in full through the protocol's own log.
103    pub fn headers_json(&self) -> serde_json::Value {
104        let mut map = serde_json::Map::new();
105        for (k, v) in &self.headers {
106            map.entry(k.clone())
107                .or_insert_with(|| serde_json::Value::String(v.clone()));
108        }
109        serde_json::Value::Object(map)
110    }
111
112    /// The MX header as an integer, clamped to [`MAX_MX_SECONDS`] per UDA 1.1 §1.3.2.
113    ///
114    /// `None` when absent or unparseable. A negative or garbage MX is *not* silently read as
115    /// zero: the caller distinguishes "no MX given" from "MX 0" in the event it shows the
116    /// model.
117    pub fn mx(&self) -> Option<u32> {
118        self.header("MX")
119            .and_then(|v| v.trim().parse::<u32>().ok())
120            .map(|v| v.min(MAX_MX_SECONDS))
121    }
122}
123
124/// Parse a datagram into an [`HttpuMessage`].
125pub fn parse(data: &[u8]) -> Result<HttpuMessage, ParseError> {
126    if data.len() > MAX_MESSAGE_LEN {
127        return Err(ParseError::TooLong(data.len()));
128    }
129    let text = std::str::from_utf8(data).map_err(|_| ParseError::NotUtf8)?;
130
131    // Real implementations are sloppy about line endings, so accept a bare LF as well as
132    // CRLF. Everything we *emit* is strictly CRLF.
133    let mut lines = text.split('\n').map(|l| l.trim_end_matches('\r'));
134
135    let start_line = loop {
136        match lines.next() {
137            Some(l) if l.trim().is_empty() => continue,
138            Some(l) => break l.trim().to_string(),
139            None => return Err(ParseError::Empty),
140        }
141    };
142
143    let method = classify_start_line(&start_line)?;
144
145    let mut headers = Vec::new();
146    for line in lines {
147        // A blank line ends the header block. Anything after it is not part of the message
148        // (SSDP has no body) and is ignored.
149        if line.trim().is_empty() {
150            break;
151        }
152        let (name, value) = line
153            .split_once(':')
154            .ok_or_else(|| ParseError::MalformedHeader(line.to_string()))?;
155        let name = name.trim();
156        if name.is_empty() {
157            return Err(ParseError::MalformedHeader(line.to_string()));
158        }
159        headers.push((name.to_ascii_uppercase(), value.trim().to_string()));
160    }
161
162    Ok(HttpuMessage {
163        start_line,
164        method,
165        headers,
166    })
167}
168
169/// Decide whether a start line is a request (and which method) or a status line.
170///
171/// Anything else is refused. In particular an unrecognised method is an error rather than
172/// `None`: `None` means "this is a response", and conflating the two would let a stray
173/// `FOO * HTTP/1.1` take the response path and be silently ignored instead of logged.
174fn classify_start_line(line: &str) -> Result<Option<String>, ParseError> {
175    if line.starts_with("HTTP/") {
176        return Ok(None);
177    }
178    let mut parts = line.split_whitespace();
179    let method = parts
180        .next()
181        .ok_or_else(|| ParseError::MalformedStartLine(line.to_string()))?
182        .to_ascii_uppercase();
183    // `<method> <target> HTTP/<version>` — three fields, and the last must name HTTP.
184    let target = parts.next();
185    let version = parts.next();
186    match (target, version) {
187        (Some(_), Some(v)) if v.starts_with("HTTP/") => Ok(Some(method)),
188        _ => Err(ParseError::MalformedStartLine(line.to_string())),
189    }
190}
191
192// ---------------------------------------------------------------------------
193// Rendering
194// ---------------------------------------------------------------------------
195
196/// A header name or value that would break the message framing.
197///
198/// CR and LF in a model-supplied header would let one action emit several headers, or end
199/// the message early and append a second one — the HTTP response-splitting shape. Rejected
200/// rather than sanitised, so the model is told what it did.
201pub fn validate_header_piece(kind: &str, name: &str, s: &str) -> anyhow::Result<()> {
202    if s.contains('\r') || s.contains('\n') {
203        return Err(anyhow::anyhow!(
204            "SSDP header {kind} for '{name}' contains a carriage return or newline, which \
205             would split the message into two. Remove it."
206        ));
207    }
208    Ok(())
209}
210
211/// The `DATE` header value in the RFC 1123 form HTTP requires.
212///
213/// `chrono`'s `to_rfc2822` renders the offset as `+0000`; HTTP-date wants the literal
214/// `GMT`, so the format is spelled out. `%a`/`%b` are English in chrono regardless of
215/// locale, which is what the grammar requires.
216pub fn http_date(now: chrono::DateTime<chrono::Utc>) -> String {
217    now.format("%a, %d %b %Y %H:%M:%S GMT").to_string()
218}
219
220/// Fields of the unicast `HTTP/1.1 200 OK` answer to an M-SEARCH (UDA 1.1 §1.3.3).
221#[derive(Debug, Clone)]
222pub struct SearchResponse {
223    pub st: String,
224    pub usn: String,
225    pub location: String,
226    pub server: String,
227    pub max_age: u32,
228    pub date: String,
229    /// Extra headers, appended in order after the mandatory set.
230    pub extra_headers: Vec<(String, String)>,
231}
232
233/// Render an M-SEARCH response.
234///
235/// The mandatory header set is exactly the one UDA 1.1 §1.3.3 lists: `CACHE-CONTROL`,
236/// `DATE`, `EXT`, `LOCATION`, `SERVER`, `ST`, `USN`. `EXT` is deliberately emitted with an
237/// empty value — it is a marker header, and a control point that does not see it treats the
238/// response as coming from a device that ignored the `MAN` extension.
239pub fn render_search_response(r: &SearchResponse) -> String {
240    let mut out = String::with_capacity(256);
241    out.push_str("HTTP/1.1 200 OK\r\n");
242    let _ = writeln!(out, "CACHE-CONTROL: max-age={}\r", r.max_age);
243    let _ = writeln!(out, "DATE: {}\r", r.date);
244    out.push_str("EXT:\r\n");
245    let _ = writeln!(out, "LOCATION: {}\r", r.location);
246    let _ = writeln!(out, "SERVER: {}\r", r.server);
247    let _ = writeln!(out, "ST: {}\r", r.st);
248    let _ = writeln!(out, "USN: {}\r", r.usn);
249    for (k, v) in &r.extra_headers {
250        let _ = writeln!(out, "{k}: {v}\r");
251    }
252    out.push_str("\r\n");
253    out
254}
255
256/// Fields of a multicast `NOTIFY * HTTP/1.1` announcement (UDA 1.1 §1.2).
257#[derive(Debug, Clone)]
258pub struct NotifyMessage {
259    pub host: String,
260    pub nt: String,
261    pub nts: String,
262    pub usn: String,
263    /// Required for `ssdp:alive` and `ssdp:update`; omitted from `ssdp:byebye`.
264    pub location: Option<String>,
265    pub server: Option<String>,
266    pub max_age: Option<u32>,
267}
268
269/// Render a NOTIFY.
270///
271/// A `ssdp:byebye` carries **only** `HOST`, `NT`, `NTS` and `USN` (UDA 1.1 §1.2.3). Sending
272/// `LOCATION` and `CACHE-CONTROL` on a byebye contradicts the message: the announcement says
273/// the device is going away while the headers describe where to reach it and for how long to
274/// cache that. The caller passes `None` for those fields on a byebye and this renders
275/// accordingly; it does not second-guess the caller beyond that.
276pub fn render_notify(n: &NotifyMessage) -> String {
277    let mut out = String::with_capacity(256);
278    out.push_str("NOTIFY * HTTP/1.1\r\n");
279    let _ = writeln!(out, "HOST: {}\r", n.host);
280    if let Some(max_age) = n.max_age {
281        let _ = writeln!(out, "CACHE-CONTROL: max-age={max_age}\r");
282    }
283    if let Some(location) = &n.location {
284        let _ = writeln!(out, "LOCATION: {location}\r");
285    }
286    let _ = writeln!(out, "NT: {}\r", n.nt);
287    let _ = writeln!(out, "NTS: {}\r", n.nts);
288    if let Some(server) = &n.server {
289        let _ = writeln!(out, "SERVER: {server}\r");
290    }
291    let _ = writeln!(out, "USN: {}\r", n.usn);
292    out.push_str("\r\n");
293    out
294}
295
296// ---------------------------------------------------------------------------
297// MX jitter
298// ---------------------------------------------------------------------------
299
300/// Upper bound, in milliseconds, on how long a response to this M-SEARCH may be held back.
301///
302/// UDA 1.1 §1.3.3 says a device waits a random interval between 0 and MX seconds before
303/// answering a multicast M-SEARCH; the whole point is to spread a whole network's answers so
304/// the control point is not flooded. That behaviour is what makes NetGet's traffic look like
305/// a real device's rather than like a machine answering instantly every time.
306///
307/// `cap_ms` is the operator's ceiling (`max_response_delay_ms`), because a faithful 5-second
308/// wait makes every test that touches this protocol five seconds slower. A cap of 0 disables
309/// the jitter entirely.
310pub fn response_delay_bound_ms(mx: Option<u32>, cap_ms: u64) -> u64 {
311    let mx_ms = u64::from(mx.unwrap_or(0).min(MAX_MX_SECONDS)) * 1000;
312    mx_ms.min(cap_ms)
313}
314
315/// Draw the actual delay: uniform over `0..=bound`.
316pub fn response_delay_ms(mx: Option<u32>, cap_ms: u64) -> u64 {
317    use rand::Rng as _;
318    let bound = response_delay_bound_ms(mx, cap_ms);
319    if bound == 0 {
320        return 0;
321    }
322    rand::thread_rng().gen_range(0..=bound)
323}