netget/server/mdns/mod.rs
1//! mDNS/DNS-SD server implementation
2pub mod actions;
3
4use anyhow::Result;
5use std::net::SocketAddr;
6use std::sync::Arc;
7use tokio::sync::mpsc;
8use tracing::{error, info};
9
10#[cfg(not(target_arch = "wasm32"))]
11use crate::llm::action_helper::call_llm;
12#[cfg(not(target_arch = "wasm32"))]
13use crate::llm::ollama_client::OllamaClient;
14#[cfg(not(target_arch = "wasm32"))]
15use crate::logging::emit::Log;
16#[cfg(not(target_arch = "wasm32"))]
17use crate::protocol::Event;
18#[cfg(not(target_arch = "wasm32"))]
19use crate::server::MdnsProtocol;
20#[cfg(not(target_arch = "wasm32"))]
21use crate::state::app_state::AppState;
22use crate::{console_error, console_info};
23#[cfg(not(target_arch = "wasm32"))]
24use actions::MDNS_SERVER_STARTUP_EVENT;
25
26/// Port advertised in the SRV record when neither the service definition nor
27/// the server's own listen address names one.
28#[cfg(not(target_arch = "wasm32"))]
29const DEFAULT_ADVERTISED_PORT: u16 = 8080;
30
31/// The IPv4 multicast group and port mDNS announces on (RFC 6762).
32///
33/// Reported as *information*, never as this server's endpoint: joining a group is not
34/// binding it, and nobody owns it. See the return value of `spawn_with_llm_actions`.
35#[cfg(not(target_arch = "wasm32"))]
36const MDNS_GROUP: &str = "224.0.0.251:5353";
37
38/// Port component of the "this protocol binds no listening socket" placeholder address.
39///
40/// `server_startup::is_bound_addr` treats port 0 as exactly that — the OS never resolves a
41/// real bind to port 0 — and declines to advertise an endpoint for it.
42#[cfg(not(target_arch = "wasm32"))]
43const NO_LISTENING_SOCKET_PORT: u16 = 0;
44
45/// mDNS server that advertises services based on LLM instructions
46pub struct MdnsServer;
47
48#[cfg(not(target_arch = "wasm32"))]
49impl MdnsServer {
50 /// Spawn mDNS server with integrated LLM actions
51 pub async fn spawn_with_llm_actions(
52 listen_addr: SocketAddr,
53 llm_client: OllamaClient,
54 app_state: Arc<AppState>,
55 status_tx: mpsc::UnboundedSender<String>,
56 server_id: crate::state::ServerId,
57 startup_params: Option<crate::protocol::StartupParams>,
58 ) -> Result<SocketAddr> {
59 use mdns_sd::ServiceDaemon;
60
61 // mDNS itself does not bind a listening socket, so the server's own
62 // port is only meaningful as the default port to advertise for the
63 // service being announced.
64 //
65 // The `port` startup parameter wins over it, on every path. It used to be read only
66 // inside the two `startup_params` registration branches, so a caller that supplied
67 // `port` *and* an instruction - no `service_type`, no `services`, so the model does
68 // the registering - had the value silently dropped and got `DEFAULT_ADVERTISED_PORT`
69 // in the SRV record. The parameter's own description promises it is "the port clients
70 // will connect to", which was then untrue in exactly the case where a human wrote a
71 // sentence rather than a service definition.
72 let param_port = startup_params
73 .as_ref()
74 .map(|p| p.get_optional_u64("port"))
75 .transpose()?
76 .flatten()
77 .and_then(|p| u16::try_from(p).ok())
78 .filter(|p| *p != 0);
79
80 let default_port = param_port.unwrap_or(match listen_addr.port() {
81 0 => DEFAULT_ADVERTISED_PORT,
82 p => p,
83 });
84
85 info!("mDNS server (action-based) starting");
86 Log::new(Some(&status_tx)).info("mDNS server starting");
87
88 let protocol = Arc::new(MdnsProtocol::new());
89
90 // Create mDNS daemon
91 let mdns = ServiceDaemon::new()
92 .map_err(|e| anyhow::anyhow!("Failed to create mDNS daemon: {}", e))?;
93 info!("mDNS daemon created");
94
95 // Track if we successfully processed startup_params
96 let mut used_startup_params = false;
97
98 // If startup_params are provided, register services directly
99 if let Some(ref params) = startup_params {
100 // Check for multiple services array
101 if let Some(services) = params.get_optional_array("services")? {
102 info!(
103 "Registering {} services from startup_params",
104 services.len()
105 );
106 used_startup_params = true;
107 let requested = services.len();
108 let mut registered = 0usize;
109 for service in services {
110 if let Some(service_obj) = service.as_object() {
111 let service_type = service_obj
112 .get("service_type")
113 .and_then(|v| v.as_str())
114 .unwrap_or("_http._tcp.local.");
115 let service_name = service_obj
116 .get("service_name")
117 .and_then(|v| v.as_str())
118 .unwrap_or("Service");
119 let properties = service_obj
120 .get("properties")
121 .and_then(|v| v.as_object())
122 .map(|obj| {
123 obj.iter()
124 .filter_map(|(k, v)| v.as_str().map(|s| (k.as_str(), s)))
125 .collect::<Vec<_>>()
126 })
127 .unwrap_or_default();
128
129 // The advertised port is what discovering clients will
130 // actually connect to; publishing 0 makes the SRV
131 // record useless.
132 let port = service_obj
133 .get("port")
134 .and_then(|v| v.as_u64())
135 .and_then(|p| u16::try_from(p).ok())
136 .unwrap_or(default_port);
137
138 // Don't fail server startup if registration fails
139 if register_service(
140 &mdns,
141 service_type,
142 service_name,
143 port,
144 &properties,
145 &status_tx,
146 )
147 .is_ok()
148 {
149 registered += 1;
150 }
151 }
152 }
153 // The operator supplied the service list, so no model was asked. Say so:
154 // "advertising nothing" because nobody asked for anything must not read the
155 // same as "advertising nothing" because the backend failed.
156 Log::new(Some(&status_tx)).info(format!(
157 "mDNS advertising {}/{} service(s) decision=operator_config \
158 (from startup parameters; no model call)",
159 registered, requested
160 ));
161 }
162 // Check for single service parameters
163 else if let Some(service_type) = params.get_optional_string("service_type")? {
164 info!("Registering single service from startup_params");
165 used_startup_params = true;
166 let service_name = params
167 .get_optional_string("service_name")?
168 .unwrap_or_else(|| "Service".to_string());
169 let properties = params
170 .get_optional_object("properties")?
171 .map(|obj| {
172 obj.iter()
173 .filter_map(|(k, v)| v.as_str().map(|s| (k.as_str(), s)))
174 .collect::<Vec<_>>()
175 })
176 .unwrap_or_default();
177
178 let port = params
179 .get_optional_u64("port")?
180 .and_then(|p| u16::try_from(p).ok())
181 .unwrap_or(default_port);
182
183 // Don't fail server startup if registration fails
184 let registered = register_service(
185 &mdns,
186 &service_type,
187 &service_name,
188 port,
189 &properties,
190 &status_tx,
191 )
192 .is_ok();
193 Log::new(Some(&status_tx)).info(format!(
194 "mDNS advertising {}/1 service(s) decision=operator_config \
195 (from startup parameters; no model call)",
196 usize::from(registered)
197 ));
198 }
199 }
200
201 // Only call LLM if we didn't use startup_params
202 if !used_startup_params {
203 // `service_name` and `properties` describe a service; without `service_type` or
204 // `services` there is nothing to attach them to, so they are dropped. Say so
205 // rather than registering something surprising - a caller that got this far
206 // supplied half a service definition and would otherwise see the model invent
207 // the other half from the instruction.
208 if let Some(ref params) = startup_params {
209 let mut orphaned: Vec<&str> = Vec::new();
210 if params.get_optional_string("service_name")?.is_some() {
211 orphaned.push("service_name");
212 }
213 if params.get_optional_object("properties")?.is_some() {
214 orphaned.push("properties");
215 }
216 if !orphaned.is_empty() {
217 console_error!(
218 status_tx,
219 "mDNS ignoring startup parameter(s) {}: they describe a service, and \
220 neither 'service_type' nor 'services' was given to attach them to",
221 orphaned.join(", ")
222 );
223 }
224 }
225
226 // Create mDNS server startup event
227 let event = Event::new(&MDNS_SERVER_STARTUP_EVENT, serde_json::json!({}));
228
229 // Get LLM's service registration instructions
230 // mDNS manually processes register_mdns_service actions using raw_actions
231 //
232 // Silence is the correct answer on failure here, and deliberately so - see the
233 // `else` branch below for why. It is *not* correct to swallow the error, which is
234 // what `if let Ok(..)` did: the daemon came up announcing nothing and said nothing
235 // about why.
236 let llm_outcome = call_llm(
237 &llm_client,
238 &app_state,
239 server_id,
240 None,
241 &event,
242 protocol.as_ref(),
243 )
244 .await;
245
246 // How many services the handler asked for, and how many the daemon accepted.
247 // Counted so the three ways of ending up advertising nothing — the backend
248 // failed, the handler said nothing, the handler's services were rejected —
249 // are three different log lines rather than one absence.
250 let mut requested = 0usize;
251 let mut registered = 0usize;
252
253 if let Err(ref e) = llm_outcome {
254 // Nothing goes on the wire. mDNS has no unicast error frame and no request to
255 // answer - this call is a *startup* event asking what services to advertise,
256 // not a reply to a querier - so there is no peer waiting on a response and
257 // nothing that could be sent.
258 //
259 // More than that, inventing one would be actively harmful in a way a
260 // unicast protocol's error reply is not: an mDNS answer is multicast to the
261 // whole link and every listener on it caches the record for its TTL. A
262 // fabricated PTR/SRV/A set would propagate a service that does not exist to
263 // every machine in the subnet and keep it there after the backend recovered.
264 // This is the `udp` case, one step worse.
265 //
266 // So: announce nothing, and be loud about it on both channels.
267 let category = match crate::utils::wire_failure::WireFailure::classify(e) {
268 crate::utils::wire_failure::WireFailure::Overloaded => "overloaded",
269 crate::utils::wire_failure::WireFailure::Unavailable => "unavailable",
270 };
271 // `console_error!` below logs to `tracing` as well, so the tag lives there
272 // and only there: one incident must be one `grep decision=` hit, not two.
273 error!(
274 "mDNS service-registration handler failed (category={}); the responder is \
275 running but will advertise nothing: {}",
276 category, e
277 );
278 console_error!(
279 status_tx,
280 "mDNS advertising nothing: service-registration handler failed \
281 (decision=fail_closed_llm_error category={}): {}",
282 category,
283 e
284 );
285 }
286
287 if let Ok(execution_result) = llm_outcome {
288 // Display messages from LLM
289 for message in &execution_result.messages {
290 console_info!(status_tx, "{}", message);
291 }
292
293 // Process raw actions for manual mDNS service registration
294 for action in execution_result.raw_actions {
295 if let Some(action_type) = action.get("type").and_then(|v| v.as_str()) {
296 if action_type == "register_mdns_service" {
297 // Extract service parameters
298 let service_type = action
299 .get("service_type")
300 .and_then(|v| v.as_str())
301 .unwrap_or("_http._tcp.local.");
302 let instance_name = action
303 .get("instance_name")
304 .and_then(|v| v.as_str())
305 .unwrap_or("MyService");
306 // `as u16` would silently wrap an out-of-range port
307 // (e.g. 70000 -> 4464) into a wrong SRV record.
308 let port = action
309 .get("port")
310 .and_then(|v| v.as_u64())
311 .and_then(|p| u16::try_from(p).ok())
312 .unwrap_or(default_port);
313
314 let properties = action
315 .get("properties")
316 .and_then(|v| v.as_object())
317 .map(|obj| {
318 obj.iter()
319 .filter_map(|(k, v)| v.as_str().map(|s| (k.as_str(), s)))
320 .collect::<Vec<_>>()
321 })
322 .unwrap_or_default();
323
324 // Failures are logged by register_service; one bad
325 // service must not abort the remaining registrations.
326 requested += 1;
327 if register_service(
328 &mdns,
329 service_type,
330 instance_name,
331 port,
332 &properties,
333 &status_tx,
334 )
335 .is_ok()
336 {
337 registered += 1;
338 }
339 }
340 }
341 }
342
343 // One grep-able line for what the handler's answer amounted to. mDNS has no
344 // refusal action, so there is no `model_reject` here: a handler that wants
345 // to advertise nothing and a handler that had nothing to say are the same
346 // answer, and `model_silent` is the honest name for both.
347 let log = Log::new(Some(&status_tx));
348 if registered > 0 {
349 log.info(format!(
350 "mDNS advertising {}/{} service(s) decision=model_answer",
351 registered, requested
352 ));
353 } else if requested > 0 {
354 log.error(format!(
355 "mDNS advertising nothing: all {} service(s) the handler asked for \
356 were rejected by the daemon decision=fail_closed_bad_action",
357 requested
358 ));
359 } else {
360 log.warn(
361 "mDNS advertising nothing: the handler ran and asked for no services \
362 decision=model_silent",
363 );
364 }
365 }
366 } // Close if !used_startup_params
367
368 // Keep the daemon alive for the lifetime of the server task.
369 //
370 // `ServiceDaemon` has no `Drop` impl, so simply dropping it leaves the
371 // background thread running and the services still being announced on
372 // the multicast group. `DaemonGuard` calls `shutdown()` from its own
373 // `Drop`, which runs when the task is aborted by `stop_server`.
374 let handle = tokio::spawn(async move {
375 let _guard = DaemonGuard(mdns);
376 std::future::pending::<()>().await;
377 });
378
379 // Register the task so stop_server can abort it and stop advertising.
380 app_state.register_server_task(server_id, handle).await;
381
382 // The group is real information, but it is not this server's endpoint —
383 // so it is reported as what it is, on the status stream, rather than
384 // smuggled into `local_addr`.
385 info!(
386 "mDNS announcing on the {MDNS_GROUP} multicast group; this server binds no \
387 listening socket of its own"
388 );
389 Log::new(Some(&status_tx)).info(format!(
390 "mDNS announcing on the {MDNS_GROUP} multicast group (not a listening \
391 endpoint: mdns-sd owns its sockets and does not expose them)"
392 ));
393
394 // No socket of our own to report.
395 //
396 // This used to return `224.0.0.251:5353`, the group it announces on, which the TUI
397 // and `server_status` then displayed as if it were the server's listening endpoint.
398 // It is not an endpoint at all: joining a multicast group is not binding it, nobody
399 // owns it, and a stray `zeroconf` on the same machine makes it look occupied whether
400 // or not NetGet is running. `mdns_sd::ServiceDaemon` binds its sockets internally and
401 // exposes none of them, so there is no real address to substitute — and
402 // `0.0.0.0:0` is this codebase's established "I listen on nothing" placeholder, which
403 // `server_startup::is_bound_addr` recognises and declines to advertise (WebRTC, the
404 // device-backed protocols and others already return it). Showing no endpoint is the
405 // truth; showing a group address was not.
406 Ok(SocketAddr::from((
407 std::net::Ipv4Addr::UNSPECIFIED,
408 NO_LISTENING_SOCKET_PORT,
409 )))
410 }
411}
412
413/// Shuts the mDNS daemon down when the owning task is dropped or aborted.
414#[cfg(not(target_arch = "wasm32"))]
415struct DaemonGuard(mdns_sd::ServiceDaemon);
416
417#[cfg(not(target_arch = "wasm32"))]
418impl Drop for DaemonGuard {
419 fn drop(&mut self) {
420 match self.0.shutdown() {
421 Ok(_) => info!("mDNS daemon shutdown requested"),
422 Err(e) => error!("Failed to shut down mDNS daemon: {}", e),
423 }
424 }
425}
426
427#[cfg(not(target_arch = "wasm32"))]
428fn get_local_ip() -> Option<String> {
429 use std::net::UdpSocket;
430
431 // Determine which local address the default route would use. `connect` on
432 // a UDP socket only performs a routing-table lookup - no packets are sent
433 // and the peer is never contacted - so the destination is a documentation
434 // address (RFC 5737 TEST-NET-1) rather than a real third-party host.
435 if let Ok(socket) = UdpSocket::bind("0.0.0.0:0") {
436 if socket.connect("192.0.2.1:80").is_ok() {
437 if let Ok(addr) = socket.local_addr() {
438 return Some(addr.ip().to_string());
439 }
440 }
441 }
442 None
443}
444
445#[cfg(not(target_arch = "wasm32"))]
446fn register_service(
447 mdns: &mdns_sd::ServiceDaemon,
448 service_type: &str,
449 instance_name: &str,
450 port: u16,
451 properties: &[(&str, &str)],
452 status_tx: &mpsc::UnboundedSender<String>,
453) -> Result<()> {
454 use mdns_sd::ServiceInfo;
455
456 let local_ip = get_local_ip().unwrap_or_else(|| "127.0.0.1".to_string());
457 let host_name = format!("{}.local.", instance_name.replace(" ", "-"));
458
459 // Create ServiceInfo
460 match ServiceInfo::new(
461 service_type,
462 instance_name,
463 &host_name,
464 &local_ip,
465 port,
466 properties,
467 ) {
468 Ok(service_info) => {
469 // Register service
470 match mdns.register(service_info) {
471 Ok(_) => {
472 info!(
473 "mDNS registered service: {} ({}:{})",
474 instance_name, local_ip, port
475 );
476 Log::new(Some(status_tx)).info(format!(
477 "→ mDNS registered service: {} ({}:{})",
478 instance_name, local_ip, port
479 ));
480 Ok(())
481 }
482 Err(e) => {
483 error!("Failed to register mDNS service: {}", e);
484 Log::new(Some(status_tx))
485 .error(format!("✗ Failed to register mDNS service: {}", e));
486 Err(anyhow::anyhow!("Failed to register mDNS service: {}", e))
487 }
488 }
489 }
490 Err(e) => {
491 error!("Failed to create ServiceInfo: {}", e);
492 Log::new(Some(status_tx)).error(format!("✗ Failed to create ServiceInfo: {}", e));
493 Err(anyhow::anyhow!("Failed to create ServiceInfo: {}", e))
494 }
495 }
496}
497
498#[cfg(target_arch = "wasm32")]
499impl MdnsServer {
500 pub async fn spawn_with_llm_actions(
501 _listen_addr: SocketAddr,
502 _llm_client: OllamaClient,
503 _app_state: Arc<AppState>,
504 _status_tx: mpsc::UnboundedSender<String>,
505 _server_id: crate::state::ServerId,
506 _startup_params: Option<crate::protocol::StartupParams>,
507 ) -> Result<SocketAddr> {
508 anyhow::bail!("mDNS feature not enabled")
509 }
510}