Skip to main content

netget/server/ssdp/
mod.rs

1//! SSDP / UPnP discovery server (UPnP Device Architecture 1.1, §1).
2//!
3//! HTTPU — HTTP/1.1 syntax in a single UDP datagram — on port 1900, plus the
4//! `239.255.255.250` / `FF02::C` multicast groups. This file owns the socket, the multicast
5//! join, the MX response jitter and, most importantly, **the rule that a failure produces no
6//! datagram at all**.
7//!
8//! See `src/server/ssdp/CLAUDE.md` for the design rationale, the multicast-on-loopback
9//! situation, and the list of things this server deliberately does not implement.
10
11pub mod actions;
12pub mod message;
13
14pub use actions::SsdpProtocol;
15
16use crate::llm::action_helper::call_llm;
17use crate::llm::actions::protocol_trait::ActionResult;
18use crate::logging::emit::Log;
19use crate::protocol::{Event, SpawnContext};
20use crate::server::connection::ConnectionId;
21use crate::state::app_state::AppState;
22use crate::state::ServerId;
23use anyhow::{Context, Result};
24use std::net::{IpAddr, Ipv4Addr, SocketAddr};
25use std::sync::Arc;
26use tokio::net::UdpSocket;
27use tokio::sync::mpsc;
28use tracing::{debug, info, trace, warn};
29
30use actions::{RequestContext, SSDP_MSEARCH_EVENT, SSDP_NOTIFY_EVENT};
31use message::{HttpuMessage, SSDP_GROUP_V4, SSDP_GROUP_V6, SSDP_PORT};
32
33/// Default ceiling on the MX jitter. A faithful 5-second wait is correct SSDP and makes
34/// every exchange that touches this protocol unusably slow, so the ceiling is an operator
35/// knob (`max_response_delay_ms`) with a value that still produces visible jitter.
36const DEFAULT_MAX_RESPONSE_DELAY_MS: u64 = 1000;
37
38/// How the datagram was disposed of.
39///
40/// SSDP cannot express any of these on the wire — a device that answers and a device that is
41/// broken are equally silent to a control point — so the log is the *only* place the
42/// distinction can live. That is the `radius` discipline applied to a protocol whose safe
43/// default is silence rather than denial: `model_reject` (the device does not match, and the
44/// model said so) must never be confused with `model_silent` (the model produced nothing) or
45/// with `fail_closed_llm_error` (the backend fell over).
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Decision {
48    /// The model answered with `send_ssdp_response`; a 200 OK went to the searcher.
49    ModelResponse,
50    /// The model answered with `send_ssdp_notify`; an announcement went to the group.
51    ModelNotify,
52    /// The model answered with `no_response`: an explicit, correct refusal to advertise.
53    ModelReject,
54    /// The model returned no protocol action at all. Nothing is sent.
55    ModelSilent,
56    /// The model's action could not be rendered. Nothing is sent.
57    FailClosedActionError,
58    /// The LLM call itself failed. Nothing is sent.
59    FailClosedLlmError,
60}
61
62impl Decision {
63    /// Stable, grep-able token. `decision=fail_closed_` finds every datagram the model did
64    /// not actually answer.
65    pub fn as_str(&self) -> &'static str {
66        match self {
67            Decision::ModelResponse => "model_response",
68            Decision::ModelNotify => "model_notify",
69            Decision::ModelReject => "model_reject",
70            Decision::ModelSilent => "model_silent",
71            Decision::FailClosedActionError => "fail_closed_action_error",
72            Decision::FailClosedLlmError => "fail_closed_llm_error",
73        }
74    }
75
76    /// True when the server, not the model, is the reason nothing was sent.
77    pub fn is_fail_closed(&self) -> bool {
78        matches!(
79            self,
80            Decision::FailClosedActionError | Decision::FailClosedLlmError
81        )
82    }
83}
84
85/// What the model asked to be put on the wire, if anything.
86enum Reply {
87    /// Unicast back to the sender (an M-SEARCH response).
88    Unicast(Vec<u8>),
89    /// To the multicast group, or to `notify_target` when the operator overrode it.
90    Announcement(Vec<u8>),
91}
92
93/// Settings resolved once at startup and shared by every datagram.
94#[derive(Clone, Debug)]
95struct SsdpConfig {
96    max_response_delay_ms: u64,
97    server_header: String,
98    /// `HOST` header value for an outbound NOTIFY — the *protocol's* group, always, because
99    /// that is what the header means regardless of where the datagram is actually sent.
100    notify_host: String,
101    /// Where an outbound NOTIFY is actually sent.
102    notify_target: SocketAddr,
103}
104
105pub struct SsdpServer;
106
107impl SsdpServer {
108    /// Bind the socket, join the multicast group if asked, and start serving.
109    ///
110    /// Returns `Err` — so `server_startup` sets `ServerStatus::Error` — when the socket
111    /// cannot be bound or a startup parameter is unusable. It does **not** return `Err` when
112    /// the multicast join fails: see `join_group`.
113    pub async fn spawn_with_llm_actions(ctx: SpawnContext) -> Result<SocketAddr> {
114        let listen_addr = ctx.legacy_listen_addr();
115        let SpawnContext {
116            llm_client,
117            state,
118            status_tx,
119            server_id,
120            startup_params,
121            ..
122        } = ctx;
123
124        // Every parameter is read here, and every read one is declared. Errors propagate
125        // with `?` so an undeclared key or a wrong type names itself instead of panicking
126        // the task that is starting the server.
127        let (
128            max_response_delay_ms,
129            server_header,
130            join_multicast,
131            multicast_interface,
132            notify_override,
133        ) = match &startup_params {
134            Some(p) => (
135                p.get_optional_u64("max_response_delay_ms")?
136                    .unwrap_or(DEFAULT_MAX_RESPONSE_DELAY_MS),
137                p.get_optional_string("server_header")?,
138                p.get_optional_bool("join_multicast")?.unwrap_or(true),
139                p.get_optional_string("multicast_interface")?,
140                p.get_optional_string("notify_target")?,
141            ),
142            None => (DEFAULT_MAX_RESPONSE_DELAY_MS, None, true, None, None),
143        };
144
145        let socket = Arc::new(
146            UdpSocket::bind(listen_addr)
147                .await
148                .with_context(|| format!("SSDP failed to bind {listen_addr}"))?,
149        );
150        let local_addr = socket.local_addr()?;
151
152        // The group depends on the family we actually bound, not on what was asked for.
153        let (notify_host, default_notify_target) = match local_addr.ip() {
154            IpAddr::V4(_) => (
155                format!("{SSDP_GROUP_V4}:{SSDP_PORT}"),
156                SocketAddr::from((SSDP_GROUP_V4, SSDP_PORT)),
157            ),
158            IpAddr::V6(_) => (
159                format!("[{SSDP_GROUP_V6}]:{SSDP_PORT}"),
160                SocketAddr::from((SSDP_GROUP_V6, SSDP_PORT)),
161            ),
162        };
163
164        let notify_target = match notify_override {
165            Some(raw) => raw.parse::<SocketAddr>().with_context(|| {
166                format!("SSDP notify_target {raw:?} is not a valid ip:port address")
167            })?,
168            None => default_notify_target,
169        };
170
171        let config = SsdpConfig {
172            max_response_delay_ms,
173            server_header: server_header
174                .unwrap_or_else(|| actions::DEFAULT_SERVER_HEADER.to_string()),
175            notify_host,
176            notify_target,
177        };
178
179        if join_multicast {
180            join_group(
181                &socket,
182                local_addr,
183                multicast_interface.as_deref(),
184                &status_tx,
185            );
186        } else {
187            info!("SSDP multicast join skipped: join_multicast=false");
188            Log::new(Some(&status_tx)).debug(
189                "SSDP not joining the multicast group (join_multicast=false); unicast \
190                 M-SEARCH still works",
191            );
192        }
193
194        info!(
195            "SSDP server listening on {} (announcements to {})",
196            local_addr, config.notify_target
197        );
198        Log::new(Some(&status_tx)).info(format!("SSDP server listening on {local_addr}"));
199
200        let task_registrar = state.clone();
201        let accept_handle = tokio::spawn(async move {
202            // 65535 is the largest a UDP datagram can be; `message::parse` refuses anything
203            // over MAX_MESSAGE_LEN, so an over-long datagram is reported rather than
204            // silently truncated into plausible-looking headers.
205            let mut buffer = vec![0u8; 65535];
206
207            loop {
208                let (n, peer_addr) = match socket.recv_from(&mut buffer).await {
209                    Ok(pair) => pair,
210                    Err(e) => {
211                        Log::new(Some(&status_tx)).error(format!("SSDP receive error: {e}"));
212                        break;
213                    }
214                };
215
216                let data = buffer[..n].to_vec();
217                trace!(
218                    "SSDP {} bytes from {}: {:?}",
219                    n,
220                    peer_addr,
221                    String::from_utf8_lossy(&data)
222                );
223
224                let parsed = match message::parse(&data) {
225                    Ok(m) => m,
226                    Err(e) => {
227                        Log::new(Some(&status_tx))
228                            .warn(format!("SSDP dropped datagram from {peer_addr}: {e}"));
229                        continue;
230                    }
231                };
232
233                // A status line is a *response* — another device answering somebody else's
234                // search, which lands here whenever we are joined to the group. Answering a
235                // response would be a discovery loop.
236                let Some(method) = parsed.method.clone() else {
237                    debug!(
238                        "SSDP ignoring a response ({}) from {}: not a request",
239                        parsed.start_line, peer_addr
240                    );
241                    continue;
242                };
243
244                let connection_id =
245                    Self::record_connection(&state, server_id, local_addr, peer_addr, n).await;
246                let _ = status_tx.send("__UPDATE_UI__".to_string());
247
248                let llm = llm_client.clone();
249                let st = state.clone();
250                let tx = status_tx.clone();
251                let sock = socket.clone();
252                let cfg = config.clone();
253
254                let handle = tokio::spawn(async move {
255                    Self::handle_request(
256                        parsed,
257                        method,
258                        peer_addr,
259                        connection_id,
260                        sock,
261                        llm,
262                        st,
263                        tx,
264                        server_id,
265                        cfg,
266                    )
267                    .await;
268                });
269
270                // Registered, not detached. An M-SEARCH answer can be held back for up to
271                // `max_response_delay_ms`, so a task spawned here routinely outlives the
272                // datagram that created it, and `stop_server` must be able to cancel it —
273                // otherwise a stopped server can still emit an advertisement. (BGP's
274                // keepalive timer is the precedent: aborting a loop does not abort what the
275                // loop spawned.) `register_server_task` prunes finished handles on every
276                // call, so this does not accumulate.
277                state.register_server_task(server_id, handle).await;
278            }
279        });
280
281        task_registrar
282            .register_server_task(server_id, accept_handle)
283            .await;
284
285        Ok(local_addr)
286    }
287
288    async fn record_connection(
289        state: &Arc<AppState>,
290        server_id: ServerId,
291        local_addr: SocketAddr,
292        peer_addr: SocketAddr,
293        bytes: usize,
294    ) -> ConnectionId {
295        use crate::state::server::{
296            ConnectionState as ServerConnectionState, ConnectionStatus, ProtocolConnectionInfo,
297        };
298        let connection_id = ConnectionId::new(state.get_next_unified_id().await);
299        let now = crate::utils::clock::Instant::now();
300        let conn_state = ServerConnectionState {
301            id: connection_id,
302            remote_addr: peer_addr,
303            local_addr,
304            bytes_sent: 0,
305            bytes_received: bytes as u64,
306            packets_sent: 0,
307            packets_received: 1,
308            last_activity: now,
309            status: ConnectionStatus::Active,
310            status_changed_at: now,
311            protocol_info: ProtocolConnectionInfo::empty(),
312        };
313        state.add_connection_to_server(server_id, conn_state).await;
314        connection_id
315    }
316
317    /// Ask the model, apply the MX jitter, then send what it asked for — or nothing.
318    #[allow(clippy::too_many_arguments)]
319    async fn handle_request(
320        request: HttpuMessage,
321        method: String,
322        peer_addr: SocketAddr,
323        connection_id: ConnectionId,
324        socket: Arc<UdpSocket>,
325        llm_client: crate::llm::ollama_client::OllamaClient,
326        state: Arc<AppState>,
327        status_tx: mpsc::UnboundedSender<String>,
328        server_id: ServerId,
329        config: SsdpConfig,
330    ) {
331        let search_target = request.header("ST").map(str::to_string);
332
333        let (event, kind) = match method.as_str() {
334            "M-SEARCH" => (
335                Event::new(&SSDP_MSEARCH_EVENT, Self::msearch_data(&request, peer_addr)),
336                "M-SEARCH",
337            ),
338            "NOTIFY" => (
339                Event::new(&SSDP_NOTIFY_EVENT, Self::notify_data(&request, peer_addr)),
340                "NOTIFY",
341            ),
342            other => {
343                // SUBSCRIBE/UNSUBSCRIBE are GENA and travel over TCP; anything else is not
344                // SSDP at all. Dropped, and said out loud so it is not mistaken for the
345                // deliberate silence below.
346                warn!(
347                    "SSDP ignoring {} from {}: not a message this server serves (only \
348                     M-SEARCH and NOTIFY)",
349                    other, peer_addr
350                );
351                return;
352            }
353        };
354
355        // A real device waits a random interval before answering so a whole network's
356        // replies do not arrive at once. Computing the deadline *before* the LLM call and
357        // sleeping to it afterwards means the model's own latency counts towards the wait
358        // instead of being added to it — so the jitter is a floor on the response time
359        // rather than a tax on top of it.
360        let delay_ms = message::response_delay_ms(request.mx(), config.max_response_delay_ms);
361        let send_at = tokio::time::Instant::now() + std::time::Duration::from_millis(delay_ms);
362
363        let protocol = SsdpProtocol::for_request(RequestContext {
364            search_target: search_target.clone(),
365            server_header: config.server_header.clone(),
366            notify_host: config.notify_host.clone(),
367        });
368
369        let llm_outcome = call_llm(&llm_client, &state, server_id, None, &event, &protocol).await;
370        let (decision, reply, reason) = Self::decide(llm_outcome, &status_tx, peer_addr);
371
372        // Log the decision before acting on it. Because nothing distinguishes these cases on
373        // the wire, this line is the only record that exists.
374        let summary = format!(
375            "SSDP {} from {} st={} decision={}{}",
376            kind,
377            peer_addr,
378            search_target.as_deref().unwrap_or("-"),
379            decision.as_str(),
380            reason
381                .as_deref()
382                .map(|r| format!(" reason={r:?}"))
383                .unwrap_or_default(),
384        );
385        let log = Log::new(Some(&status_tx));
386        if decision.is_fail_closed() {
387            log.error(format!(
388                "{summary} (nothing sent: SSDP has no error message, and every reply it \
389                 defines asserts that a device exists)"
390            ));
391        } else {
392            log.info(&summary);
393        }
394
395        let Some(reply) = reply else {
396            debug!(
397                "SSDP sending nothing to {} ({})",
398                peer_addr,
399                decision.as_str()
400            );
401            return;
402        };
403
404        let (bytes, destination) = match reply {
405            Reply::Unicast(bytes) => (bytes, peer_addr),
406            Reply::Announcement(bytes) => (bytes, config.notify_target),
407        };
408
409        if delay_ms > 0 {
410            debug!(
411                "SSDP holding the answer to {} for {}ms (MX jitter)",
412                peer_addr, delay_ms
413            );
414            tokio::time::sleep_until(send_at).await;
415        }
416
417        match socket.send_to(&bytes, destination).await {
418            Ok(sent) => {
419                trace!(
420                    "SSDP sent {} bytes to {}: {:?}",
421                    sent,
422                    destination,
423                    String::from_utf8_lossy(&bytes)
424                );
425                state
426                    .update_connection_stats(
427                        server_id,
428                        connection_id,
429                        None,
430                        Some(sent as u64),
431                        None,
432                        Some(1),
433                    )
434                    .await;
435                let _ = status_tx.send("__UPDATE_UI__".to_string());
436            }
437            Err(e) => {
438                // A send to the multicast group commonly fails on a loopback-bound socket
439                // (ENETUNREACH / EHOSTUNREACH). That is an environment limitation, not a
440                // protocol error, and it must be visible rather than swallowed.
441                Log::new(Some(&status_tx)).error(format!(
442                    "SSDP failed to send to {destination}: {e}. A send to the multicast \
443                     group from a socket bound to 127.0.0.1 fails this way \
444                     (EADDRNOTAVAIL): the loopback interface has no multicast route. Bind \
445                     0.0.0.0, or set the notify_target startup parameter to a reachable \
446                     address."
447                ));
448            }
449        }
450    }
451
452    /// **The silence rule.**
453    ///
454    /// Returns the decision, the bytes to send (if any) and an optional human reason for the
455    /// log. Nothing in here can invent a reply: every `Some` came from an action the model
456    /// named.
457    fn decide(
458        llm_outcome: Result<crate::llm::ExecutionResult>,
459        status_tx: &mpsc::UnboundedSender<String>,
460        peer_addr: SocketAddr,
461    ) -> (Decision, Option<Reply>, Option<String>) {
462        let log = Log::new(Some(status_tx));
463
464        let execution = match llm_outcome {
465            Ok(result) => {
466                for message in &result.messages {
467                    log.info(message);
468                }
469                result
470            }
471            Err(e) => {
472                // The category is kept because an overloaded backend and a dead one are
473                // worth telling apart after the fact — but neither reaches the wire. There
474                // is no header in an SSDP message that could carry it, and inventing one
475                // would still be a well-formed advertisement for a device that does not
476                // exist.
477                let category = match crate::utils::wire_failure::WireFailure::classify(&e) {
478                    crate::utils::wire_failure::WireFailure::Overloaded => "overloaded",
479                    crate::utils::wire_failure::WireFailure::Unavailable => "unavailable",
480                };
481                log.error(format!(
482                    "SSDP LLM call failed for {peer_addr} (category={category}): {e}"
483                ));
484                return (Decision::FailClosedLlmError, None, None);
485            }
486        };
487
488        // Take the first result that says something. Extra results are a model error, not a
489        // licence to advertise several devices from one datagram — a control point would
490        // record every one of them.
491        let mut chosen: Option<(Decision, Option<Reply>, Option<String>)> = None;
492        let mut extra = 0usize;
493
494        for result in &execution.protocol_results {
495            let candidate = match result {
496                ActionResult::Output(bytes) => Some((
497                    Decision::ModelResponse,
498                    Some(Reply::Unicast(bytes.clone())),
499                    None,
500                )),
501                ActionResult::Custom { name, data } if name == "ssdp_notify" => {
502                    data.get("message").and_then(|v| v.as_str()).map(|m| {
503                        (
504                            Decision::ModelNotify,
505                            Some(Reply::Announcement(m.as_bytes().to_vec())),
506                            data.get("nts").and_then(|v| v.as_str()).map(str::to_string),
507                        )
508                    })
509                }
510                ActionResult::Custom { name, data } if name == "ssdp_no_response" => Some((
511                    Decision::ModelReject,
512                    None,
513                    data.get("reason")
514                        .and_then(|v| v.as_str())
515                        .map(str::to_string),
516                )),
517                // NoAction covers show_message / set_memory and friends: bookkeeping, not
518                // an answer. It must not be read as a decision either way.
519                _ => None,
520            };
521
522            match candidate {
523                Some(c) if chosen.is_none() => chosen = Some(c),
524                Some(_) => extra += 1,
525                None => {}
526            }
527        }
528
529        if extra > 0 {
530            warn!(
531                "SSDP ignored {} extra protocol result(s) for {}; one datagram gets one \
532                 answer",
533                extra, peer_addr
534            );
535        }
536
537        if let Some(chosen) = chosen {
538            return chosen;
539        }
540
541        // Nothing usable came back. If the model produced actions that failed to render,
542        // that is the server's problem to report; if it produced none at all, it simply said
543        // nothing. Neither is `model_reject`: an explicit refusal is an action, and
544        // conflating them is the OAuth2 mistake in its silence-shaped form.
545        if !execution.failures.is_empty() {
546            for failure in &execution.failures {
547                log.error(format!(
548                    "SSDP could not render the model's action for {peer_addr}: {failure:?}"
549                ));
550            }
551            return (Decision::FailClosedActionError, None, None);
552        }
553        (Decision::ModelSilent, None, None)
554    }
555
556    /// Structured payload for `ssdp_msearch`. Headers as a map, never a rendered blob.
557    fn msearch_data(request: &HttpuMessage, peer_addr: SocketAddr) -> serde_json::Value {
558        serde_json::json!({
559            "st": request.header("ST"),
560            "mx": request.mx(),
561            "man": request.header("MAN"),
562            "host": request.header("HOST"),
563            "source_address": peer_addr.to_string(),
564            "user_agent": request.header("USER-AGENT"),
565            "headers": request.headers_json(),
566        })
567    }
568
569    /// Structured payload for `ssdp_notify`.
570    fn notify_data(request: &HttpuMessage, peer_addr: SocketAddr) -> serde_json::Value {
571        serde_json::json!({
572            "nt": request.header("NT"),
573            "nts": request.header("NTS"),
574            "usn": request.header("USN"),
575            "location": request.header("LOCATION"),
576            "server": request.header("SERVER"),
577            "cache_control_max_age": max_age_of(request),
578            "host": request.header("HOST"),
579            "source_address": peer_addr.to_string(),
580            "headers": request.headers_json(),
581        })
582    }
583}
584
585/// `max-age` out of a `CACHE-CONTROL` header, as a number the model can compare.
586///
587/// Handed over as a number rather than the raw `"max-age=1800"` string because a model asked
588/// "is this announcement still fresh?" should not have to parse a header directive first.
589fn max_age_of(request: &HttpuMessage) -> Option<u64> {
590    let value = request.header("CACHE-CONTROL")?;
591    value.split(',').find_map(|directive| {
592        let (name, v) = directive.split_once('=')?;
593        if name.trim().eq_ignore_ascii_case("max-age") {
594            v.trim().parse::<u64>().ok()
595        } else {
596            None
597        }
598    })
599}
600
601/// Join the SSDP multicast group. **Best effort, and deliberately so.**
602///
603/// A join depends on the interface, the routing table and, on Linux, `CAP_NET_ADMIN`-adjacent
604/// policy — none of which this protocol declares a `PrivilegeRequirement` for, because it does
605/// not need one to be useful. A server that refused to start when the join failed would be
606/// unusable for exactly the local testing this protocol is most often used for: **a unicast
607/// M-SEARCH sent straight to the port is answered whether or not the join succeeded**, and
608/// that is the whole functional surface minus the ability to overhear the group.
609///
610/// So a failed join is a warning on both channels, not an `Err`. It is *logged* rather than
611/// passed over because a silent failure here presents as "the server is up but never sees any
612/// searches", which is indistinguishable from the server being broken.
613///
614/// **Measured on macOS 27 (Darwin), and not what was expected**: the join succeeds from a
615/// socket bound to `127.0.0.1` as well as from `0.0.0.0`, on both `127.0.0.1` and `0.0.0.0`
616/// as the interface argument. What fails on a loopback bind is *sending* to the group —
617/// `sendto(239.255.255.250:1900)` returns `EADDRNOTAVAIL`, because loopback carries no
618/// multicast route. That asymmetry is why `notify_target` exists and why the join is not the
619/// thing standing between this server and a local test.
620fn join_group(
621    socket: &UdpSocket,
622    local_addr: SocketAddr,
623    multicast_interface: Option<&str>,
624    status_tx: &mpsc::UnboundedSender<String>,
625) {
626    let log = Log::new(Some(status_tx));
627
628    let result = match local_addr.ip() {
629        IpAddr::V4(_) => {
630            let iface = match multicast_interface {
631                Some(raw) => match raw.parse::<Ipv4Addr>() {
632                    Ok(ip) => ip,
633                    Err(e) => {
634                        log.warn(format!(
635                            "SSDP multicast_interface {raw:?} is not an IPv4 address ({e}); \
636                             falling back to 0.0.0.0"
637                        ));
638                        Ipv4Addr::UNSPECIFIED
639                    }
640                },
641                None => Ipv4Addr::UNSPECIFIED,
642            };
643            socket
644                .join_multicast_v4(SSDP_GROUP_V4, iface)
645                .map(|_| format!("{SSDP_GROUP_V4} on interface {iface}"))
646        }
647        IpAddr::V6(_) => socket
648            .join_multicast_v6(&SSDP_GROUP_V6, 0)
649            .map(|_| format!("{SSDP_GROUP_V6} on interface index 0")),
650    };
651
652    match result {
653        Ok(what) => {
654            info!("SSDP joined multicast group {}", what);
655            log.info(format!("SSDP joined multicast group {what}"));
656        }
657        Err(e) => {
658            warn!("SSDP multicast join failed: {}", e);
659            log.warn(format!(
660                "SSDP could not join the multicast group ({e}); the server is running and \
661                 still answers unicast M-SEARCH sent to {local_addr}, but it will not \
662                 overhear multicast searches. Check the multicast_interface parameter, or \
663                 pass join_multicast: false if unicast is all you need."
664            ));
665        }
666    }
667}