Skip to main content

agentmux_srv/backend/
lan_mdns_diag.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Why can't this host announce itself on mDNS? One line for the log.
5//!
6//! `mdns-sd` skips an interface whose socket cannot be set up and says so only at
7//! `debug!`, so when `lan_mdns_health` finds the host undiscoverable the log has no
8//! reason. On Area54 (2026-10-02, 0.59.4) the watchdog rebuilt the daemon three
9//! times and gave up while the cause, Chrome and then the Windows DNS client taking
10//! turns holding UDP 5353, had to be found by hand over several cloud round trips.
11//! This replays what `mdns-sd` does on one interface, and names who holds the port,
12//! so that the next one is a single `WARN` line.
13//!
14//! It runs only AFTER the daemon has already failed to announce, once per recovery
15//! attempt, and each probe socket lives for a few milliseconds. It is a diagnosis,
16//! not a health check: a health check on UDP 5353 would compete with the daemon it
17//! is checking (Codex P1 on #4133), and this never decides anything.
18//! See docs/specs/SPEC_LAN_FIREWALL_SETUP_2026_10_01.md sections 4.7 and 8.1.
19
20use std::net::{Ipv4Addr, SocketAddrV4};
21
22use socket2::{Domain, Protocol, Socket, Type};
23
24const MDNS_PORT: u16 = 5353;
25const MDNS_GROUP: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 251);
26
27/// The step of `mdns-sd`'s per-interface set-up that failed, and the OS's error.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct BindFailure {
30    pub step: &'static str,
31    /// `raw_os_error`, for example 10048 (`WSAEADDRINUSE`) or 10013 (`WSAEACCES`).
32    pub os_error: Option<i32>,
33    pub message: String,
34}
35
36fn fail(step: &'static str, e: std::io::Error) -> BindFailure {
37    BindFailure { step, os_error: e.raw_os_error(), message: e.to_string() }
38}
39
40/// Replay `mdns-sd`'s `new_socket_bind` for the IPv4 interface `ip`: a UDP socket
41/// with address reuse, bound to `0.0.0.0:5353`, joined to the mDNS group on `ip`,
42/// with `ip` as the multicast interface, then one empty packet to the group.
43/// `Ok` means every step worked, so a failure to announce was not a bind failure.
44pub fn probe_bind(ip: Ipv4Addr) -> Result<(), BindFailure> {
45    let sock = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)).map_err(|e| fail("create", e))?;
46    sock.set_reuse_address(true).map_err(|e| fail("reuse-address", e))?;
47    #[cfg(unix)]
48    sock.set_reuse_port(true).map_err(|e| fail("reuse-port", e))?;
49    let any = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, MDNS_PORT);
50    sock.bind(&any.into()).map_err(|e| fail("bind", e))?;
51    sock.join_multicast_v4(&MDNS_GROUP, &ip).map_err(|e| fail("join-group", e))?;
52    sock.set_multicast_if_v4(&ip).map_err(|e| fail("set-multicast-if", e))?;
53    let group = SocketAddrV4::new(MDNS_GROUP, MDNS_PORT);
54    sock.send_to(&[0u8; 12], &group.into()).map_err(|e| fail("send", e))?;
55    Ok(())
56}
57
58/// A process holding a socket on UDP 5353.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct PortHolder {
61    pub pid: u32,
62    pub process: String,
63    /// The local address it is bound to: `0.0.0.0`, `::`, or a specific one.
64    pub address: String,
65}
66
67/// Who holds UDP 5353 right now. Windows only (`GetExtendedUdpTable`); elsewhere it
68/// says so rather than guessing.
69#[cfg(windows)]
70pub fn udp_5353_holders() -> Result<Vec<PortHolder>, String> {
71    windows_impl::holders()
72}
73
74#[cfg(not(windows))]
75pub fn udp_5353_holders() -> Result<Vec<PortHolder>, String> {
76    Err("holder lookup is only implemented on Windows".to_string())
77}
78
79#[cfg(windows)]
80mod windows_impl {
81    use std::net::{Ipv4Addr, Ipv6Addr};
82
83    use windows_sys::Win32::NetworkManagement::IpHelper::{
84        GetExtendedUdpTable, MIB_UDP6ROW_OWNER_PID, MIB_UDPROW_OWNER_PID, UDP_TABLE_OWNER_PID,
85    };
86    use windows_sys::Win32::Networking::WinSock::{AF_INET, AF_INET6};
87
88    use super::{PortHolder, MDNS_PORT};
89
90    const ERROR_INSUFFICIENT_BUFFER: u32 = 122;
91
92    /// The owner-PID UDP table for one address family, as raw bytes.
93    fn table(family: u32) -> Result<Vec<u8>, String> {
94        let mut buf: Vec<u8> = Vec::new();
95        let mut size: u32 = 0;
96        // The first call reports the size it needs; the table can grow between calls,
97        // so a few tries.
98        for _ in 0..5 {
99            // SAFETY: `buf` is at least `size` bytes whenever it is passed (empty with
100            // size 0 on the first call), and both outlive the call.
101            let rc = unsafe {
102                GetExtendedUdpTable(buf.as_mut_ptr().cast(), &mut size, 0, family, UDP_TABLE_OWNER_PID, 0)
103            };
104            match rc {
105                0 => {
106                    buf.truncate(size as usize);
107                    return Ok(buf);
108                }
109                ERROR_INSUFFICIENT_BUFFER => buf.resize(size as usize, 0),
110                other => return Err(format!("GetExtendedUdpTable failed with {other}")),
111            }
112        }
113        Err("the UDP table kept growing".to_string())
114    }
115
116    /// `(local address, local port, pid)` rows of the IPv4 table.
117    fn rows_v4(buf: &[u8]) -> Vec<(String, u16, u32)> {
118        let count = buf.get(..4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])).unwrap_or(0) as usize;
119        let row = std::mem::size_of::<MIB_UDPROW_OWNER_PID>();
120        (0..count)
121            .filter_map(|i| {
122                let at = 4 + i * row;
123                if at + row > buf.len() {
124                    return None;
125                }
126                // SAFETY: bounds checked above; read_unaligned because the buffer is bytes.
127                let r = unsafe { std::ptr::read_unaligned(buf.as_ptr().add(at).cast::<MIB_UDPROW_OWNER_PID>()) };
128                let addr = Ipv4Addr::from(r.dwLocalAddr.to_ne_bytes()).to_string();
129                // The port is in network byte order in the low 16 bits.
130                Some((addr, u16::from_be((r.dwLocalPort & 0xFFFF) as u16), r.dwOwningPid))
131            })
132            .collect()
133    }
134
135    fn rows_v6(buf: &[u8]) -> Vec<(String, u16, u32)> {
136        let count = buf.get(..4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])).unwrap_or(0) as usize;
137        let row = std::mem::size_of::<MIB_UDP6ROW_OWNER_PID>();
138        (0..count)
139            .filter_map(|i| {
140                let at = 4 + i * row;
141                if at + row > buf.len() {
142                    return None;
143                }
144                // SAFETY: bounds checked above; read_unaligned because the buffer is bytes.
145                let r = unsafe { std::ptr::read_unaligned(buf.as_ptr().add(at).cast::<MIB_UDP6ROW_OWNER_PID>()) };
146                let addr = Ipv6Addr::from(r.ucLocalAddr).to_string();
147                Some((addr, u16::from_be((r.dwLocalPort & 0xFFFF) as u16), r.dwOwningPid))
148            })
149            .collect()
150    }
151
152    pub fn holders() -> Result<Vec<PortHolder>, String> {
153        let mut found: Vec<(String, u32)> = Vec::new();
154        for (family, v6) in [(AF_INET as u32, false), (AF_INET6 as u32, true)] {
155            let buf = table(family)?;
156            let rows = if v6 { rows_v6(&buf) } else { rows_v4(&buf) };
157            found.extend(rows.into_iter().filter(|(_, port, _)| *port == MDNS_PORT).map(|(a, _, pid)| (a, pid)));
158        }
159        let pids: Vec<sysinfo::Pid> = found.iter().map(|(_, pid)| sysinfo::Pid::from_u32(*pid)).collect();
160        let mut sys = sysinfo::System::new();
161        sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&pids), false);
162        Ok(found
163            .into_iter()
164            .map(|(address, pid)| PortHolder {
165                pid,
166                process: sys
167                    .process(sysinfo::Pid::from_u32(pid))
168                    .map(|p| p.name().to_string_lossy().to_string())
169                    .unwrap_or_else(|| "(exited or not visible)".to_string()),
170                address,
171            })
172            .collect())
173    }
174}
175
176/// At most this many distinct holders are named; the rest are counted.
177const MAX_HOLDERS_NAMED: usize = 8;
178
179/// One entry per (process, pid, address) with a count, because a program holds one socket
180/// per interface and per family: on a developer machine Chrome alone is dozens of
181/// sockets, and a line that repeats it is unreadable. Sorted by pid so the line is stable.
182fn group_holders(holders: &[PortHolder], own_pid: u32) -> String {
183    let mut groups: Vec<((u32, &str, &str), usize)> = Vec::new();
184    for h in holders {
185        let key = (h.pid, h.process.as_str(), h.address.as_str());
186        match groups.iter_mut().find(|(k, _)| *k == key) {
187            Some((_, n)) => *n += 1,
188            None => groups.push((key, 1)),
189        }
190    }
191    groups.sort_by(|a, b| a.0.cmp(&b.0));
192    let shown: Vec<String> = groups
193        .iter()
194        .take(MAX_HOLDERS_NAMED)
195        .map(|((pid, process, address), n)| {
196            let me = if *pid == own_pid { ", this process" } else { "" };
197            let times = if *n > 1 { format!(" x{n}") } else { String::new() };
198            format!("{process} (pid {pid}{me}) on {address}{times}")
199        })
200        .collect();
201    let more = groups.len().saturating_sub(MAX_HOLDERS_NAMED);
202    let tail = if more > 0 { format!(", and {more} more" ) } else { String::new() };
203    format!("{}{tail}", shown.join(", "))
204}
205
206/// The one log line. Pure, so every shape is unit-tested without a network.
207pub fn format_diagnosis(
208    probes: &[(Ipv4Addr, Result<(), BindFailure>)],
209    holders: &Result<Vec<PortHolder>, String>,
210    own_pid: u32,
211) -> String {
212    let probes_text = if probes.is_empty() {
213        "no interface to probe".to_string()
214    } else {
215        probes
216            .iter()
217            .map(|(ip, r)| match r {
218                Ok(()) => format!("{ip}: set-up works (so this was not a bind failure)"),
219                Err(f) => match f.os_error {
220                    Some(code) => format!("{ip}: FAILED at {} (os error {code}: {})", f.step, f.message),
221                    None => format!("{ip}: FAILED at {} ({})", f.step, f.message),
222                },
223            })
224            .collect::<Vec<_>>()
225            .join("; ")
226    };
227    let holders_text = match holders {
228        Err(why) => format!("holder lookup unavailable ({why})"),
229        Ok(h) if h.is_empty() => "nobody holds UDP 5353".to_string(),
230        Ok(h) => format!("UDP 5353 is held by {}", group_holders(h, own_pid)),
231    };
232    format!("{probes_text}. {holders_text}")
233}
234
235/// Probe each uncovered address and look up the holders. Blocking; call it from a
236/// blocking context. Never fails: a problem becomes part of the text.
237pub fn diagnose(missing: &[Ipv4Addr]) -> String {
238    let probes: Vec<(Ipv4Addr, Result<(), BindFailure>)> = missing.iter().map(|ip| (*ip, probe_bind(*ip))).collect();
239    format_diagnosis(&probes, &udp_5353_holders(), std::process::id())
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    fn ip(s: &str) -> Ipv4Addr {
247        s.parse().unwrap()
248    }
249
250    fn holder(pid: u32, process: &str, address: &str) -> PortHolder {
251        PortHolder { pid, process: process.to_string(), address: address.to_string() }
252    }
253
254    /// The Area54 situation of 2026-10-02: Chrome and the DNS client holding the port,
255    /// and a bind that fails, in one readable line.
256    #[test]
257    fn the_area54_case_reads_as_one_line_naming_the_holders() {
258        let probes = vec![(
259            ip("192.168.1.26"),
260            Err(BindFailure { step: "bind", os_error: Some(10048), message: "Only one usage of each socket address".into() }),
261        )];
262        let holders = Ok(vec![
263            holder(23800, "chrome.exe", "::"),
264            holder(5648, "chrome.exe", "0.0.0.0"),
265            holder(2964, "svchost.exe", "0.0.0.0"),
266        ]);
267        let text = format_diagnosis(&probes, &holders, 10708);
268        assert!(text.contains("192.168.1.26: FAILED at bind (os error 10048"), "{text}");
269        assert!(text.contains("chrome.exe (pid 23800) on ::"), "{text}");
270        assert!(text.contains("svchost.exe (pid 2964) on 0.0.0.0"), "{text}");
271        assert!(!text.contains("this process"), "{text}");
272    }
273
274    #[test]
275    fn a_working_set_up_says_so_because_it_rules_out_the_bind() {
276        let text = format_diagnosis(&[(ip("192.168.1.26"), Ok(()))], &Ok(vec![holder(10708, "agentmux-srv.exe", "0.0.0.0")]), 10708);
277        assert!(text.contains("set-up works (so this was not a bind failure)"), "{text}");
278        assert!(text.contains("agentmux-srv.exe (pid 10708, this process) on 0.0.0.0"), "{text}");
279    }
280
281    /// Read off narko on 2026-10-02: one program holds a socket per interface and family, so
282    /// the raw list repeated Chrome and node dozens of times.
283    #[test]
284    fn a_program_holding_many_sockets_is_one_entry_with_a_count_and_the_list_is_capped() {
285        let mut holders = Vec::new();
286        for _ in 0..9 {
287            holders.push(holder(29204, "chrome.exe", "0.0.0.0"));
288        }
289        holders.push(holder(11936, "node.exe", "0.0.0.0"));
290        holders.push(holder(11936, "node.exe", "0.0.0.0"));
291        let text = format_diagnosis(&[], &Ok(holders), 1);
292        assert!(text.contains("chrome.exe (pid 29204) on 0.0.0.0 x9"), "{text}");
293        assert!(text.contains("node.exe (pid 11936) on 0.0.0.0 x2"), "{text}");
294        assert_eq!(text.matches("chrome.exe").count(), 1, "{text}");
295
296        let many: Vec<PortHolder> = (1..=12).map(|pid| holder(pid, "svc.exe", "0.0.0.0")).collect();
297        let capped = format_diagnosis(&[], &Ok(many), 1);
298        assert!(capped.contains(", and 4 more"), "{capped}");
299        assert_eq!(capped.matches("svc.exe").count(), MAX_HOLDERS_NAMED, "{capped}");
300    }
301
302    #[test]
303    fn every_other_shape_still_reads_sensibly() {
304        assert!(format_diagnosis(&[], &Ok(vec![]), 1).contains("no interface to probe"));
305        assert!(format_diagnosis(&[], &Ok(vec![]), 1).contains("nobody holds UDP 5353"));
306        let unavailable = format_diagnosis(&[], &Err("only on Windows".into()), 1);
307        assert!(unavailable.contains("holder lookup unavailable (only on Windows)"), "{unavailable}");
308        let no_code = format_diagnosis(
309            &[(ip("10.0.0.5"), Err(BindFailure { step: "join-group", os_error: None, message: "weird".into() }))],
310            &Ok(vec![]),
311            1,
312        );
313        assert!(no_code.contains("FAILED at join-group (weird)"), "{no_code}");
314    }
315
316    /// Deterministic on every platform: a socket that holds the port WITHOUT address
317    /// reuse makes the reuse-bind fail, and the probe says it failed at the bind step
318    /// with the OS's own error code. This is the failure the diagnosis exists to name.
319    #[test]
320    fn an_exclusive_holder_makes_the_probe_fail_at_the_bind_step() {
321        let holder = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)).unwrap();
322        // Windows: an exclusive bind. Elsewhere: a plain bind with no reuse flags.
323        #[cfg(windows)]
324        {
325            use std::os::windows::io::AsRawSocket;
326            const SOL_SOCKET: i32 = 0xffff;
327            const SO_EXCLUSIVEADDRUSE: i32 = !4;
328            let on: i32 = 1;
329            // SAFETY: a valid socket and a 4-byte option value.
330            let rc = unsafe {
331                windows_sys::Win32::Networking::WinSock::setsockopt(
332                    holder.as_raw_socket() as usize,
333                    SOL_SOCKET,
334                    SO_EXCLUSIVEADDRUSE,
335                    (&on as *const i32).cast(),
336                    4,
337                )
338            };
339            assert_eq!(rc, 0, "set SO_EXCLUSIVEADDRUSE");
340        }
341        let any = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, MDNS_PORT);
342        if holder.bind(&any.into()).is_err() {
343            // Something on this machine already holds UDP 5353 in a way that blocks
344            // even this; the case under test cannot be set up here, which is itself
345            // the situation the module diagnoses. Nothing to assert.
346            return;
347        }
348        let failure = probe_bind(Ipv4Addr::LOCALHOST).expect_err("an exclusive holder must make the bind fail");
349        assert_eq!(failure.step, "bind", "{failure:?}");
350        assert!(failure.os_error.is_some(), "the OS's own error code is kept: {failure:?}");
351        drop(holder);
352    }
353
354    /// Manual: prints the diagnosis line this machine would log right now, so the format
355    /// can be read on real data. `cargo test -p agentmux-srv -- --ignored --nocapture
356    /// prints_the_diagnosis_for_this_machine`
357    #[test]
358    #[ignore = "reads this machine's sockets; run by hand"]
359    fn prints_the_diagnosis_for_this_machine() {
360        let ips: Vec<Ipv4Addr> = if_addrs::get_if_addrs()
361            .unwrap_or_default()
362            .into_iter()
363            .filter_map(|i| match i.ip() {
364                std::net::IpAddr::V4(v4) if !v4.is_loopback() && !v4.is_link_local() => Some(v4),
365                _ => None,
366            })
367            .collect();
368        println!("DIAGNOSIS: {}", diagnose(&ips));
369    }
370
371    /// The lookup must see this very process: bind the port here, then ask. This is the
372    /// check that the table parsing (byte order, row size, process names) is right.
373    #[cfg(windows)]
374    #[test]
375    fn the_holder_lookup_finds_this_process_on_the_port() {
376        let sock = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)).unwrap();
377        sock.set_reuse_address(true).unwrap();
378        let any = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, MDNS_PORT);
379        if sock.bind(&any.into()).is_err() {
380            return; // another program holds the port exclusively; nothing to check
381        }
382        let holders = udp_5353_holders().expect("the lookup works");
383        let me = holders.iter().find(|h| h.pid == std::process::id());
384        let me = me.unwrap_or_else(|| panic!("this process (pid {}) must be listed: {holders:?}", std::process::id()));
385        assert_eq!(me.address, "0.0.0.0");
386        assert!(!me.process.is_empty() && me.process != "(exited or not visible)", "{me:?}");
387        drop(sock);
388    }
389}