Skip to main content

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}