agentmux_srv\backend/
lan_discovery.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! LAN instance discovery via mDNS/DNS-SD.
5//!
6//! Each AgentMux backend advertises itself as `_agentmux._tcp.local.` and
7//! continuously browses for peers. Discovered instances are tracked in memory
8//! and broadcast to frontend clients via EventBus.
9
10use std::collections::HashMap;
11use std::net::IpAddr;
12use std::sync::Arc;
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
16use parking_lot::RwLock;
17use serde::{Deserialize, Serialize};
18use serde_json::json;
19
20use super::eventbus::{EventBus, WSEventType};
21
22const SERVICE_TYPE: &str = "_agentmux._tcp.local.";
23const LAN_AGENT_CACHE_TTL_SECS: u64 = 60;
24const LAN_PEER_QUERY_TIMEOUT_SECS: u64 = 2;
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct LanInstance {
28    pub instance_id: String,
29    pub hostname: String,
30    pub version: String,
31    pub address: String,
32    pub port: u16,
33    pub auth_key: String,
34    pub agents: Vec<String>,
35    pub first_seen: u64,
36    pub last_seen: u64,
37}
38
39struct LanCacheEntry {
40    /// `None` = negative cache entry: agent is not on any LAN peer.
41    peer_url: Option<String>,
42    auth_key: String,
43    expires: std::time::Instant,
44}
45
46pub struct LanDiscovery {
47    daemon: ServiceDaemon,
48    instances: Arc<RwLock<HashMap<String, LanInstance>>>,
49    instance_id: String,
50    event_bus: Arc<EventBus>,
51    service_fullname: String,
52    auth_key: String,
53}
54
55/// Normalize an OS hostname into a valid mDNS host name by appending the
56/// `.local.` suffix that mdns-sd's `ServiceInfo::new` requires. Idempotent
57/// — already-normalized inputs pass through unchanged. We also strip any
58/// trailing dot first so `"foo.local"` doesn't end up as `"foo.local..local."`.
59fn mdns_hostname(os_hostname: &str) -> String {
60    let trimmed = os_hostname.trim_end_matches('.');
61    if trimmed.ends_with(".local") {
62        format!("{trimmed}.")
63    } else {
64        format!("{trimmed}.local.")
65    }
66}
67
68impl LanDiscovery {
69    /// Start LAN discovery: register this instance and browse for peers.
70    pub fn start(
71        instance_id: String,
72        hostname: String,
73        version: String,
74        port: u16,
75        auth_key: String,
76        event_bus: Arc<EventBus>,
77    ) -> Result<Arc<Self>, String> {
78        let daemon = ServiceDaemon::new().map_err(|e| format!("mDNS daemon failed: {e}"))?;
79
80        // Register this instance. mdns-sd requires the host name passed to
81        // `ServiceInfo::new` to end with `.local.` — we always normalize so
82        // a raw OS hostname like "claudius" becomes "claudius.local.".
83        let service_name = format!("agentmux-{}", &instance_id);
84        let host_name_mdns = mdns_hostname(&hostname);
85        let properties = [
86            ("version", version.as_str()),
87            ("hostname", hostname.as_str()),
88            ("instance_id", instance_id.as_str()),
89            ("auth_key", auth_key.as_str()),
90        ];
91        let service_info = ServiceInfo::new(
92            SERVICE_TYPE,
93            &service_name,
94            &host_name_mdns,
95            "",  // empty = auto-detect IP
96            port,
97            &properties[..],
98        )
99        .map_err(|e| format!("ServiceInfo creation failed: {e}"))?;
100
101        let service_fullname = service_info.get_fullname().to_string();
102
103        daemon
104            .register(service_info)
105            .map_err(|e| format!("mDNS register failed: {e}"))?;
106
107        // Browse for peers — keep the receiver for the event loop
108        let browse_receiver = daemon
109            .browse(SERVICE_TYPE)
110            .map_err(|e| format!("mDNS browse failed: {e}"))?;
111
112        let instances = Arc::new(RwLock::new(HashMap::new()));
113
114        let discovery = Arc::new(Self {
115            daemon,
116            instances: instances.clone(),
117            instance_id: instance_id.clone(),
118            event_bus: event_bus.clone(),
119            service_fullname,
120            auth_key,
121        });
122
123        // Spawn event receiver on a blocking thread to avoid starving the tokio runtime
124        let disc = discovery.clone();
125        tokio::task::spawn_blocking(move || {
126            disc.event_loop(browse_receiver);
127        });
128
129        tracing::info!(
130            instance_id = %instance_id,
131            port = port,
132            "LAN discovery started (mDNS)"
133        );
134
135        Ok(discovery)
136    }
137
138    fn event_loop(&self, receiver: mdns_sd::Receiver<ServiceEvent>) {
139        loop {
140            match receiver.recv() {
141                Ok(event) => self.handle_event(event),
142                Err(_) => {
143                    tracing::warn!("mDNS event receiver closed");
144                    break;
145                }
146            }
147        }
148    }
149
150    fn handle_event(&self, event: ServiceEvent) {
151        match event {
152            ServiceEvent::ServiceResolved(info) => {
153                let peer_id = info
154                    .get_property_val_str("instance_id")
155                    .unwrap_or_default()
156                    .to_string();
157
158                // Skip self
159                if peer_id == self.instance_id {
160                    return;
161                }
162
163                let now = SystemTime::now()
164                    .duration_since(UNIX_EPOCH)
165                    .unwrap_or_default()
166                    .as_secs();
167
168                let address = info
169                    .get_addresses()
170                    .iter()
171                    .find(|a| matches!(a, IpAddr::V4(_)))
172                    .or_else(|| info.get_addresses().iter().next())
173                    .map(|a| a.to_string())
174                    .unwrap_or_default();
175
176                let hostname = info
177                    .get_property_val_str("hostname")
178                    .unwrap_or_default()
179                    .to_string();
180                let version = info
181                    .get_property_val_str("version")
182                    .unwrap_or_default()
183                    .to_string();
184                let auth_key = info
185                    .get_property_val_str("auth_key")
186                    .unwrap_or_default()
187                    .to_string();
188
189                let fullname = info.get_fullname().to_string();
190                let mut instances = self.instances.write();
191                let entry = instances.entry(fullname).or_insert_with(|| LanInstance {
192                    instance_id: peer_id.clone(),
193                    hostname: hostname.clone(),
194                    version: version.clone(),
195                    address: address.clone(),
196                    port: info.get_port(),
197                    auth_key: auth_key.clone(),
198                    agents: Vec::new(),
199                    first_seen: now,
200                    last_seen: now,
201                });
202                entry.last_seen = now;
203                entry.hostname = hostname;
204                entry.version = version;
205                entry.address = address;
206                entry.port = info.get_port();
207                entry.auth_key = auth_key;
208                drop(instances);
209
210                tracing::info!(
211                    peer_id = %peer_id,
212                    address = %info.get_addresses().iter().next().map(|a| a.to_string()).unwrap_or_default(),
213                    port = info.get_port(),
214                    "LAN peer discovered"
215                );
216
217                self.broadcast_instances();
218            }
219            ServiceEvent::ServiceRemoved(_, fullname) => {
220                let removed = {
221                    let mut instances = self.instances.write();
222                    instances.remove(&fullname).is_some()
223                };
224                if removed {
225                    tracing::info!(fullname = %fullname, "LAN peer removed");
226                    self.broadcast_instances();
227                }
228            }
229            _ => {}
230        }
231    }
232
233    fn broadcast_instances(&self) {
234        let instances: Vec<LanInstance> = self.instances.read().values().cloned().collect();
235        self.event_bus.broadcast_event(&WSEventType {
236            eventtype: "laninstances".to_string(),
237            oref: String::new(),
238            data: Some(json!(instances)),
239        });
240    }
241
242    /// Get current list of discovered LAN peers (excludes self).
243    pub fn get_instances(&self) -> Vec<LanInstance> {
244        self.instances.read().values().cloned().collect()
245    }
246
247    /// Get peer count (excludes self).
248    #[allow(dead_code)]
249    pub fn peer_count(&self) -> usize {
250        self.instances.read().len()
251    }
252
253    /// Stop the mDNS daemon — synchronously closes the daemon socket
254    /// (UDP:5353), causing the `browse_receiver` to return Err and the
255    /// event-loop thread spawned by `start()` to exit.
256    ///
257    /// Required for live-disable to actually stop discovery: the event-loop
258    /// thread holds its own `Arc<Self>` clone, so simply dropping the
259    /// controller's Arc never reaches refcount zero and `Drop` does not
260    /// run. Callers must invoke `shutdown()` before clearing their Arc.
261    /// Idempotent — safe to call from both the explicit path and `Drop`.
262    pub fn shutdown(&self) {
263        if let Err(e) = self.daemon.unregister(&self.service_fullname) {
264            // Likely already unregistered; do not warn loudly.
265            tracing::debug!("mDNS unregister returned: {e}");
266        }
267        if let Err(e) = self.daemon.shutdown() {
268            tracing::debug!("mDNS daemon shutdown returned: {e}");
269        }
270    }
271}
272
273impl Drop for LanDiscovery {
274    fn drop(&mut self) {
275        // Fallback path. Under normal live-toggle flow the controller calls
276        // `shutdown()` explicitly; this only fires for process exit, when
277        // the event-loop thread has already terminated and the final Arc
278        // is being released.
279        self.shutdown();
280    }
281}
282
283/// Controller for live start/stop of `LanDiscovery` in response to setting changes.
284///
285/// Owns the daemon slot plus the start arguments, so toggling
286/// `network:lan_discovery` from the UI (or from an external edit of
287/// `settings.json`) can start or stop the daemon without restarting the
288/// process.
289///
290/// Spec: specs/lan-discovery-toggle.md
291pub struct LanDiscoveryController {
292    slot: Arc<RwLock<Option<Arc<LanDiscovery>>>>,
293    instance_id: String,
294    hostname: String,
295    version: String,
296    port: u16,
297    auth_key: String,
298    event_bus: Arc<EventBus>,
299    /// Short-lived cache mapping agent_id → (peer_url, auth_key). Entries expire
300    /// after LAN_AGENT_CACHE_TTL_SECS to handle agent migration between peers.
301    agent_cache: std::sync::RwLock<HashMap<String, LanCacheEntry>>,
302}
303
304impl LanDiscoveryController {
305    pub fn new(
306        instance_id: String,
307        hostname: String,
308        version: String,
309        port: u16,
310        event_bus: Arc<EventBus>,
311        auth_key: String,
312    ) -> Self {
313        Self {
314            slot: Arc::new(RwLock::new(None)),
315            instance_id,
316            hostname,
317            version,
318            port,
319            auth_key,
320            event_bus,
321            agent_cache: std::sync::RwLock::new(HashMap::new()),
322        }
323    }
324
325    /// Query LAN peers for which one hosts `agent_id`. Returns `(peer_url,
326    /// auth_key)` for the first peer that responds 2xx to the agent-lookup
327    /// endpoint. Results — both positive and negative — are cached for
328    /// `LAN_AGENT_CACHE_TTL_SECS` seconds to avoid a blocking peer fan-out on
329    /// every inject for cloud-only agents.
330    ///
331    /// Security: `auth_key` is broadcast in the mDNS TXT record. This is
332    /// intentional and matches the same trust assumption as tier-2 loopback
333    /// forwarding — LAN traffic is trusted (private network). Anyone on the LAN
334    /// who can already intercept mDNS multicast can intercept the HTTP traffic
335    /// too, so the key adds no exposure beyond what already exists.
336    pub async fn find_agent(
337        &self,
338        agent_id: &str,
339        http: &reqwest::Client,
340    ) -> Option<(String, String)> {
341        // Fast path: valid cache hit (positive or negative)
342        if let Ok(cache) = self.agent_cache.read() {
343            if let Some(e) = cache.get(agent_id) {
344                if e.expires > std::time::Instant::now() {
345                    return e.peer_url.as_ref().map(|url| (url.clone(), e.auth_key.clone()));
346                }
347            }
348        }
349
350        // Slow path: query each peer. Use reqwest's .query() for safe
351        // percent-encoding of the agent_id (handles spaces, &, =, #, etc.).
352        let peers = self.get_instances();
353        for peer in &peers {
354            if peer.address.is_empty() || peer.auth_key.is_empty() {
355                continue;
356            }
357            let peer_url = format!("http://{}:{}", peer.address, peer.port);
358            let result = http
359                .get(format!("{}/agentmux/reactive/agent", peer_url))
360                .query(&[("id", agent_id)])
361                .header("X-AuthKey", &peer.auth_key)
362                .timeout(std::time::Duration::from_secs(LAN_PEER_QUERY_TIMEOUT_SECS))
363                .send()
364                .await;
365            if matches!(result, Ok(ref r) if r.status().is_success()) {
366                tracing::debug!(agent_id, peer_url = %peer_url, "LAN agent found on peer");
367                if let Ok(mut cache) = self.agent_cache.write() {
368                    cache.insert(
369                        agent_id.to_string(),
370                        LanCacheEntry {
371                            peer_url: Some(peer_url.clone()),
372                            auth_key: peer.auth_key.clone(),
373                            expires: std::time::Instant::now()
374                                + std::time::Duration::from_secs(LAN_AGENT_CACHE_TTL_SECS),
375                        },
376                    );
377                }
378                return Some((peer_url, peer.auth_key.clone()));
379            }
380        }
381
382        // No peer has this agent — write a negative cache entry so future
383        // injects for cloud-only agents skip the full peer fan-out.
384        if let Ok(mut cache) = self.agent_cache.write() {
385            cache.insert(
386                agent_id.to_string(),
387                LanCacheEntry {
388                    peer_url: None,
389                    auth_key: String::new(),
390                    expires: std::time::Instant::now()
391                        + std::time::Duration::from_secs(LAN_AGENT_CACHE_TTL_SECS),
392                },
393            );
394        }
395        None
396    }
397
398    /// Evict a stale cache entry (e.g. after a forward to that peer failed).
399    pub fn evict_agent(&self, agent_id: &str) {
400        if let Ok(mut cache) = self.agent_cache.write() {
401            cache.remove(agent_id);
402        }
403    }
404
405    /// Idempotent: starts the daemon when `enabled` and not running, stops it
406    /// when `!enabled` and running. Re-entrant safe.
407    ///
408    /// Holds the slot's write lock for the entire check-and-modify transaction
409    /// to avoid a TOCTOU race between the `is_running` read and the slot
410    /// mutation. `apply()` is called from toggle clicks and setting writes —
411    /// low frequency — so briefly blocking concurrent peer-list reads is
412    /// acceptable. `LanDiscovery::start()` and `Drop` are both fast (mDNS
413    /// daemon construction + service register/unregister are local socket ops).
414    pub fn apply(&self, enabled: bool) {
415        let mut slot = self.slot.write();
416        let is_running = slot.is_some();
417        match (enabled, is_running) {
418            (true, false) => {
419                match LanDiscovery::start(
420                    self.instance_id.clone(),
421                    self.hostname.clone(),
422                    self.version.clone(),
423                    self.port,
424                    self.auth_key.clone(),
425                    self.event_bus.clone(),
426                ) {
427                    Ok(d) => {
428                        *slot = Some(d);
429                        tracing::info!("LAN discovery enabled via setting");
430                    }
431                    Err(e) => {
432                        tracing::warn!("LAN discovery start failed: {e}");
433                        // Surface to the UI so the user sees why the toggle
434                        // didn't take effect (e.g. Windows Firewall blocked).
435                        // `e` is already a String, but `.to_string()` is the
436                        // documented contract for the wire payload (frontend
437                        // reads `event.data.error` as a string).
438                        self.event_bus.broadcast_event(&WSEventType {
439                            eventtype: "laninstances:error".to_string(),
440                            oref: String::new(),
441                            data: Some(json!({ "error": e.to_string() })),
442                        });
443                    }
444                }
445            }
446            (false, true) => {
447                // Explicitly shut down before dropping our Arc. The
448                // spawn_blocking event-loop thread holds an `Arc<LanDiscovery>`
449                // clone (see `start()` line ~94), so dropping the slot's Arc
450                // alone does not reach refcount zero — `Drop` would never run
451                // and the daemon would keep advertising/browsing. `shutdown()`
452                // closes the mDNS socket synchronously, the receiver returns
453                // Err, the event-loop exits, and the spawned thread releases
454                // its Arc. `Drop`'s subsequent call to `shutdown()` is a
455                // no-op (idempotent).
456                if let Some(d) = slot.as_ref() {
457                    d.shutdown();
458                }
459                *slot = None;
460                tracing::info!("LAN discovery disabled via setting");
461                self.event_bus.broadcast_event(&WSEventType {
462                    eventtype: "laninstances".to_string(),
463                    oref: String::new(),
464                    data: Some(json!([])),
465                });
466            }
467            _ => {}
468        }
469    }
470
471    /// Read the current peer list. Returns empty when the daemon is not
472    /// running (discovery disabled or start failed).
473    pub fn get_instances(&self) -> Vec<LanInstance> {
474        self.slot
475            .read()
476            .as_ref()
477            .map(|d| d.get_instances())
478            .unwrap_or_default()
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::mdns_hostname;
485
486    #[test]
487    fn appends_local_dot_to_bare_hostname() {
488        assert_eq!(mdns_hostname("claudius"), "claudius.local.");
489    }
490
491    #[test]
492    fn preserves_already_fully_qualified_name() {
493        assert_eq!(mdns_hostname("claudius.local."), "claudius.local.");
494    }
495
496    #[test]
497    fn appends_trailing_dot_to_local_suffix() {
498        // mdns-sd needs the trailing dot; we add it without doubling .local.
499        assert_eq!(mdns_hostname("claudius.local"), "claudius.local.");
500    }
501
502    #[test]
503    fn handles_trailing_dot_on_bare_hostname() {
504        assert_eq!(mdns_hostname("claudius."), "claudius.local.");
505    }
506
507    #[test]
508    fn does_not_double_suffix() {
509        // Two passes through the normalizer produce the same result.
510        let once = mdns_hostname("claudius");
511        let twice = mdns_hostname(&once);
512        assert_eq!(twice, once);
513    }
514}