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}