Skip to main content

agentmux_srv/backend/lan_discovery/
udp_peers.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Desktop-to-desktop discovery that does not depend on mDNS
5//! (`docs/specs/SPEC_LAN_FIREWALL_SETUP_2026_10_01.md` §4.7 item 5,
6//! `docs/specs/SPEC_LAN_UDP_PEER_DISCOVERY_2026_10_02.md`).
7//!
8//! mDNS can fail for reasons nothing on our side can fix: another program holds
9//! UDP 5353 (Area54, 2026-10-01: Chrome, then the DNS Client service), a guest
10//! network filters multicast, an interface never gets a socket. The UDP probe
11//! responder on 47891 only ever served mobile clients, so two desktops had one
12//! route to each other and no second.
13//!
14//! Each instance binds one UDP port ([`DESKTOP_DISCOVERY_PORT`]) while LAN is on
15//! and, from that same socket:
16//!
17//! * sends the existing probe to the limited broadcast address and to each
18//!   interface's directed broadcast every [`PROBE_INTERVAL`];
19//! * answers a probe from a private-range source with the identity payload the
20//!   47891 responder sends (`probe_response_json`), without its `siblings`;
21//! * reads identity payloads that come back and records the sender as a peer.
22//!
23//! Probing and answering share one socket on purpose: a peer's reply goes to the
24//! probe's source port, so it lands on a port the firewall rule names rather than
25//! on an ephemeral one.
26//!
27//! **Trust is the same as mDNS.** A reply is network-claimed and unauthenticated,
28//! exactly like a TXT record; what it gains an attacker is a place in the peer
29//! table, not an identity. Jekt signing and LAN key pinning are untouched. The
30//! limits below (private-range source, field sizes, port floor, a peer cap) are
31//! the same defence in depth the mDNS path has.
32//!
33//! Like the 47891 responder this socket does not set `SO_REUSEADDR`, so only the
34//! first instance on a host holds it. A second instance (a `task dev` build next
35//! to an installed one) quietly does without; hosts with one install are the
36//! case this exists for.
37
38use super::*;
39
40/// UDP port for desktop-to-desktop discovery. Inside the fixed block
41/// (`lan_ports::LAN_PORT_RANGE`, below every OS's ephemeral range) so one
42/// firewall rule can name it. UDP and TCP port numbers are separate, so sharing
43/// a number with a TCP listener in the block is harmless.
44pub(crate) const DESKTOP_DISCOVERY_PORT: u16 = 29700;
45
46/// How often each instance probes.
47pub(super) const PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
48///
49/// How long a UDP-learned peer stays listed after its last reply. This is the
50/// staleness floor every peer gets (`peer_staleness_window_secs` clamps a
51/// smaller `other_ttl_secs` up to it), so it is the real lifetime, not a hint:
52/// ten missed probes. A departed peer therefore lingers five minutes, which is
53/// still far shorter than an mDNS peer's TTL.
54const UDP_PEER_TTL_SECS: u32 = LAN_PEER_STALE_TIMEOUT_FLOOR_SECS as u32;
55
56/// Key prefix of a peer learned over UDP in `LanDiscovery::instances` (mDNS
57/// peers are keyed by their service name).
58const UDP_KEY_PREFIX: &str = "udp:";
59
60/// Peers learned over UDP at once. An unauthenticated broadcast must not be able
61/// to grow the table without bound; a real LAN has a handful of machines.
62const MAX_UDP_PEERS: usize = 64;
63
64const MAX_ID_LEN: usize = 128;
65const MAX_HOSTNAME_LEN: usize = 253;
66const MAX_VERSION_LEN: usize = 64;
67const MAX_CHANNEL_LEN: usize = 64;
68const MAX_AUTH_KEY_LEN: usize = 256;
69/// Below this a port is privileged or a well-known service, never one of ours.
70const MIN_PEER_PORT: u16 = 1024;
71
72/// The identity a peer's reply claims, after validation.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub(super) struct UdpPeer {
75    pub instance_id: String,
76    pub hostname: String,
77    pub version: String,
78    /// Empty when the peer predates advertising it.
79    pub channel: String,
80    /// The peer's operating system, only if it is a plain lowercase token.
81    /// Empty when the peer predates advertising it.
82    pub os: String,
83    pub address: IpAddr,
84    pub port: u16,
85    pub auth_key: String,
86}
87
88/// Validate a datagram as a peer's identity reply from `src`. `None` for noise,
89/// a probe, another protocol version, a source outside the private ranges, or
90/// any field that is missing, empty or implausibly large.
91pub(super) fn parse_peer_reply(bytes: &[u8], src: &std::net::SocketAddr) -> Option<UdpPeer> {
92    let value: serde_json::Value = serde_json::from_slice(bytes).ok()?;
93    if value.get("type").and_then(|t| t.as_str()) != Some(UDP_RESPONSE_TYPE)
94        || !is_probe_version_match(&value)
95    {
96        return None;
97    }
98    if !is_lan_source(src) {
99        return None;
100    }
101    let text = |key: &str, max: usize| -> Option<String> {
102        let s = value.get(key)?.as_str()?;
103        (s.len() <= max).then(|| s.to_string())
104    };
105    let instance_id = text("instance_id", MAX_ID_LEN).filter(|s| !s.is_empty())?;
106    // Without a key the peer cannot be queried at all (`query_peers_concurrently`
107    // skips it), so there is nothing to record.
108    let auth_key = text("auth_key", MAX_AUTH_KEY_LEN).filter(|s| !s.is_empty())?;
109    let hostname = text("hostname", MAX_HOSTNAME_LEN)?;
110    let version = text("version", MAX_VERSION_LEN)?;
111    // Optional: a peer from before it was advertised sends none, and an
112    // oversize one is dropped rather than refusing the whole reply.
113    let channel = text("channel", MAX_CHANNEL_LEN).unwrap_or_default();
114    let os = value
115        .get("os")
116        .and_then(|v| v.as_str())
117        .and_then(crate::backend::host_os::sanitize_os)
118        .unwrap_or_default();
119    let port = u16::try_from(value.get("port")?.as_u64()?)
120        .ok()
121        .filter(|p| *p >= MIN_PEER_PORT)?;
122    Some(UdpPeer {
123        instance_id,
124        hostname,
125        version,
126        channel,
127        os,
128        address: src.ip(),
129        port,
130        auth_key,
131    })
132}
133
134/// What [`merge_udp_peer`] did.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub(super) enum Merge {
137    /// A new peer: the table changed, so clients should be told.
138    Inserted,
139    /// Already known (by mDNS or an earlier reply); only its liveness moved.
140    Refreshed,
141    /// Not recorded (table full).
142    Dropped,
143}
144
145/// A UDP peer's key: the address its reply came from and the port it serves.
146///
147/// Not the instance id. That is `v<version>` (the launcher's `AGENTMUX_INSTANCE_ID`),
148/// so every machine on the same release shares it, and keying on it merged two
149/// such machines into one entry that flapped between them (Codex P1 on #4241).
150fn udp_key(address: &str, port: u16) -> String {
151    format!("{UDP_KEY_PREFIX}{address}:{port}")
152}
153
154/// The same peer as `peer` already in the table, by the one identity that is
155/// unique on a LAN: the address it answers on and the port it serves.
156fn find_existing<'a>(
157    instances: &'a mut HashMap<String, LanInstance>,
158    peer: &UdpPeer,
159) -> Option<(&'a String, &'a mut LanInstance)> {
160    let address = peer.address.to_string();
161    instances
162        .iter_mut()
163        .find(|(_, i)| i.address == address && i.port == peer.port)
164}
165
166/// Record `peer` in the table without ever duplicating a peer mDNS already
167/// knows: `find_agent` queries every entry, so a double would double every
168/// lookup. An mDNS entry is authoritative and only has its liveness refreshed.
169pub(super) fn merge_udp_peer(
170    instances: &mut HashMap<String, LanInstance>,
171    peer: &UdpPeer,
172    now: u64,
173) -> Merge {
174    if let Some((key, existing)) = find_existing(instances, peer) {
175        existing.last_seen = now;
176        if key.starts_with(UDP_KEY_PREFIX) {
177            // Ours: the reply is the freshest word on who this is. A peer that
178            // restarted on a new release keeps its address and port but reports a
179            // new version and instance id; the key does not depend on either.
180            existing.instance_id = peer.instance_id.clone();
181            existing.hostname = peer.hostname.clone();
182            existing.version = peer.version.clone();
183            // An older build, or an oversize field, sends none: keep the channel
184            // already known rather than fall back to the port label (Codex P2 on
185            // #4241), as the mDNS path does.
186            if !peer.channel.is_empty() {
187                existing.channel = peer.channel.clone();
188            }
189            if !peer.os.is_empty() {
190                existing.os = peer.os.clone();
191            }
192            existing.auth_key = peer.auth_key.clone();
193        } else {
194            if existing.auth_key.is_empty() {
195                existing.auth_key = peer.auth_key.clone();
196            }
197            if existing.channel.is_empty() {
198                existing.channel = peer.channel.clone();
199            }
200            if existing.os.is_empty() {
201                existing.os = peer.os.clone();
202            }
203        }
204        return Merge::Refreshed;
205    }
206    if instances
207        .keys()
208        .filter(|k| k.starts_with(UDP_KEY_PREFIX))
209        .count()
210        >= MAX_UDP_PEERS
211    {
212        return Merge::Dropped;
213    }
214    let address = peer.address.to_string();
215    instances.insert(
216        udp_key(&address, peer.port),
217        LanInstance {
218            instance_id: peer.instance_id.clone(),
219            hostname: peer.hostname.clone(),
220            version: peer.version.clone(),
221            channel: peer.channel.clone(),
222            os: peer.os.clone(),
223            address,
224            port: peer.port,
225            auth_key: peer.auth_key.clone(),
226            agents: Vec::new(),
227            agent_status: Default::default(),
228            agent_lines: Default::default(),
229            first_seen: now,
230            last_seen: now,
231            last_polled_ok: 0,
232            other_ttl_secs: UDP_PEER_TTL_SECS,
233        },
234    );
235    Merge::Inserted
236}
237
238/// Remove UDP-learned entries that an mDNS entry now covers (the same address
239/// and port). Called after an mDNS resolution so the order the two routes find a
240/// peer in does not decide whether it is listed twice. Instance ids are not
241/// compared: they are per release, not per machine (see [`udp_key`]).
242pub(super) fn drop_udp_duplicates(instances: &mut HashMap<String, LanInstance>) {
243    let mdns: Vec<(String, u16)> = instances
244        .iter()
245        .filter(|(k, _)| !k.starts_with(UDP_KEY_PREFIX))
246        .map(|(_, i)| (i.address.clone(), i.port))
247        .collect();
248    instances.retain(|key, i| {
249        !key.starts_with(UDP_KEY_PREFIX)
250            || !mdns
251                .iter()
252                .any(|(addr, port)| *addr == i.address && *port == i.port)
253    });
254}
255
256/// Where to send a probe: the limited broadcast address, plus each non-loopback
257/// IPv4 interface's directed broadcast. The limited broadcast alone leaves by
258/// one interface only on Windows, which is the wrong one on a host with a VPN or
259/// a virtual adapter (narko has four).
260pub(super) fn probe_targets(
261    broadcasts: impl IntoIterator<Item = Ipv4Addr>,
262    port: u16,
263) -> Vec<std::net::SocketAddr> {
264    let mut all: BTreeSet<Ipv4Addr> = broadcasts
265        .into_iter()
266        .filter(|b| !b.is_loopback())
267        .collect();
268    all.insert(Ipv4Addr::BROADCAST);
269    all.into_iter()
270        .map(|b| std::net::SocketAddr::from((b, port)))
271        .collect()
272}
273
274/// The directed broadcast address of every usable local IPv4 interface.
275pub(super) fn local_broadcasts() -> Vec<Ipv4Addr> {
276    if_addrs::get_if_addrs()
277        .unwrap_or_default()
278        .into_iter()
279        .filter(|i| !i.is_loopback())
280        .filter_map(|i| match i.addr {
281            if_addrs::IfAddr::V4(v4) => v4.broadcast,
282            _ => None,
283        })
284        .collect()
285}
286
287/// The probe datagram. The same one a mobile client sends, so every responder in
288/// the field already understands it.
289pub(super) fn probe_datagram() -> Vec<u8> {
290    serde_json::to_vec(&json!({ "type": UDP_PROBE_TYPE, "v": UDP_PROTOCOL_VERSION }))
291        .unwrap_or_default()
292}
293
294impl LanDiscovery {
295    /// Bind the discovery socket and run [`Self::udp_peer_loop_on`] over it. A
296    /// failed bind (most likely another local instance holds the port) is
297    /// logged at debug and ends the task; mDNS is unaffected.
298    pub(super) async fn udp_peer_loop(self: Arc<Self>, cancel: oneshot::Receiver<()>) {
299        let socket = match UdpSocket::bind(("0.0.0.0", DESKTOP_DISCOVERY_PORT)).await {
300            Ok(s) => s,
301            Err(e) => {
302                tracing::debug!(
303                    port = DESKTOP_DISCOVERY_PORT,
304                    "UDP peer discovery not started (bind failed, likely another local instance \
305                     holds this port): {e}"
306                );
307                return;
308            }
309        };
310        if let Err(e) = socket.set_broadcast(true) {
311            tracing::warn!(error = %e, "UDP peer discovery: cannot enable broadcast, not starting");
312            return;
313        }
314        tracing::debug!(
315            port = DESKTOP_DISCOVERY_PORT,
316            "UDP peer discovery listening"
317        );
318        let targets = || probe_targets(local_broadcasts(), DESKTOP_DISCOVERY_PORT);
319        self.udp_peer_loop_on(socket, targets, PROBE_INTERVAL, cancel)
320            .await;
321    }
322
323    /// The loop proper, over a socket and target list the caller supplies so a
324    /// test can drive two instances on loopback without the production port.
325    pub(super) async fn udp_peer_loop_on(
326        self: Arc<Self>,
327        socket: UdpSocket,
328        targets: impl Fn() -> Vec<std::net::SocketAddr>,
329        every: std::time::Duration,
330        mut cancel: oneshot::Receiver<()>,
331    ) {
332        let own_port = socket.local_addr().map(|a| a.port()).unwrap_or(0);
333        let probe = probe_datagram();
334        let mut ticker = tokio::time::interval(every);
335        ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
336        let mut buf = [0u8; 1024];
337        loop {
338            tokio::select! {
339                _ = &mut cancel => {
340                    tracing::debug!("UDP peer discovery stopping");
341                    return;
342                }
343                _ = ticker.tick() => {
344                    for target in targets() {
345                        // An interface that is down, or a host with no route to
346                        // a broadcast address, fails here; the others still go.
347                        if let Err(e) = socket.send_to(&probe, target).await {
348                            tracing::debug!(%target, error = %e, "UDP peer probe send failed");
349                        }
350                    }
351                }
352                result = socket.recv_from(&mut buf) => match result {
353                    Ok((len, src)) => self.handle_datagram(&socket, own_port, &buf[..len], src).await,
354                    Err(e) => {
355                        // A persistent error (Windows reports an ICMP
356                        // port-unreachable from our own earlier send as
357                        // WSAECONNRESET) must not spin this loop.
358                        tracing::debug!("UDP peer discovery recv_from error: {e}");
359                        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
360                    }
361                },
362            }
363        }
364    }
365
366    async fn handle_datagram(
367        &self,
368        socket: &UdpSocket,
369        own_port: u16,
370        bytes: &[u8],
371        src: std::net::SocketAddr,
372    ) {
373        // Our own broadcast comes back to our own socket on most stacks.
374        let own_addrs = crate::backend::lan_listeners::cached_local_addresses();
375        if src.port() == own_port && own_addrs.contains(&src.ip()) {
376            return;
377        }
378        if is_valid_probe(bytes) {
379            // Same trust boundary as the 47891 responder: the reply carries
380            // `auth_key`, so only a private-range source gets one.
381            if !is_lan_source(&src) {
382                return;
383            }
384            // Identity only, without the 47891 reply's `siblings`: a desktop
385            // reads replies into a 1024-byte buffer, which siblings could
386            // overflow, and finds a host's other channels over mDNS anyway.
387            if let Ok(payload) = serde_json::to_vec(&self.build_identity_response()) {
388                if let Err(e) = socket.send_to(&payload, src).await {
389                    tracing::debug!(%src, error = %e, "UDP peer reply send failed");
390                }
391            }
392            return;
393        }
394        let Some(peer) = parse_peer_reply(bytes, &src) else {
395            return;
396        };
397        // Not `peer.instance_id == self.instance_id`: that id is the release
398        // (`v<version>`), so every other machine on our release would have been
399        // ignored. "Us" is our own address with our own port, checked below.
400        let own_addr_list = [peer.address];
401        if is_self_resolution(peer.port, &own_addr_list, self.port, &own_addrs) {
402            return;
403        }
404        let now = agentmux_common::time::now_secs_u64();
405        let outcome = merge_udp_peer(&mut self.instances.write(), &peer, now);
406        match outcome {
407            Merge::Inserted => {
408                tracing::info!(
409                    peer_id = %peer.instance_id,
410                    address = %peer.address,
411                    port = peer.port,
412                    "LAN peer discovered (UDP broadcast)"
413                );
414                self.broadcast_instances();
415            }
416            Merge::Refreshed => {}
417            Merge::Dropped => {
418                tracing::debug!(%src, "UDP peer table full, reply ignored");
419            }
420        }
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    fn src(ip: &str) -> std::net::SocketAddr {
429        format!("{ip}:{DESKTOP_DISCOVERY_PORT}").parse().unwrap()
430    }
431
432    fn reply(id: &str, port: u64) -> Vec<u8> {
433        serde_json::to_vec(&json!({
434            "type": UDP_RESPONSE_TYPE, "v": 1, "instance_id": id, "hostname": "area54",
435            "version": "0.59.4", "port": port, "auth_key": "k-secret",
436        }))
437        .unwrap()
438    }
439
440    fn peer(id: &str, addr: &str, port: u16) -> UdpPeer {
441        UdpPeer {
442            instance_id: id.into(),
443            hostname: "h".into(),
444            version: "0.59.4".into(),
445            channel: "stable".into(),
446            os: String::new(),
447            address: addr.parse().unwrap(),
448            port,
449            auth_key: "k".into(),
450        }
451    }
452
453    fn mdns_entry(id: &str, addr: &str, port: u16) -> LanInstance {
454        LanInstance {
455            instance_id: id.into(),
456            hostname: "mdns-name".into(),
457            version: "0.59.4".into(),
458            channel: String::new(),
459            os: String::new(),
460            address: addr.into(),
461            port,
462            auth_key: "mdns-key".into(),
463            agents: vec!["korp".into()],
464            agent_status: Default::default(),
465            agent_lines: Default::default(),
466            first_seen: 1,
467            last_seen: 1,
468            last_polled_ok: 0,
469            other_ttl_secs: 4500,
470        }
471    }
472
473    #[test]
474    fn a_well_formed_reply_from_a_lan_address_is_a_peer() {
475        let p = parse_peer_reply(&reply("abc", 29700), &src("10.0.0.26")).unwrap();
476        assert_eq!(p.instance_id, "abc");
477        assert_eq!(p.address, "10.0.0.26".parse::<IpAddr>().unwrap());
478        assert_eq!(p.port, 29700);
479        assert_eq!(p.auth_key, "k-secret");
480    }
481
482    #[test]
483    fn the_channel_is_read_when_present_and_optional_when_not() {
484        let mut v: serde_json::Value = serde_json::from_slice(&reply("abc", 29700)).unwrap();
485        assert_eq!(
486            parse_peer_reply(&serde_json::to_vec(&v).unwrap(), &src("10.0.0.26"))
487                .unwrap()
488                .channel,
489            ""
490        );
491        v["channel"] = json!("dev-fix-lan");
492        assert_eq!(
493            parse_peer_reply(&serde_json::to_vec(&v).unwrap(), &src("10.0.0.26"))
494                .unwrap()
495                .channel,
496            "dev-fix-lan"
497        );
498        // Oversize: dropped, the reply still counts.
499        v["channel"] = json!("c".repeat(MAX_CHANNEL_LEN + 1));
500        assert_eq!(
501            parse_peer_reply(&serde_json::to_vec(&v).unwrap(), &src("10.0.0.26"))
502                .unwrap()
503                .channel,
504            ""
505        );
506    }
507
508    // The platform tag (SPEC_SWARM_REMOTE_AGENTS_PLATFORM_TAG_AND_SELECTION_2026_10_03.md §3).
509    #[test]
510    fn the_os_is_read_when_it_is_a_plain_token_and_dropped_otherwise() {
511        let mut v: serde_json::Value = serde_json::from_slice(&reply("abc", 29700)).unwrap();
512        let os_of = |v: &serde_json::Value| {
513            parse_peer_reply(&serde_json::to_vec(v).unwrap(), &src("10.0.0.26"))
514                .unwrap()
515                .os
516        };
517        assert_eq!(os_of(&v), "");
518        for ok in ["windows", "macos", "linux"] {
519            v["os"] = json!(ok);
520            assert_eq!(os_of(&v), ok);
521        }
522        // Anything else is dropped; the reply still counts.
523        for bad in ["Windows", "<b>x</b>", "../etc", "", "a-very-long-platform-name"] {
524            v["os"] = json!(bad);
525            assert_eq!(os_of(&v), "", "{bad:?}");
526        }
527        v["os"] = json!(42);
528        assert_eq!(os_of(&v), "");
529    }
530
531    #[test]
532    fn a_reply_without_an_os_keeps_the_known_one() {
533        let mut t = HashMap::new();
534        let mut with = peer("abc", "10.0.0.26", 29700);
535        with.os = "macos".into();
536        merge_udp_peer(&mut t, &with, 100);
537        assert_eq!(t["udp:10.0.0.26:29700"].os, "macos");
538        merge_udp_peer(&mut t, &peer("abc", "10.0.0.26", 29700), 200);
539        assert_eq!(t["udp:10.0.0.26:29700"].os, "macos");
540    }
541
542    #[test]
543    fn an_mdns_entry_with_no_os_learns_it_from_a_udp_reply() {
544        let mut t = HashMap::new();
545        t.insert("mdns-full".to_string(), mdns_entry("abc", "10.0.0.26", 29700));
546        let mut udp = peer("abc", "10.0.0.26", 29700);
547        udp.os = "linux".into();
548        merge_udp_peer(&mut t, &udp, 100);
549        assert_eq!(t["mdns-full"].os, "linux");
550    }
551
552    #[test]
553    fn the_address_is_the_datagrams_source_never_a_claimed_one() {
554        let mut v: serde_json::Value = serde_json::from_slice(&reply("abc", 29700)).unwrap();
555        v["address"] = json!("10.9.9.9");
556        let p = parse_peer_reply(&serde_json::to_vec(&v).unwrap(), &src("10.0.0.26")).unwrap();
557        assert_eq!(p.address.to_string(), "10.0.0.26");
558    }
559
560    #[test]
561    fn replies_that_are_not_trustworthy_enough_to_record_are_refused() {
562        let ok = src("10.0.0.26");
563        // Not from the private ranges: a public host must not seed the table.
564        assert!(parse_peer_reply(&reply("abc", 29700), &src("8.8.8.8")).is_none());
565        // A probe, noise, another version.
566        assert!(parse_peer_reply(&probe_datagram(), &ok).is_none());
567        assert!(parse_peer_reply(b"not json", &ok).is_none());
568        let mut v: serde_json::Value = serde_json::from_slice(&reply("abc", 29700)).unwrap();
569        v["v"] = json!(2);
570        assert!(parse_peer_reply(&serde_json::to_vec(&v).unwrap(), &ok).is_none());
571        // Privileged or out-of-range ports.
572        assert!(parse_peer_reply(&reply("abc", 80), &ok).is_none());
573        assert!(parse_peer_reply(&reply("abc", 70000), &ok).is_none());
574        // Missing or empty identity or key, and oversize fields.
575        assert!(parse_peer_reply(&reply("", 29700), &ok).is_none());
576        for field in ["instance_id", "auth_key", "hostname", "version", "port"] {
577            let mut v: serde_json::Value = serde_json::from_slice(&reply("abc", 29700)).unwrap();
578            v.as_object_mut().unwrap().remove(field);
579            assert!(
580                parse_peer_reply(&serde_json::to_vec(&v).unwrap(), &ok).is_none(),
581                "{field}"
582            );
583        }
584        let mut v: serde_json::Value = serde_json::from_slice(&reply("abc", 29700)).unwrap();
585        v["auth_key"] = json!("k".repeat(MAX_AUTH_KEY_LEN + 1));
586        assert!(parse_peer_reply(&serde_json::to_vec(&v).unwrap(), &ok).is_none());
587        v["auth_key"] = json!("");
588        assert!(parse_peer_reply(&serde_json::to_vec(&v).unwrap(), &ok).is_none());
589    }
590
591    #[test]
592    fn a_new_peer_is_recorded_under_a_udp_key_with_a_short_ttl() {
593        let mut t = HashMap::new();
594        assert_eq!(
595            merge_udp_peer(&mut t, &peer("abc", "10.0.0.26", 29700), 100),
596            Merge::Inserted
597        );
598        let e = &t["udp:10.0.0.26:29700"];
599        assert_eq!(
600            (e.last_seen, e.first_seen, e.other_ttl_secs),
601            (100, 100, UDP_PEER_TTL_SECS)
602        );
603        assert!(e.agents.is_empty());
604    }
605
606    #[test]
607    fn a_peer_mdns_already_knows_is_not_listed_twice() {
608        // By address and port, whatever the TXT record says or has not said yet.
609        for known in [
610            mdns_entry("abc", "10.0.0.26", 29700),
611            mdns_entry("", "10.0.0.26", 29700),
612        ] {
613            let mut t = HashMap::new();
614            t.insert("agentmux-x._agentmux._tcp.local.".to_string(), known);
615            assert_eq!(
616                merge_udp_peer(&mut t, &peer("abc", "10.0.0.26", 29700), 500),
617                Merge::Refreshed
618            );
619            assert_eq!(t.len(), 1, "no second entry");
620            let e = &t["agentmux-x._agentmux._tcp.local."];
621            assert_eq!(e.last_seen, 500, "liveness moves");
622            assert_eq!(e.hostname, "mdns-name", "mDNS data is authoritative");
623            assert_eq!(e.auth_key, "mdns-key");
624        }
625    }
626
627    #[test]
628    fn a_reply_without_a_channel_keeps_the_known_one() {
629        // Codex P2 on #4241: an older build (or an oversize field) sends none.
630        let mut t = HashMap::new();
631        merge_udp_peer(&mut t, &peer("abc", "10.0.0.26", 29700), 100);
632        assert_eq!(t["udp:10.0.0.26:29700"].channel, "stable");
633        let mut no_channel = peer("abc", "10.0.0.26", 29700);
634        no_channel.channel = String::new();
635        merge_udp_peer(&mut t, &no_channel, 200);
636        assert_eq!(t["udp:10.0.0.26:29700"].channel, "stable");
637    }
638
639    #[test]
640    fn a_later_reply_updates_a_udp_entry_in_place() {
641        let mut t = HashMap::new();
642        merge_udp_peer(&mut t, &peer("abc", "10.0.0.26", 29700), 100);
643        let mut renamed = peer("abc", "10.0.0.26", 29700);
644        renamed.hostname = "area54-renamed".into();
645        assert_eq!(merge_udp_peer(&mut t, &renamed, 200), Merge::Refreshed);
646        assert_eq!(t.len(), 1);
647        let e = &t["udp:10.0.0.26:29700"];
648        assert_eq!((e.hostname.as_str(), e.last_seen), ("area54-renamed", 200));
649    }
650
651    /// Codex P1 on #4241: the instance id is the release (`v<version>`), so two
652    /// machines on the same release send the same one. They are two peers.
653    #[test]
654    fn two_machines_on_the_same_release_are_two_peers() {
655        let mut t = HashMap::new();
656        assert_eq!(
657            merge_udp_peer(&mut t, &peer("v0.59.5", "10.0.0.26", 29700), 1),
658            Merge::Inserted
659        );
660        assert_eq!(
661            merge_udp_peer(&mut t, &peer("v0.59.5", "10.0.0.195", 29700), 1),
662            Merge::Inserted
663        );
664        assert_eq!(t.len(), 2);
665        // And an mDNS entry for one of them does not swallow the other.
666        t.insert(
667            "mdns-a".into(),
668            mdns_entry("v0.59.5", "10.0.0.26", 29700),
669        );
670        drop_udp_duplicates(&mut t);
671        assert!(t.contains_key("udp:10.0.0.195:29700"));
672        assert!(!t.contains_key("udp:10.0.0.26:29700"));
673    }
674
675    #[test]
676    fn the_udp_peer_table_is_bounded() {
677        let mut t = HashMap::new();
678        for n in 0..MAX_UDP_PEERS {
679            assert_eq!(
680                merge_udp_peer(
681                    &mut t,
682                    &peer(
683                        &format!("id{n}"),
684                        &format!("10.0.{}.{}", n / 200, n % 200 + 1),
685                        29700
686                    ),
687                    1
688                ),
689                Merge::Inserted
690            );
691        }
692        assert_eq!(
693            merge_udp_peer(&mut t, &peer("one-too-many", "10.1.0.1", 29700), 1),
694            Merge::Dropped
695        );
696        // A peer already in the table still refreshes at the cap.
697        assert_eq!(
698            merge_udp_peer(&mut t, &peer("id0", "10.0.0.1", 29700), 2),
699            Merge::Refreshed
700        );
701        // mDNS entries are not counted against the UDP cap.
702        t.insert("mdns".into(), mdns_entry("m", "10.2.0.1", 29700));
703        assert_eq!(
704            t.keys().filter(|k| k.starts_with("udp:")).count(),
705            MAX_UDP_PEERS
706        );
707    }
708
709    #[test]
710    fn an_mdns_resolution_after_the_udp_one_replaces_it() {
711        let mut t = HashMap::new();
712        merge_udp_peer(&mut t, &peer("abc", "10.0.0.26", 29700), 100);
713        merge_udp_peer(&mut t, &peer("other", "10.0.0.40", 29700), 100);
714        t.insert(
715            "agentmux-x._agentmux._tcp.local.".to_string(),
716            mdns_entry("abc", "10.0.0.26", 29700),
717        );
718        drop_udp_duplicates(&mut t);
719        assert!(!t.contains_key("udp:10.0.0.26:29700"));
720        assert!(
721            t.contains_key("udp:10.0.0.40:29700"),
722            "an unrelated UDP peer stays"
723        );
724        assert!(t.contains_key("agentmux-x._agentmux._tcp.local."));
725    }
726
727    #[test]
728    fn probes_go_to_the_limited_and_every_directed_broadcast_once() {
729        let t = probe_targets(
730            [
731                "192.168.1.255".parse().unwrap(),
732                "172.17.31.255".parse().unwrap(),
733                "192.168.1.255".parse().unwrap(),
734                "127.255.255.255".parse().unwrap(),
735            ],
736            DESKTOP_DISCOVERY_PORT,
737        );
738        let t: Vec<String> = t.iter().map(|a| a.to_string()).collect();
739        assert_eq!(
740            t,
741            [
742                "172.17.31.255:29700",
743                "192.168.1.255:29700",
744                "255.255.255.255:29700"
745            ]
746        );
747        // No interface at all still probes the limited broadcast.
748        assert_eq!(probe_targets([], 29700).len(), 1);
749    }
750
751    #[test]
752    fn the_probe_is_one_every_responder_already_accepts() {
753        assert!(is_valid_probe(&probe_datagram()));
754    }
755
756    #[test]
757    fn the_desktop_port_is_in_the_fixed_block() {
758        assert!(crate::backend::lan_ports::LAN_PORT_RANGE.contains(&DESKTOP_DISCOVERY_PORT));
759        assert!(
760            DESKTOP_DISCOVERY_PORT < 32768,
761            "below every OS's ephemeral range"
762        );
763    }
764
765    /// A `LanDiscovery` with a real (idle) mDNS daemon but none of `start()`'s
766    /// registration or tasks, as `handle_event_tests` builds one.
767    fn instance(id: &str, host: &str, port: u16, key: &str) -> Arc<LanDiscovery> {
768        Arc::new(LanDiscovery {
769            daemon: ServiceDaemon::new().expect("daemon construction (no register/browse)"),
770            instances: Arc::new(RwLock::new(HashMap::new())),
771            instance_id: id.to_string(),
772            event_bus: Arc::new(crate::backend::eventbus::EventBus::new()),
773            service_fullname: String::new(),
774            auth_key: key.to_string(),
775            hostname: host.to_string(),
776            version: "0.59.4".to_string(),
777            port,
778            udp_cancel: Mutex::new(None),
779            udp_peer_cancel: Mutex::new(None),
780            agent_names_cancel: Mutex::new(None),
781            instance_record_cancel: Mutex::new(None),
782            instances_dir: None,
783            instance_record_live: Mutex::new(false),
784            announced_v4: Arc::new(Mutex::new(BTreeSet::new())),
785            monitored: true,
786            started_at: std::time::Instant::now(),
787        })
788    }
789
790    /// The whole route, with mDNS out of the picture: two instances probe each
791    /// other over loopback sockets and each ends up with exactly one entry for
792    /// the other, carrying what it advertised.
793    #[tokio::test]
794    async fn two_instances_find_each_other_by_probe_alone() {
795        let a = instance("id-a", "host-a", 55001, "key-a");
796        let b = instance("id-b", "host-b", 55002, "key-b");
797        let sa = UdpSocket::bind("127.0.0.1:0").await.unwrap();
798        let sb = UdpSocket::bind("127.0.0.1:0").await.unwrap();
799        let (addr_a, addr_b) = (sa.local_addr().unwrap(), sb.local_addr().unwrap());
800        let (cancel_a, rx_a) = oneshot::channel();
801        let (cancel_b, rx_b) = oneshot::channel();
802        let every = std::time::Duration::from_millis(50);
803        // Each also probes its own address, as a real broadcast comes back to the
804        // sender's own socket.
805        tokio::spawn(
806            a.clone()
807                .udp_peer_loop_on(sa, move || vec![addr_b, addr_a], every, rx_a),
808        );
809        tokio::spawn(
810            b.clone()
811                .udp_peer_loop_on(sb, move || vec![addr_a, addr_b], every, rx_b),
812        );
813
814        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
815        let (seen_by_a, seen_by_b) = loop {
816            let (x, y) = (a.get_instances(), b.get_instances());
817            if (!x.is_empty() && !y.is_empty()) || std::time::Instant::now() > deadline {
818                break (x, y);
819            }
820            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
821        };
822        let _ = (cancel_a.send(()), cancel_b.send(()));
823
824        assert_eq!(seen_by_a.len(), 1, "a sees b once: {seen_by_a:?}");
825        assert_eq!(seen_by_b.len(), 1, "b sees a once: {seen_by_b:?}");
826        let b_as_seen = &seen_by_a[0];
827        assert_eq!(
828            (
829                b_as_seen.instance_id.as_str(),
830                b_as_seen.hostname.as_str(),
831                b_as_seen.port,
832                b_as_seen.auth_key.as_str()
833            ),
834            ("id-b", "host-b", 55002, "key-b")
835        );
836        assert_eq!(
837            b_as_seen.address, "127.0.0.1",
838            "the address is the datagram's source"
839        );
840        assert_eq!(seen_by_b[0].instance_id, "id-a");
841        // Neither listed itself, which its own broadcast would have caused.
842        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
843        assert!(
844            a.get_instances().iter().all(|i| i.instance_id != "id-a"),
845            "a listed itself"
846        );
847        assert!(
848            b.get_instances().iter().all(|i| i.instance_id != "id-b"),
849            "b listed itself"
850        );
851    }
852
853    /// "Us" is our own address serving our own port, not our instance id: that
854    /// id is the release, shared by every machine on it (Codex P1 on #4241).
855    #[tokio::test]
856    async fn a_machine_on_our_release_is_a_peer_and_our_own_address_is_us() {
857        let a = instance("v0.59.5", "host-a", 55001, "key-a");
858        let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap();
859        let own_port = socket.local_addr().unwrap().port();
860        // Same instance id as ours (the release), from another machine: a peer.
861        let from: std::net::SocketAddr = "10.0.0.77:29700".parse().unwrap();
862        a.handle_datagram(&socket, own_port, &reply("v0.59.5", 29799), from)
863            .await;
864        assert_eq!(
865            a.get_instances().len(),
866            1,
867            "another machine on our release is not us"
868        );
869        // Our own address serving our own port: us, never a peer.
870        if let Some(own) = crate::backend::lan_listeners::cached_local_addresses()
871            .iter()
872            .find(|ip| ip.is_ipv4() && !ip.is_loopback())
873        {
874            let from_self = std::net::SocketAddr::new(*own, 29700);
875            a.handle_datagram(&socket, own_port, &reply("v0.59.5", 55001), from_self)
876                .await;
877            assert_eq!(a.get_instances().len(), 1, "our own address and port is us");
878        }
879    }
880
881    /// A UDP peer is listed for the staleness floor after its last reply and not
882    /// a second longer, whatever `other_ttl_secs` says (ReAgent P2 on #4230: the
883    /// constant used to claim 120 s while the floor made it 300).
884    #[tokio::test]
885    async fn a_udp_peer_is_listed_for_exactly_the_staleness_floor() {
886        let a = instance("id-a", "host-a", 55001, "key-a");
887        let now = agentmux_common::time::now_secs_u64();
888        let floor = LAN_PEER_STALE_TIMEOUT_FLOOR_SECS;
889        assert_eq!(
890            u64::from(UDP_PEER_TTL_SECS),
891            floor,
892            "the constant states the real lifetime"
893        );
894        for (age, listed) in [(0, true), (floor - 5, true), (floor + 5, false)] {
895            merge_udp_peer(
896                &mut a.instances.write(),
897                &peer("abc", "10.0.0.26", 29700),
898                now - age,
899            );
900            assert_eq!(a.get_instances().len(), usize::from(listed), "age {age}s");
901            a.instances.write().clear();
902        }
903    }
904
905    /// A peer that restarts keeps its address and port and gets a new instance
906    /// id (ReAgent P2 on #4230): the entry follows it, re-keyed, so id-based
907    /// dedup compares against the live id and not a stale one.
908    #[test]
909    fn a_restarted_peer_keeps_one_entry_under_its_new_id() {
910        let mut t = HashMap::new();
911        merge_udp_peer(&mut t, &peer("v0.59.4", "10.0.0.26", 29700), 100);
912        assert_eq!(
913            merge_udp_peer(&mut t, &peer("v0.59.5", "10.0.0.26", 29700), 200),
914            Merge::Refreshed
915        );
916        assert_eq!(t.len(), 1, "{:?}", t.keys().collect::<Vec<_>>());
917        let e = &t["udp:10.0.0.26:29700"];
918        assert_eq!(
919            (e.instance_id.as_str(), e.first_seen, e.last_seen),
920            ("v0.59.5", 100, 200)
921        );
922    }
923}