1pub 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
33const DEFAULT_MAX_RESPONSE_DELAY_MS: u64 = 1000;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Decision {
48 ModelResponse,
50 ModelNotify,
52 ModelReject,
54 ModelSilent,
56 FailClosedActionError,
58 FailClosedLlmError,
60}
61
62impl Decision {
63 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 pub fn is_fail_closed(&self) -> bool {
78 matches!(
79 self,
80 Decision::FailClosedActionError | Decision::FailClosedLlmError
81 )
82 }
83}
84
85enum Reply {
87 Unicast(Vec<u8>),
89 Announcement(Vec<u8>),
91}
92
93#[derive(Clone, Debug)]
95struct SsdpConfig {
96 max_response_delay_ms: u64,
97 server_header: String,
98 notify_host: String,
101 notify_target: SocketAddr,
103}
104
105pub struct SsdpServer;
106
107impl SsdpServer {
108 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 _ => 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 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 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 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
585fn 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
601fn 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}