Skip to main content

netget/server/ssdp/
actions.rs

1//! SSDP / UPnP discovery protocol vocabulary and executor.
2//!
3//! # Every SSDP reply is a positive assertion
4//!
5//! There is no such thing as an SSDP error message. The protocol has exactly two things a
6//! device can put on the wire — a `200 OK` answering an M-SEARCH, and a `NOTIFY` — and both
7//! of them mean *"a device of this type exists, and its description is at this URL"*. There
8//! is no code for "I do not know", no equivalent of DNS SERVFAIL or HTTP 503.
9//!
10//! That makes SSDP a member of the deliberately-silent class in the root `CLAUDE.md`, and
11//! for the strongest reason in that list. A control point caches what it is told for
12//! `CACHE-CONTROL: max-age` seconds — half an hour by convention — and will then go and
13//! fetch the `LOCATION` URL. A fabricated advertisement therefore does not merely mislead
14//! one peer once; it plants a non-existent device in every listener's device table and sends
15//! them all at a URL that answers nothing. Silence, by contrast, is the *normal* SSDP
16//! outcome: a device that does not match the search target is required by UDA 1.1 §1.3.3 to
17//! say nothing at all, so every control point already handles it.
18//!
19//! Three structural consequences, each of which a reader can point at:
20//!
21//! 1. **Nothing here can synthesise a response.** `execute_action` produces only what a
22//!    named action asked for. There is no default, no fallback and no "if the model said
23//!    nothing, advertise ourselves".
24//! 2. **Declining is an action of its own.** `no_response` exists so that "this device does
25//!    not match the search target" is a decision the model states, distinguishable in the
26//!    log from a model that returned nothing and from a backend that fell over. That is the
27//!    `radius` shape applied to a protocol whose safe default is silence rather than denial.
28//! 3. **An LLM failure writes nothing.** `mod.rs` logs `decision=fail_closed_llm_error` and
29//!    sends no datagram. There is deliberately no `WireFailure` string on the wire, because
30//!    there is no header in which SSDP could carry one — anything we emitted would still be
31//!    a well-formed advertisement.
32
33use crate::llm::actions::{
34    protocol_trait::{ActionResult, Protocol, Server},
35    ActionDefinition, Parameter, ParameterDefinition,
36};
37use crate::protocol::EventType;
38use crate::state::app_state::AppState;
39use anyhow::{Context, Result};
40use serde_json::json;
41use std::sync::LazyLock;
42
43use super::message::{self, NotifyMessage, SearchResponse};
44
45/// `SERVER` header used when neither the action nor the `server_header` startup parameter
46/// names one. The UDA 1.1 grammar is `OS/version UPnP/1.1 product/version`.
47pub const DEFAULT_SERVER_HEADER: &str = "NetGet/1.0 UPnP/1.1 NetGet-SSDP/1.0";
48
49/// Default `CACHE-CONTROL: max-age`, in seconds. UDA 1.1 §1.2.2 requires at least 1800 and
50/// recommends it as the value.
51pub const DEFAULT_MAX_AGE: u32 = 1800;
52
53/// Per-request context, built by `mod.rs` from the datagram that provoked the LLM call.
54///
55/// The registry holds a context-free `SsdpProtocol`; `mod.rs` builds one of these per
56/// datagram. It exists for two things the model should not have to restate, and one it
57/// cannot know:
58///
59/// * the search target to echo when the model's action omits `st`;
60/// * the operator's `server_header` default;
61/// * the `HOST` a NOTIFY must be addressed to, which differs between the IPv4 and IPv6
62///   groups.
63#[derive(Clone, Debug)]
64pub struct RequestContext {
65    /// `ST` from the M-SEARCH being answered, if this is an M-SEARCH at all.
66    pub search_target: Option<String>,
67    /// `SERVER` header default from `server_header`.
68    pub server_header: String,
69    /// `HOST` for an outbound NOTIFY, e.g. `239.255.255.250:1900` or `[FF02::C]:1900`.
70    pub notify_host: String,
71}
72
73impl RequestContext {
74    /// The IPv4 defaults, for a server that never resolved anything else.
75    pub fn ipv4_defaults(search_target: Option<String>) -> Self {
76        Self {
77            search_target,
78            server_header: DEFAULT_SERVER_HEADER.to_string(),
79            notify_host: format!("{}:{}", message::SSDP_GROUP_V4, message::SSDP_PORT),
80        }
81    }
82}
83
84/// SSDP protocol handler.
85pub struct SsdpProtocol {
86    request: Option<RequestContext>,
87}
88
89impl Default for SsdpProtocol {
90    fn default() -> Self {
91        Self::new()
92    }
93}
94
95impl SsdpProtocol {
96    pub fn new() -> Self {
97        Self { request: None }
98    }
99
100    pub fn for_request(ctx: RequestContext) -> Self {
101        Self { request: Some(ctx) }
102    }
103
104    fn server_default(&self) -> &str {
105        match &self.request {
106            Some(ctx) => &ctx.server_header,
107            None => DEFAULT_SERVER_HEADER,
108        }
109    }
110
111    fn notify_host(&self) -> String {
112        match &self.request {
113            Some(ctx) => ctx.notify_host.clone(),
114            None => format!("{}:{}", message::SSDP_GROUP_V4, message::SSDP_PORT),
115        }
116    }
117
118    /// The `ST` to put in a search response: what the model said, or — when it said nothing
119    /// — the target the control point asked for.
120    ///
121    /// Echoing the request's `ST` is not the server inventing an answer: the answer is the
122    /// whole advertisement, and the model has already decided to send one. What the echo
123    /// prevents is a response a control point silently discards, which is
124    /// indistinguishable from the server being down. The one case where it cannot help is a
125    /// wildcard search (`ssdp:all`, `upnp:rootdevice`), because the response has to name the
126    /// *concrete* type — see `resolve_st`.
127    fn resolve_st(&self, action: &serde_json::Value) -> Result<String> {
128        if let Some(st) = action.get("st").and_then(|v| v.as_str()) {
129            if !st.trim().is_empty() {
130                return Ok(st.trim().to_string());
131            }
132        }
133        let requested = self
134            .request
135            .as_ref()
136            .and_then(|c| c.search_target.as_deref())
137            .map(str::trim)
138            .filter(|s| !s.is_empty());
139
140        match requested {
141            Some("ssdp:all") | Some("upnp:rootdevice") | None => Err(anyhow::anyhow!(
142                "send_ssdp_response needs an 'st'. The search target was {}, which is a \
143                 wildcard, so the response must name the concrete device or service type \
144                 this answer is for (e.g. \"urn:schemas-upnp-org:device:MediaServer:1\"). \
145                 Only a search for a specific type can have its ST echoed automatically.",
146                requested
147                    .map(|s| format!("'{s}'"))
148                    .unwrap_or_else(|| { "not available in this context".to_string() })
149            )),
150            Some(other) => Ok(other.to_string()),
151        }
152    }
153}
154
155// ---------------------------------------------------------------------------
156// Field helpers
157// ---------------------------------------------------------------------------
158
159fn required_str(action: &serde_json::Value, key: &str, why: &str) -> Result<String> {
160    let raw = action
161        .get(key)
162        .and_then(|v| v.as_str())
163        .with_context(|| format!("'{key}' is required: {why}"))?;
164    let trimmed = raw.trim();
165    if trimmed.is_empty() {
166        return Err(anyhow::anyhow!("'{key}' must not be empty: {why}"));
167    }
168    message::validate_header_piece("value", key, trimmed)?;
169    Ok(trimmed.to_string())
170}
171
172fn optional_str(action: &serde_json::Value, key: &str) -> Result<Option<String>> {
173    match action.get(key).and_then(|v| v.as_str()) {
174        Some(s) if !s.trim().is_empty() => {
175            let trimmed = s.trim();
176            message::validate_header_piece("value", key, trimmed)?;
177            Ok(Some(trimmed.to_string()))
178        }
179        _ => Ok(None),
180    }
181}
182
183/// `LOCATION` must be a URL a control point can actually fetch.
184///
185/// This is checked rather than passed through because the whole meaning of a response is
186/// "the description document is *there*". A LOCATION that is not an absolute HTTP URL makes
187/// the advertisement unusable while still looking valid on the wire, which is exactly the
188/// class of failure that is impossible to debug from the client end.
189fn parse_location(action: &serde_json::Value, key: &str) -> Result<String> {
190    let raw = required_str(
191        action,
192        key,
193        "it is the absolute http(s) URL of the device description document that this \
194         advertisement points a control point at",
195    )?;
196    let url = url::Url::parse(&raw)
197        .with_context(|| format!("'{key}' is not a valid absolute URL: {raw:?}"))?;
198    match url.scheme() {
199        "http" | "https" => Ok(raw),
200        other => Err(anyhow::anyhow!(
201            "'{key}' has scheme '{other}'; a UPnP LOCATION must be http or https because a \
202             control point fetches the description document over HTTP"
203        )),
204    }
205}
206
207fn parse_max_age(action: &serde_json::Value) -> Result<u32> {
208    match action.get("cache_control_max_age") {
209        None | Some(serde_json::Value::Null) => Ok(DEFAULT_MAX_AGE),
210        Some(v) => {
211            let n = v
212                .as_u64()
213                .context("'cache_control_max_age' must be a non-negative number of seconds")?;
214            u32::try_from(n).context("'cache_control_max_age' is too large for a max-age")
215        }
216    }
217}
218
219/// `extra_headers` as an ordered list, with every name and value checked for CR/LF.
220///
221/// A map, not a blob — the root `CLAUDE.md` rule. `serde_json::Map` preserves insertion
222/// order only with the `preserve_order` feature, which is not enabled here, so the output is
223/// sorted; that is deterministic and SSDP attaches no meaning to header order.
224fn parse_extra_headers(action: &serde_json::Value) -> Result<Vec<(String, String)>> {
225    let Some(value) = action.get("extra_headers") else {
226        return Ok(Vec::new());
227    };
228    if value.is_null() {
229        return Ok(Vec::new());
230    }
231    let obj = value.as_object().context(
232        "'extra_headers' must be an object of header name to value, e.g. \
233         {\"BOOTID.UPNP.ORG\": \"1\"}",
234    )?;
235
236    const RESERVED: &[&str] = &[
237        "CACHE-CONTROL",
238        "DATE",
239        "EXT",
240        "LOCATION",
241        "SERVER",
242        "ST",
243        "USN",
244    ];
245
246    let mut out = Vec::with_capacity(obj.len());
247    for (name, v) in obj {
248        let name = name.trim();
249        if name.is_empty() {
250            return Err(anyhow::anyhow!("'extra_headers' has an empty header name"));
251        }
252        message::validate_header_piece("name", name, name)?;
253        if name.contains(':') {
254            return Err(anyhow::anyhow!(
255                "extra header name '{name}' contains ':', which would produce a malformed \
256                 header line"
257            ));
258        }
259        let upper = name.to_ascii_uppercase();
260        if RESERVED.contains(&upper.as_str()) {
261            return Err(anyhow::anyhow!(
262                "'{name}' is one of the mandatory response headers and is set from this \
263                 action's own parameters ({}). Setting it again through extra_headers would \
264                 emit it twice.",
265                RESERVED.join(", ")
266            ));
267        }
268        let value = v
269            .as_str()
270            .with_context(|| format!("extra header '{name}' must have a string value; got {v}"))?;
271        message::validate_header_piece("value", name, value)?;
272        out.push((upper, value.trim().to_string()));
273    }
274    Ok(out)
275}
276
277/// The three NTS values UDA 1.1 defines. Anything else is refused rather than forwarded:
278/// a control point ignores an unknown NTS, so an unchecked one is a silent no-op.
279fn parse_nts(action: &serde_json::Value) -> Result<String> {
280    let nts = required_str(
281        action,
282        "nts",
283        "it says which kind of announcement this is: \"ssdp:alive\", \"ssdp:byebye\" or \
284         \"ssdp:update\"",
285    )?;
286    match nts.as_str() {
287        "ssdp:alive" | "ssdp:byebye" | "ssdp:update" => Ok(nts),
288        other => Err(anyhow::anyhow!(
289            "Unknown nts '{other}'. UDA 1.1 defines exactly three: \"ssdp:alive\" (the \
290             device is present), \"ssdp:byebye\" (it is leaving) and \"ssdp:update\" (its \
291             description changed). A control point ignores anything else."
292        )),
293    }
294}
295
296fn p(name: &str, type_hint: &str, description: &str) -> Parameter {
297    Parameter {
298        name: name.to_string(),
299        type_hint: type_hint.to_string(),
300        description: description.to_string(),
301        required: false,
302    }
303}
304
305fn required(name: &str, type_hint: &str, description: &str) -> Parameter {
306    Parameter {
307        name: name.to_string(),
308        type_hint: type_hint.to_string(),
309        description: description.to_string(),
310        required: true,
311    }
312}
313
314// ---------------------------------------------------------------------------
315// Actions
316// ---------------------------------------------------------------------------
317
318fn send_ssdp_response_action() -> ActionDefinition {
319    ActionDefinition {
320        name: "send_ssdp_response".to_string(),
321        description: "Answer an M-SEARCH with a unicast HTTP/1.1 200 OK, asserting that a \
322                      device of type 'st' exists here and that its description document is \
323                      at 'location'. Send this ONLY when the device you are playing actually \
324                      matches the search target — a control point will cache what you say \
325                      for cache_control_max_age seconds and then fetch the LOCATION URL. If \
326                      it does not match, use no_response."
327            .to_string(),
328        parameters: vec![
329            required(
330                "st",
331                "string",
332                "The device or service type this response is for, e.g. \
333                 'upnp:rootdevice', 'urn:schemas-upnp-org:device:MediaServer:1' or a \
334                 'uuid:...'. It MUST be the concrete type. When the search target was a \
335                 specific type it is echoed automatically if you omit this, but a search \
336                 for 'ssdp:all' or 'upnp:rootdevice' has no single answer, so name the type",
337            ),
338            required(
339                "usn",
340                "string",
341                "Unique Service Name: the composite identity of this advertisement, \
342                 conventionally 'uuid:<device-uuid>::<st>' (or just 'uuid:<device-uuid>' \
343                 when st is that uuid). It is what a control point de-duplicates on",
344            ),
345            required(
346                "location",
347                "string",
348                "Absolute http(s) URL of the device description XML, e.g. \
349                 'http://192.168.1.10:8080/description.xml'. Must be reachable; a control \
350                 point fetches it immediately",
351            ),
352            p(
353                "server",
354                "string",
355                "SERVER header, in the form 'OS/version UPnP/1.1 product/version'. Defaults \
356                 to the server's server_header startup parameter",
357            ),
358            p(
359                "cache_control_max_age",
360                "number",
361                "Seconds a control point may cache this advertisement. UDA 1.1 requires at \
362                 least 1800, which is the default",
363            ),
364            p(
365                "extra_headers",
366                "object",
367                "Additional headers as a name-to-value map, e.g. \
368                 {\"BOOTID.UPNP.ORG\": \"1\", \"CONFIGID.UPNP.ORG\": \"7\"}. The mandatory \
369                 headers (CACHE-CONTROL, DATE, EXT, LOCATION, SERVER, ST, USN) come from the \
370                 parameters above and must not be repeated here",
371            ),
372        ],
373        example: json!({
374            "type": "send_ssdp_response",
375            "st": "urn:schemas-upnp-org:device:MediaServer:1",
376            "usn": "uuid:9f8d2b31-4c5e-4a90-8f21-0e6f5a7c1d33::urn:schemas-upnp-org:device:MediaServer:1",
377            "location": "http://192.168.1.10:8080/description.xml",
378            "server": "Linux/6.1 UPnP/1.1 NetGet-SSDP/1.0",
379            "cache_control_max_age": 1800
380        }),
381        log_template: None,
382    }
383}
384
385fn send_ssdp_notify_action() -> ActionDefinition {
386    ActionDefinition {
387        name: "send_ssdp_notify".to_string(),
388        description: "Announce to the SSDP multicast group with NOTIFY * HTTP/1.1. \
389                      'ssdp:alive' asserts the device is present, 'ssdp:byebye' that it is \
390                      leaving, 'ssdp:update' that its description changed. This is broadcast \
391                      to every listener on the link, so send it only when the announcement \
392                      is true of the device you are playing."
393            .to_string(),
394        parameters: vec![
395            required(
396                "nt",
397                "string",
398                "Notification Type: the device or service type being announced, e.g. \
399                 'upnp:rootdevice' or 'urn:schemas-upnp-org:device:MediaServer:1'",
400            ),
401            required(
402                "nts",
403                "string",
404                "'ssdp:alive', 'ssdp:byebye' or 'ssdp:update'",
405            ),
406            required(
407                "usn",
408                "string",
409                "Unique Service Name, conventionally 'uuid:<device-uuid>::<nt>'",
410            ),
411            p(
412                "location",
413                "string",
414                "Absolute http(s) URL of the device description XML. Required for \
415                 'ssdp:alive' and 'ssdp:update'; omitted from an 'ssdp:byebye', which \
416                 carries only HOST, NT, NTS and USN",
417            ),
418            p(
419                "server",
420                "string",
421                "SERVER header. Defaults to the server_header startup parameter. Not sent \
422                 on an ssdp:byebye",
423            ),
424            p(
425                "cache_control_max_age",
426                "number",
427                "Seconds a control point may cache this announcement; default 1800. Not \
428                 sent on an ssdp:byebye",
429            ),
430        ],
431        example: json!({
432            "type": "send_ssdp_notify",
433            "nt": "urn:schemas-upnp-org:device:MediaServer:1",
434            "nts": "ssdp:alive",
435            "usn": "uuid:9f8d2b31-4c5e-4a90-8f21-0e6f5a7c1d33::urn:schemas-upnp-org:device:MediaServer:1",
436            "location": "http://192.168.1.10:8080/description.xml"
437        }),
438        log_template: None,
439    }
440}
441
442fn no_response_action() -> ActionDefinition {
443    ActionDefinition {
444        name: "no_response".to_string(),
445        description: "Say nothing, deliberately. This is the CORRECT and most common answer \
446                      in SSDP: UDA 1.1 §1.3.3 requires a device whose type does not match \
447                      the search target to stay silent, and every control point expects \
448                      that. Use it whenever the search target does not describe the device \
449                      you are playing. It is a real decision — the server records it as \
450                      yours, distinct from you returning nothing at all."
451            .to_string(),
452        parameters: vec![p(
453            "reason",
454            "string",
455            "Why nothing is being sent, for the log. Not put on the wire",
456        )],
457        example: json!({
458            "type": "no_response",
459            "reason": "search target urn:schemas-upnp-org:device:Printer:1 does not match this media server"
460        }),
461        log_template: None,
462    }
463}
464
465// ---------------------------------------------------------------------------
466// Events
467// ---------------------------------------------------------------------------
468
469pub static SSDP_MSEARCH_EVENT: LazyLock<EventType> = LazyLock::new(|| {
470    EventType::new(
471        "ssdp_msearch",
472        "A control point is searching for UPnP devices. Answer with send_ssdp_response ONLY \
473         if the device you are playing matches the search target; otherwise answer with \
474         no_response, which is what a real device does and is the common case. There is no \
475         error response in SSDP — anything you send is an assertion that a device exists at \
476         the LOCATION you name.",
477        json!({
478            "type": "send_ssdp_response",
479            "st": "urn:schemas-upnp-org:device:MediaServer:1",
480            "usn": "uuid:9f8d2b31-4c5e-4a90-8f21-0e6f5a7c1d33::urn:schemas-upnp-org:device:MediaServer:1",
481            "location": "http://192.168.1.10:8080/description.xml"
482        }),
483    )
484    .with_parameters(vec![
485        p(
486            "st",
487            "string",
488            "Search Target. Either a concrete type ('urn:schemas-upnp-org:device:...', \
489             'uuid:...'), or the wildcard 'ssdp:all' (every device and service), or \
490             'upnp:rootdevice' (root devices only). For a wildcard you must name the \
491             concrete type in your response's 'st'",
492        ),
493        p(
494            "mx",
495            "number",
496            "Maximum wait, in seconds, the control point will tolerate before an answer \
497             (1-5). The server already applies a random delay up to this bound, capped by \
498             its max_response_delay_ms parameter; you do not need to do anything with it",
499        ),
500        p(
501            "man",
502            "string",
503            "The MAN header verbatim. A conforming M-SEARCH sends \"ssdp:discover\" \
504             including the quotes; anything else means the sender is not doing UPnP \
505             discovery and is usually worth ignoring",
506        ),
507        p(
508            "host",
509            "string",
510            "The HOST header the search was addressed to — '239.255.255.250:1900' for a \
511             multicast search, or this server's own address for a unicast one",
512        ),
513        p("source_address", "string", "ip:port the search came from"),
514        p(
515            "user_agent",
516            "string",
517            "USER-AGENT header, naming the control point's OS and product, or null",
518        ),
519        p(
520            "headers",
521            "object",
522            "Every header in the message as a name-to-value map, including ones not broken \
523             out above",
524        ),
525    ])
526    .with_actions(vec![send_ssdp_response_action(), no_response_action()])
527});
528
529pub static SSDP_NOTIFY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
530    EventType::new(
531        "ssdp_notify",
532        "Another device announced itself on the SSDP multicast group. Nothing is expected in \
533         reply — a NOTIFY is not a request — so no_response is almost always right. You may \
534         answer with send_ssdp_notify to announce this server in turn, which multicasts to \
535         everyone on the link.",
536        json!({ "type": "no_response", "reason": "a NOTIFY needs no answer" }),
537    )
538    .with_parameters(vec![
539        p(
540            "nt",
541            "string",
542            "Notification Type: the device or service type being announced",
543        ),
544        p(
545            "nts",
546            "string",
547            "'ssdp:alive', 'ssdp:byebye' or 'ssdp:update'",
548        ),
549        p("usn", "string", "Unique Service Name of the announcer"),
550        p(
551            "location",
552            "string",
553            "URL of the announcer's description document, or null (a byebye carries none)",
554        ),
555        p("server", "string", "The announcer's SERVER header, or null"),
556        p(
557            "cache_control_max_age",
558            "number",
559            "Seconds the announcement is valid for, from CACHE-CONTROL, or null",
560        ),
561        p("host", "string", "The HOST header of the announcement"),
562        p(
563            "source_address",
564            "string",
565            "ip:port the announcement came from",
566        ),
567        p(
568            "headers",
569            "object",
570            "Every header in the message as a name-to-value map",
571        ),
572    ])
573    .with_actions(vec![send_ssdp_notify_action(), no_response_action()])
574});
575
576// ---------------------------------------------------------------------------
577// Protocol / Server impls
578// ---------------------------------------------------------------------------
579
580impl Protocol for SsdpProtocol {
581    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
582        // SSDP is purely reactive here. An async action would be dispatched on the
583        // registry's stateless instance, which owns no socket, so it could only ever return
584        // NoAction — an advertised verb that silently does nothing. See CLAUDE.md's
585        // "Announcing on our own initiative".
586        Vec::new()
587    }
588
589    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
590        vec![
591            send_ssdp_response_action(),
592            send_ssdp_notify_action(),
593            no_response_action(),
594        ]
595    }
596
597    fn protocol_name(&self) -> &'static str {
598        "SSDP"
599    }
600
601    fn get_event_types(&self) -> Vec<EventType> {
602        vec![SSDP_MSEARCH_EVENT.clone(), SSDP_NOTIFY_EVENT.clone()]
603    }
604
605    fn stack_name(&self) -> &'static str {
606        "ETH>IP>UDP>SSDP"
607    }
608
609    fn keywords(&self) -> Vec<&'static str> {
610        // Narrow on purpose. "discovery" would hijack keyword resolution from mdns, llmnr
611        // and netbios-ns, which are all discovery protocols too.
612        vec![
613            "ssdp",
614            "upnp",
615            "upnp discovery",
616            "m-search",
617            "ssdp:discover",
618        ]
619    }
620
621    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
622        use crate::protocol::metadata::{
623            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
624        };
625
626        ProtocolMetadataV2::builder()
627            // Every datagram is its own peer entry and nothing closes it, so the 10-second
628            // idle sweep is what keeps the connection list from growing without bound. This
629            // flag is what enables it (see the root CLAUDE.md).
630            .connectionless()
631            // Deliberately silent: SSDP has no error message - a 200 OK and a NOTIFY both assert
632            // that a device exists at a URL, and a control point caches that for max-age. A device
633            // that does not match a search is required to say nothing, so silence is normal.
634            .deliberately_silent()
635            .state(DevelopmentState::Experimental)
636            // 1900 is above 1023, so PrivilegedPort would be dead code — the
637            // svn/PrivilegedPort(3690) mistake.
638            .privilege_requirement(PrivilegeRequirement::None)
639            .well_known_udp_port(1900)
640            .implementation(
641                "Hand-rolled HTTPU codec (src/server/ssdp/message.rs) over a tokio \
642                 UdpSocket. Parses M-SEARCH and NOTIFY, renders the UDA 1.1 §1.3.3 response \
643                 header set and §1.2 announcements, applies the MX response jitter, and \
644                 attempts a multicast group join for 239.255.255.250 / [FF02::C] that is \
645                 logged but non-fatal when it fails. Does NOT implement the UPnP device \
646                 description document, SOAP control, GENA eventing, or any HTTP server for \
647                 the LOCATION URL it advertises: the model supplies a LOCATION, and \
648                 something else must serve it.",
649            )
650            .llm_control(
651                "The model decides whether this server matches each search target at all, \
652                 and invents every device: its type, UUID, USN, description URL, SERVER \
653                 string and cache lifetime. Declining is an explicit action (no_response) \
654                 rather than an absence.",
655            )
656            .e2e_testing(
657                "Mocked end-to-end through the real binary over a raw UDP socket bound to \
658                 127.0.0.1, sending unicast M-SEARCH and NOTIFY. Codec asserted against \
659                 literal UDA 1.1 message text. NOT validated against any third-party UPnP \
660                 control point: no Rust SSDP client found could be pointed at a unicast \
661                 loopback address and an ephemeral port, and the real-world control points \
662                 (Windows/macOS/Sonos) only ever multicast to 239.255.255.250:1900, which \
663                 needs the privileged well-known port and a real link.",
664            )
665            .notes(
666                "DELIBERATELY SILENT ON FAILURE. SSDP has no error message: both messages a \
667                 device can send are positive assertions that a device exists at a URL, and \
668                 a control point caches them for max-age and then fetches that URL. So an \
669                 LLM error, an unusable answer, or an explicit no_response all put NOTHING \
670                 on the wire, and the distinction is carried in the log as \
671                 decision=fail_closed_llm_error / model_silent / model_reject. Do not \
672                 'fix' the silence with a WireFailure string. The multicast join is \
673                 best-effort and logged rather than fatal; a unicast M-SEARCH sent straight \
674                 to the port is answered whether or not it succeeded. Measured on macOS: \
675                 the join succeeds even from a 127.0.0.1 bind, but SENDING to the group \
676                 from one fails with EADDRNOTAVAIL, which is what notify_target is for.",
677            )
678            .max_inbound_bytes(crate::server::ssdp::message::MAX_MESSAGE_LEN)
679            .build()
680    }
681
682    fn description(&self) -> &'static str {
683        "SSDP / UPnP discovery server (HTTPU over UDP): answers M-SEARCH and multicasts \
684         NOTIFY announcements"
685    }
686
687    fn example_prompt(&self) -> &'static str {
688        "run an ssdp server on port 1900 pretending to be a MediaServer at \
689         http://192.168.1.10:8080/description.xml; answer searches for MediaServer and \
690         ssdp:all, and stay silent for anything else"
691    }
692
693    fn group_name(&self) -> &'static str {
694        "Application"
695    }
696
697    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
698        vec![
699            ParameterDefinition {
700                name: "max_response_delay_ms".to_string(),
701                type_hint: "number".to_string(),
702                description: "Ceiling, in milliseconds, on the random delay applied before \
703                              answering an M-SEARCH. A real device waits a random interval \
704                              between 0 and the request's MX seconds so a whole network's \
705                              answers do not arrive at once, and this cap keeps that \
706                              realistic without making every exchange take up to 5 seconds. \
707                              The actual delay is uniform over 0..min(MX*1000, this). \
708                              Default 1000; set 0 to answer immediately."
709                    .to_string(),
710                required: false,
711                example: json!(1000),
712                default: None,
713            },
714            ParameterDefinition {
715                name: "server_header".to_string(),
716                type_hint: "string".to_string(),
717                description: "Default SERVER header for responses and announcements, in the \
718                              UDA 1.1 form 'OS/version UPnP/1.1 product/version'. An \
719                              action's own 'server' parameter overrides it. Default \
720                              'NetGet/1.0 UPnP/1.1 NetGet-SSDP/1.0'."
721                    .to_string(),
722                required: false,
723                example: json!("Linux/6.1 UPnP/1.1 MiniDLNA/1.3.0"),
724                default: None,
725            },
726            ParameterDefinition {
727                name: "join_multicast".to_string(),
728                type_hint: "boolean".to_string(),
729                description: "Whether to join the SSDP multicast group (239.255.255.250 for \
730                              IPv4, FF02::C for IPv6) so multicast M-SEARCHes are received. \
731                              Default true. The join is best-effort: if it fails the server \
732                              logs a warning and keeps running, and still answers unicast \
733                              M-SEARCH sent straight to its port."
734                    .to_string(),
735                required: false,
736                example: json!(true),
737                default: None,
738            },
739            ParameterDefinition {
740                name: "multicast_interface".to_string(),
741                type_hint: "string".to_string(),
742                description: "Local IPv4 address of the interface to join the group on, e.g. \
743                              '192.168.1.10'. Default '0.0.0.0', letting the kernel choose. \
744                              Only used for an IPv4 bind; an IPv6 bind joins on interface \
745                              index 0."
746                    .to_string(),
747                required: false,
748                example: json!("0.0.0.0"),
749                default: None,
750            },
751            ParameterDefinition {
752                name: "notify_target".to_string(),
753                type_hint: "string".to_string(),
754                description: "ip:port that send_ssdp_notify announcements are actually sent \
755                              to. Defaults to the protocol's multicast group — \
756                              '239.255.255.250:1900' for an IPv4 bind, '[ff02::c]:1900' for \
757                              IPv6. Override it to point announcements at one listener \
758                              instead: a socket bound to loopback usually has no route to \
759                              the multicast group, so on 127.0.0.1 the default send fails \
760                              and this is how announcements are made observable. The HOST \
761                              header of the announcement always names the group, whatever \
762                              this is set to, because that is what the header means."
763                    .to_string(),
764                required: false,
765                example: json!("239.255.255.250:1900"),
766                default: None,
767            },
768        ]
769    }
770
771    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
772        use crate::llm::actions::StartupExamples;
773
774        StartupExamples::new(
775            // LLM mode
776            json!({
777                "type": "open_server",
778                "port": 1900,
779                "base_stack": "ssdp",
780                "startup_params": {
781                    "server_header": "Linux/6.1 UPnP/1.1 MiniDLNA/1.3.0",
782                    "max_response_delay_ms": 1000
783                },
784                "instruction": "You are a UPnP MediaServer with uuid \
785                                9f8d2b31-4c5e-4a90-8f21-0e6f5a7c1d33, described at \
786                                http://192.168.1.10:8080/description.xml. Answer searches \
787                                for ssdp:all, upnp:rootdevice and \
788                                urn:schemas-upnp-org:device:MediaServer:1. Stay silent for \
789                                every other search target."
790            }),
791            // Script mode
792            json!({
793                "type": "open_server",
794                "port": 1900,
795                "base_stack": "ssdp",
796                "event_handlers": [{
797                    "event_pattern": "ssdp_msearch",
798                    "handler": {
799                        "type": "script",
800                        "language": "python",
801                        "code": "st = event.get('st', '')\nuuid = 'uuid:9f8d2b31-4c5e-4a90-8f21-0e6f5a7c1d33'\ntarget = 'urn:schemas-upnp-org:device:MediaServer:1'\nif st in ('ssdp:all', 'upnp:rootdevice', target):\n    respond([{'type': 'send_ssdp_response', 'st': target, 'usn': uuid + '::' + target, 'location': 'http://192.168.1.10:8080/description.xml'}])\nelse:\n    respond([{'type': 'no_response', 'reason': 'not a media server search'}])"
802                    }
803                }]
804            }),
805            // Static mode
806            json!({
807                "type": "open_server",
808                "port": 1900,
809                "base_stack": "ssdp",
810                "event_handlers": [{
811                    "event_pattern": "ssdp_msearch",
812                    "handler": {
813                        "type": "static",
814                        "actions": [{
815                            "type": "send_ssdp_response",
816                            "st": "upnp:rootdevice",
817                            "usn": "uuid:9f8d2b31-4c5e-4a90-8f21-0e6f5a7c1d33::upnp:rootdevice",
818                            "location": "http://192.168.1.10:8080/description.xml"
819                        }]
820                    }
821                }]
822            }),
823        )
824    }
825}
826
827impl Server for SsdpProtocol {
828    fn spawn(
829        &self,
830        ctx: crate::protocol::SpawnContext,
831    ) -> std::pin::Pin<
832        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
833    > {
834        Box::pin(async move { super::SsdpServer::spawn_with_llm_actions(ctx).await })
835    }
836
837    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
838        let action_type = action
839            .get("type")
840            .and_then(|v| v.as_str())
841            .context("Missing 'type' field in action")?;
842
843        match action_type {
844            "send_ssdp_response" => {
845                let st = self.resolve_st(&action)?;
846                let usn = required_str(
847                    &action,
848                    "usn",
849                    "it is the unique identity a control point de-duplicates advertisements \
850                     on, conventionally 'uuid:<device-uuid>::<st>'",
851                )?;
852                let location = parse_location(&action, "location")?;
853                let server = optional_str(&action, "server")?
854                    .unwrap_or_else(|| self.server_default().to_string());
855                let max_age = parse_max_age(&action)?;
856                let extra_headers = parse_extra_headers(&action)?;
857
858                let rendered = message::render_search_response(&SearchResponse {
859                    st,
860                    usn,
861                    location,
862                    server,
863                    max_age,
864                    date: message::http_date(chrono::Utc::now()),
865                    extra_headers,
866                });
867                Ok(ActionResult::Output(rendered.into_bytes()))
868            }
869
870            "send_ssdp_notify" => {
871                let nt = required_str(
872                    &action,
873                    "nt",
874                    "it is the device or service type being announced",
875                )?;
876                let nts = parse_nts(&action)?;
877                let usn = required_str(
878                    &action,
879                    "usn",
880                    "it is the unique identity of the advertisement, conventionally \
881                     'uuid:<device-uuid>::<nt>'",
882                )?;
883
884                // A byebye says the device is gone. Carrying LOCATION and CACHE-CONTROL on
885                // it contradicts the message — it would tell the control point where to
886                // reach a device that just announced it is leaving, and for how long to
887                // keep believing in it. UDA 1.1 §1.2.3 lists four headers and these are not
888                // among them.
889                let is_byebye = nts == "ssdp:byebye";
890                let (location, server, max_age) = if is_byebye {
891                    (None, None, None)
892                } else {
893                    (
894                        Some(parse_location(&action, "location")?),
895                        Some(
896                            optional_str(&action, "server")?
897                                .unwrap_or_else(|| self.server_default().to_string()),
898                        ),
899                        Some(parse_max_age(&action)?),
900                    )
901                };
902
903                let rendered = message::render_notify(&NotifyMessage {
904                    host: self.notify_host(),
905                    nt,
906                    nts: nts.clone(),
907                    usn,
908                    location,
909                    server,
910                    max_age,
911                });
912
913                // Not `Output`: an Output goes back to the peer that spoke to us, and a
914                // NOTIFY is addressed to the whole multicast group. `mod.rs` routes on this
915                // name.
916                Ok(ActionResult::Custom {
917                    name: "ssdp_notify".to_string(),
918                    data: json!({ "message": rendered, "nts": nts }),
919                })
920            }
921
922            "no_response" => {
923                // Deliberately not `ActionResult::NoAction`. NoAction is what a bookkeeping
924                // action such as `show_message` returns, so it cannot be told apart from a
925                // model that produced no protocol action at all — and telling those two
926                // apart is the entire point of this action existing.
927                let reason = action
928                    .get("reason")
929                    .and_then(|v| v.as_str())
930                    .unwrap_or("no reason given");
931                Ok(ActionResult::Custom {
932                    name: "ssdp_no_response".to_string(),
933                    data: json!({ "reason": reason }),
934                })
935            }
936
937            _ => Err(anyhow::anyhow!("Unknown SSDP action: {}", action_type)),
938        }
939    }
940}