netget/client/ssdp/mod.rs
1//! SSDP / UPnP discovery client — socket loop.
2//!
3//! A control point: it sends `M-SEARCH * HTTP/1.1` and reads what answers. Pointed at a real
4//! LAN this enumerates actual hardware — routers, TVs, printers, media servers — and the
5//! model decides from what it finds what to search for next.
6//!
7//! # The shape that makes this different from every other client here
8//!
9//! **SSDP is one-to-many.** Every other client in this tree issues a request and reads *the*
10//! response; SSDP issues one datagram and an unknown number of devices answer over the next
11//! `MX` seconds, each from its own address. Returning on the first datagram would report one
12//! device on a network of thirty. So a search opens a *collection window*: each responder
13//! raises its own `ssdp_search_response` event as it arrives, and when the window closes a
14//! single `ssdp_search_complete` says how many answered and who they were.
15//!
16//! # One task owns the search
17//!
18//! Three things need to interleave — datagrams arriving, the collection window expiring, and
19//! injected commands from the dashboard — and all three mutate the same search state. Rather
20//! than share it behind a mutex, one task ([`Session::run`]) owns it outright and selects
21//! over all three sources. A separate, minimal receive task does nothing but `recv_from` and
22//! forward, so the socket is always ready even while the session is inside an LLM call and no
23//! datagram is dropped for want of a reader.
24//!
25//! The cost is that an injected `[ send ]` waits behind an in-flight LLM call rather than
26//! running concurrently (`udp` gives commands their own task for exactly that reason). That
27//! is the right trade here: a command that started a second search from another task would
28//! race the window bookkeeping, and the command channel is bounded, so the command queues
29//! rather than being lost.
30//!
31//! # Iterative discovery is bounded, and the bound is loud
32//!
33//! Discovery is inherently recursive: search, see what answers, search again more
34//! specifically. Nothing in that shape converges on its own, so a search started in reply to
35//! an event carries a depth, and [`MAX_FOLLOWUP_DEPTH`] refuses to go deeper — with an ERROR
36//! on both log channels naming the limit, never silently.
37//!
38//! No `Box::pin` is needed for it, and that is worth saying because the usual fix here is a
39//! boxed recursive call. The cycle already passes through a queue: `send_msearch` only
40//! *enqueues* a search, the session loop starts it, the socket delivers the answers later,
41//! and the events those raise are handled on a subsequent turn of the same loop. There is no
42//! `async fn` that awaits itself, so there is no infinitely-sized future to box — the same
43//! situation the root `CLAUDE.md` describes for `datalink`'s pcap loop.
44
45pub mod actions;
46
47pub use actions::SsdpClientProtocol;
48
49use anyhow::{Context, Result};
50use std::collections::{HashSet, VecDeque};
51use std::net::{Ipv4Addr, SocketAddr};
52use std::sync::Arc;
53use std::time::Duration;
54use tokio::net::UdpSocket;
55use tokio::sync::mpsc;
56use tokio::time::Instant;
57use tracing::{debug, error, info, trace, warn};
58
59use crate::client::llm_budget::call_llm_for_client;
60use crate::client::ssdp::actions::{
61 SSDP_CLIENT_CONNECTED_EVENT, SSDP_NOTIFY_RECEIVED_EVENT, SSDP_SEARCH_COMPLETE_EVENT,
62 SSDP_SEARCH_RESPONSE_EVENT,
63};
64use crate::llm::actions::client_trait::{Client, ClientActionResult};
65use crate::llm::ollama_client::OllamaClient;
66use crate::llm::ClientLlmResult;
67use crate::protocol::{Event, StartupParams};
68use crate::server::ssdp::message::{self, HttpuMessage, SSDP_GROUP_V4, SSDP_PORT};
69use crate::state::app_state::AppState;
70use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
71use crate::state::{AccessLogOwner, ClientId, ClientStatus};
72
73/// How many searches deep a chain started by an event may go.
74///
75/// Chain: connected event → search (depth 0) → its responses/completion (depth 1) → a
76/// narrower search (depth 1) → … A model that answers every `ssdp_search_complete` with
77/// another search would otherwise sweep the network forever, and the per-client LLM budget
78/// (100 calls) is a blunt backstop rather than a bound on *this*.
79///
80/// Six allows the search-narrow-narrow pattern a real enumeration needs with room to spare,
81/// and stops well short of the budget so exhaustion is reported as what it is.
82pub const MAX_FOLLOWUP_DEPTH: usize = 6;
83
84/// Default `USER-AGENT` when the operator does not set one.
85const DEFAULT_USER_AGENT: &str = "NetGet/1.0 UPnP/1.1 NetGet-SSDP-Client/1.0";
86
87/// A datagram handed from the receive task to the session task.
88struct Inbound {
89 data: Vec<u8>,
90 source: SocketAddr,
91}
92
93/// A search that has been asked for but not yet put on the wire.
94#[derive(Clone, Debug)]
95struct PendingSearch {
96 st: String,
97 mx: u32,
98 target: SocketAddr,
99 /// Depth of the *event* that asked for this search; see [`MAX_FOLLOWUP_DEPTH`].
100 depth: usize,
101}
102
103/// A search whose collection window is open.
104struct ActiveSearch {
105 st: String,
106 mx: u32,
107 target: SocketAddr,
108 depth: usize,
109 /// When the window closes and `ssdp_search_complete` fires.
110 deadline: Instant,
111 /// One entry per distinct responder, in arrival order.
112 responders: Vec<serde_json::Value>,
113 /// `(source address, USN)` of everything already reported. A device that retransmits
114 /// its answer is one device, not two — but a device answering `ssdp:all` with several
115 /// *different* USNs is genuinely several services, and the pair keeps those apart.
116 seen: HashSet<(String, String)>,
117 duplicates: usize,
118}
119
120/// SSDP discovery client.
121pub struct SsdpClient;
122
123impl SsdpClient {
124 /// Bind the search socket and run discovery under LLM control.
125 ///
126 /// Returns as soon as the socket is bound and the loops are spawned: the
127 /// `ssdp_connected` LLM call happens inside the session task, so a `manual` routing rule
128 /// that parks it for a human does not hold up client creation.
129 pub async fn connect_with_llm_actions(
130 remote_addr: String,
131 llm_client: OllamaClient,
132 app_state: Arc<AppState>,
133 status_tx: mpsc::UnboundedSender<String>,
134 client_id: ClientId,
135 startup_params: Option<StartupParams>,
136 ) -> Result<SocketAddr> {
137 let params = startup_params.as_ref();
138
139 let bind_address = match params {
140 Some(p) => p
141 .get_optional_string("bind_address")?
142 .unwrap_or_else(|| "0.0.0.0".to_string()),
143 None => "0.0.0.0".to_string(),
144 };
145 let local_port = match params {
146 Some(p) => p.get_optional_u64("local_port")?.unwrap_or(0),
147 None => 0,
148 };
149 let join_multicast = match params {
150 Some(p) => p.get_optional_bool("join_multicast")?.unwrap_or(true),
151 None => true,
152 };
153 let multicast_interface = match params {
154 Some(p) => p
155 .get_optional_string("multicast_interface")?
156 .unwrap_or_else(|| "0.0.0.0".to_string()),
157 None => "0.0.0.0".to_string(),
158 };
159 let response_window_ms = match params {
160 Some(p) => p.get_optional_u64("response_window_ms")?.unwrap_or(0),
161 None => 0,
162 };
163 let user_agent = match params {
164 Some(p) => p
165 .get_optional_string("user_agent")?
166 .unwrap_or_else(|| DEFAULT_USER_AGENT.to_string()),
167 None => DEFAULT_USER_AGENT.to_string(),
168 };
169
170 let local_port = u16::try_from(local_port)
171 .map_err(|_| anyhow::anyhow!("local_port must be 0..=65535, got {local_port}"))?;
172
173 // Where searches go unless an action overrides it. The multicast group is the
174 // default a real scan wants; a unicast address searches exactly one device.
175 let default_target = resolve_target(&remote_addr).await?;
176
177 // Bind first and propagate the error: a client reported Connected on a socket that
178 // never bound is the ARP/DataLink defect wearing a different hat.
179 let socket = UdpSocket::bind(format!("{bind_address}:{local_port}"))
180 .await
181 .with_context(|| {
182 format!(
183 "Failed to bind SSDP search socket to {bind_address}:{local_port}. \
184 Port 1900 is often already held by a system UPnP service; use \
185 local_port 0 to search from an ephemeral port instead (NOTIFY \
186 announcements will then not be seen)."
187 )
188 })?;
189 let local_addr = socket.local_addr()?;
190
191 // Best effort, exactly like the server half. Searching does not depend on the join —
192 // replies to our own M-SEARCH come back unicast — so refusing to start here would
193 // make the client unusable for the local testing it is most used for. Logged rather
194 // than passed over, because a silent failure presents as "no device ever announces
195 // itself", which is indistinguishable from a broken client.
196 let mut multicast_joined = false;
197 if join_multicast {
198 match multicast_interface.parse::<Ipv4Addr>() {
199 Ok(iface) => match socket.join_multicast_v4(SSDP_GROUP_V4, iface) {
200 Ok(()) => {
201 multicast_joined = true;
202 info!(
203 "SSDP client {} joined {} on interface {}",
204 client_id, SSDP_GROUP_V4, iface
205 );
206 if local_port != SSDP_PORT {
207 // Announcements are multicast to the group on the well-known
208 // port; a socket on an ephemeral port is in the group but will
209 // never be delivered any.
210 warn!(
211 "SSDP client {} joined the group but is bound to port {} \
212 rather than {}, so NOTIFY announcements will not arrive. \
213 Set startup param local_port to {} to overhear them.",
214 client_id, local_port, SSDP_PORT, SSDP_PORT
215 );
216 }
217 }
218 Err(e) => {
219 warn!(
220 "SSDP client {} could not join {}: {}. Unicast searches still \
221 work; unsolicited NOTIFY announcements will not be seen.",
222 client_id, SSDP_GROUP_V4, e
223 );
224 let _ = status_tx.send(format!(
225 "[CLIENT] ⚠ SSDP client {} could not join the multicast group: {}",
226 client_id, e
227 ));
228 }
229 },
230 Err(e) => {
231 warn!(
232 "SSDP client {} ignoring multicast_interface '{}': not an IPv4 \
233 address ({})",
234 client_id, multicast_interface, e
235 );
236 }
237 }
238 }
239
240 info!(
241 "SSDP client {} bound to {} (default search target: {}, multicast joined: {})",
242 client_id, local_addr, default_target, multicast_joined
243 );
244
245 app_state
246 .update_client_status(client_id, ClientStatus::Connected)
247 .await;
248 // `ClientInstance::connection` (and its byte counters) is never constructed anywhere
249 // in this tree, so there is no per-client stats plumbing to feed. `protocol_data` is
250 // the mechanism that does exist and that the dashboard reads.
251 app_state
252 .with_client_mut(client_id, |client| {
253 client.set_protocol_field(
254 "ssdp_local_addr".to_string(),
255 serde_json::json!(local_addr.to_string()),
256 );
257 client.set_protocol_field(
258 "ssdp_default_target".to_string(),
259 serde_json::json!(default_target.to_string()),
260 );
261 client.set_protocol_field(
262 "ssdp_multicast_joined".to_string(),
263 serde_json::json!(multicast_joined),
264 );
265 })
266 .await;
267 let _ = status_tx.send(format!(
268 "[CLIENT] SSDP client {} ready on {} (searching {})",
269 client_id, local_addr, default_target
270 ));
271 let _ = status_tx.send("__UPDATE_UI__".to_string());
272
273 // Registered BEFORE anything that can park: a dashboard-created client defaults to a
274 // `*` -> manual rule, so the connected event can wait on a human for minutes, and
275 // `[ send ]` must work for the whole park rather than reading "no command channel".
276 let command_rx =
277 crate::client::command_support::register_command_channel(&app_state, client_id).await;
278
279 let socket = Arc::new(socket);
280 let (inbound_tx, inbound_rx) = mpsc::unbounded_channel::<Inbound>();
281
282 // Receive task: nothing but recv_from and forward. Kept free of LLM calls on purpose
283 // — the session task is inside a model round-trip for most of a busy window, and a
284 // reader that waited on it would leave datagrams in the kernel buffer to be dropped.
285 let recv_socket = socket.clone();
286 let recv_state = app_state.clone();
287 let recv_status = status_tx.clone();
288 let recv_task = tokio::spawn(async move {
289 // MAX_MESSAGE_LEN is the largest message the codec will parse; one byte more
290 // lets an over-long datagram be *detected* rather than silently truncated into
291 // plausible-looking headers.
292 let mut buffer = vec![0u8; message::MAX_MESSAGE_LEN + 1];
293 loop {
294 match recv_socket.recv_from(&mut buffer).await {
295 Ok((n, source)) => {
296 trace!(
297 "SSDP client {} received {} bytes from {}",
298 client_id,
299 n,
300 source
301 );
302 if inbound_tx
303 .send(Inbound {
304 data: buffer[..n].to_vec(),
305 source,
306 })
307 .is_err()
308 {
309 // The session task ended; nothing left to hand datagrams to.
310 break;
311 }
312 }
313 Err(e) => {
314 error!("SSDP client {} receive error: {}", client_id, e);
315 recv_state
316 .update_client_status(client_id, ClientStatus::Error(e.to_string()))
317 .await;
318 let _ = recv_status.send(format!(
319 "[CLIENT] ✖ SSDP client {} receive error: {}",
320 client_id, e
321 ));
322 let _ = recv_status.send("__UPDATE_UI__".to_string());
323 break;
324 }
325 }
326 }
327 });
328 app_state.register_client_task(client_id, recv_task).await;
329
330 let session = Session {
331 client_id,
332 socket: socket.clone(),
333 app_state: app_state.clone(),
334 llm_client,
335 status_tx: status_tx.clone(),
336 protocol: SsdpClientProtocol::new(),
337 default_target,
338 local_addr,
339 multicast_joined,
340 response_window_ms,
341 user_agent,
342 memory: String::new(),
343 active: None,
344 queue: VecDeque::new(),
345 done: false,
346 };
347
348 let session_task = tokio::spawn(async move {
349 session.run(inbound_rx, command_rx).await;
350 });
351 app_state
352 .register_client_task(client_id, session_task)
353 .await;
354
355 Ok(local_addr)
356 }
357}
358
359/// Resolve the client's `remote_addr` into the default search destination.
360///
361/// A literal `ip:port` is used as-is; anything else goes through the resolver, so
362/// `router.local:1900` works. The default for a real scan is the multicast group
363/// `239.255.255.250:1900`.
364async fn resolve_target(remote_addr: &str) -> Result<SocketAddr> {
365 if let Ok(addr) = remote_addr.parse::<SocketAddr>() {
366 return Ok(addr);
367 }
368 tokio::net::lookup_host(remote_addr)
369 .await
370 .with_context(|| format!("Failed to resolve SSDP search target '{remote_addr}'"))?
371 .next()
372 .ok_or_else(|| anyhow::anyhow!("'{remote_addr}' resolved to no addresses"))
373}
374
375/// What applying one action did. Shared by the LLM path and the command channel so an
376/// injected `[ send ]` and a model-chosen action cannot diverge.
377enum Applied {
378 /// A search was queued. It reaches the wire on the next turn of the session loop, so no
379 /// byte count exists yet and reporting one would be inventing it.
380 Queued {
381 st: String,
382 },
383 Waited,
384 Disconnect,
385 Refused(String),
386}
387
388/// Everything one discovery session owns. Single-task by construction: no mutex, because
389/// nothing else touches it.
390struct Session {
391 client_id: ClientId,
392 socket: Arc<UdpSocket>,
393 app_state: Arc<AppState>,
394 llm_client: OllamaClient,
395 status_tx: mpsc::UnboundedSender<String>,
396 protocol: SsdpClientProtocol,
397 default_target: SocketAddr,
398 local_addr: SocketAddr,
399 multicast_joined: bool,
400 response_window_ms: u64,
401 user_agent: String,
402 memory: String,
403 active: Option<ActiveSearch>,
404 queue: VecDeque<PendingSearch>,
405 done: bool,
406}
407
408impl Session {
409 async fn run(
410 mut self,
411 mut inbound_rx: mpsc::UnboundedReceiver<Inbound>,
412 mut command_rx: mpsc::Receiver<ClientCommand>,
413 ) {
414 // The session opens with the connected event: nothing is on the wire yet, and the
415 // model's answer is what decides the first search.
416 let event = Event::new(
417 &SSDP_CLIENT_CONNECTED_EVENT,
418 serde_json::json!({
419 "local_addr": self.local_addr.to_string(),
420 "default_target": self.default_target.to_string(),
421 "multicast_joined": self.multicast_joined,
422 }),
423 );
424 // Bound before the `if let`, deliberately: a temporary in an `if let` scrutinee lives
425 // for the whole block, so `if let Some(a) = self.call_model(..).await` keeps the
426 // `&mut self` borrow alive across `self.apply_actions(..)` inside it and does not
427 // compile. Every call site here has the same shape for the same reason.
428 let actions = self.call_model(&event).await;
429 if let Some(actions) = actions {
430 self.apply_actions(actions, 0).await;
431 }
432
433 while !self.done {
434 // Start whatever the last turn asked for. A search is only ever *enqueued* by an
435 // action; it is started here, which is what keeps the search → event → search
436 // cycle from being a recursive call.
437 self.drain_queue().await;
438
439 let deadline = self.active.as_ref().map(|s| s.deadline);
440
441 tokio::select! {
442 biased;
443
444 inbound = inbound_rx.recv() => {
445 match inbound {
446 Some(inbound) => self.handle_inbound(inbound).await,
447 None => {
448 debug!(
449 "SSDP client {} receive loop ended; session closing",
450 self.client_id
451 );
452 break;
453 }
454 }
455 }
456
457 command = command_rx.recv() => {
458 match command {
459 Some(command) => self.handle_command(command).await,
460 None => break,
461 }
462 }
463
464 _ = tokio::time::sleep_until(deadline.unwrap_or_else(Instant::now)),
465 if deadline.is_some() =>
466 {
467 self.finish_search().await;
468 }
469 }
470 }
471
472 self.app_state
473 .update_client_status(self.client_id, ClientStatus::Disconnected)
474 .await;
475 // Every exit lands here: drop the command handle so the dashboard stops offering
476 // [ send ] on a dead client and a late send fails fast instead of timing out.
477 self.app_state.remove_client_handle(self.client_id).await;
478 let _ = self.status_tx.send(format!(
479 "[CLIENT] SSDP client {} discovery finished",
480 self.client_id
481 ));
482 let _ = self.status_tx.send("__UPDATE_UI__".to_string());
483 }
484
485 // -----------------------------------------------------------------------
486 // Searching
487 // -----------------------------------------------------------------------
488
489 /// Start queued searches while nothing is in flight.
490 ///
491 /// A send failure is reported to the **model**, not only to the log: it raises
492 /// `ssdp_search_complete` with `send_error` set and no responders, so the model can react
493 /// — which matters most for the one failure that actually happens here, sending to the
494 /// multicast group from a loopback-bound socket (`EADDRNOTAVAIL`, because loopback
495 /// carries no multicast route). The answer to that is a unicast `target`, and only the
496 /// model can choose one.
497 async fn drain_queue(&mut self) {
498 while self.active.is_none() {
499 let Some(pending) = self.queue.pop_front() else {
500 break;
501 };
502 match self.send_search(&pending).await {
503 Ok(bytes) => {
504 info!(
505 "SSDP client {} sent M-SEARCH ST={} MX={} to {} ({} bytes)",
506 self.client_id, pending.st, pending.mx, pending.target, bytes
507 );
508 let _ = self.status_tx.send(format!(
509 "[CLIENT] SSDP client {} searching for {} (MX {})",
510 self.client_id, pending.st, pending.mx
511 ));
512 let _ = self.status_tx.send("__UPDATE_UI__".to_string());
513 }
514 Err(e) => {
515 let detail = send_failure_hint(&e, pending.target, self.local_addr);
516 error!(
517 "SSDP client {} could not send M-SEARCH ST={} to {}: {}",
518 self.client_id, pending.st, pending.target, detail
519 );
520 let _ = self.status_tx.send(format!(
521 "[CLIENT] ✖ SSDP client {} M-SEARCH to {} failed: {}",
522 self.client_id, pending.target, detail
523 ));
524 self.complete_search(&pending, Vec::new(), 0, Some(detail))
525 .await;
526 }
527 }
528 }
529 }
530
531 /// Render and send one M-SEARCH, then open its collection window.
532 async fn send_search(&mut self, pending: &PendingSearch) -> Result<usize> {
533 let datagram = render_msearch(&pending.st, pending.mx, pending.target, &self.user_agent);
534 let bytes = self
535 .socket
536 .send_to(datagram.as_bytes(), pending.target)
537 .await?;
538
539 // The window is what the devices were told to expect: MX seconds, unless the
540 // operator overrode it. A window shorter than MX drops the answers of every device
541 // whose jitter landed late, which is exactly the devices a real network has.
542 let window_ms = if self.response_window_ms > 0 {
543 self.response_window_ms
544 } else {
545 u64::from(pending.mx) * 1000
546 };
547
548 self.active = Some(ActiveSearch {
549 st: pending.st.clone(),
550 mx: pending.mx,
551 target: pending.target,
552 depth: pending.depth,
553 deadline: Instant::now() + Duration::from_millis(window_ms),
554 responders: Vec::new(),
555 seen: HashSet::new(),
556 duplicates: 0,
557 });
558
559 Ok(bytes)
560 }
561
562 /// Close the open window and tell the model what it found.
563 async fn finish_search(&mut self) {
564 let Some(search) = self.active.take() else {
565 return;
566 };
567 let pending = PendingSearch {
568 st: search.st,
569 mx: search.mx,
570 target: search.target,
571 depth: search.depth,
572 };
573 self.complete_search(&pending, search.responders, search.duplicates, None)
574 .await;
575 }
576
577 /// Raise `ssdp_search_complete` and act on the answer.
578 async fn complete_search(
579 &mut self,
580 search: &PendingSearch,
581 responders: Vec<serde_json::Value>,
582 duplicates: usize,
583 send_error: Option<String>,
584 ) {
585 let depth = search.depth + 1;
586 info!(
587 "SSDP client {} search for {} complete: {} responder(s), {} duplicate(s)",
588 self.client_id,
589 search.st,
590 responders.len(),
591 duplicates
592 );
593 let _ = self.status_tx.send(format!(
594 "[CLIENT] SSDP client {} finished searching {}: {} device(s) answered",
595 self.client_id,
596 search.st,
597 responders.len()
598 ));
599 let _ = self.status_tx.send("__UPDATE_UI__".to_string());
600
601 let mut data = serde_json::json!({
602 "st": search.st,
603 "mx": search.mx,
604 "target": search.target.to_string(),
605 "responder_count": responders.len(),
606 "responders": responders,
607 "duplicate_count": duplicates,
608 "search_depth": depth,
609 });
610 if let Some(err) = send_error {
611 data["send_error"] = serde_json::Value::String(err);
612 }
613
614 let event = Event::new(&SSDP_SEARCH_COMPLETE_EVENT, data);
615 let actions = self.call_model(&event).await;
616 if let Some(actions) = actions {
617 self.apply_actions(actions, depth).await;
618 }
619 }
620
621 // -----------------------------------------------------------------------
622 // Inbound datagrams
623 // -----------------------------------------------------------------------
624
625 async fn handle_inbound(&mut self, inbound: Inbound) {
626 let msg = match message::parse(&inbound.data) {
627 Ok(msg) => msg,
628 Err(e) => {
629 // Dropped, not answered: a control point that reacted to malformed input
630 // would be reacting to whatever is on the group, which on a real network is
631 // plenty. Logged at DEBUG so it is visible when looked for.
632 debug!(
633 "SSDP client {} dropping {} bytes from {}: {}",
634 self.client_id,
635 inbound.data.len(),
636 inbound.source,
637 e
638 );
639 return;
640 }
641 };
642
643 match msg.method.as_deref() {
644 // A status line is a device answering a search — ours, or somebody else's if we
645 // are sitting on the group.
646 None => self.handle_search_response(msg, inbound.source).await,
647 Some("NOTIFY") => self.handle_notify(msg, inbound.source).await,
648 Some("M-SEARCH") => {
649 // Another control point searching. We are not a device; answering would be
650 // a lie, and answering our own group traffic would be a loop.
651 debug!(
652 "SSDP client {} ignoring an M-SEARCH from {} (we are a control point, \
653 not a device)",
654 self.client_id, inbound.source
655 );
656 }
657 Some(other) => {
658 warn!(
659 "SSDP client {} ignoring unexpected SSDP method '{}' from {}",
660 self.client_id, other, inbound.source
661 );
662 }
663 }
664 }
665
666 async fn handle_search_response(&mut self, msg: HttpuMessage, source: SocketAddr) {
667 // UDA 1.1 §1.3.3: the answer to an M-SEARCH is a 200. Anything else is a device
668 // reporting a problem with a search, and treating it as a discovery would put a
669 // device that refused us into the results.
670 if !msg.start_line.contains(" 200") {
671 warn!(
672 "SSDP client {} ignoring non-200 response from {}: {:?}",
673 self.client_id, source, msg.start_line
674 );
675 return;
676 }
677
678 let st = msg.header("ST").unwrap_or_default().to_string();
679 let usn = msg.header("USN").unwrap_or_default().to_string();
680 let source_address = source.to_string();
681
682 // Dedupe within the open window only, keyed on address *and* USN: a device
683 // retransmitting is one device, but a device answering `ssdp:all` with several
684 // services is several genuinely distinct results.
685 let depth = match self.active.as_mut() {
686 Some(search) => {
687 let key = (source_address.clone(), usn.clone());
688 if !search.seen.insert(key) {
689 search.duplicates += 1;
690 debug!(
691 "SSDP client {} suppressing a repeat answer from {} (USN {})",
692 self.client_id, source_address, usn
693 );
694 return;
695 }
696 search.responders.push(serde_json::json!({
697 "st": st,
698 "usn": usn,
699 "location": msg.header("LOCATION"),
700 "server": msg.header("SERVER"),
701 "source_address": source_address,
702 }));
703 search.depth + 1
704 }
705 // No window open: a response overheard on the group, or one that arrived after
706 // ours closed. Still worth telling the model about — it is a real device.
707 None => 0,
708 };
709
710 info!(
711 "SSDP client {} discovered {} at {} (ST {})",
712 self.client_id,
713 if usn.is_empty() {
714 "a device"
715 } else {
716 usn.as_str()
717 },
718 msg.header("LOCATION").unwrap_or("<no LOCATION>"),
719 if st.is_empty() { "<none>" } else { st.as_str() }
720 );
721
722 let event = Event::new(
723 &SSDP_SEARCH_RESPONSE_EVENT,
724 serde_json::json!({
725 "st": st,
726 "usn": usn,
727 "location": msg.header("LOCATION"),
728 "server": msg.header("SERVER"),
729 "cache_control": msg.header("CACHE-CONTROL"),
730 "cache_control_max_age": max_age_of(&msg),
731 "source_address": source_address,
732 "headers": msg.headers_json(),
733 }),
734 );
735 let actions = self.call_model(&event).await;
736 if let Some(actions) = actions {
737 self.apply_actions(actions, depth).await;
738 }
739 }
740
741 async fn handle_notify(&mut self, msg: HttpuMessage, source: SocketAddr) {
742 let nt = msg.header("NT").unwrap_or_default().to_string();
743 let nts = msg.header("NTS").unwrap_or_default().to_string();
744 let usn = msg.header("USN").unwrap_or_default().to_string();
745
746 info!(
747 "SSDP client {} heard {} from {} (NT {}, USN {})",
748 self.client_id,
749 if nts.is_empty() {
750 "an announcement"
751 } else {
752 nts.as_str()
753 },
754 source,
755 if nt.is_empty() { "<none>" } else { nt.as_str() },
756 usn
757 );
758
759 let event = Event::new(
760 &SSDP_NOTIFY_RECEIVED_EVENT,
761 serde_json::json!({
762 "nt": nt,
763 "nts": nts,
764 "usn": usn,
765 "location": msg.header("LOCATION"),
766 "server": msg.header("SERVER"),
767 "cache_control_max_age": max_age_of(&msg),
768 "source_address": source.to_string(),
769 "headers": msg.headers_json(),
770 }),
771 );
772 // Announcements are unsolicited, so they start their own chain at depth 0.
773 let actions = self.call_model(&event).await;
774 if let Some(actions) = actions {
775 self.apply_actions(actions, 0).await;
776 }
777 }
778
779 // -----------------------------------------------------------------------
780 // Model plumbing
781 // -----------------------------------------------------------------------
782
783 /// Ask the model what to do about `event`.
784 ///
785 /// `None` means there is nothing to act on — no instruction configured, or the call
786 /// failed. It never means "the answer was discarded": every `Some` is executed by the
787 /// caller, which is the one thing this repo's clients get wrong most often.
788 async fn call_model(&mut self, event: &Event) -> Option<Vec<serde_json::Value>> {
789 let instruction = self
790 .app_state
791 .get_instruction_for_client(self.client_id)
792 .await?;
793
794 // Bound rather than used directly as a `match` scrutinee: temporaries in a scrutinee
795 // live for the whole match, so the `&self.protocol` / `&self.memory` borrows would
796 // still be alive in the arm that assigns `self.memory`.
797 let result = call_llm_for_client(
798 &self.llm_client,
799 &self.app_state,
800 self.client_id.to_string(),
801 &instruction,
802 &self.memory,
803 Some(event),
804 &self.protocol,
805 &self.status_tx,
806 )
807 .await;
808
809 match result {
810 Ok(ClientLlmResult {
811 actions,
812 memory_updates,
813 }) => {
814 if let Some(memory) = memory_updates {
815 self.memory = memory.clone();
816 self.app_state
817 .set_memory_for_client(self.client_id, memory)
818 .await;
819 }
820 Some(actions)
821 }
822 Err(e) => {
823 error!(
824 "SSDP client {} LLM error on '{}': {}",
825 self.client_id,
826 event.id(),
827 e
828 );
829 let _ = self.status_tx.send(format!(
830 "[CLIENT] ✖ SSDP client {} could not get an answer for '{}': {}",
831 self.client_id,
832 event.id(),
833 e
834 ));
835 None
836 }
837 }
838 }
839
840 /// Execute everything the model returned, in order.
841 async fn apply_actions(&mut self, actions: Vec<serde_json::Value>, depth: usize) {
842 for action in actions {
843 match self.apply_action(&action, depth).await {
844 Ok(Applied::Disconnect) => {
845 info!("SSDP client {} disconnecting on request", self.client_id);
846 self.done = true;
847 return;
848 }
849 Ok(Applied::Refused(reason)) => {
850 error!(
851 "SSDP client {} refused an action: {}",
852 self.client_id, reason
853 );
854 let _ = self.status_tx.send(format!(
855 "[CLIENT] ⚠ SSDP client {}: {}",
856 self.client_id, reason
857 ));
858 }
859 Ok(Applied::Queued { .. }) | Ok(Applied::Waited) => {}
860 Err(e) => {
861 error!(
862 "SSDP client {} could not execute action {}: {}",
863 self.client_id, action, e
864 );
865 }
866 }
867 }
868 }
869
870 /// Execute one action. The single place an action becomes an effect, shared by the LLM
871 /// path and the command channel.
872 async fn apply_action(&mut self, action: &serde_json::Value, depth: usize) -> Result<Applied> {
873 match self.protocol.execute_action(action.clone())? {
874 ClientActionResult::Custom { name, data } if name == "send_msearch" => {
875 if depth > MAX_FOLLOWUP_DEPTH {
876 return Ok(Applied::Refused(format!(
877 "refusing a {}-deep follow-up search (limit {}). Discovery is \
878 iterative and does not converge on its own; report what has been \
879 found instead of searching again.",
880 depth, MAX_FOLLOWUP_DEPTH
881 )));
882 }
883 let st = data["st"].as_str().unwrap_or_default().to_string();
884 let mx = data["mx"].as_u64().unwrap_or(0) as u32;
885 let target = match data["target"].as_str() {
886 // Already validated as a SocketAddr by the executor, so this cannot fail
887 // on model input; the fallback keeps a future refactor honest.
888 Some(s) => s.parse().unwrap_or(self.default_target),
889 None => self.default_target,
890 };
891 self.queue.push_back(PendingSearch {
892 st: st.clone(),
893 mx,
894 target,
895 depth,
896 });
897 Ok(Applied::Queued { st })
898 }
899 ClientActionResult::Custom { name, .. } => Ok(Applied::Refused(format!(
900 "'{name}' is not an SSDP client verb"
901 ))),
902 ClientActionResult::WaitForMore => {
903 trace!("SSDP client {} waiting for more", self.client_id);
904 Ok(Applied::Waited)
905 }
906 ClientActionResult::Disconnect => Ok(Applied::Disconnect),
907 other => Ok(Applied::Refused(format!("no wire effect: {other:?}"))),
908 }
909 }
910
911 // -----------------------------------------------------------------------
912 // Injected commands (the dashboard's [ send ])
913 // -----------------------------------------------------------------------
914
915 /// Bespoke rather than `command_support::handle_stream_client_command`: that helper
916 /// writes `SendData` to a write half, and every SSDP verb is a `Custom` over a shared
917 /// `UdpSocket` with no write half at all. The logging and reply are what the helper does.
918 async fn handle_command(&mut self, command: ClientCommand) {
919 use crate::llm::actions::protocol_trait::Protocol;
920
921 let action = command.action.clone();
922 // Depth 0: an operator pressing [ send ] is starting a chain, not continuing one.
923 //
924 // Every arm is `Ok(..)`: a bad action is a `Rejected` outcome the caller can read,
925 // not a transport failure. The annotation is what tells the `Err` arm of the
926 // access-log conversion below what error type it is looking at.
927 let outcome: anyhow::Result<ClientSendOutcome> = match self.apply_action(&action, 0).await {
928 Ok(Applied::Queued { st }) => Ok(ClientSendOutcome::Executed {
929 detail: format!("M-SEARCH for '{st}' queued"),
930 }),
931 Ok(Applied::Waited) => Ok(ClientSendOutcome::Executed {
932 detail: "wait_for_more".to_string(),
933 }),
934 Ok(Applied::Disconnect) => {
935 self.done = true;
936 Ok(ClientSendOutcome::Disconnected)
937 }
938 Ok(Applied::Refused(reason)) => Ok(ClientSendOutcome::Rejected { error: reason }),
939 Err(e) => Ok(ClientSendOutcome::Rejected {
940 error: e.to_string(),
941 }),
942 };
943
944 let outcome_json = match &outcome {
945 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
946 Err(e) => serde_json::json!({"error": e.to_string()}),
947 };
948 self.app_state
949 .record_access_log(
950 AccessLogOwner::Client(self.client_id.as_u32()),
951 self.protocol.protocol_name(),
952 None,
953 "injected_action",
954 action,
955 vec![outcome_json],
956 )
957 .await;
958 let _ = self.status_tx.send("__UPDATE_UI__".to_string());
959
960 crate::client::command_support::reply(command, outcome);
961 }
962}
963
964// ---------------------------------------------------------------------------
965// Pure helpers
966// ---------------------------------------------------------------------------
967
968/// Render an `M-SEARCH * HTTP/1.1` request (UDA 1.1 §1.3.2).
969///
970/// `HOST` names the address the search is actually sent to. For the multicast form that is
971/// the group, which is what the specification prints; for a unicast search it is the device,
972/// which is what a device expects to see and what NetGet's own server test sends.
973///
974/// `MAN` keeps its quotes — `"ssdp:discover"` including them is what conforming control
975/// points send, and devices have been observed to ignore searches without them.
976///
977/// Not in `server/ssdp/message.rs` because that file is the device's half and is not ours to
978/// edit; the parsing side, which is the part with real grammar in it, *is* shared from there.
979/// `pub` so `tests/client/ssdp/e2e_test.rs` can assert the exact bytes against the codec
980/// without a socket: all tests live in `tests/`, so anything a test needs to see has to be
981/// public here.
982pub fn render_msearch(st: &str, mx: u32, target: SocketAddr, user_agent: &str) -> String {
983 format!(
984 "M-SEARCH * HTTP/1.1\r\n\
985 HOST: {target}\r\n\
986 MAN: \"ssdp:discover\"\r\n\
987 MX: {mx}\r\n\
988 ST: {st}\r\n\
989 USER-AGENT: {user_agent}\r\n\
990 \r\n"
991 )
992}
993
994/// The `max-age` directive of `CACHE-CONTROL`, as a number.
995///
996/// Given to the model already parsed: asked "is this device still fresh?", a model should not
997/// first have to pull an integer out of a header directive.
998pub fn max_age_of(msg: &HttpuMessage) -> Option<u64> {
999 let value = msg.header("CACHE-CONTROL")?;
1000 value.split(',').find_map(|part| {
1001 let part = part.trim();
1002 let rest = part.strip_prefix("max-age")?.trim_start();
1003 let rest = rest.strip_prefix('=')?.trim();
1004 rest.parse::<u64>().ok()
1005 })
1006}
1007
1008/// Turn a send error into something an operator can act on.
1009///
1010/// One failure dominates here and is deeply unobvious: sending to `239.255.255.250` from a
1011/// socket bound to loopback fails with `EADDRNOTAVAIL` because loopback carries no multicast
1012/// route — measured on macOS 27, where the *join* succeeds and only the send fails, which is
1013/// the opposite of the usual expectation. Left as the bare OS message it reads as a bug in
1014/// NetGet.
1015fn send_failure_hint(error: &anyhow::Error, target: SocketAddr, local: SocketAddr) -> String {
1016 let base = error.to_string();
1017 let target_is_multicast = match target.ip() {
1018 std::net::IpAddr::V4(ip) => ip.is_multicast(),
1019 std::net::IpAddr::V6(ip) => ip.is_multicast(),
1020 };
1021 if target_is_multicast && local.ip().is_loopback() {
1022 return format!(
1023 "{base} — the socket is bound to loopback ({local}) and loopback carries no \
1024 multicast route, so nothing can be sent to {target}. Either set startup param \
1025 bind_address to 0.0.0.0, or give send_msearch a unicast 'target'."
1026 );
1027 }
1028 base
1029}