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::{BTreeMap, BTreeSet, HashMap};
11use std::net::{IpAddr, Ipv4Addr};
12use std::sync::Arc;
13
14use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
15use parking_lot::{Mutex, RwLock};
16
17use crate::backend::agent_state::AgentState;
18use crate::backend::lan_mdns_diag;
19use crate::backend::lan_mdns_health;
20use serde::{Deserialize, Serialize};
21use serde_json::json;
22use tokio::net::UdpSocket;
23use tokio::sync::oneshot;
24
25use super::eventbus::EventBus;
26
27mod agent_names;
28mod events;
29mod lan_instances;
30mod udp_peers;
31
32pub use events::{LanDiscoverability, LanDiscoveryError, LanInstanceList};
33pub(crate) use agent_names::{parse_agent_names_body, PeerAgents};
34pub(crate) use udp_peers::DESKTOP_DISCOVERY_PORT;
35
36const SERVICE_TYPE: &str = "_agentmux._tcp.local.";
37const LAN_AGENT_CACHE_TTL_SECS: u64 = 60;
38const LAN_PEER_QUERY_TIMEOUT_SECS: u64 = 2;
39/// How long a peer is kept after its most recent `ServiceResolved` before
40/// `get_instances()` treats it as gone. mdns-sd's own `ServiceRemoved` event
41/// is NOT used for this (see the `ServiceRemoved` arm of `handle_event`
42/// below) — live logs showed it firing on ordinary TTL churn seconds before
43/// the same peer re-resolved, and every re-resolution after a `remove()` came
44/// back with a blank TXT record, so deleting on `ServiceRemoved` permanently
45/// wiped a live peer's hostname/instance_id/version/auth_key for no real
46/// disappearance.
47///
48/// This is a FLOOR, not the actual timeout — see `peer_staleness_window_secs`.
49/// A fixed 300s (this constant's original, only value — see
50/// docs/retro/retro-lan-peer-stale-timeout-too-short-for-slow-reannounce-peers-2026-09-17.md)
51/// was tuned against one fast-reannouncing peer seen in live logs (1-2 minute
52/// re-fire gaps) and wrongly pruned peers whose own advertised record TTL is
53/// much longer: RFC 6762 §10's recommended (and mdns-sd 0.12's hardcoded,
54/// unconfigurable-from-this-crate-version) default `other_ttl` for PTR/TXT
55/// records is 4500s/75min, and real LAN peers were observed re-announcing on
56/// gaps up to ~46 minutes — comfortably inside that TTL, but 9x past the old
57/// fixed cutoff. 300s remains only as a safety floor against a peer that
58/// somehow advertises an implausibly small TTL.
59const LAN_PEER_STALE_TIMEOUT_FLOOR_SECS: u64 = 300;
60/// Ceiling on `other_ttl_secs`-driven staleness (ReAgent P1, PR #3301):
61/// `other_ttl_secs` is taken directly from a peer's own mDNS advertisement,
62/// which this file already treats as adversarial elsewhere (see
63/// `LAN_AGENT_NAMES_MAX_*` above — "anything that can fake an mDNS
64/// advertisement... makes it into `self.instances`"). Without a cap, a
65/// spoofed peer advertising `other_ttl` near `u32::MAX` (~136 years) would be
66/// honored by both `get_instances()` and the periodic GC, permanently pinning
67/// it as "alive" — defeating the entire self-pruning design this exists for.
68/// 2x the RFC 6762 §10 / mdns-sd 0.12 default (4500s) comfortably covers every
69/// legitimate cadence actually observed on this LAN (narko/starpower's ~46min
70/// gaps) with margin, while bounding attacker-controlled input the same way
71/// the byte/count/length caps above already do.
72const LAN_PEER_STALE_TIMEOUT_CEIL_SECS: u64 = 9000;
73
74/// A peer is stale once `last_seen` exceeds ITS OWN advertised `other_ttl` —
75/// the standards-defined lifetime of the PTR/TXT records that make up an mDNS
76/// announcement — not one fixed cutoff shared by every peer regardless of how
77/// often it actually re-announces. Clamped between
78/// `LAN_PEER_STALE_TIMEOUT_FLOOR_SECS` (guards an implausibly small
79/// advertised TTL) and `LAN_PEER_STALE_TIMEOUT_CEIL_SECS` (guards an
80/// adversarially large one — `other_ttl_secs` is untrusted peer input).
81fn peer_staleness_window_secs(other_ttl_secs: u32) -> u64 {
82 (other_ttl_secs as u64).clamp(LAN_PEER_STALE_TIMEOUT_FLOOR_SECS, LAN_PEER_STALE_TIMEOUT_CEIL_SECS)
83}
84/// How often each known LAN peer is asked for its agent-name list. Peers are
85/// few (one per machine) and the request is a single small GET, so this is
86/// cheap; it's deliberately slower than `LAN_AGENT_CACHE_TTL_SECS` because
87/// this list is for *display* ("who lives on that host"), not for delivery
88/// routing — `find_agent` still does its own authoritative lookup.
89const LAN_AGENT_NAMES_REFRESH_SECS: u64 = 30;
90/// Hard cap on a single peer's `/agentmux/reactive/agent-names` response body
91/// (Codex P1 on PR #3102): the `lan_key` a peer advertised authenticates
92/// THIS client TO that peer, not the peer to us — anything that can fake an
93/// mDNS advertisement for `_agentmux._tcp.local` makes it into
94/// `self.instances` and gets polled by this loop. Enforced while reading, not
95/// via `Content-Length` (a malicious peer can omit or lie about that while
96/// still streaming an unbounded chunked body). 64 KiB is generous for a
97/// plain name list — real fleets are agents-per-machine, not per-thousand.
98pub(crate) const LAN_AGENT_NAMES_MAX_RESPONSE_BYTES: usize = 64 * 1024;
99/// Defense in depth past the byte cap above: even a response that fits in
100/// 64 KiB could be an adversarially compact array of many short strings.
101const LAN_AGENT_NAMES_MAX_COUNT: usize = 500;
102/// A legitimate agent name is short; anything absurdly long is more likely
103/// an attempt to waste memory than a real name, so it's dropped rather than
104/// truncated (truncating could quietly merge two distinct long names).
105const LAN_AGENT_NAMES_MAX_NAME_LEN: usize = 256;
106
107/// UDP broadcast discovery fallback (Layer 2), for LANs where mDNS multicast
108/// is filtered (common on corporate/guest WiFi). Mobile clients broadcast a
109/// small JSON probe to this port; any listening desktop instance unicasts a
110/// response straight back to the sender's source address.
111///
112/// Port 47891 is picked from the "ephemeral-safe-but-memorable" range: above
113/// both the well-known (0-1023) and IANA-registered (1024-49151) ranges, so
114/// it never collides with a registered service, while still being a fixed,
115/// easy-to-grep value in logs and firewall rules (unlike a random ephemeral
116/// port, which a fixed-port broadcast probe cannot target).
117pub(crate) const UDP_DISCOVERY_PORT: u16 = 47891;
118
119/// Wire-protocol `type` value a probe datagram must carry.
120pub(crate) const UDP_PROBE_TYPE: &str = "agentmux_discover";
121/// Wire-protocol `type` value this responder replies with.
122const UDP_RESPONSE_TYPE: &str = "agentmux_discover_response";
123/// Wire-protocol version. Bump alongside the mobile client if the schema
124/// changes; `is_valid_probe` rejects anything else.
125pub(crate) const UDP_PROTOCOL_VERSION: u64 = 1;
126/// How often an instance that could not bind `UDP_DISCOVERY_PORT` tries
127/// again, so another LAN-enabled channel on this host takes over the probe
128/// port when its holder stops (SPEC_LAN_FLEET_FEED_2026_10_03.md §3).
129const UDP_RESPONDER_RETRY: std::time::Duration = std::time::Duration::from_secs(15);
130
131/// Build the JSON response payload for a valid probe. Pure/free function
132/// (no `&self`) so it is trivially testable without spinning up a full
133/// `LanDiscovery` (which owns a real mDNS `ServiceDaemon`). `LanDiscovery`'s
134/// production loop calls this via `build_probe_response`.
135fn probe_response_json(
136 instance_id: &str,
137 hostname: &str,
138 version: &str,
139 port: u16,
140 auth_key: &str,
141) -> serde_json::Value {
142 json!({
143 "type": UDP_RESPONSE_TYPE,
144 "v": UDP_PROTOCOL_VERSION,
145 "instance_id": instance_id,
146 "hostname": hostname,
147 "version": version,
148 "port": port,
149 "auth_key": auth_key,
150 })
151}
152
153/// Validate a received UDP datagram as an in-protocol discovery probe.
154/// Anything that fails to parse as JSON, or does not carry the exact
155/// `type`/`v` fields, is treated as noise (unrelated LAN traffic or a
156/// future/older protocol version) and silently ignored by the caller.
157fn is_valid_probe(bytes: &[u8]) -> bool {
158 let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
159 return false;
160 };
161 value.get("type").and_then(|t| t.as_str()) == Some(UDP_PROBE_TYPE) && is_probe_version_match(&value)
162}
163
164/// Match the probe's `v` field against `UDP_PROTOCOL_VERSION`, accepting
165/// either JSON integer or float encoding. Some JSON serializers (e.g. a
166/// client whose numeric type is inferred as `double`) emit a whole number
167/// like `1` as `1.0`; `Value::as_u64()` alone returns `None` for that
168/// representation, which would silently drop an otherwise-conformant probe.
169fn is_probe_version_match(value: &serde_json::Value) -> bool {
170 let Some(v) = value.get("v") else {
171 return false;
172 };
173 v.as_u64() == Some(UDP_PROTOCOL_VERSION) || v.as_f64() == Some(UDP_PROTOCOL_VERSION as f64)
174}
175
176/// Trust-boundary check for the UDP responder: is `addr` reachable only from
177/// a private/link-local network, i.e. plausibly on this LAN? Unlike mDNS
178/// (link-local multicast, structurally non-routable off-subnet), this socket
179/// binds `0.0.0.0` and would otherwise answer *any* routable unicast probe
180/// with `auth_key` — an internet-facing disclosure oracle / UDP reflection
181/// vector if the port is ever reachable externally (port forward, hairpin
182/// NAT). Mirrors the private/loopback/link-local classification used for the
183/// opposite purpose (SSRF egress checks) in
184/// `drone/executor/blocks/api.rs::is_reserved_v4`/`is_reserved_v6`, minus the
185/// broadcast/unspecified/multicast cases that don't apply to a source addr.
186fn is_lan_source(addr: &std::net::SocketAddr) -> bool {
187 match addr.ip() {
188 IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(),
189 IpAddr::V6(v6) => {
190 if v6.is_loopback()
191 // unique-local fc00::/7
192 || (v6.segments()[0] & 0xfe00) == 0xfc00
193 // link-local fe80::/10
194 || (v6.segments()[0] & 0xffc0) == 0xfe80
195 {
196 return true;
197 }
198 // IPv4-mapped/-compatible v6 literals route to the embedded v4
199 // address on most kernels — delegate so a private v4 source
200 // wearing a v6 wrapper isn't misclassified as public.
201 if let Some(v4) = v6.to_ipv4_mapped() {
202 return v4.is_loopback() || v4.is_private() || v4.is_link_local();
203 }
204 #[allow(deprecated)]
205 if let Some(v4) = v6.to_ipv4() {
206 return v4.is_loopback() || v4.is_private() || v4.is_link_local();
207 }
208 false
209 }
210 }
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
214#[ts(export, export_to = "../../../frontend/types/rpc/")]
215pub struct LanInstance {
216 pub instance_id: String,
217 pub hostname: String,
218 pub version: String,
219 /// The advertising instance's channel (`AGENTMUX_CHANNEL`, default
220 /// "stable"), so a host running two channels can be told apart in the
221 /// Swarm (SPEC_SWARM_OTHER_HOSTS_AND_CHANNELS_2026_10_02.md Phase 2). Empty
222 /// for a peer on a build from before it was advertised.
223 #[serde(default)]
224 pub channel: String,
225 /// The peer's operating system (`windows`, `macos`, `linux`), for the
226 /// platform tag in the Swarm. Display only; empty for a peer that does not
227 /// advertise it (SPEC_SWARM_REMOTE_AGENTS_PLATFORM_TAG_AND_SELECTION_2026_10_03.md §3).
228 #[serde(default)]
229 pub os: String,
230 pub address: String,
231 pub port: u16,
232 /// The peer's `lan_key`; never serialized, as no reader of the JSON needs it.
233 #[serde(skip_serializing, default)]
234 #[ts(skip)]
235 pub auth_key: String,
236 pub agents: Vec<String>,
237 /// Each listed agent's state and summary line as the peer reported them, by
238 /// lower-cased name (`lan_discovery/agent_names.rs`). Self-reported, for the Swarm.
239 #[serde(default)]
240 pub agent_status: BTreeMap<String, AgentState>,
241 #[serde(default)]
242 pub agent_lines: BTreeMap<String, String>,
243 #[ts(type = "number")]
244 pub first_seen: u64,
245 #[ts(type = "number")]
246 pub last_seen: u64,
247 /// Unix seconds of the last answered agent-name poll, 0 for never. The Swarm
248 /// judges liveness by the later of this and `last_seen` (mDNS re-resolves only).
249 #[serde(default)]
250 #[ts(type = "number")]
251 pub last_polled_ok: u64,
252 /// This peer's advertised PTR/TXT record TTL (seconds) at its latest
253 /// `ServiceResolved` (`get_other_ttl()`); drives `peer_staleness_window_secs`.
254 pub other_ttl_secs: u32,
255}
256
257struct LanCacheEntry {
258 /// `None` = negative cache entry: agent is not on any LAN peer.
259 peer_url: Option<String>,
260 auth_key: String,
261 expires: std::time::Instant,
262}
263
264/// One in-flight `GET /agentmux/reactive/agent?id=…` against a single peer.
265type PeerQueryOutcome = (String, String, Result<reqwest::Response, reqwest::Error>);
266
267/// Query EVERY eligible LAN peer concurrently for `agent_id`, yielding results
268/// as they arrive.
269///
270/// Replaces the sequential `for peer in &peers { … .await }` both lookups used
271/// to run. That loop cost up to `LAN_PEER_QUERY_TIMEOUT_SECS × peer_count`
272/// before a message could be delivered — 10s across five peers — and the
273/// pathological ordering (a dead or slow peer ahead of the right one) is the
274/// common case on a laptop network where stale peers linger until their mDNS
275/// TTL expires. Fanning out makes the worst case ~one timeout regardless of
276/// peer count.
277///
278/// Returns a stream so each caller keeps its OWN acceptance rule: `find_agent`
279/// accepts any 2xx, while `find_agent_lan_pubkey` must keep trying after a 2xx
280/// whose body carries no `lan_public_key` (that peer hosts the agent but hasn't
281/// minted a key). A shared "first success wins" helper could not express both.
282///
283/// **Dropping the returned stream cancels every still-in-flight request** —
284/// so a caller that `break`s on the first acceptable answer stops paying for
285/// the rest, rather than merely ignoring them.
286fn query_peers_concurrently<'a>(
287 peers: &'a [LanInstance],
288 agent_id: &'a str,
289 http: &'a reqwest::Client,
290) -> futures_util::stream::FuturesUnordered<impl std::future::Future<Output = PeerQueryOutcome> + 'a> {
291 let futures = futures_util::stream::FuturesUnordered::new();
292 for peer in peers {
293 // Same eligibility rule the sequential loops used: a peer with no
294 // address or no scoped lan_key can't be queried at all.
295 if peer.address.is_empty() || peer.auth_key.is_empty() {
296 continue;
297 }
298 let peer_url = format!("http://{}:{}", peer.address, peer.port);
299 let auth_key = peer.auth_key.clone();
300 futures.push(async move {
301 let result = http
302 .get(format!("{peer_url}/agentmux/reactive/agent"))
303 .query(&[("id", agent_id)])
304 .header(agentmux_common::AUTH_KEY_HEADER, &auth_key)
305 .timeout(std::time::Duration::from_secs(LAN_PEER_QUERY_TIMEOUT_SECS))
306 .send()
307 .await;
308 (peer_url, auth_key, result)
309 });
310 }
311 futures
312}
313
314/// Pure predicate behind `LanDiscovery::resolves_to_this_instance`, split out
315/// so it can be tested without standing up an mDNS `ServiceDaemon`.
316///
317/// `any` rather than `all` on the address match: a resolution for our own
318/// service can legitimately carry an address that interface enumeration missed
319/// (they race), and requiring every address to be ours would let that one
320/// stray entry resurrect the phantom. `any` cannot produce a false positive —
321/// combined with the port equality, a genuine remote peer would have to be
322/// listening on our port *at one of our own interface addresses*, which it
323/// cannot be. Another AgentMux instance on this same host does share our
324/// addresses, but never our port.
325fn is_self_resolution(
326 resolved_port: u16,
327 resolved_addrs: &[IpAddr],
328 own_port: u16,
329 own_addrs: &std::collections::HashSet<IpAddr>,
330) -> bool {
331 if resolved_port != own_port || own_addrs.is_empty() {
332 return false;
333 }
334 resolved_addrs.iter().any(|a| own_addrs.contains(a))
335}
336
337pub struct LanDiscovery {
338 daemon: ServiceDaemon,
339 instances: Arc<RwLock<HashMap<String, LanInstance>>>,
340 instance_id: String,
341 event_bus: Arc<EventBus>,
342 service_fullname: String,
343 auth_key: String,
344 hostname: String,
345 version: String,
346 port: u16,
347 /// Cancellation half for the UDP responder task spawned in `start()`.
348 /// `None` once `shutdown()` has fired. Set even if the UDP socket never
349 /// binds (`udp_responder_loop` then just exits, and `shutdown()`'s send
350 /// is a harmless no-op). Guarded by a sync mutex since
351 /// `shutdown()` is `&self` and called from both an explicit live-toggle
352 /// path and `Drop`.
353 udp_cancel: Mutex<Option<oneshot::Sender<()>>>,
354 /// Cancellation half for the desktop-to-desktop UDP discovery task
355 /// (`udp_peers`). Same contract as `udp_cancel`: the task holds its own
356 /// `Arc<LanDiscovery>`, so dropping the controller's Arc does not stop it.
357 udp_peer_cancel: Mutex<Option<oneshot::Sender<()>>>,
358 /// Cancels `agent_names_refresh_loop`. Needed for the same reason
359 /// `udp_cancel` is: that task holds its own `Arc<LanDiscovery>` clone, so
360 /// dropping the controller's Arc on `apply(false)` never reaches refcount
361 /// zero and the loop would otherwise keep issuing authenticated requests
362 /// to peers forever — leaking one orphaned task per disable/enable cycle.
363 agent_names_cancel: Mutex<Option<oneshot::Sender<()>>>,
364 /// Cancels `instance_record_loop`. Same contract as `udp_cancel`.
365 instance_record_cancel: Mutex<Option<oneshot::Sender<()>>>,
366 /// Where this instance's `lan_instances` record is written; `None` when
367 /// there is nowhere to write it (see `lan_instances::instances_dir`).
368 instances_dir: Option<std::path::PathBuf>,
369 /// `true` while the record may be (re)written; cleared for good by
370 /// `withdraw_instance_record`.
371 instance_record_live: Mutex<bool>,
372 /// IPv4 addresses `mdns-sd` reported announcing our service on, from its
373 /// monitor channel. An interface whose socket failed to bind never appears
374 /// here (see `lan_mdns_health`). Filled by `monitor_loop`.
375 announced_v4: Arc<Mutex<BTreeSet<Ipv4Addr>>>,
376 /// Whether the monitor channel was obtained. Without it `announced_v4` never
377 /// fills, so `health()` has no basis for a verdict and stays `Pending`.
378 monitored: bool,
379 /// When this daemon was built, for the start-up grace in `health()`.
380 started_at: std::time::Instant,
381}
382
383/// Normalize an OS hostname into a valid mDNS host name by appending the
384/// `.local.` suffix that mdns-sd's `ServiceInfo::new` requires. Idempotent
385/// — already-normalized inputs pass through unchanged. We also strip any
386/// trailing dot first so `"foo.local"` doesn't end up as `"foo.local..local."`.
387fn mdns_hostname(os_hostname: &str) -> String {
388 let trimmed = os_hostname.trim_end_matches('.');
389 if trimmed.ends_with(".local") {
390 format!("{trimmed}.")
391 } else {
392 format!("{trimmed}.local.")
393 }
394}
395
396/// Build this instance's DNS-SD *instance name* — the first label of the
397/// service fullname. Must be BOTH unique per running instance AND free of
398/// dots.
399///
400/// Both properties were violated before 2026-09-06, and together they broke
401/// LAN discovery outright between two hosts on the same build:
402///
403/// - **Not unique.** The name was `format!("agentmux-{instance_id}")`, and
404/// `instance_id` is the *version* (`config.rs`'s `args.instance`, which the
405/// launcher sets to e.g. `v0.55.37`). Every host on the same release
406/// therefore registered the identical name, and `handle_event`'s self-skip —
407/// which compares incoming fullnames against our own — discarded genuine
408/// peers as if they were us.
409/// - **Not a single label.** `v0.55.37` contains dots. In DNS-SD the instance
410/// name is ONE label and dots must be escaped; nothing escaped them, so
411/// mdns-sd split at the first dot, treated `agentmux-v0` as the label and
412/// `.55.37` as part of the service type. Observed verbatim in a peer's log
413/// as `agentmux-v0 (2).55.37._agentmux._tcp.local.` — note the conflict
414/// suffix landing mid-version-string.
415///
416/// The two compounded: the collision triggered mdns-sd's conflict-renaming,
417/// and because `service_fullname` was captured *before* `register()` (fixed
418/// alongside this), the renamed host kept a stale self-name that exactly
419/// matched the OTHER host's real name — so the renamed host silently dropped
420/// every announcement from its peer, permanently, with no self-heal. That is
421/// why discovery was observed to work in one direction only.
422///
423/// `hostname` + `port` is unique per instance (hostname per machine, port per
424/// srv on that machine) and carries no version, which is deliberate: a version
425/// does not belong in an identity key, and it was the version being in there
426/// that created the cross-host collision. Any character that isn't
427/// alphanumeric, `-` or `_` is replaced, so a FQDN hostname
428/// (`box.corp.example`) or an exotic one can't reintroduce the dot bug.
429fn mdns_instance_label(hostname: &str, port: u16) -> String {
430 let sanitized: String = hostname
431 .trim_end_matches('.')
432 .chars()
433 .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
434 .collect();
435 // An empty/all-punctuation hostname would otherwise yield `agentmux--<port>`;
436 // the port alone still disambiguates instances on this host, and remote
437 // peers are disambiguated by address.
438 let host_part = if sanitized.is_empty() { "unknown".to_string() } else { sanitized };
439 // A DNS label is capped at 63 bytes. "agentmux-" (9) + "-" + a 5-digit port
440 // (6) leaves 48; truncate to 45 for headroom. Windows caps hostnames at 15
441 // but Linux allows 64, so this is reachable in practice, and an
442 // over-length label would be rejected or silently mangled rather than
443 // failing loudly [Manpo review, PR #3048].
444 let host_part: String = host_part.chars().take(45).collect();
445 // Residual collision: two hosts sharing BOTH hostname and port — e.g. two
446 // fresh VM clones that are each "ubuntu" — still produce the same label and
447 // will be conflict-renamed. That no longer blinds either host (the
448 // self-check requires port+address agreement, see `handle_event`), but the
449 // two do collapse into one peer-map entry with a flapping address. A
450 // persisted per-install random suffix would close it for good; not done
451 // here because it needs somewhere durable to live.
452 format!("agentmux-{host_part}-{port}")
453}
454
455impl LanDiscovery {
456 /// Start LAN discovery: register this instance and browse for peers.
457 pub fn start(
458 instance_id: String,
459 hostname: String,
460 version: String,
461 port: u16,
462 auth_key: String,
463 event_bus: Arc<EventBus>,
464 ) -> Result<Arc<Self>, String> {
465 let daemon = ServiceDaemon::new().map_err(|e| format!("mDNS daemon failed: {e}"))?;
466 // Taken before `register` so the registration's own announcement is not
467 // missed. Not fatal if unavailable: health then stays `Pending` and the
468 // watchdog never acts, which is the pre-existing behaviour.
469 let monitor = match daemon.monitor() {
470 Ok(rx) => Some(rx),
471 Err(e) => {
472 tracing::warn!("mDNS monitor unavailable, discoverability cannot be checked: {e}");
473 None
474 }
475 };
476 let announced_v4: Arc<Mutex<BTreeSet<Ipv4Addr>>> = Arc::new(Mutex::new(BTreeSet::new()));
477
478 // Register this instance. mdns-sd requires the host name passed to
479 // `ServiceInfo::new` to end with `.local.` — we always normalize so
480 // a raw OS hostname like "narko" becomes "narko.local.".
481 // Unique per INSTANCE and dot-free — see `mdns_instance_label`. The
482 // version still travels in the TXT record (`instance_id` below), which
483 // is where peers actually read it from; it just must not be the
484 // identity key.
485 let service_name = mdns_instance_label(&hostname, port);
486 let host_name_mdns = mdns_hostname(&hostname);
487 let channel = crate::backend::reactive::registry::local_channel_id();
488 let os = crate::backend::host_os::local_os();
489 // Not `channels_running`: it changes, and a TXT update is costly; a
490 // peer reads it from the fleet feed instead.
491 let install_id = crate::backend::fleet_source::install_id();
492 let mut properties = vec![
493 ("version", version.as_str()),
494 ("hostname", hostname.as_str()),
495 ("instance_id", instance_id.as_str()),
496 ("channel", channel.as_str()),
497 ("os", os.as_str()),
498 ("auth_key", auth_key.as_str()),
499 ];
500 if let Some(id) = install_id.as_deref() {
501 properties.push(("install_id", id));
502 }
503 // `""` alone does NOT mean "auto-detect" despite how that reads —
504 // `AsIpAddrs for &str`'s own doc comment: "If the string is empty,
505 // will return an empty set." Auto-detection is a SEPARATE opt-in:
506 // `.enable_addr_auto()` tells the daemon to fill in (and keep
507 // updated) this service's addresses from the host's own interfaces.
508 // Without it, a service registered via `ServiceInfo::new(..., "",
509 // ...)` has zero addresses and `addr_auto: false` (the struct's
510 // hardcoded default) — `register()` still returns `Ok(())` (it just
511 // enqueues a `Command::Register`, no synchronous validation), so
512 // this silently produced a service that was never actually
513 // discoverable by ANY peer, not even another mDNS client on the same
514 // host. Caught by `a_registered_instance_is_discoverable_by_an_
515 // independent_mdns_client` below.
516 let service_info = ServiceInfo::new(
517 SERVICE_TYPE,
518 &service_name,
519 &host_name_mdns,
520 "",
521 port,
522 &properties[..],
523 )
524 .map_err(|e| format!("ServiceInfo creation failed: {e}"))?
525 .enable_addr_auto();
526
527 let service_fullname = service_info.get_fullname().to_string();
528
529 daemon
530 .register(service_info)
531 .map_err(|e| format!("mDNS register failed: {e}"))?;
532
533 // Log the name we registered under. Before this, an affected host was
534 // mute: the ONLY site that logged a fullname was the `ServiceRemoved`
535 // branch, so a host that discovered nothing (the exact failure this
536 // commit fixes) could not self-report its own service name at all, and
537 // the bug had to be diagnosed from the *peer's* log. `service_fullname`
538 // is the pre-register name — if mdns-sd ever conflict-renames us it
539 // will differ from what is actually on the wire, which is itself worth
540 // seeing, and is why the self-check below no longer trusts it alone.
541 tracing::info!(
542 service_fullname = %service_fullname,
543 instance_id = %instance_id,
544 port,
545 "LAN discovery registered mDNS service"
546 );
547
548 // Browse for peers — keep the receiver for the event loop
549 let browse_receiver = daemon
550 .browse(SERVICE_TYPE)
551 .map_err(|e| format!("mDNS browse failed: {e}"))?;
552
553 let instances = Arc::new(RwLock::new(HashMap::new()));
554
555 let discovery = Arc::new(Self {
556 daemon,
557 instances: instances.clone(),
558 instance_id: instance_id.clone(),
559 event_bus: event_bus.clone(),
560 service_fullname,
561 auth_key,
562 hostname,
563 version,
564 port,
565 udp_cancel: Mutex::new(None),
566 udp_peer_cancel: Mutex::new(None),
567 agent_names_cancel: Mutex::new(None),
568 instance_record_cancel: Mutex::new(None),
569 instances_dir: lan_instances::instances_dir(),
570 instance_record_live: Mutex::new(true),
571 announced_v4: announced_v4.clone(),
572 monitored: monitor.is_some(),
573 started_at: std::time::Instant::now(),
574 });
575
576 if let Some(rx) = monitor {
577 tokio::task::spawn_blocking(move || monitor_loop(rx, announced_v4));
578 }
579
580 // Spawn event receiver on a blocking thread to avoid starving the tokio runtime
581 let disc = discovery.clone();
582 tokio::task::spawn_blocking(move || {
583 disc.event_loop(browse_receiver);
584 });
585
586 // Spawn the UDP broadcast-probe responder (Layer 2 fallback for
587 // filtered mDNS). `UdpSocket::recv_from` is natively async, so this
588 // uses `tokio::spawn` (not `spawn_blocking`, unlike the mdns-sd event
589 // loop above whose receiver is a sync channel).
590 let (cancel_tx, cancel_rx) = oneshot::channel();
591 *discovery.udp_cancel.lock() = Some(cancel_tx);
592 let disc_udp = discovery.clone();
593 tokio::spawn(async move {
594 disc_udp.udp_responder_loop(cancel_rx).await;
595 });
596
597 // Desktop-to-desktop discovery that does not depend on mDNS: probes the
598 // LAN by broadcast and records who answers (`udp_peers`). Started
599 // whenever LAN discovery is, so a host whose mDNS is blocked or
600 // contested still finds, and is found by, its peers.
601 let (peer_cancel_tx, peer_cancel_rx) = oneshot::channel();
602 *discovery.udp_peer_cancel.lock() = Some(peer_cancel_tx);
603 tokio::spawn(discovery.clone().udp_peer_loop(peer_cancel_rx));
604
605 // Keeps each peer's `agents` list populated (see
606 // `agent_names_refresh_loop`). Cancelled explicitly by `shutdown()`
607 // via its own oneshot — NOT by Arc drop, since this task holds its
608 // own clone and would otherwise outlive `apply(false)` forever.
609 let (names_cancel_tx, names_cancel_rx) = oneshot::channel();
610 *discovery.agent_names_cancel.lock() = Some(names_cancel_tx);
611 tokio::spawn(
612 discovery
613 .clone()
614 .agent_names_refresh_loop(names_cancel_rx),
615 );
616
617 // Lists this instance as a sibling for whichever channel on this host
618 // holds the UDP probe port (`lan_instances`). Written now, then kept
619 // fresh; `shutdown()` withdraws it.
620 discovery.publish_instance_record();
621 let (record_cancel_tx, record_cancel_rx) = oneshot::channel();
622 *discovery.instance_record_cancel.lock() = Some(record_cancel_tx);
623 tokio::spawn(discovery.clone().instance_record_loop(record_cancel_rx));
624
625 tracing::info!(
626 instance_id = %instance_id,
627 port = port,
628 "LAN discovery started (mDNS)"
629 );
630
631 Ok(discovery)
632 }
633
634 /// Periodically ask every known peer which agents it hosts, so
635 /// `LanInstance::agents` reflects reality instead of staying the empty
636 /// vec it's created with. Without this, a peer shows up with a correct
637 /// hostname/version/address but `agents: []`, so there is no way to
638 /// discover *which* agents live on another machine — you can only
639 /// address one blind by name and hope. See
640 /// `handle_reactive_agent_names`' doc comment for the security scoping
641 /// of the endpoint this calls (names only, `lan_key`-readable).
642 ///
643 /// Failures are deliberately non-destructive: a peer that's briefly
644 /// unreachable keeps its last-known list rather than being blanked, since
645 /// staleness is already tracked by `last_seen` and a flapping list is
646 /// worse than a slightly-old one.
647 async fn agent_names_refresh_loop(self: Arc<Self>, mut cancel: oneshot::Receiver<()>) {
648 let http = match reqwest::Client::builder()
649 .timeout(std::time::Duration::from_secs(LAN_PEER_QUERY_TIMEOUT_SECS))
650 .build()
651 {
652 Ok(c) => c,
653 Err(e) => {
654 tracing::warn!(error = %e, "LAN agent-name refresh disabled: HTTP client build failed");
655 return;
656 }
657 };
658
659 loop {
660 // Cancellation is checked at the sleep, not just between
661 // iterations: this task holds its own `Arc<LanDiscovery>`, so
662 // without an explicit signal `apply(false)` cannot stop it and it
663 // would keep hitting peers indefinitely (one leaked task per
664 // toggle cycle).
665 tokio::select! {
666 _ = tokio::time::sleep(std::time::Duration::from_secs(
667 LAN_AGENT_NAMES_REFRESH_SECS,
668 )) => {}
669 _ = &mut cancel => {
670 tracing::debug!("LAN agent-name refresh loop cancelled");
671 return;
672 }
673 }
674
675 // Garbage-collect entries `get_instances()` would already hide as
676 // stale. This is the only place `self.instances` actually shrinks
677 // now that `ServiceRemoved` no longer deletes (see
678 // `peer_staleness_window_secs`) — without it a genuinely departed
679 // peer would sit in the map forever and this very loop would keep
680 // polling it every cycle.
681 let any_pruned = {
682 let now = agentmux_common::time::now_secs_u64();
683 let mut instances = self.instances.write();
684 let before = instances.len();
685 instances.retain(|_, inst| {
686 now.saturating_sub(inst.last_seen) <= peer_staleness_window_secs(inst.other_ttl_secs)
687 });
688 instances.len() != before
689 };
690 // A connected frontend only updates `lanInstancesAtom` on a
691 // `laninstances` WS event — without this, a peer pruned here
692 // would keep showing in an already-open client until some
693 // unrelated ServiceResolved happened to fire a broadcast
694 // [Codex P1 on PR #3245].
695 if any_pruned {
696 self.broadcast_instances();
697 }
698
699 // Snapshot (key, url, lan_key) so the lock is not held across any
700 // await — the map is read by every inject/discovery call.
701 let targets: Vec<(String, String, String)> = {
702 let instances = self.instances.read();
703 instances
704 .iter()
705 .map(|(key, inst)| {
706 (
707 key.clone(),
708 format!("http://{}:{}", inst.address, inst.port),
709 inst.auth_key.clone(),
710 )
711 })
712 .collect()
713 };
714
715 // Fan out concurrently, same pattern as `query_peers_concurrently`
716 // — sequential awaits here would let N stale/unreachable peers
717 // each burn their full LAN_PEER_QUERY_TIMEOUT_SECS in turn
718 // (Codex P2 on PR #3102), stretching the nominal 30s refresh
719 // interval by however many peers are currently slow to answer.
720 let outcomes: Vec<(String, Option<PeerAgents>)> = {
721 use futures_util::StreamExt as _;
722 let http_ref = &http;
723 let mut inflight = futures_util::stream::FuturesUnordered::new();
724 for (key, base_url, lan_key) in &targets {
725 inflight.push(async move {
726 (
727 key.clone(),
728 Self::fetch_peer_agent_names(http_ref, base_url, lan_key).await,
729 )
730 });
731 }
732 let mut out = Vec::with_capacity(targets.len());
733 while let Some(item) = inflight.next().await {
734 out.push(item);
735 }
736 out
737 };
738
739 // Only broadcast `laninstances` when something actually changed
740 // (Codex P2 on PR #3102) — otherwise the display-oriented point
741 // of this loop is defeated: without this, a refresh that DOES
742 // find new names still leaves connected clients showing the
743 // stale `agents: []` from first mDNS resolution until some
744 // unrelated, unsynchronized mDNS event happens to fire.
745 let mut changed = false;
746 {
747 let mut instances = self.instances.write();
748 for (key, peer) in outcomes {
749 if let Some(entry) = instances.get_mut(&key) {
750 let now = agentmux_common::time::now_secs_u64();
751 changed |= agent_names::apply_agent_names_poll(entry, peer, now);
752 }
753 }
754 }
755 if changed {
756 self.broadcast_instances();
757 }
758 }
759 }
760
761 /// One peer's contribution to `agent_names_refresh_loop`. Returns `None`
762 /// on any failure (unreachable, non-2xx, oversized, or malformed body) —
763 /// this list is display-only, so a bad peer response should leave the
764 /// last-known list in place rather than blank it (see the loop's own
765 /// "non-destructive" framing).
766 async fn fetch_peer_agent_names(
767 http: &reqwest::Client,
768 base_url: &str,
769 lan_key: &str,
770 ) -> Option<PeerAgents> {
771 let url = format!("{base_url}/agentmux/reactive/agent-names");
772 let resp = match http.get(&url).header(agentmux_common::AUTH_KEY_HEADER, lan_key).send().await {
773 Ok(r) => r,
774 Err(e) => {
775 tracing::debug!(peer = %base_url, error = %e, "LAN agent-name refresh: unreachable");
776 return None;
777 }
778 };
779 if !resp.status().is_success() {
780 // A peer running a build without this route answers 404 —
781 // expected during a rolling upgrade, not an error.
782 tracing::debug!(peer = %base_url, status = %resp.status(), "LAN agent-name refresh: non-2xx");
783 return None;
784 }
785
786 let body = match Self::read_body_capped(resp, LAN_AGENT_NAMES_MAX_RESPONSE_BYTES).await {
787 Ok(b) => b,
788 Err(e) => {
789 tracing::warn!(peer = %base_url, error = %e, "LAN agent-name refresh: response too large, discarded");
790 return None;
791 }
792 };
793
794 let value: serde_json::Value = match serde_json::from_slice(&body) {
795 Ok(v) => v,
796 Err(e) => {
797 tracing::debug!(peer = %base_url, error = %e, "LAN agent-name refresh: bad JSON");
798 return None;
799 }
800 };
801
802 Some(parse_agent_names_body(&value))
803 }
804
805 /// Reads an HTTP response body up to `max_bytes`, erroring out as soon as
806 /// the cap is exceeded rather than buffering the full body first —
807 /// `Response::bytes()`/`.json()` have no size limit of their own, so a
808 /// peer that responds successfully but with an enormous body would
809 /// otherwise be read into memory in full before this code ever gets a
810 /// chance to reject it.
811 pub(crate) async fn read_body_capped(
812 mut resp: reqwest::Response,
813 max_bytes: usize,
814 ) -> Result<Vec<u8>, String> {
815 let mut buf = Vec::new();
816 while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
817 buf.extend_from_slice(&chunk);
818 if buf.len() > max_bytes {
819 return Err(format!("response exceeded {max_bytes}-byte cap"));
820 }
821 }
822 Ok(buf)
823 }
824
825 /// Build the JSON identity payload for this instance — see
826 /// `probe_response_json` for the pure field-assembly logic shared with
827 /// tests. Desktop peers get this (`udp_peers`); phones get
828 /// [`Self::build_probe_response`].
829 fn build_identity_response(&self) -> serde_json::Value {
830 self.identity_response_with(
831 crate::backend::fleet_source::install_id().as_deref(),
832 crate::backend::fleet_source::latest_channels_running(),
833 crate::backend::viewer::advertised_viewer_port(),
834 )
835 }
836
837 /// [`Self::build_identity_response`] with the install id, channel count
838 /// and viewer port given, so a test needs no WAN identity or fleet feed.
839 fn identity_response_with(
840 &self,
841 install_id: Option<&str>,
842 channels_running: Option<u32>,
843 viewer_port: Option<u16>,
844 ) -> serde_json::Value {
845 let mut response = probe_response_json(
846 &self.instance_id,
847 &self.hostname,
848 &self.version,
849 self.port,
850 &self.auth_key,
851 );
852 // The channel, as the TXT record carries it, so a desktop that finds
853 // this instance over UDP can tell two channels on one host apart. An
854 // extra field: older parsers (mobile included) ignore it.
855 response["channel"] = json!(crate::backend::reactive::registry::local_channel_id());
856 response["os"] = json!(crate::backend::host_os::local_os());
857 // What a phone merges this install with its cloud record by, and how
858 // many channels this machine runs, shared on the LAN or not
859 // (agentmux-mobile's SPEC_FLEET_HOST_TAGS_AND_CLOUD_HOSTS_2026_10_06
860 // §5.3). Both only when known.
861 if let Some(id) = install_id {
862 response["install_id"] = json!(id);
863 }
864 if let Some(n) = channels_running {
865 response["channels_running"] = json!(n);
866 }
867 // The viewer listener's port while it is up, so a paired device finds
868 // it again after an address change (agentmux-mobile's
869 // SPEC_AGENT_STATUS_AND_LIVE_PANE_FEED_2026_10_07 §13.2).
870 if let Some(port) = viewer_port {
871 response["viewer_port"] = json!(port);
872 }
873 response
874 }
875
876 /// The 47891 reply: the identity payload plus `siblings`, the other
877 /// LAN-enabled channels on this host (`lan_instances`), kept within one
878 /// datagram (`lan_instances::MAX_REPLY_BYTES`). Each sibling carries its
879 /// own `lan_key`, which it already broadcasts over mDNS.
880 fn build_probe_response(&self) -> serde_json::Value {
881 self.probe_response_with(
882 crate::backend::fleet_source::install_id().as_deref(),
883 crate::backend::fleet_source::latest_channels_running(),
884 crate::backend::viewer::advertised_viewer_port(),
885 )
886 }
887
888 fn probe_response_with(
889 &self,
890 install_id: Option<&str>,
891 channels_running: Option<u32>,
892 viewer_port: Option<u16>,
893 ) -> serde_json::Value {
894 let mut response = self.identity_response_with(install_id, channels_running, viewer_port);
895 lan_instances::attach_siblings(&mut response, &self.sibling_entries(), lan_instances::MAX_REPLY_BYTES);
896 response
897 }
898
899 /// UDP broadcast-probe responder loop (Layer 2 discovery fallback).
900 ///
901 /// Binds `0.0.0.0:UDP_DISCOVERY_PORT` and answers valid probes from
902 /// private/link-local source addresses (see `is_lan_source`) with a
903 /// unicast response back to the sender's `recv_from` source address.
904 /// Deliberately does NOT set `SO_REUSEADDR`: this codebase supports
905 /// running multiple AgentMux instances on one host simultaneously, and
906 /// on Windows `SO_REUSEADDR` lets a second process silently steal a UDP
907 /// port already owned by another — an inappropriate risk for a socket
908 /// that hands out `auth_key`. If the bind fails (most likely because
909 /// another local instance already holds the port), this task retries
910 /// every `UDP_RESPONDER_RETRY` until it gets the port or is cancelled, so
911 /// the next LAN-enabled channel takes over when the holder stops. mDNS
912 /// discovery (already running via `event_loop`) is unaffected either way,
913 /// matching the "never fail `start()` over this" contract.
914 ///
915 /// We do not call `set_broadcast(true)`: that flag is only required to
916 /// *send* to a broadcast address, and this socket only receives (probes
917 /// arrive as broadcast/subnet-broadcast datagrams addressed to us, which
918 /// requires no special socket option on the receiving end) and replies
919 /// with a plain unicast send back to the probe's source address.
920 async fn udp_responder_loop(self: Arc<Self>, cancel_rx: oneshot::Receiver<()>) {
921 self.udp_responder_loop_on(UDP_DISCOVERY_PORT, UDP_RESPONDER_RETRY, cancel_rx)
922 .await;
923 }
924
925 /// [`Self::udp_responder_loop`] on a given port and retry interval, so a
926 /// test can drive the takeover without the production port.
927 async fn udp_responder_loop_on(
928 self: Arc<Self>,
929 port: u16,
930 retry_every: std::time::Duration,
931 mut cancel_rx: oneshot::Receiver<()>,
932 ) {
933 let mut retrying = false;
934 let socket = loop {
935 match UdpSocket::bind(("0.0.0.0", port)).await {
936 Ok(s) => break s,
937 Err(e) => {
938 // Logged once, not per attempt: a second channel on this
939 // host retries for as long as the holder runs.
940 if !retrying {
941 tracing::debug!(
942 port,
943 "UDP discovery responder waiting for the port (bind failed, likely \
944 another local instance holds it); retrying every {}s: {e}",
945 retry_every.as_secs()
946 );
947 retrying = true;
948 }
949 tokio::select! {
950 _ = tokio::time::sleep(retry_every) => {}
951 _ = &mut cancel_rx => {
952 tracing::debug!("UDP discovery responder stopping before it got the port");
953 return;
954 }
955 }
956 }
957 }
958 };
959
960 if retrying {
961 tracing::info!(port, "UDP discovery responder took over the probe port");
962 } else {
963 tracing::debug!(port, "UDP discovery responder listening");
964 }
965
966 let mut buf = [0u8; 1024];
967 loop {
968 tokio::select! {
969 _ = &mut cancel_rx => {
970 tracing::debug!("UDP discovery responder stopping");
971 break;
972 }
973 result = socket.recv_from(&mut buf) => {
974 match result {
975 Ok((len, src)) => {
976 // Noise (unrelated LAN UDP traffic, malformed
977 // packets) is expected on a live network — do
978 // not log per-packet above debug.
979 if !is_valid_probe(&buf[..len]) {
980 continue;
981 }
982 // Trust boundary: never hand out auth_key to a
983 // source address outside the private/link-local
984 // ranges, even if the packet is a well-formed
985 // probe (see is_lan_source doc comment).
986 if !is_lan_source(&src) {
987 tracing::debug!(
988 src = %src,
989 "UDP discovery probe from non-LAN source, ignoring"
990 );
991 continue;
992 }
993 tracing::debug!(src = %src, "UDP discovery probe received");
994 let response = self.build_probe_response();
995 match serde_json::to_vec(&response) {
996 Ok(payload) => {
997 if let Err(e) = socket.send_to(&payload, src).await {
998 tracing::debug!("UDP discovery response send failed: {e}");
999 } else {
1000 tracing::debug!(src = %src, "UDP discovery probe answered");
1001 }
1002 }
1003 Err(e) => {
1004 tracing::debug!("UDP discovery response serialize failed: {e}");
1005 }
1006 }
1007 }
1008 Err(e) => {
1009 // A persistent OS-level error (e.g. Windows
1010 // WSAECONNRESET/10054 from an ICMP
1011 // port-unreachable triggered by our own prior
1012 // send_to) would otherwise spin this loop at
1013 // full CPU with no delay between retries.
1014 tracing::debug!("UDP discovery recv_from error: {e}");
1015 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1016 }
1017 }
1018 }
1019 }
1020 }
1021 }
1022
1023 fn event_loop(&self, receiver: mdns_sd::Receiver<ServiceEvent>) {
1024 loop {
1025 match receiver.recv() {
1026 Ok(event) => self.handle_event(event),
1027 Err(_) => {
1028 tracing::warn!("mDNS event receiver closed");
1029 break;
1030 }
1031 }
1032 }
1033 }
1034
1035 /// True when a resolved service is really this instance, reached over one
1036 /// of this host's own addresses.
1037 ///
1038 /// Both conditions are required. The port alone is far too weak (two hosts
1039 /// can land on the same ephemeral port), and an address alone is too weak
1040 /// while several AgentMux instances share a machine — but no remote peer
1041 /// can be listening on OUR port at an address that is OURS.
1042 fn resolves_to_this_instance(&self, info: &ServiceInfo) -> bool {
1043 // Port first, and BEFORE touching the address set. A resolution on a
1044 // different port cannot be us, and that is the overwhelmingly common
1045 // case (every genuine remote peer), so it must not pay for an address
1046 // lookup. Passing the set as an argument would defeat this — arguments
1047 // are evaluated eagerly, so the cheap check inside `is_self_resolution`
1048 // would come too late [reagent #3025 P2].
1049 if info.get_port() != self.port {
1050 return false;
1051 }
1052 let addrs: Vec<IpAddr> = info.get_addresses().iter().copied().collect();
1053 is_self_resolution(
1054 info.get_port(),
1055 &addrs,
1056 self.port,
1057 &crate::backend::lan_listeners::cached_local_addresses(),
1058 )
1059 }
1060
1061 fn handle_event(&self, event: ServiceEvent) {
1062 match event {
1063 ServiceEvent::ServiceResolved(info) => {
1064 let fullname = info.get_fullname().to_string();
1065
1066 // Skip self. Compared by `fullname` (deterministic from
1067 // `service_name`, set once at registration — see `start()`)
1068 // rather than the TXT-record `instance_id` property: on this
1069 // machine's own virtual/link-local interfaces (WSL/Hyper-V/
1070 // VPN adapters), `enable_addr_auto()` re-fires
1071 // `ServiceResolved` far more often than fresh TXT data
1072 // arrives — most of those events resolve with an empty TXT
1073 // record (confirmed via `LAN peer discovered` log: ~96% of
1074 // events for this instance's own addresses logged
1075 // `peer_id=""`). Comparing on `instance_id` alone meant a
1076 // blank-TXT self-resolution was never recognized as self and
1077 // was inserted as a phantom peer that could never self-heal
1078 // (see the instance_id-preservation fix below for why).
1079 // THE self-check. One test, and it is the only one: a service
1080 // on OUR port at an address belonging to THIS machine is us. A
1081 // genuine remote peer cannot satisfy both — it would have to be
1082 // reachable at one of our own interface addresses.
1083 //
1084 // There used to be a second, independent check above this one
1085 // comparing the resolved `fullname` against our own. It is gone
1086 // as of 2026-09-06, because it was actively harmful and,
1087 // once this check existed, entirely redundant (any event a
1088 // fullname match could legitimately skip is one this check
1089 // already skips — reagent P2 on PR #3048 caught that keeping it
1090 // as a conjunct left dead code that also bypassed the debug log
1091 // below).
1092 //
1093 // Why it was harmful, and the answer to a question the previous
1094 // revision of this comment recorded as unexplained ("Why the
1095 // fullname check misses them is not established — plausibly
1096 // mdns-sd conflict-renaming"): it WAS conflict-renaming. The
1097 // mDNS instance label used to be `agentmux-{instance_id}` where
1098 // `instance_id` is the VERSION, so every host on a release
1099 // registered the same name AND that name contained dots, which
1100 // is malformed for a single DNS-SD label. mdns-sd split it at
1101 // the first dot and conflict-renamed the loser
1102 // ("agentmux-v0 (2).55.37._agentmux._tcp.local."). Two
1103 // consequences: a genuine peer's fullname could equal ours
1104 // exactly, and on a renamed host `service_fullname` — captured
1105 // before `register()` — went stale and matched the peer that
1106 // WON the name. Either way that host silently dropped every
1107 // announcement from its peer, forever, reporting `lan: []`
1108 // while the peer saw it fine. `mdns_instance_label` now makes
1109 // the collision impossible; deleting the check makes the
1110 // consequence impossible even if a future naming scheme
1111 // regresses.
1112 //
1113 // The blank-TXT self-resolution storm the fullname check was
1114 // originally added for is still caught here: live logs showed
1115 // ~96% of resolution events for this instance's own
1116 // virtual/link-local addresses carrying an empty TXT record
1117 // (`peer_id=""`), all on this instance's own port at its own
1118 // addresses — so this check catches them on its own, which is
1119 // what made the fullname comparison redundant rather than
1120 // load-bearing.
1121 //
1122 // Cost of getting it wrong, and why it is worth being careful:
1123 // `find_agent` fans out concurrently to every known peer, so a
1124 // phantom self-entry spent a request per lookup asking
1125 // ourselves a question we had already answered locally.
1126 if self.resolves_to_this_instance(&info) {
1127 tracing::debug!(
1128 address = %info.get_addresses().iter().next().map(|a| a.to_string()).unwrap_or_default(),
1129 port = info.get_port(),
1130 "skipping mDNS resolution of this instance's own service"
1131 );
1132 return;
1133 }
1134
1135 let peer_id = info
1136 .get_property_val_str("instance_id")
1137 .unwrap_or_default()
1138 .to_string();
1139
1140 let now = agentmux_common::time::now_secs_u64();
1141
1142 let address = info
1143 .get_addresses()
1144 .iter()
1145 .find(|a| matches!(a, IpAddr::V4(_)))
1146 .or_else(|| info.get_addresses().iter().next())
1147 .map(|a| a.to_string())
1148 .unwrap_or_default();
1149
1150 let hostname = info
1151 .get_property_val_str("hostname")
1152 .unwrap_or_default()
1153 .to_string();
1154 let version = info
1155 .get_property_val_str("version")
1156 .unwrap_or_default()
1157 .to_string();
1158 let auth_key = info
1159 .get_property_val_str("auth_key")
1160 .unwrap_or_default()
1161 .to_string();
1162 let channel = info
1163 .get_property_val_str("channel")
1164 .unwrap_or_default()
1165 .to_string();
1166 // Self-reported by the peer, so only a plain token is kept.
1167 let os = info
1168 .get_property_val_str("os")
1169 .and_then(crate::backend::host_os::sanitize_os)
1170 .unwrap_or_default();
1171 let other_ttl_secs = info.get_other_ttl();
1172
1173 let mut instances = self.instances.write();
1174 // Cloned for the log line below: `entry()` takes ownership of
1175 // the key, and the peer's service name is the field that makes
1176 // that line correlatable with the sender's own registration.
1177 let fullname_for_log = fullname.clone();
1178 let entry = instances.entry(fullname).or_insert_with(|| LanInstance {
1179 instance_id: peer_id.clone(),
1180 hostname: hostname.clone(),
1181 version: version.clone(),
1182 channel: channel.clone(),
1183 os: os.clone(),
1184 address: address.clone(),
1185 port: info.get_port(),
1186 auth_key: auth_key.clone(),
1187 agents: Vec::new(),
1188 agent_status: Default::default(),
1189 agent_lines: Default::default(),
1190 first_seen: now,
1191 last_seen: now,
1192 last_polled_ok: 0,
1193 other_ttl_secs,
1194 });
1195 entry.last_seen = now;
1196 entry.address = address;
1197 entry.port = info.get_port();
1198 // A record TTL, not TXT content — always present on a real
1199 // resolution (unlike hostname/version/auth_key below), so it's
1200 // safe to overwrite unconditionally the same as address/port.
1201 entry.other_ttl_secs = other_ttl_secs;
1202 // TXT-derived fields only: `enable_addr_auto()` re-fires
1203 // `ServiceResolved` on every interface/address change, and
1204 // most of those re-fires resolve with an empty TXT record
1205 // (see the self-skip comment above) — apply a value only
1206 // when this event actually carried one, so a stale re-fire
1207 // never erases previously-known-good peer identity. Address/
1208 // port come from the SRV/A record, not TXT, and are always
1209 // populated, so they're safe to overwrite unconditionally.
1210 if !peer_id.is_empty() {
1211 entry.instance_id = peer_id.clone();
1212 }
1213 if !hostname.is_empty() {
1214 entry.hostname = hostname;
1215 }
1216 if !version.is_empty() {
1217 entry.version = version;
1218 }
1219 if !auth_key.is_empty() {
1220 entry.auth_key = auth_key;
1221 }
1222 if !channel.is_empty() {
1223 entry.channel = channel;
1224 }
1225 if !os.is_empty() {
1226 entry.os = os;
1227 }
1228 // The UDP route may have found this peer first; one entry per
1229 // peer, the mDNS one (udp_peers::merge_udp_peer is the converse).
1230 udp_peers::drop_udp_duplicates(&mut instances);
1231 drop(instances);
1232
1233 tracing::info!(
1234 peer_id = %peer_id,
1235 // The peer's own service name. Without it this line could
1236 // not be correlated with the sender's registration, which
1237 // is what made the 2026-09-06 collision take two hosts and
1238 // an ssh session to pin down [Manpo review, PR #3048].
1239 fullname = %fullname_for_log,
1240 address = %info.get_addresses().iter().next().map(|a| a.to_string()).unwrap_or_default(),
1241 port = info.get_port(),
1242 "LAN peer discovered"
1243 );
1244
1245 self.broadcast_instances();
1246 }
1247 ServiceEvent::ServiceRemoved(_, fullname) => {
1248 // Deliberately NOT deleted from `self.instances` here (see
1249 // `peer_staleness_window_secs`): a real departure and ordinary
1250 // TTL/interface churn look identical on the wire, and removing
1251 // throws away last-known-good TXT fields that a post-removal
1252 // re-resolution (almost always blank-TXT) can never restore.
1253 // `get_instances()` prunes on `last_seen` age instead, which
1254 // only advances on an actual `ServiceResolved`.
1255 tracing::debug!(
1256 fullname = %fullname,
1257 "mDNS ServiceRemoved received (not acted on; peer expires via last_seen staleness instead)"
1258 );
1259 }
1260 _ => {}
1261 }
1262 }
1263
1264 fn broadcast_instances(&self) {
1265 // Via `get_instances()`, not a raw map read, so a stale entry never
1266 // reaches the frontend over the live `laninstances` event when the
1267 // REST/RPC path (which calls the same method) would already hide it.
1268 let instances: Vec<LanInstance> = self.get_instances();
1269 self.event_bus.broadcast_event(&EventBus::typed_ws_event::<crate::backend::mps_events::LanInstances>(&LanInstanceList(instances), String::new()));
1270 }
1271
1272 /// Is this instance really announced on an IPv4 interface other machines
1273 /// can reach? `Pending` until the start-up grace has passed.
1274 pub fn health(&self) -> lan_mdns_health::MdnsHealth {
1275 // Cheap exits first: no monitor, or still inside the grace.
1276 if !self.monitored || self.started_at.elapsed() < lan_mdns_health::GRACE {
1277 return lan_mdns_health::MdnsHealth::Pending;
1278 }
1279 // Grouped by interface: `mdns-sd` announces on one address per
1280 // interface and IP version, so an interface with two IPv4 addresses is
1281 // covered by either one (ReAgent P2 on #4148).
1282 let local = if_addrs::get_if_addrs()
1283 .unwrap_or_default()
1284 .into_iter()
1285 .map(|i| (i.name.clone(), i.ip()));
1286 let expected = lan_mdns_health::expected_by_interface(local);
1287 let announced = self.announced_v4.lock().clone();
1288 lan_mdns_health::verdict(self.monitored, self.started_at.elapsed(), &expected, &announced)
1289 }
1290
1291 /// Get current list of discovered LAN peers (excludes self).
1292 pub fn get_instances(&self) -> Vec<LanInstance> {
1293 let now = agentmux_common::time::now_secs_u64();
1294 self.instances
1295 .read()
1296 .values()
1297 .filter(|inst| now.saturating_sub(inst.last_seen) <= peer_staleness_window_secs(inst.other_ttl_secs))
1298 .cloned()
1299 .collect()
1300 }
1301
1302 /// Get peer count (excludes self).
1303 #[allow(dead_code)]
1304 pub fn peer_count(&self) -> usize {
1305 self.instances.read().len()
1306 }
1307
1308 /// Stop the mDNS daemon and the UDP broadcast-probe responder.
1309 ///
1310 /// The mDNS side synchronously closes the daemon socket (UDP:5353),
1311 /// causing the `browse_receiver` to return Err and the event-loop thread
1312 /// spawned by `start()` to exit. The UDP responder is stopped by firing
1313 /// its cancellation channel, which the `tokio::select!` in
1314 /// `udp_responder_loop` is racing against `recv_from` — this is what
1315 /// makes a live setting toggle actually stop responding to probes, not
1316 /// just stop the mDNS advertisement.
1317 ///
1318 /// Required for live-disable to actually stop discovery: both background
1319 /// tasks hold their own `Arc<Self>` clone, so simply dropping the
1320 /// controller's Arc never reaches refcount zero and `Drop` does not
1321 /// run. Callers must invoke `shutdown()` before clearing their Arc.
1322 /// Idempotent — safe to call from both the explicit path and `Drop`.
1323 pub fn shutdown(&self) {
1324 if let Some(tx) = self.udp_cancel.lock().take() {
1325 // Ignore send errors: an `Err` here just means the responder
1326 // task already exited on its own (e.g. the bind failed), which
1327 // is a no-op we're happy with.
1328 let _ = tx.send(());
1329 }
1330 if let Some(tx) = self.udp_peer_cancel.lock().take() {
1331 let _ = tx.send(());
1332 }
1333 // Same contract as `udp_cancel` above — and load-bearing rather than
1334 // tidy-up: the refresh task owns an `Arc<LanDiscovery>` clone, so
1335 // without this signal it survives `apply(false)` and keeps issuing
1336 // authenticated requests to LAN peers indefinitely.
1337 if let Some(tx) = self.agent_names_cancel.lock().take() {
1338 let _ = tx.send(());
1339 }
1340 if let Some(tx) = self.instance_record_cancel.lock().take() {
1341 let _ = tx.send(());
1342 }
1343 self.withdraw_instance_record();
1344 if let Err(e) = self.daemon.unregister(&self.service_fullname) {
1345 // Likely already unregistered; do not warn loudly.
1346 tracing::debug!("mDNS unregister returned: {e}");
1347 }
1348 if let Err(e) = self.daemon.shutdown() {
1349 tracing::debug!("mDNS daemon shutdown returned: {e}");
1350 }
1351 }
1352}
1353
1354impl Drop for LanDiscovery {
1355 fn drop(&mut self) {
1356 // Fallback path. Under normal live-toggle flow the controller calls
1357 // `shutdown()` explicitly; this only fires for process exit, when
1358 // the event-loop thread has already terminated and the final Arc
1359 // is being released.
1360 self.shutdown();
1361 }
1362}
1363
1364/// How often the watchdog looks at discoverability. Two consecutive
1365/// undiscoverable ticks are needed before a rebuild
1366/// (`lan_mdns_health::STRIKES_BEFORE_REBUILD`).
1367const HEALTH_TICK: std::time::Duration = std::time::Duration::from_secs(15);
1368
1369/// Reads the daemon's monitor channel and records the IPv4 addresses it
1370/// announced our service on. Ends when the daemon shuts down (the channel
1371/// disconnects). Runs on a blocking thread, like the browse loop.
1372fn monitor_loop(
1373 rx: mdns_sd::Receiver<mdns_sd::DaemonEvent>,
1374 announced: Arc<Mutex<BTreeSet<Ipv4Addr>>>,
1375) {
1376 while let Ok(event) = rx.recv() {
1377 record_daemon_event(event, &announced);
1378 }
1379}
1380
1381/// Folds one daemon event into the set of IPv4 addresses we announced on.
1382fn record_daemon_event(event: mdns_sd::DaemonEvent, announced: &Mutex<BTreeSet<Ipv4Addr>>) {
1383 use mdns_sd::DaemonEvent;
1384 match event {
1385 DaemonEvent::Announce(_, addrs) => {
1386 let found = lan_mdns_health::ipv4_in(&addrs);
1387 if !found.is_empty() {
1388 announced.lock().extend(found);
1389 }
1390 }
1391 DaemonEvent::IpDel(IpAddr::V4(gone)) => {
1392 announced.lock().remove(&gone);
1393 }
1394 DaemonEvent::Error(e) => {
1395 tracing::warn!("mDNS daemon error: {e}");
1396 }
1397 _ => {}
1398 }
1399}
1400
1401/// Controller for live start/stop of `LanDiscovery` in response to setting changes.
1402///
1403/// Owns the daemon slot plus the start arguments, so toggling
1404/// `network:lan_discovery` from the UI (or from an external edit of
1405/// `settings.json`) can start or stop the daemon without restarting the
1406/// process.
1407///
1408/// Spec: docs/specs/lan-discovery-toggle.md
1409pub struct LanDiscoveryController {
1410 slot: Arc<RwLock<Option<Arc<LanDiscovery>>>>,
1411 /// What the last `apply` asked for: LAN discovery should be running. Set
1412 /// under the slot's write lock, so it never disagrees with the slot for
1413 /// longer than one `apply`. It stays `true` when a start fails, which is how
1414 /// the watchdog tells "wanted but down" (retry) from "off" (leave alone).
1415 desired: std::sync::atomic::AtomicBool,
1416 instance_id: String,
1417 hostname: String,
1418 version: String,
1419 port: u16,
1420 auth_key: String,
1421 event_bus: Arc<EventBus>,
1422 /// Short-lived cache mapping agent_id → (peer_url, auth_key). Entries expire
1423 /// after LAN_AGENT_CACHE_TTL_SECS to handle agent migration between peers.
1424 agent_cache: std::sync::RwLock<HashMap<String, LanCacheEntry>>,
1425 /// Separate cache mapping agent_id → LAN Ed25519 public key bytes.
1426 /// SPEC_JEKT_LAN_TIER_SIGNING_2026_08_15.md §2.2 — deliberately its own
1427 /// map rather than folded into `agent_cache`: the two are keyed by the
1428 /// same agent_id but answer different questions ("which peer currently
1429 /// hosts this agent" churns on process restarts/migration; "what is
1430 /// this agent's public key" is effectively static for the agent's
1431 /// lifetime), and a lookup for one is not always paired with a lookup
1432 /// for the other (a message TO agent X needs `agent_cache`'s entry for
1433 /// X; verifying a message FROM agent Y needs `pubkey_cache`'s entry for
1434 /// Y — different agent_ids on the same inbound request).
1435 pubkey_cache: std::sync::RwLock<HashMap<String, LanPubkeyCacheEntry>>,
1436 /// reagentx P1 on the LAN signing PR: `find_agent_lan_pubkey`'s negative
1437 /// cache is keyed by agent_id, so a caller holding only the `lan_key`
1438 /// can force a fresh multi-peer fan-out on every single request just by
1439 /// sending a novel random `source_agent` each time — the cache never
1440 /// hits, and this expensive walk runs (in `verify_lan_signature`)
1441 /// BEFORE `Handler::inject_message`'s own rate limiter is ever reached.
1442 /// A simple global token bucket on the fan-out ITSELF (not per-agent_id
1443 /// — the abuse pattern is specifically about varying the id to dodge a
1444 /// per-id cache) bounds the damage regardless of how many distinct
1445 /// identities are probed.
1446 pubkey_lookup_limiter: std::sync::Mutex<LookupRateLimiter>,
1447}
1448
1449struct LanPubkeyCacheEntry {
1450 /// `None` = negative cache entry: no peer has a LAN public key on file
1451 /// for this agent_id (never minted one, or genuinely unknown).
1452 public_key: Option<Vec<u8>>,
1453 /// Identity M4d-6: the UID and UID key from the same peer's answer, when
1454 /// it gave both.
1455 uid_key: Option<(String, Vec<u8>)>,
1456 expires: std::time::Instant,
1457}
1458
1459/// Outcome of `find_agent_lan_pubkey`. Three states, not two — reagentx P0
1460/// follow-up on the LAN signing PR: collapsing "the lookup was skipped
1461/// (rate-limited)" into the same `None`/"not found" outcome as "genuinely
1462/// no peer has this key" let an attacker exhaust
1463/// `LAN_PUBKEY_LOOKUP_RATE_LIMIT` with junk lookups, then slip a forged
1464/// signature for a REAL agent's identity through as unverified (benign)
1465/// instead of a verification failure (forced sensitive). By the time this
1466/// function is ever called, `verify_lan_signature` has already confirmed a
1467/// `lan_sig` was actually presented — so "we didn't check" must be
1468/// distinguishable from "there was nothing to check."
1469pub(crate) enum LanPubkeyLookup {
1470 /// A peer has a public key on file for this agent_id.
1471 Found(Vec<u8>),
1472 /// No peer (that answered) has ever minted a LAN key for this agent_id
1473 /// — genuinely unknown sender, not a red flag on its own (same
1474 /// "unverified, not escalated" treatment self-declared senders already
1475 /// get elsewhere in this system).
1476 NotFound,
1477 /// The lookup was skipped by the rate limiter — unknown, NOT the same
1478 /// as `NotFound`. Callers verifying an actual signature attempt must
1479 /// treat this conservatively (as a failure), never as "nothing to
1480 /// check."
1481 RateLimited,
1482}
1483
1484/// Minimal token bucket, refilled once per second — same shape as
1485/// `backend::reactive::handler::RateLimiter`, duplicated locally rather
1486/// than widening that one's visibility across modules for a single caller.
1487struct LookupRateLimiter {
1488 tokens: u32,
1489 max_tokens: u32,
1490 last_refill: std::time::Instant,
1491}
1492
1493impl LookupRateLimiter {
1494 fn new(max_tokens: u32) -> Self {
1495 Self {
1496 tokens: max_tokens,
1497 max_tokens,
1498 last_refill: std::time::Instant::now(),
1499 }
1500 }
1501
1502 fn check(&mut self) -> bool {
1503 let now = std::time::Instant::now();
1504 if now.duration_since(self.last_refill) >= std::time::Duration::from_secs(1) {
1505 self.tokens = self.max_tokens;
1506 self.last_refill = now;
1507 }
1508 if self.tokens > 0 {
1509 self.tokens -= 1;
1510 true
1511 } else {
1512 false
1513 }
1514 }
1515}
1516
1517/// Fan-outs per second `find_agent_lan_pubkey` will perform before failing
1518/// closed (returning `None` without querying any peer). Generous for
1519/// legitimate traffic — LAN jekts to previously-unseen senders are rare
1520/// once agents have been talking a while, and the 60s positive/negative
1521/// cache absorbs repeat lookups for the SAME agent_id — while still
1522/// bounding the worst case to a small, fixed number of outbound requests
1523/// per second regardless of how many distinct agent_ids are probed.
1524const LAN_PUBKEY_LOOKUP_RATE_LIMIT: u32 = 10;
1525
1526impl LanDiscoveryController {
1527 pub fn new(
1528 instance_id: String,
1529 hostname: String,
1530 version: String,
1531 port: u16,
1532 event_bus: Arc<EventBus>,
1533 auth_key: String,
1534 ) -> Self {
1535 Self {
1536 slot: Arc::new(RwLock::new(None)),
1537 desired: std::sync::atomic::AtomicBool::new(false),
1538 instance_id,
1539 hostname,
1540 version,
1541 port,
1542 auth_key,
1543 event_bus,
1544 agent_cache: std::sync::RwLock::new(HashMap::new()),
1545 pubkey_cache: std::sync::RwLock::new(HashMap::new()),
1546 pubkey_lookup_limiter: std::sync::Mutex::new(LookupRateLimiter::new(LAN_PUBKEY_LOOKUP_RATE_LIMIT)),
1547 }
1548 }
1549
1550 /// Query LAN peers for which one hosts `agent_id`. Returns `(peer_url,
1551 /// lan_key)` for the first peer that responds 2xx to the agent-lookup
1552 /// endpoint. Results — both positive and negative — are cached for
1553 /// `LAN_AGENT_CACHE_TTL_SECS` seconds to avoid a blocking peer fan-out on
1554 /// every inject for cloud-only agents.
1555 ///
1556 /// Security:
1557 /// the value broadcast in the mDNS TXT record / UDP probe response
1558 /// (`self.auth_key` field name kept for wire back-compat with older
1559 /// peer versions — see `Config::lan_key`'s doc comment) is now a
1560 /// separate, narrowly-scoped credential, NOT the instance's full-access
1561 /// `auth_key`. A passive LAN listener who captures it gets standing
1562 /// access to only the three LAN-forwarding routes
1563 /// (`lan_or_full_auth_middleware` in `server/mod.rs`) — not the full
1564 /// `/agentmux/service` surface this used to expose. Broadcasting
1565 /// *something* in cleartext to the LAN is still an accepted trade-off
1566 /// of this opt-in feature (mDNS/UDP have no confidentiality of their
1567 /// own); this change is about shrinking what a captured value is worth,
1568 /// not about hiding it in transit — see the spec's LAN P1-1 for the
1569 /// separate, not-yet-implemented "encrypt/authenticate the transport
1570 /// too" hardening.
1571 pub async fn find_agent(
1572 &self,
1573 agent_id: &str,
1574 http: &reqwest::Client,
1575 ) -> Option<(String, String)> {
1576 // Fast path: valid cache hit (positive or negative)
1577 if let Ok(cache) = self.agent_cache.read() {
1578 if let Some(e) = cache.get(agent_id) {
1579 if e.expires > std::time::Instant::now() {
1580 return e.peer_url.as_ref().map(|url| (url.clone(), e.auth_key.clone()));
1581 }
1582 }
1583 }
1584
1585 // Slow path: fan out to every peer CONCURRENTLY and take the first
1586 // 2xx. Use reqwest's .query() for safe percent-encoding of the
1587 // agent_id (handles spaces, &, =, #, etc.) — see
1588 // `query_peers_concurrently`, which also explains why this is a stream
1589 // rather than a first-success-wins helper.
1590 let peers = self.get_instances();
1591 {
1592 use futures_util::StreamExt as _;
1593 let mut inflight = query_peers_concurrently(&peers, agent_id, http);
1594 while let Some((peer_url, auth_key, result)) = inflight.next().await {
1595 if matches!(result, Ok(ref r) if r.status().is_success()) {
1596 tracing::debug!(agent_id, peer_url = %peer_url, "LAN agent found on peer");
1597 if let Ok(mut cache) = self.agent_cache.write() {
1598 cache.insert(
1599 agent_id.to_string(),
1600 LanCacheEntry {
1601 peer_url: Some(peer_url.clone()),
1602 auth_key: auth_key.clone(),
1603 expires: std::time::Instant::now()
1604 + std::time::Duration::from_secs(LAN_AGENT_CACHE_TTL_SECS),
1605 },
1606 );
1607 }
1608 // Dropping `inflight` here cancels the remaining requests.
1609 return Some((peer_url, auth_key));
1610 }
1611 }
1612 }
1613
1614 // No peer has this agent — write a negative cache entry so future
1615 // injects for cloud-only agents skip the full peer fan-out.
1616 if let Ok(mut cache) = self.agent_cache.write() {
1617 cache.insert(
1618 agent_id.to_string(),
1619 LanCacheEntry {
1620 peer_url: None,
1621 auth_key: String::new(),
1622 expires: std::time::Instant::now()
1623 + std::time::Duration::from_secs(LAN_AGENT_CACHE_TTL_SECS),
1624 },
1625 );
1626 }
1627 None
1628 }
1629
1630 /// Look up a claimed LAN sender's Ed25519 public key by fanning out to
1631 /// every discovered LAN peer, same query the target-agent lookup above
1632 /// makes (`GET /agentmux/reactive/agent?id=<agent_id>`, now extended —
1633 /// see `handle_reactive_agent` — to embed `lan_public_key` when the
1634 /// responding peer has minted one). A SEPARATE lookup from `find_agent`
1635 /// even though it hits the same endpoint: verifying an inbound message
1636 /// FROM agent Y needs Y's key, which is almost never the same agent_id
1637 /// as whatever THIS request's own target-agent lookup (if any) was for.
1638 ///
1639 /// Returns the raw decoded public key bytes, or `None` if no peer has
1640 /// one on file for this agent_id (cached negatively, same as
1641 /// `find_agent`'s "not found" case) — callers (`verify_lan_signature`)
1642 /// treat that identically to "no signature attempted": nothing to check
1643 /// against, not a red flag on its own.
1644 pub async fn find_agent_lan_pubkey(&self, agent_id: &str, http: &reqwest::Client) -> LanPubkeyLookup {
1645 if let Ok(cache) = self.pubkey_cache.read() {
1646 if let Some(e) = cache.get(agent_id) {
1647 if e.expires > std::time::Instant::now() {
1648 return match &e.public_key {
1649 Some(k) => LanPubkeyLookup::Found(k.clone()),
1650 None => LanPubkeyLookup::NotFound,
1651 };
1652 }
1653 }
1654 }
1655
1656 // reagentx P1: fail closed on the FAN-OUT (no outbound peer
1657 // queries) rather than let an attacker force unbounded network
1658 // traffic by varying agent_id on every request — see
1659 // LAN_PUBKEY_LOOKUP_RATE_LIMIT's doc comment. Returning
1660 // `RateLimited` here (reagentx P0 follow-up, NOT the same as
1661 // `NotFound`) is the point: by the time this function is called at
1662 // all, `verify_lan_signature` has already confirmed a `lan_sig` WAS
1663 // presented, so "we didn't check" must never collapse into the same
1664 // outcome as "genuinely nothing to check" — a caller could
1665 // otherwise exhaust this limiter with junk lookups, then slip a
1666 // forged signature for a REAL agent's identity through unverified
1667 // instead of forced-sensitive.
1668 let allowed = self
1669 .pubkey_lookup_limiter
1670 .lock()
1671 .map(|mut limiter| limiter.check())
1672 .unwrap_or(true);
1673 if !allowed {
1674 tracing::debug!(agent_id, "LAN pubkey lookup rate-limited — skipping peer fan-out");
1675 return LanPubkeyLookup::RateLimited;
1676 }
1677
1678 // Concurrent fan-out, same as `find_agent` — but note the acceptance
1679 // rule differs: a 2xx whose body has no `lan_public_key` means that
1680 // peer hosts the agent but hasn't minted a key, so we must keep
1681 // consuming the stream rather than stopping at the first 2xx.
1682 let peers = self.get_instances();
1683 {
1684 use futures_util::StreamExt as _;
1685 let mut inflight = query_peers_concurrently(&peers, agent_id, http);
1686 while let Some((peer_url, _auth_key, result)) = inflight.next().await {
1687 let Ok(resp) = result else { continue };
1688 if !resp.status().is_success() {
1689 continue;
1690 }
1691 let Ok(body) = resp.json::<serde_json::Value>().await else { continue };
1692 let Some(pubkey_b64) = body.get("lan_public_key").and_then(|v| v.as_str()) else {
1693 continue; // this peer has the agent but no LAN key minted for it yet
1694 };
1695 use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
1696 let Ok(pubkey_bytes) = BASE64.decode(pubkey_b64) else { continue };
1697 tracing::debug!(agent_id, peer_url = %peer_url, "LAN agent pubkey found on peer");
1698 let uid = body.get("uid").and_then(|v| v.as_str()).filter(|u| !u.is_empty());
1699 let uid_key = body.get("uid_public_key").and_then(|v| v.as_str()).and_then(|k| BASE64.decode(k).ok());
1700 if let Ok(mut cache) = self.pubkey_cache.write() {
1701 cache.insert(
1702 agent_id.to_string(),
1703 LanPubkeyCacheEntry {
1704 public_key: Some(pubkey_bytes.clone()),
1705 uid_key: uid.zip(uid_key).map(|(u, k)| (u.to_string(), k)),
1706 expires: std::time::Instant::now()
1707 + std::time::Duration::from_secs(LAN_AGENT_CACHE_TTL_SECS),
1708 },
1709 );
1710 }
1711 // Dropping `inflight` here cancels the remaining requests.
1712 return LanPubkeyLookup::Found(pubkey_bytes);
1713 }
1714 }
1715
1716 if let Ok(mut cache) = self.pubkey_cache.write() {
1717 cache.insert(
1718 agent_id.to_string(),
1719 LanPubkeyCacheEntry {
1720 public_key: None,
1721 uid_key: None,
1722 expires: std::time::Instant::now()
1723 + std::time::Duration::from_secs(LAN_AGENT_CACHE_TTL_SECS),
1724 },
1725 );
1726 }
1727 LanPubkeyLookup::NotFound
1728 }
1729
1730 /// The UID and UID key the peer that answered [`Self::find_agent_lan_pubkey`]
1731 /// for `agent_id` gave beside its name key, while that answer is cached
1732 /// (identity M4d-6).
1733 pub fn cached_lan_uid_key(&self, agent_id: &str) -> Option<(String, Vec<u8>)> {
1734 let cache = self.pubkey_cache.read().ok()?;
1735 let e = cache.get(agent_id).filter(|e| e.expires > std::time::Instant::now())?;
1736 e.uid_key.clone()
1737 }
1738
1739 /// Cache a peer's answer for `agent_id` as `find_agent_lan_pubkey` would.
1740 #[cfg(test)]
1741 pub fn seed_lan_pubkey_for_test(&self, agent_id: &str, public_key: Vec<u8>, uid_key: Option<(String, Vec<u8>)>) {
1742 self.pubkey_cache.write().unwrap().insert(
1743 agent_id.to_string(),
1744 LanPubkeyCacheEntry {
1745 public_key: Some(public_key),
1746 uid_key,
1747 expires: std::time::Instant::now() + std::time::Duration::from_secs(LAN_AGENT_CACHE_TTL_SECS),
1748 },
1749 );
1750 }
1751
1752 /// Evict a stale cache entry (e.g. after a forward to that peer failed).
1753 pub fn evict_agent(&self, agent_id: &str) {
1754 if let Ok(mut cache) = self.agent_cache.write() {
1755 cache.remove(agent_id);
1756 }
1757 }
1758
1759 /// Idempotent: starts the daemon when `enabled` and not running, stops it
1760 /// when `!enabled` and running. Re-entrant safe.
1761 ///
1762 /// Holds the slot's write lock for the entire check-and-modify transaction
1763 /// to avoid a TOCTOU race between the `is_running` read and the slot
1764 /// mutation. `apply()` is called from toggle clicks and setting writes —
1765 /// low frequency — so briefly blocking concurrent peer-list reads is
1766 /// acceptable. `LanDiscovery::start()` and `Drop` are both fast (mDNS
1767 /// daemon construction + service register/unregister are local socket ops).
1768 pub fn apply(&self, enabled: bool) {
1769 let mut slot = self.slot.write();
1770 self.desired.store(enabled, std::sync::atomic::Ordering::SeqCst);
1771 let is_running = slot.is_some();
1772 match (enabled, is_running) {
1773 (true, false) => {
1774 match self.start_daemon() {
1775 Ok(d) => {
1776 *slot = Some(d);
1777 tracing::info!("LAN discovery enabled via setting");
1778 }
1779 Err(e) => {
1780 tracing::warn!("LAN discovery start failed: {e}");
1781 // Surface to the UI so the user sees why the toggle
1782 // didn't take effect (e.g. Windows Firewall blocked).
1783 // `e` is already a String, but `.to_string()` is the
1784 // documented contract for the wire payload (frontend
1785 // reads `event.data.error` as a string).
1786 self.event_bus.broadcast_event(&EventBus::typed_ws_event::<crate::backend::mps_events::LanInstancesError>(&LanDiscoveryError { error: e.to_string() }, String::new()));
1787 }
1788 }
1789 }
1790 (false, true) => {
1791 // Explicitly shut down before dropping our Arc. The
1792 // spawn_blocking event-loop thread holds an `Arc<LanDiscovery>`
1793 // clone (see `start()` line ~94), so dropping the slot's Arc
1794 // alone does not reach refcount zero — `Drop` would never run
1795 // and the daemon would keep advertising/browsing. `shutdown()`
1796 // closes the mDNS socket synchronously, the receiver returns
1797 // Err, the event-loop exits, and the spawned thread releases
1798 // its Arc. `Drop`'s subsequent call to `shutdown()` is a
1799 // no-op (idempotent).
1800 if let Some(d) = slot.as_ref() {
1801 d.shutdown();
1802 }
1803 *slot = None;
1804 tracing::info!("LAN discovery disabled via setting");
1805 self.event_bus.broadcast_event(&EventBus::typed_ws_event::<crate::backend::mps_events::LanInstances>(&LanInstanceList(Vec::new()), String::new()));
1806 }
1807 _ => {}
1808 }
1809 }
1810
1811 fn start_daemon(&self) -> Result<Arc<LanDiscovery>, String> {
1812 LanDiscovery::start(
1813 self.instance_id.clone(),
1814 self.hostname.clone(),
1815 self.version.clone(),
1816 self.port,
1817 self.auth_key.clone(),
1818 self.event_bus.clone(),
1819 )
1820 }
1821
1822 /// Remove this instance's `lan_instances` record on clean process exit,
1823 /// when `LanDiscovery`'s own `Drop` may never run (its tasks hold `Arc`s).
1824 /// Leaves mDNS and the sockets alone; the process is going away.
1825 pub fn withdraw_instance_record(&self) {
1826 if let Some(d) = self.slot.read().as_ref() {
1827 d.withdraw_instance_record();
1828 }
1829 }
1830
1831 /// Should a daemon be running right now, per the last `apply`?
1832 pub fn is_wanted(&self) -> bool {
1833 self.desired.load(std::sync::atomic::Ordering::SeqCst)
1834 }
1835
1836 /// Start a daemon when one is wanted but none is running: the recovery for a
1837 /// rebuild whose new daemon failed to start (ReAgent P2 on #4148). Does
1838 /// nothing, and returns `false`, when LAN is not wanted or a daemon is
1839 /// already up, checked under the same write lock `apply` uses, so it cannot
1840 /// race a switch-off.
1841 pub fn restart_if_wanted(&self) -> bool {
1842 let mut slot = self.slot.write();
1843 if slot.is_some() || !self.is_wanted() {
1844 return false;
1845 }
1846 match self.start_daemon() {
1847 Ok(fresh) => {
1848 *slot = Some(fresh);
1849 // Clears the error a failed rebuild showed, and resets the peer list.
1850 self.event_bus.broadcast_event(&EventBus::typed_ws_event::<crate::backend::mps_events::LanInstances>(&LanInstanceList(Vec::new()), String::new()));
1851 tracing::info!("mDNS daemon restarted after a failed rebuild");
1852 true
1853 }
1854 Err(e) => {
1855 tracing::warn!("mDNS daemon restart failed: {e}");
1856 false
1857 }
1858 }
1859 }
1860
1861 /// Replace a RUNNING mDNS daemon with a fresh one, atomically.
1862 ///
1863 /// Only ever replaces: if no daemon is running (LAN was switched off, or the
1864 /// supervisor's `enabled && reachable` gate in `bootstrap/network.rs` stopped
1865 /// it) this does nothing and returns `false`, so a rebuild can never start
1866 /// advertising on its own. The whole swap happens under one write lock, so
1867 /// `apply(false)` from the supervisor cannot interleave between the stop and
1868 /// the start (ReAgent P1 on #4148). The first draft called `apply(false)` then
1869 /// `apply(true)`, which released the lock in between and would have restarted
1870 /// a daemon the setting had just turned off.
1871 pub fn rebuild(&self) -> bool {
1872 let mut slot = self.slot.write();
1873 let Some(old) = slot.take() else {
1874 return false;
1875 };
1876 old.shutdown();
1877 match self.start_daemon() {
1878 Ok(fresh) => {
1879 *slot = Some(fresh);
1880 // The peer list starts over with the new daemon.
1881 self.event_bus.broadcast_event(&EventBus::typed_ws_event::<crate::backend::mps_events::LanInstances>(&LanInstanceList(Vec::new()), String::new()));
1882 tracing::info!("mDNS daemon rebuilt");
1883 true
1884 }
1885 Err(e) => {
1886 tracing::warn!("mDNS daemon rebuild failed: {e}");
1887 self.event_bus.broadcast_event(&EventBus::typed_ws_event::<crate::backend::mps_events::LanInstancesError>(&LanDiscoveryError { error: e.to_string() }, String::new()));
1888 false
1889 }
1890 }
1891 }
1892
1893 /// The running daemon's discoverability, or `None` when LAN discovery is
1894 /// off or failed to start.
1895 pub fn health(&self) -> Option<lan_mdns_health::MdnsHealth> {
1896 self.slot.read().as_ref().map(|d| d.health())
1897 }
1898
1899 /// Start the watchdog that notices an instance nobody can find and
1900 /// rebuilds its mDNS daemon, the same path as switching LAN off and on
1901 /// (which cleared the fault on Area54, SPEC_LAN_FIREWALL_SETUP 8.1). It
1902 /// also tells the frontend, via `laninstances:health`, so the indicator can
1903 /// say so: a host can see peers and still be invisible to them.
1904 pub fn spawn_health_watchdog(self: Arc<Self>) {
1905 tokio::spawn(async move {
1906 let mut watchdog = lan_mdns_health::Watchdog::default();
1907 let mut last_sent: Option<(String, Vec<Ipv4Addr>, u32)> = None;
1908 let mut tick = tokio::time::interval(HEALTH_TICK);
1909 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1910 loop {
1911 tick.tick().await;
1912 let Some(health) = self.health() else {
1913 if self.is_wanted() {
1914 // Wanted but no daemon: a rebuild whose new daemon failed to
1915 // start. Retry, and keep the rebuild budget (no `reset`).
1916 if watchdog.observe_down() {
1917 self.restart_if_wanted();
1918 }
1919 continue;
1920 }
1921 watchdog.reset();
1922 // LAN went off: clear whatever the indicator was showing.
1923 if last_sent.as_ref().is_some_and(|(state, _, _)| state != "off") {
1924 self.publish_health("off", &[], 0);
1925 last_sent = Some(("off".to_string(), Vec::new(), 0));
1926 }
1927 continue;
1928 };
1929 let rebuilds_before = watchdog.rebuilds();
1930 match watchdog.observe(&health) {
1931 lan_mdns_health::Action::Rebuild => {
1932 let missing = health.missing().to_vec();
1933 // Say WHY before rebuilding: replay the daemon's own socket set-up
1934 // on each address it failed to announce on, and name whoever holds
1935 // UDP 5353. `mdns-sd` knows, but only tells the log what it can, and
1936 // on Area54 this took several cloud round trips to find by hand.
1937 let probe_these = missing.clone();
1938 let diagnosis = tokio::task::spawn_blocking(move || lan_mdns_diag::diagnose(&probe_these))
1939 .await
1940 .unwrap_or_else(|e| format!("the diagnosis did not run ({e})"));
1941 tracing::warn!(
1942 missing = ?missing,
1943 attempt = watchdog.rebuilds(),
1944 next_retry_secs = u64::from(watchdog.retry_in_ticks()) * HEALTH_TICK.as_secs(),
1945 %diagnosis,
1946 "mDNS announced on none of this host's IPv4 addresses, so other machines cannot find it; rebuilding the mDNS daemon"
1947 );
1948 self.rebuild();
1949 }
1950 lan_mdns_health::Action::Nothing => {
1951 if matches!(health, lan_mdns_health::MdnsHealth::Healthy) && rebuilds_before > 0 {
1952 tracing::info!(rebuilds = rebuilds_before, "mDNS is announced on this host's IPv4 addresses again");
1953 }
1954 if let lan_mdns_health::MdnsHealth::Degraded { missing } = &health {
1955 // Logged once per change below, not per tick.
1956 if last_sent.as_ref().map(|(s, _, _)| s.as_str()) != Some("degraded") {
1957 tracing::warn!(
1958 ?missing,
1959 "mDNS announced on some of this host's IPv4 addresses but not these (often a virtual adapter)"
1960 );
1961 }
1962 }
1963 }
1964 }
1965 let sent = (
1966 health.wire_state().to_string(),
1967 health.missing().to_vec(),
1968 watchdog.rebuilds(),
1969 );
1970 // A standing problem is re-sent every tick so a window opened later
1971 // (or a reconnected WebSocket) learns of it; see `should_publish`.
1972 let changed = last_sent.as_ref() != Some(&sent);
1973 if lan_mdns_health::should_publish(&health, changed) {
1974 self.publish_health(&sent.0, &sent.1, sent.2);
1975 last_sent = Some(sent);
1976 }
1977 }
1978 });
1979 }
1980
1981 fn publish_health(&self, state: &str, missing: &[Ipv4Addr], rebuilds: u32) {
1982 let health = LanDiscoverability {
1983 state: state.to_string(),
1984 missing: missing.iter().map(|a| a.to_string()).collect(),
1985 rebuilds,
1986 };
1987 self.event_bus.broadcast_event(&EventBus::typed_ws_event::<crate::backend::mps_events::LanInstancesHealth>(&health, String::new()));
1988 }
1989
1990 /// Read the current peer list. Returns empty when the daemon is not
1991 /// running (discovery disabled or start failed).
1992 pub fn get_instances(&self) -> Vec<LanInstance> {
1993 self.slot
1994 .read()
1995 .as_ref()
1996 .map(|d| d.get_instances())
1997 .unwrap_or_default()
1998 }
1999}
2000
2001#[cfg(test)]
2002mod tests {
2003 use super::{
2004 is_lan_source, is_valid_probe, mdns_hostname, probe_response_json, record_daemon_event,
2005 LanDiscovery, SERVICE_TYPE, UDP_PROBE_TYPE, UDP_PROTOCOL_VERSION, UDP_RESPONSE_TYPE,
2006 };
2007 use crate::backend::lan_mdns_health;
2008 use mdns_sd::ServiceEvent;
2009 use parking_lot::Mutex;
2010 use serde_json::json;
2011 use std::collections::BTreeSet;
2012 use std::net::{Ipv4Addr, SocketAddr};
2013 use std::sync::Arc;
2014 use tokio::net::UdpSocket;
2015
2016 #[test]
2017 fn is_lan_source_accepts_private_v4_ranges() {
2018 for addr in [
2019 "127.0.0.1:1234",
2020 "10.0.0.5:1234",
2021 "172.16.4.4:1234",
2022 "192.168.1.50:1234",
2023 "169.254.1.1:1234", // link-local / APIPA
2024 ] {
2025 let addr: SocketAddr = addr.parse().unwrap();
2026 assert!(is_lan_source(&addr), "{addr} should be treated as LAN");
2027 }
2028 }
2029
2030 #[test]
2031 fn is_lan_source_rejects_public_v4() {
2032 for addr in ["8.8.8.8:1234", "1.1.1.1:1234", "203.0.113.7:1234"] {
2033 let addr: SocketAddr = addr.parse().unwrap();
2034 assert!(!is_lan_source(&addr), "{addr} should NOT be treated as LAN");
2035 }
2036 }
2037
2038 #[test]
2039 fn is_lan_source_accepts_v6_loopback_and_local_ranges() {
2040 for addr in ["[::1]:1234", "[fe80::1]:1234", "[fc00::1]:1234"] {
2041 let addr: SocketAddr = addr.parse().unwrap();
2042 assert!(is_lan_source(&addr), "{addr} should be treated as LAN");
2043 }
2044 }
2045
2046 #[test]
2047 fn is_lan_source_rejects_public_v6() {
2048 let addr: SocketAddr = "[2001:4860:4860::8888]:1234".parse().unwrap();
2049 assert!(!is_lan_source(&addr));
2050 }
2051
2052 #[test]
2053 fn is_lan_source_unwraps_v4_mapped_v6() {
2054 // A private v4 address wearing a v6 mapped-address wrapper must
2055 // still be classified by its embedded v4 range, not treated as an
2056 // opaque public v6 address.
2057 let addr: SocketAddr = "[::ffff:10.0.0.5]:1234".parse().unwrap();
2058 assert!(is_lan_source(&addr));
2059 let addr: SocketAddr = "[::ffff:8.8.8.8]:1234".parse().unwrap();
2060 assert!(!is_lan_source(&addr));
2061 }
2062
2063 #[test]
2064 fn appends_local_dot_to_bare_hostname() {
2065 assert_eq!(mdns_hostname("narko"), "narko.local.");
2066 }
2067
2068 #[test]
2069 fn preserves_already_fully_qualified_name() {
2070 assert_eq!(mdns_hostname("narko.local."), "narko.local.");
2071 }
2072
2073 #[test]
2074 fn appends_trailing_dot_to_local_suffix() {
2075 // mdns-sd needs the trailing dot; we add it without doubling .local.
2076 assert_eq!(mdns_hostname("narko.local"), "narko.local.");
2077 }
2078
2079 #[test]
2080 fn handles_trailing_dot_on_bare_hostname() {
2081 assert_eq!(mdns_hostname("narko."), "narko.local.");
2082 }
2083
2084 #[test]
2085 fn does_not_double_suffix() {
2086 // Two passes through the normalizer produce the same result.
2087 let once = mdns_hostname("narko");
2088 let twice = mdns_hostname(&once);
2089 assert_eq!(twice, once);
2090 }
2091
2092 // -- UDP broadcast-probe wire protocol (Layer 2 discovery fallback) --
2093
2094 #[test]
2095 fn is_valid_probe_accepts_well_formed_probe() {
2096 let bytes = serde_json::to_vec(&json!({"type": UDP_PROBE_TYPE, "v": 1})).unwrap();
2097 assert!(is_valid_probe(&bytes));
2098 }
2099
2100 #[test]
2101 fn is_valid_probe_rejects_non_json() {
2102 assert!(!is_valid_probe(b"not json at all"));
2103 }
2104
2105 #[test]
2106 fn is_valid_probe_rejects_wrong_type() {
2107 let bytes = serde_json::to_vec(&json!({"type": "something_else", "v": 1})).unwrap();
2108 assert!(!is_valid_probe(&bytes));
2109 }
2110
2111 #[test]
2112 fn is_valid_probe_rejects_wrong_version() {
2113 let bytes = serde_json::to_vec(&json!({"type": UDP_PROBE_TYPE, "v": 2})).unwrap();
2114 assert!(!is_valid_probe(&bytes));
2115 }
2116
2117 #[test]
2118 fn is_valid_probe_accepts_version_encoded_as_float() {
2119 // A client whose JSON serializer infers a `double` type for the
2120 // version field emits `1.0` rather than `1` — both must validate.
2121 let bytes = serde_json::to_vec(&json!({"type": UDP_PROBE_TYPE, "v": 1.0})).unwrap();
2122 assert!(is_valid_probe(&bytes));
2123 }
2124
2125 #[test]
2126 fn is_valid_probe_rejects_version_encoded_as_non_integral_float() {
2127 let bytes = serde_json::to_vec(&json!({"type": UDP_PROBE_TYPE, "v": 1.5})).unwrap();
2128 assert!(!is_valid_probe(&bytes));
2129 }
2130
2131 #[test]
2132 fn is_valid_probe_rejects_unrelated_json() {
2133 // Simulates non-AgentMux JSON noise landing on the port.
2134 let bytes = serde_json::to_vec(&json!({"hello": "world"})).unwrap();
2135 assert!(!is_valid_probe(&bytes));
2136 }
2137
2138 #[test]
2139 fn is_valid_probe_rejects_missing_version_field() {
2140 let bytes = serde_json::to_vec(&json!({"type": UDP_PROBE_TYPE})).unwrap();
2141 assert!(!is_valid_probe(&bytes));
2142 }
2143
2144 #[test]
2145 fn is_valid_probe_rejects_missing_type_field() {
2146 let bytes = serde_json::to_vec(&json!({"v": UDP_PROTOCOL_VERSION})).unwrap();
2147 assert!(!is_valid_probe(&bytes));
2148 }
2149
2150 #[test]
2151 fn probe_response_json_populates_all_fields() {
2152 let response = probe_response_json("inst-1", "myhost", "1.2.3", 9999, "secret-key");
2153 assert_eq!(response["type"], UDP_RESPONSE_TYPE);
2154 assert_eq!(response["v"], UDP_PROTOCOL_VERSION);
2155 assert_eq!(response["instance_id"], "inst-1");
2156 assert_eq!(response["hostname"], "myhost");
2157 assert_eq!(response["version"], "1.2.3");
2158 assert_eq!(response["port"], 9999);
2159 assert_eq!(response["auth_key"], "secret-key");
2160 }
2161
2162 // -- Real mDNS registration round-trip (reagent-style investigation:
2163 // LAN peer discovery was silently broken end-to-end — see below) --
2164
2165 /// Registers a real LanDiscovery instance (exactly as `start()` does in
2166 /// production) and browses for it with a SEPARATE, independent
2167 /// `mdns_sd::ServiceDaemon` — mirroring what a real peer's browse-side
2168 /// would see. This is the one test in this module that touches the real
2169 /// network stack (multicast on 5353) — it's slow (~3s) but it's the only
2170 /// way to catch a registration bug that a browse-only or probe-only unit
2171 /// test can't reach.
2172 ///
2173 /// This test is the reason the `""` (empty string) IP passed to
2174 /// `ServiceInfo::new` in `start()` was found to be broken: mdns-sd's own
2175 /// doc comment on `AsIpAddrs for &str` says plainly "If the string is
2176 /// empty, will return an empty set" — NOT "auto-detect". Combined with
2177 /// `ServiceInfo::new` hardcoding `addr_auto: false` and `start()` never
2178 /// calling the builder's `.enable_addr_auto()`, every registered service
2179 /// had zero addresses and no instruction to ever fill them in — it was
2180 /// never actually discoverable, not by a real peer, not even by another
2181 /// mDNS client on the SAME machine. `register()` itself has no
2182 /// synchronous validation for this (it just enqueues a `Command::
2183 /// Register` and returns `Ok`), so `start()` logged "LAN discovery
2184 /// started" successfully every time despite never actually working.
2185 ///
2186 /// IGNORED by default (opt in with `cargo test -- --ignored`) —
2187 /// confirmed environment-fragile in two independent, unrelated ways, not
2188 /// just "flaky CI networking":
2189 ///
2190 /// 1. GitHub Actions' `macos-latest` runner doesn't support UDP
2191 /// multicast at all: `mDNSResponder` is disabled there
2192 /// (actions/runner-images#9628) and macOS 15+ runners separately lack
2193 /// the "Local Network" permission sandboxed processes need for
2194 /// multicast (actions/runner-images discussion #170669). Confirmed
2195 /// deterministic (3/3 nightly runs), not intermittent.
2196 /// 2. Fails on real macOS hardware too, outside any CI — reproduced with
2197 /// a minimal from-scratch `mdns-sd` program (no AgentMux code at all)
2198 /// on a dev machine with several active VPN/tunnel interfaces: two
2199 /// `ServiceDaemon`s in the *same process*, both sharing port 5353
2200 /// with the OS's own `mDNSResponder`, and the browse side received
2201 /// zero packets — not even `ServiceFound`. The OS-level `dns-sd`
2202 /// CLI (two separate *processes*) discovers the identical
2203 /// registration instantly on the same machine, so this isn't a
2204 /// broken network or firewall; it's specific to same-process
2205 /// multi-`ServiceDaemon` port-5353 sharing in this crate.
2206 ///
2207 /// Both failure modes are outside this codebase's control (GitHub's
2208 /// runner sandboxing; mdns-sd's own same-process daemon behavior), so
2209 /// this stays a real, valuable, manually-run integration check —
2210 /// exactly how the bug above was actually caught — rather than
2211 /// something CI can gate on. See
2212 /// docs/retro/retro-macos-ci-mdns-multicast-unsupported-2026-08-12.md.
2213 #[tokio::test]
2214 #[ignore = "environment-fragile: real multicast, not gateable in GH Actions macOS CI \
2215 (no multicast support there) or reliably on multi-interface dev machines \
2216 (same-process ServiceDaemon port-5353 sharing) — see retro doc"]
2217 async fn a_registered_instance_is_discoverable_by_an_independent_mdns_client() {
2218 let event_bus = Arc::new(crate::backend::eventbus::EventBus::new());
2219 let instance_id = format!("test-{}", std::process::id());
2220 let discovery = LanDiscovery::start(
2221 instance_id.clone(),
2222 "test-host".to_string(),
2223 "0.0.0-test".to_string(),
2224 54321,
2225 "test-auth-key".to_string(),
2226 event_bus,
2227 )
2228 .expect("LanDiscovery::start should succeed");
2229
2230 // Independent daemon — a stand-in for a real peer's browse side.
2231 // Deliberately NOT the same ServiceDaemon `discovery` uses; a
2232 // same-process shortcut (e.g. reading `discovery`'s own in-memory
2233 // `instances` map) wouldn't exercise the actual wire announcement.
2234 let browser_daemon = mdns_sd::ServiceDaemon::new().expect("browser daemon");
2235 let receiver = browser_daemon.browse(SERVICE_TYPE).expect("browse");
2236
2237 let mut found = false;
2238 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2239 while std::time::Instant::now() < deadline {
2240 if let Ok(event) = receiver.recv_timeout(std::time::Duration::from_millis(200)) {
2241 if let ServiceEvent::ServiceResolved(info) = event {
2242 if info.get_property_val_str("instance_id") == Some(instance_id.as_str()) {
2243 found = true;
2244 break;
2245 }
2246 }
2247 }
2248 }
2249
2250 let _ = browser_daemon.shutdown();
2251 discovery.shutdown();
2252
2253 assert!(
2254 found,
2255 "an independent mDNS client must be able to discover a freshly-registered LanDiscovery instance within 5s"
2256 );
2257 }
2258
2259 /// ReAgent P1 on #4148: a rebuild may only REPLACE a running daemon. With none
2260 /// running (LAN switched off, or the supervisor's `enabled && reachable` gate
2261 /// stopped it) it must do nothing, never start advertising on its own.
2262 #[tokio::test]
2263 async fn a_rebuild_never_starts_a_daemon_that_is_not_running() {
2264 let controller = super::LanDiscoveryController::new(
2265 "test-instance".to_string(),
2266 "test-host".to_string(),
2267 "0.0.0-test".to_string(),
2268 54323,
2269 Arc::new(crate::backend::eventbus::EventBus::new()),
2270 "test-key".to_string(),
2271 );
2272 assert!(controller.health().is_none());
2273 assert!(!controller.rebuild(), "nothing to rebuild");
2274 assert!(controller.health().is_none(), "and it must not have started one");
2275 assert!(controller.get_instances().is_empty());
2276
2277 // The same after LAN was on and has been switched off again.
2278 controller.apply(true);
2279 controller.apply(false);
2280 assert!(!controller.rebuild());
2281 assert!(controller.health().is_none());
2282 }
2283
2284 /// ReAgent P2 on #4148: the controller remembers that LAN is wanted even when
2285 /// the start failed, so the watchdog can retry; and it never restarts a
2286 /// daemon the setting has switched off.
2287 #[tokio::test]
2288 async fn a_restart_only_happens_while_lan_is_wanted() {
2289 let controller = super::LanDiscoveryController::new(
2290 "test-instance".to_string(),
2291 "test-host".to_string(),
2292 "0.0.0-test".to_string(),
2293 54324,
2294 Arc::new(crate::backend::eventbus::EventBus::new()),
2295 "test-key".to_string(),
2296 );
2297 assert!(!controller.is_wanted());
2298 assert!(!controller.restart_if_wanted(), "not wanted: nothing to restart");
2299 assert!(controller.health().is_none());
2300
2301 controller.apply(true); // wanted whether or not this host can start mDNS
2302 assert!(controller.is_wanted());
2303 controller.apply(false);
2304 assert!(!controller.is_wanted());
2305 assert!(!controller.restart_if_wanted(), "switched off again: must not restart");
2306 assert!(controller.health().is_none());
2307 }
2308
2309 /// The daemon's announcements, in the two payload shapes `mdns-sd` 0.12 uses,
2310 /// accumulate into the announced set, and an address that goes away leaves it.
2311 #[test]
2312 fn daemon_events_fold_into_the_announced_set() {
2313 use mdns_sd::DaemonEvent;
2314 let announced = Mutex::new(BTreeSet::new());
2315 record_daemon_event(
2316 DaemonEvent::Announce("a._agentmux._tcp.local.".into(), "[192.168.1.26, fe80::1]".into()),
2317 &announced,
2318 );
2319 record_daemon_event(
2320 DaemonEvent::Announce("a._agentmux._tcp.local.".into(), "Area54.local.:172.17.16.1".into()),
2321 &announced,
2322 );
2323 // Unrelated events change nothing.
2324 record_daemon_event(DaemonEvent::IpAdd("10.0.0.9".parse().unwrap()), &announced);
2325 record_daemon_event(DaemonEvent::Respond("192.168.1.26".parse().unwrap()), &announced);
2326 let want: BTreeSet<Ipv4Addr> = ["192.168.1.26", "172.17.16.1"].iter().map(|s| s.parse().unwrap()).collect();
2327 assert_eq!(*announced.lock(), want);
2328
2329 record_daemon_event(DaemonEvent::IpDel("172.17.16.1".parse().unwrap()), &announced);
2330 assert!(!announced.lock().contains(&"172.17.16.1".parse::<Ipv4Addr>().unwrap()));
2331 assert!(announced.lock().contains(&"192.168.1.26".parse::<Ipv4Addr>().unwrap()));
2332 }
2333
2334 /// Real daemon, real multicast: a freshly started instance must report itself
2335 /// announced on this host's IPv4 addresses. This is what proves the payload
2336 /// shape `ipv4_in` parses is the one the crate actually emits, and it is the
2337 /// check that would have failed on Area54. Manual, like the neighbouring
2338 /// test, for the same reason (CI has no reliable multicast).
2339 #[tokio::test]
2340 #[ignore = "real multicast: run by hand, e.g. `cargo test -p agentmux-srv -- --ignored a_started_instance_reports_itself_healthy`"]
2341 async fn a_started_instance_reports_itself_healthy() {
2342 let event_bus = Arc::new(crate::backend::eventbus::EventBus::new());
2343 let discovery = LanDiscovery::start(
2344 format!("health-{}", std::process::id()),
2345 "health-host".to_string(),
2346 "0.0.0-test".to_string(),
2347 54322,
2348 "test-auth-key".to_string(),
2349 event_bus,
2350 )
2351 .expect("LanDiscovery::start should succeed");
2352
2353 tokio::time::sleep(lan_mdns_health::GRACE + std::time::Duration::from_secs(1)).await;
2354 let health = discovery.health();
2355 let announced = discovery.announced_v4.lock().clone();
2356 discovery.shutdown();
2357
2358 assert!(
2359 matches!(health, lan_mdns_health::MdnsHealth::Healthy | lan_mdns_health::MdnsHealth::Degraded { .. }),
2360 "a started instance must be announced on at least one IPv4 address; health={health:?} announced={announced:?}"
2361 );
2362 }
2363
2364 /// End-to-end probe/response round trip over real loopback sockets
2365 /// (ephemeral ports, NOT the fixed UDP_DISCOVERY_PORT — avoids CI port
2366 /// collisions and avoids needing a full `LanDiscovery` + real mDNS
2367 /// `ServiceDaemon` just to exercise the wire format). Exercises the same
2368 /// `is_valid_probe` / `probe_response_json` functions the production
2369 /// `udp_responder_loop` calls.
2370 #[tokio::test]
2371 async fn udp_probe_round_trip_returns_valid_response() {
2372 let responder = UdpSocket::bind("127.0.0.1:0").await.unwrap();
2373 let responder_addr = responder.local_addr().unwrap();
2374 let prober = UdpSocket::bind("127.0.0.1:0").await.unwrap();
2375
2376 let probe = json!({"type": UDP_PROBE_TYPE, "v": UDP_PROTOCOL_VERSION});
2377 prober
2378 .send_to(&serde_json::to_vec(&probe).unwrap(), responder_addr)
2379 .await
2380 .unwrap();
2381
2382 let mut buf = [0u8; 1024];
2383 let (len, src) = responder.recv_from(&mut buf).await.unwrap();
2384 assert!(is_valid_probe(&buf[..len]));
2385
2386 let response = probe_response_json("inst-1", "myhost", "1.2.3", 9999, "secret-key");
2387 responder
2388 .send_to(&serde_json::to_vec(&response).unwrap(), src)
2389 .await
2390 .unwrap();
2391
2392 let (len, _) = prober.recv_from(&mut buf).await.unwrap();
2393 let received: serde_json::Value = serde_json::from_slice(&buf[..len]).unwrap();
2394 assert_eq!(received["type"], UDP_RESPONSE_TYPE);
2395 assert_eq!(received["v"], UDP_PROTOCOL_VERSION);
2396 assert_eq!(received["instance_id"], "inst-1");
2397 assert_eq!(received["hostname"], "myhost");
2398 assert_eq!(received["version"], "1.2.3");
2399 assert_eq!(received["port"], 9999);
2400 assert_eq!(received["auth_key"], "secret-key");
2401 }
2402}
2403
2404// -- `handle_event` self-skip + TXT-clobber regression tests --
2405//
2406// See docs/specs/SPEC_LAN_DISCOVERY_TXT_CLOBBER_FIX_2026_08_16.md. Root
2407// cause: `enable_addr_auto()` makes mdns-sd re-fire `ServiceResolved` for
2408// the SAME service far more often than fresh TXT data actually arrives —
2409// live logs showed ~96% of resolution events for this instance's own
2410// virtual/link-local addresses carrying an empty TXT record while
2411// address/port were always populated. These tests exercise
2412// `LanDiscovery::handle_event` directly against hand-built `ServiceInfo`
2413// values (no real daemon register/browse — construction of a
2414// `ServiceDaemon` is required only to satisfy the struct field, exactly
2415// as the existing ignored round-trip test above already relies on being
2416// safe) so they run fast and are not subject to that test's documented
2417// multicast flakiness.
2418#[cfg(test)]
2419mod handle_event_tests {
2420 use super::{LanDiscovery, LanInstance, SERVICE_TYPE};
2421 use mdns_sd::{ServiceEvent, ServiceInfo};
2422 use parking_lot::{Mutex, RwLock};
2423 use std::collections::{BTreeSet, HashMap};
2424 use std::sync::Arc;
2425 use tokio::sync::oneshot;
2426
2427 /// Build a `ServiceInfo` for `instance_id`, mirroring the exact
2428 /// `service_name`/host-name shape `LanDiscovery::start()` registers
2429 /// with, so `get_fullname()` matches what production code would
2430 /// compute for the same `instance_id`.
2431 fn test_service_info(host_seed: &str, port: u16, properties: &[(&str, &str)]) -> ServiceInfo {
2432 test_service_info_at(host_seed, port, "127.0.0.1", properties)
2433 }
2434
2435 /// As [`test_service_info`], but with an explicit address. Self-fixtures
2436 /// need a genuinely local one, because the self-check now requires the
2437 /// port AND address to say "this is us" — a fullname match alone no
2438 /// longer skips. See `handle_event`.
2439 fn test_service_info_at(
2440 host_seed: &str,
2441 port: u16,
2442 addr: &str,
2443 properties: &[(&str, &str)],
2444 ) -> ServiceInfo {
2445 // Mirror production's naming exactly (`mdns_instance_label`), so
2446 // `get_fullname()` is what `start()` would really register.
2447 let service_name = super::mdns_instance_label(host_seed, port);
2448 test_service_info_with_label(&service_name, port, addr, properties)
2449 }
2450
2451 /// Build a `ServiceInfo` with an explicit instance label, decoupled from
2452 /// the SRV port. Needed because the peer map is keyed by fullname and the
2453 /// label now embeds the port: a test that models "the SAME service moved
2454 /// to a new port" must hold the label fixed while the SRV port changes,
2455 /// which the label-derives-from-port helpers cannot express.
2456 fn test_service_info_with_label(
2457 label: &str,
2458 port: u16,
2459 addr: &str,
2460 properties: &[(&str, &str)],
2461 ) -> ServiceInfo {
2462 ServiceInfo::new(SERVICE_TYPE, label, "test-host.local.", addr, port, properties)
2463 .expect("ServiceInfo::new should succeed for a well-formed test fixture")
2464 }
2465
2466 /// One address that genuinely belongs to this host, so a self-fixture
2467 /// satisfies `resolves_to_this_instance`. Falls back to loopback on the
2468 /// (practically impossible) empty-set machine.
2469 fn own_addr() -> String {
2470 crate::backend::lan_listeners::cached_local_addresses()
2471 .iter()
2472 .next()
2473 .map(|a| a.to_string())
2474 .unwrap_or_else(|| "127.0.0.1".to_string())
2475 }
2476
2477 /// A `LanDiscovery` whose `service_fullname` is derived the same way
2478 /// production's `start()` derives it (from a real `ServiceInfo` for
2479 /// `self_host_seed`), so the self-skip comparison in `handle_event`
2480 /// is exercised exactly as it runs in production — not asserted
2481 /// against a hand-typed guess at mdns-sd's fullname format.
2482 /// `self_port` is now load-bearing: it is BOTH the port baked into our
2483 /// own `service_fullname` and the `port` the self-check compares against,
2484 /// exactly as in production where both come from the same bind. The old
2485 /// fixture set `port: 0` while minting self-events on a different port —
2486 /// harmless when a bare fullname match was enough to skip, but it did not
2487 /// represent a real self-resolution.
2488 fn test_discovery(self_host_seed: &str, self_port: u16) -> LanDiscovery {
2489 let self_info = test_service_info(self_host_seed, self_port, &[]);
2490 LanDiscovery {
2491 daemon: mdns_sd::ServiceDaemon::new().expect("daemon construction (no register/browse)"),
2492 instances: Arc::new(RwLock::new(HashMap::new())),
2493 instance_id: self_host_seed.to_string(),
2494 event_bus: Arc::new(crate::backend::eventbus::EventBus::new()),
2495 service_fullname: self_info.get_fullname().to_string(),
2496 auth_key: String::new(),
2497 hostname: String::new(),
2498 version: String::new(),
2499 port: self_port,
2500 udp_cancel: Mutex::new(None),
2501 udp_peer_cancel: Mutex::new(None),
2502 agent_names_cancel: Mutex::new(None),
2503 instance_record_cancel: Mutex::new(None),
2504 instances_dir: None,
2505 instance_record_live: Mutex::new(false),
2506 announced_v4: Arc::new(Mutex::new(BTreeSet::new())),
2507 monitored: true,
2508 started_at: std::time::Instant::now(),
2509 }
2510 }
2511
2512 fn get(instances: &[LanInstance], instance_id: &str) -> Option<LanInstance> {
2513 instances.iter().find(|i| i.instance_id == instance_id).cloned()
2514 }
2515
2516 // -- cross-host discovery regression (2026-09-06) --
2517 //
2518 // Two hosts on the same build stopped discovering each other in one
2519 // direction. `service_name` was `agentmux-{instance_id}` and `instance_id`
2520 // is the VERSION, so both hosts registered the identical fullname; the
2521 // self-skip then dropped the genuine peer as "us". The dots in the version
2522 // also made the label malformed, triggering mdns-sd conflict-renaming,
2523 // which left the renamed host with a stale `service_fullname` that matched
2524 // its peer's real name — so the renamed host went blind permanently.
2525 // Found jointly with Manpo@gamerlove against two live hosts.
2526
2527 #[test]
2528 fn a_peer_whose_fullname_equals_ours_is_still_discovered() {
2529 // THE regression. A remote peer announcing under a fullname identical
2530 // to our own (same-version collision, or a stale self-name after a
2531 // conflict-rename) must NOT be mistaken for us: it is on a different
2532 // port, which is proof it is somebody else.
2533 let discovery = test_discovery("collide", 59859);
2534
2535 // The peer must carry OUR EXACT fullname while arriving from a foreign
2536 // address on a foreign port — that is the shape the old
2537 // `fullname == self.service_fullname` guard silently dropped. Pin the
2538 // label explicitly: deriving it from the peer's own (foreign) port
2539 // would produce a DIFFERENT fullname, the old guard would never fire
2540 // on it, and this test would pass against the unfixed code — vacuous.
2541 // (Caught by Manpo@gamerlove reviewing this PR; verified by reverting
2542 // the guard locally and watching this test go red.)
2543 let self_label = super::mdns_instance_label("collide", 59859);
2544 let remote_peer = test_service_info_with_label(
2545 &self_label,
2546 60237,
2547 "192.168.1.68",
2548 &[("instance_id", "v0.55.37"), ("hostname", "gamerlove")],
2549 );
2550 assert_eq!(
2551 remote_peer.get_fullname(),
2552 discovery.service_fullname,
2553 "fixture must actually reproduce the fullname collision, or this test proves nothing"
2554 );
2555
2556 discovery.handle_event(ServiceEvent::ServiceResolved(remote_peer));
2557
2558 assert_eq!(
2559 discovery.get_instances().len(),
2560 1,
2561 "a remote peer must be discovered even when its fullname collides with ours"
2562 );
2563 }
2564
2565 #[test]
2566 fn instance_label_is_free_of_dots_and_unique_per_instance() {
2567 // Dots made the label malformed (mdns-sd split it and appended its
2568 // conflict suffix mid-string, observed as
2569 // "agentmux-v0 (2).55.37._agentmux._tcp.local.").
2570 let fqdn = super::mdns_instance_label("box.corp.example", 59859);
2571 assert!(!fqdn.contains('.'), "label must contain no dots, got {fqdn}");
2572
2573 // Same host, two instances -> different labels (differing ports).
2574 assert_ne!(
2575 super::mdns_instance_label("narko", 59859),
2576 super::mdns_instance_label("narko", 60237),
2577 );
2578 // Two hosts on the SAME version -> different labels. This is the
2579 // property the old version-derived name did not have.
2580 assert_ne!(
2581 super::mdns_instance_label("narko", 59859),
2582 super::mdns_instance_label("gamerlove", 59859),
2583 );
2584 // No version anywhere in it, deliberately.
2585 assert!(!super::mdns_instance_label("narko", 59859).contains("0.55"));
2586 }
2587
2588 #[test]
2589 fn instance_label_survives_a_hostname_with_no_usable_characters() {
2590 let label = super::mdns_instance_label("...", 59859);
2591 assert!(!label.contains('.'));
2592 assert_eq!(label, "agentmux-unknown-59859");
2593 }
2594
2595 #[test]
2596 fn self_resolution_with_blank_txt_is_never_inserted_as_a_peer() {
2597 // This is the exact failure mode from the live logs: mdns-sd
2598 // re-resolves this instance's own service on a virtual/link-local
2599 // interface and the TXT record (instance_id/hostname/version/
2600 // auth_key) comes back empty. Skipping by `fullname` (SRV-record
2601 // derived, always present) rather than the TXT `instance_id`
2602 // property must catch this even though the property itself is
2603 // blank on this event.
2604 let discovery = test_discovery("self-id", 56023);
2605 let blank_self_event = test_service_info_at("self-id", 56023, &own_addr(), &[]);
2606
2607 discovery.handle_event(ServiceEvent::ServiceResolved(blank_self_event));
2608
2609 assert!(
2610 discovery.get_instances().is_empty(),
2611 "a blank-TXT resolution of this instance's own service must never appear as a peer"
2612 );
2613 }
2614
2615 #[test]
2616 fn self_resolution_with_full_txt_is_also_never_inserted_as_a_peer() {
2617 // Sanity check that the fullname-based skip doesn't regress the
2618 // straightforward case (this was already correct before the fix).
2619 let discovery = test_discovery("self-id", 56023);
2620 let full_self_event = test_service_info_at(
2621 "self-id",
2622 56023,
2623 &own_addr(),
2624 &[("instance_id", "self-id"), ("hostname", "myhost")],
2625 );
2626
2627 discovery.handle_event(ServiceEvent::ServiceResolved(full_self_event));
2628
2629 assert!(discovery.get_instances().is_empty());
2630 }
2631
2632 #[test]
2633 fn blank_txt_refire_does_not_clobber_previously_known_good_peer_fields() {
2634 let discovery = test_discovery("self-id", 56023);
2635
2636 let full_event = test_service_info(
2637 "peer-1",
2638 9999,
2639 &[
2640 ("instance_id", "peer-1"),
2641 ("hostname", "realhost"),
2642 ("version", "1.2.3"),
2643 ("auth_key", "secret"),
2644 ],
2645 );
2646 discovery.handle_event(ServiceEvent::ServiceResolved(full_event));
2647
2648 let after_full = get(&discovery.get_instances(), "peer-1").expect("peer-1 should be discovered");
2649 assert_eq!(after_full.hostname, "realhost");
2650 assert_eq!(after_full.version, "1.2.3");
2651 assert_eq!(after_full.auth_key, "secret");
2652
2653 // Same service (same fullname), a re-resolution with an empty TXT
2654 // record — exactly what mdns-sd's `enable_addr_auto()` produces on
2655 // most re-fires per the live-log evidence.
2656 let blank_refire = test_service_info("peer-1", 9999, &[]);
2657 discovery.handle_event(ServiceEvent::ServiceResolved(blank_refire));
2658
2659 let instances = discovery.get_instances();
2660 assert_eq!(instances.len(), 1, "the blank re-fire must update the existing entry, not create a duplicate");
2661 let after_blank = get(&instances, "peer-1").expect("peer-1 must still be present");
2662 assert_eq!(after_blank.hostname, "realhost", "hostname must survive a blank-TXT re-fire");
2663 assert_eq!(after_blank.version, "1.2.3", "version must survive a blank-TXT re-fire");
2664 assert_eq!(after_blank.auth_key, "secret", "auth_key must survive a blank-TXT re-fire");
2665 assert_eq!(after_blank.instance_id, "peer-1", "instance_id must survive a blank-TXT re-fire");
2666 }
2667
2668 #[test]
2669 fn service_removed_does_not_delete_a_known_peer() {
2670 // The live-log failure mode this guards against: mdns-sd fires
2671 // `ServiceRemoved` on ordinary TTL/interface churn, not just on a
2672 // genuine peer departure. The old handler deleted the map entry on
2673 // that event, so the peer's identity (hostname/version/auth_key) was
2674 // gone — and every subsequent re-resolution came back blank-TXT (see
2675 // the refire test above), so it could never recover. `ServiceRemoved`
2676 // must be inert; only staleness (`get_instances()`'s `last_seen`
2677 // check) may make a peer disappear.
2678 let discovery = test_discovery("self-id", 56023);
2679
2680 let full_event = test_service_info(
2681 "peer-1",
2682 9999,
2683 &[
2684 ("instance_id", "peer-1"),
2685 ("hostname", "realhost"),
2686 ("version", "1.2.3"),
2687 ("auth_key", "secret"),
2688 ],
2689 );
2690 let fullname = full_event.get_fullname().to_string();
2691 discovery.handle_event(ServiceEvent::ServiceResolved(full_event));
2692 assert_eq!(discovery.get_instances().len(), 1, "peer-1 should be discovered");
2693
2694 discovery.handle_event(ServiceEvent::ServiceRemoved(SERVICE_TYPE.to_string(), fullname.clone()));
2695
2696 let after_removed = get(&discovery.get_instances(), "peer-1")
2697 .expect("ServiceRemoved must not delete the peer");
2698 assert_eq!(after_removed.hostname, "realhost", "hostname must survive a ServiceRemoved event");
2699 assert_eq!(after_removed.version, "1.2.3", "version must survive a ServiceRemoved event");
2700 assert_eq!(after_removed.auth_key, "secret", "auth_key must survive a ServiceRemoved event");
2701
2702 // The failure mode in full: Removed, then a blank-TXT re-resolution
2703 // (the realistic post-removal shape per live logs) must still not
2704 // lose the identity established by the original full resolution.
2705 let blank_refire = test_service_info("peer-1", 9999, &[]);
2706 discovery.handle_event(ServiceEvent::ServiceResolved(blank_refire));
2707 let after_refire = get(&discovery.get_instances(), "peer-1")
2708 .expect("peer-1 must still be present after a post-removal blank refire");
2709 assert_eq!(after_refire.hostname, "realhost");
2710 assert_eq!(after_refire.version, "1.2.3");
2711 assert_eq!(after_refire.auth_key, "secret");
2712 }
2713
2714 #[test]
2715 fn get_instances_hides_a_peer_stale_past_its_own_ttl() {
2716 let discovery = test_discovery("self-id", 56023);
2717
2718 let full_event = test_service_info(
2719 "peer-1",
2720 9999,
2721 &[("instance_id", "peer-1"), ("hostname", "realhost")],
2722 );
2723 discovery.handle_event(ServiceEvent::ServiceResolved(full_event));
2724 assert_eq!(discovery.get_instances().len(), 1);
2725
2726 // mdns-sd 0.12 exposes no way to construct a ServiceInfo with a
2727 // custom TTL, so the fixture above always resolves with the crate's
2728 // real default (`other_ttl_secs: 4500`) — set it directly to a value
2729 // ABOVE `LAN_PEER_STALE_TIMEOUT_FLOOR_SECS` (300) so the boundary
2730 // being tested is the peer's own TTL, not the floor underneath it.
2731 {
2732 let mut instances = discovery.instances.write();
2733 for inst in instances.values_mut() {
2734 inst.other_ttl_secs = 1000;
2735 }
2736 }
2737
2738 // Backdate past the floor (300s) but still within this peer's own
2739 // 1000s TTL: must still be reported — proves the peer's own TTL, not
2740 // the floor, is what's controlling visibility here.
2741 {
2742 let mut instances = discovery.instances.write();
2743 for inst in instances.values_mut() {
2744 inst.last_seen = inst.last_seen.saturating_sub(600);
2745 }
2746 }
2747 assert_eq!(
2748 discovery.get_instances().len(),
2749 1,
2750 "600s of silence is past the old 300s floor but within this peer's own 1000s TTL"
2751 );
2752
2753 // Now push past the peer's own TTL too.
2754 {
2755 let mut instances = discovery.instances.write();
2756 for inst in instances.values_mut() {
2757 inst.last_seen = inst.last_seen.saturating_sub(401);
2758 }
2759 }
2760 assert!(
2761 discovery.get_instances().is_empty(),
2762 "a peer with no ServiceResolved in over its own advertised TTL must not be reported"
2763 );
2764 }
2765
2766 #[test]
2767 fn peer_staleness_window_secs_ceils_an_adversarially_large_advertised_ttl() {
2768 // ReAgent P1, PR #3301: other_ttl_secs comes straight from a peer's own
2769 // (spoofable) mDNS advertisement — this file already treats that input
2770 // as adversarial elsewhere (LAN_AGENT_NAMES_MAX_*). Without a ceiling, a
2771 // peer claiming an other_ttl near u32::MAX (~136 years) would be
2772 // honored forever, permanently pinning it as "alive" and defeating the
2773 // entire staleness design.
2774 assert_eq!(
2775 super::peer_staleness_window_secs(u32::MAX),
2776 super::LAN_PEER_STALE_TIMEOUT_CEIL_SECS,
2777 "an implausibly large advertised TTL must be capped, not honored as-is"
2778 );
2779 }
2780
2781 #[test]
2782 fn get_instances_hides_a_peer_with_an_adversarial_ttl_once_the_ceiling_elapses() {
2783 let discovery = test_discovery("self-id", 56023);
2784
2785 let full_event = test_service_info(
2786 "peer-1",
2787 9999,
2788 &[("instance_id", "peer-1"), ("hostname", "realhost")],
2789 );
2790 discovery.handle_event(ServiceEvent::ServiceResolved(full_event));
2791 assert_eq!(discovery.get_instances().len(), 1);
2792
2793 {
2794 let mut instances = discovery.instances.write();
2795 for inst in instances.values_mut() {
2796 inst.other_ttl_secs = u32::MAX;
2797 inst.last_seen = inst.last_seen.saturating_sub(super::LAN_PEER_STALE_TIMEOUT_CEIL_SECS + 1);
2798 }
2799 }
2800
2801 assert!(
2802 discovery.get_instances().is_empty(),
2803 "a peer claiming an implausibly large TTL must still expire at the ceiling, \
2804 not be pinned as permanently alive"
2805 );
2806 }
2807
2808 #[test]
2809 fn get_instances_does_not_prune_a_slow_reannouncing_peer_within_its_ttl() {
2810 // The real bug this guards against (2026-09-17): narko and starpower
2811 // re-announce roughly every 40-46 minutes (their actual mDNS `other_ttl`
2812 // record cycle), well past the old fixed 300s cutoff but comfortably
2813 // inside the real advertised TTL (4500s/75min default — RFC 6762 §10,
2814 // mdns-sd 0.12's hardcoded `DNS_OTHER_TTL`). A peer silent for 46
2815 // minutes is still alive and must still be reported.
2816 let discovery = test_discovery("self-id", 56023);
2817
2818 let full_event = test_service_info(
2819 "peer-1",
2820 9999,
2821 &[("instance_id", "peer-1"), ("hostname", "realhost")],
2822 );
2823 discovery.handle_event(ServiceEvent::ServiceResolved(full_event));
2824 assert_eq!(discovery.get_instances().len(), 1);
2825
2826 const FORTY_SIX_MINUTES_SECS: u64 = 46 * 60;
2827 {
2828 let mut instances = discovery.instances.write();
2829 for inst in instances.values_mut() {
2830 inst.last_seen = inst.last_seen.saturating_sub(FORTY_SIX_MINUTES_SECS);
2831 }
2832 }
2833
2834 assert_eq!(
2835 discovery.get_instances().len(),
2836 1,
2837 "a peer silent for less than its own advertised TTL must still be reported, \
2838 even though that gap is far past the old fixed 300s cutoff"
2839 );
2840 }
2841
2842 #[test]
2843 fn address_and_port_still_update_unconditionally_on_a_blank_txt_refire() {
2844 // address/port come from the SRV/A record, not TXT, and are always
2845 // populated by mdns-sd — a genuine interface/address change must
2846 // still be reflected even when the TXT-derived fields are absent
2847 // on that same event.
2848 let discovery = test_discovery("self-id", 56023);
2849
2850 // One stable label across both events: this models the same mDNS
2851 // service re-resolving on a new port, not a different service. (The
2852 // label embeds a port in production, so it is pinned here explicitly
2853 // rather than derived from the changing SRV port.)
2854 let label = super::mdns_instance_label("peer-1", 9999);
2855 let first = test_service_info_with_label(
2856 &label,
2857 9999,
2858 "127.0.0.1",
2859 &[("instance_id", "peer-1"), ("hostname", "realhost")],
2860 );
2861 discovery.handle_event(ServiceEvent::ServiceResolved(first));
2862 assert_eq!(get(&discovery.get_instances(), "peer-1").unwrap().port, 9999);
2863
2864 let moved = test_service_info_with_label(&label, 8888, "127.0.0.1", &[]);
2865 discovery.handle_event(ServiceEvent::ServiceResolved(moved));
2866
2867 let instances = discovery.get_instances();
2868 let entry = get(&instances, "peer-1").expect("peer-1 must still be present");
2869 assert_eq!(entry.port, 8888, "port must update even on a blank-TXT event");
2870 assert_eq!(entry.hostname, "realhost", "hostname must still survive despite the address/port change");
2871 }
2872
2873 #[test]
2874 fn a_new_peer_first_seen_via_blank_txt_has_no_data_to_preserve() {
2875 // Not a regression this fix is responsible for solving — merely
2876 // documents the expected (acceptable) behavior for a genuinely
2877 // first-seen blank-TXT event: nothing was ever known, so nothing
2878 // can be preserved. hostname stays empty until a TXT-bearing event
2879 // eventually arrives.
2880 let discovery = test_discovery("self-id", 56023);
2881 let blank_first = test_service_info("peer-2", 7777, &[]);
2882
2883 discovery.handle_event(ServiceEvent::ServiceResolved(blank_first));
2884
2885 let instances = discovery.get_instances();
2886 assert_eq!(instances.len(), 1);
2887 assert_eq!(instances[0].hostname, "");
2888 assert_eq!(instances[0].instance_id, "");
2889 }
2890
2891 /// The refresh loop holds its own `Arc<LanDiscovery>`, so dropping the
2892 /// controller's Arc on `apply(false)` can never stop it — only an explicit
2893 /// signal can. Without this, disabling LAN discovery left the task issuing
2894 /// authenticated requests to peers forever, leaking one per toggle cycle
2895 /// (ReAgent P1 on PR #3102 — the first version of this code shipped a doc
2896 /// comment claiming cancellation it did not actually implement, which is
2897 /// exactly why this is asserted here rather than described).
2898 #[tokio::test]
2899 async fn shutdown_cancels_the_agent_names_refresh_loop() {
2900 let discovery = Arc::new(test_discovery("cancel-host", 55123));
2901
2902 let (tx, rx) = oneshot::channel();
2903 *discovery.agent_names_cancel.lock() = Some(tx);
2904 let handle = tokio::spawn(discovery.clone().agent_names_refresh_loop(rx));
2905
2906 // Still parked on its sleep — nothing has cancelled it yet.
2907 assert!(!handle.is_finished());
2908
2909 discovery.shutdown();
2910
2911 // Must return promptly on the signal rather than after the full
2912 // LAN_AGENT_NAMES_REFRESH_SECS sleep, which is the whole point of
2913 // selecting on the cancel inside the sleep.
2914 let stopped = tokio::time::timeout(std::time::Duration::from_secs(5), handle).await;
2915 assert!(
2916 stopped.is_ok(),
2917 "refresh loop did not exit within 5s of shutdown() - it would outlive apply(false)"
2918 );
2919 }
2920}
2921
2922#[cfg(test)]
2923mod lookup_rate_limiter_tests {
2924 use super::LookupRateLimiter;
2925
2926 #[test]
2927 fn allows_up_to_max_tokens_per_window() {
2928 let mut limiter = LookupRateLimiter::new(3);
2929 assert!(limiter.check());
2930 assert!(limiter.check());
2931 assert!(limiter.check());
2932 assert!(!limiter.check(), "a 4th check within the same second must be denied");
2933 }
2934
2935 #[test]
2936 fn refills_after_the_window_elapses() {
2937 let mut limiter = LookupRateLimiter::new(1);
2938 assert!(limiter.check());
2939 assert!(!limiter.check());
2940 // Simulate the refill window having passed rather than sleeping in
2941 // a test — same approach as the existing RATE_LIMIT_MAX tests use
2942 // conceptually, just directly on the struct field since this type
2943 // has no injectable clock.
2944 limiter.last_refill -= std::time::Duration::from_secs(2);
2945 assert!(limiter.check(), "must refill once a full second has elapsed");
2946 }
2947}
2948
2949#[cfg(test)]
2950mod peer_fanout_tests {
2951 use super::*;
2952 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2953
2954 /// Spawn a minimal HTTP peer that waits `delay` before answering `status`.
2955 /// Returns its `127.0.0.1:port`. Same raw-TCP shape as the mock servers in
2956 /// `muxbus::pkce`'s tests — no extra dev-dependency needed.
2957 async fn mock_peer(delay: std::time::Duration, status: &'static str, body: &'static str) -> String {
2958 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2959 let addr = listener.local_addr().unwrap();
2960 tokio::spawn(async move {
2961 loop {
2962 let Ok((mut stream, _)) = listener.accept().await else { return };
2963 tokio::spawn(async move {
2964 let mut buf = [0u8; 2048];
2965 let _ = stream.read(&mut buf).await;
2966 tokio::time::sleep(delay).await;
2967 let resp = format!(
2968 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
2969 body.len()
2970 );
2971 let _ = stream.write_all(resp.as_bytes()).await;
2972 });
2973 }
2974 });
2975 format!("127.0.0.1:{}", addr.port())
2976 }
2977
2978 fn peer_at(addr: &str) -> LanInstance {
2979 let (host, port) = addr.rsplit_once(':').unwrap();
2980 LanInstance {
2981 instance_id: format!("peer-{port}"),
2982 hostname: "test".into(),
2983 version: "0".into(),
2984 channel: String::new(),
2985 os: String::new(),
2986 address: host.to_string(),
2987 port: port.parse().unwrap(),
2988 auth_key: "k".into(),
2989 agents: vec![],
2990 agent_status: Default::default(),
2991 agent_lines: Default::default(),
2992 first_seen: 0,
2993 last_seen: 0,
2994 last_polled_ok: 0,
2995 other_ttl_secs: 4500,
2996 }
2997 }
2998
2999 /// The regression this fan-out exists to prevent: a slow peer listed BEFORE
3000 /// the peer that actually has the agent must not delay the answer.
3001 ///
3002 /// Sequentially this took `LAN_PEER_QUERY_TIMEOUT_SECS` (2s) to time the
3003 /// slow peer out before even trying the second one. Concurrently the fast
3004 /// peer answers immediately. The 1500ms bound sits well clear of both the
3005 /// ~0ms concurrent path and the ≥2000ms sequential one, so it discriminates
3006 /// without being timing-fragile.
3007 #[tokio::test]
3008 async fn a_slow_peer_does_not_delay_a_fast_one() {
3009 let slow = mock_peer(
3010 std::time::Duration::from_secs(LAN_PEER_QUERY_TIMEOUT_SECS + 5),
3011 "200 OK",
3012 "{}",
3013 )
3014 .await;
3015 let fast = mock_peer(std::time::Duration::from_millis(0), "200 OK", "{}").await;
3016 // Slow first — the ordering that defeated the sequential loop.
3017 let peers = vec![peer_at(&slow), peer_at(&fast)];
3018 let http = reqwest::Client::new();
3019
3020 let started = std::time::Instant::now();
3021 let mut inflight = query_peers_concurrently(&peers, "agent-x", &http);
3022 let mut winner = None;
3023 {
3024 use futures_util::StreamExt as _;
3025 while let Some((peer_url, _key, result)) = inflight.next().await {
3026 if matches!(result, Ok(ref r) if r.status().is_success()) {
3027 winner = Some(peer_url);
3028 break;
3029 }
3030 }
3031 }
3032 let elapsed = started.elapsed();
3033
3034 assert_eq!(
3035 winner.as_deref(),
3036 Some(format!("http://{fast}").as_str()),
3037 "the fast peer should win even though the slow peer was queried first"
3038 );
3039 assert!(
3040 elapsed < std::time::Duration::from_millis(1500),
3041 "fan-out should not wait on the slow peer; took {elapsed:?}"
3042 );
3043 }
3044
3045 /// Peers with no address or no scoped lan_key are not queryable and must be
3046 /// skipped — same eligibility rule the sequential loops applied.
3047 #[tokio::test]
3048 async fn ineligible_peers_are_skipped() {
3049 let mut no_addr = peer_at("127.0.0.1:1");
3050 no_addr.address = String::new();
3051 let mut no_key = peer_at("127.0.0.1:2");
3052 no_key.auth_key = String::new();
3053 let peers = vec![no_addr, no_key];
3054 let http = reqwest::Client::new();
3055 let inflight = query_peers_concurrently(&peers, "agent-x", &http);
3056 assert_eq!(inflight.len(), 0, "neither peer is eligible to be queried");
3057 }
3058}
3059
3060/// `is_self_resolution` — the guard that stops this instance inserting itself
3061/// as a LAN peer.
3062///
3063/// The scenario these encode was measured on 2026-09-06, not imagined: four
3064/// `LAN peer discovered` events, all `peer_id=""`, all on this instance's own
3065/// port 55019, at 192.168.1.230 / 172.23.176.1 / fe80::5c1e:c2bb:b5bc:9655 —
3066/// every one an address of this host. The pre-existing fullname-based self-skip
3067/// did not catch them.
3068#[cfg(test)]
3069mod self_resolution_tests {
3070 use super::is_self_resolution;
3071 use std::collections::HashSet;
3072 use std::net::IpAddr;
3073
3074 fn own() -> HashSet<IpAddr> {
3075 ["192.168.1.230", "172.23.176.1", "fe80::5c1e:c2bb:b5bc:9655", "127.0.0.1"]
3076 .iter()
3077 .map(|s| s.parse().unwrap())
3078 .collect()
3079 }
3080
3081 fn ip(s: &str) -> IpAddr {
3082 s.parse().unwrap()
3083 }
3084
3085 /// The exact observed case, one per address the log showed.
3086 #[test]
3087 fn our_own_addresses_on_our_own_port_are_self() {
3088 for addr in ["192.168.1.230", "172.23.176.1", "fe80::5c1e:c2bb:b5bc:9655"] {
3089 assert!(
3090 is_self_resolution(55019, &[ip(addr)], 55019, &own()),
3091 "{addr}:55019 is this host on this instance's port — must be skipped"
3092 );
3093 }
3094 }
3095
3096 /// A real peer: different machine, so its addresses are not ours. Sharing
3097 /// our ephemeral port by coincidence must NOT be enough to discard it —
3098 /// that would silently drop a genuine peer, which is far worse than the
3099 /// phantom this guard exists to remove.
3100 #[test]
3101 fn a_remote_peer_on_a_coincidentally_equal_port_is_not_self() {
3102 assert!(!is_self_resolution(55019, &[ip("192.168.1.77")], 55019, &own()));
3103 }
3104
3105 /// Another AgentMux instance on THIS host does share our addresses — it is
3106 /// the port that distinguishes it. Two instances never hold the same port.
3107 #[test]
3108 fn a_sibling_instance_on_this_host_is_not_self() {
3109 assert!(
3110 !is_self_resolution(51095, &[ip("192.168.1.230")], 55019, &own()),
3111 "same host, different port — a real sibling instance, not us"
3112 );
3113 }
3114
3115 /// `any`, not `all`: interface enumeration and mDNS resolution race, so a
3116 /// resolution of our own service can carry one address we didn't enumerate.
3117 /// Requiring every address to match would let that stray entry resurrect
3118 /// the phantom.
3119 #[test]
3120 fn one_matching_address_is_enough_even_beside_an_unknown_one() {
3121 assert!(is_self_resolution(
3122 55019,
3123 &[ip("10.99.99.99"), ip("172.23.176.1")],
3124 55019,
3125 &own()
3126 ));
3127 }
3128
3129 /// Enumeration failing means "we cannot prove any address is ours". Degrade
3130 /// to the old behaviour (a phantom peer) rather than to something worse —
3131 /// an empty set must never make everything look like self.
3132 #[test]
3133 fn an_empty_own_set_never_claims_self() {
3134 assert!(!is_self_resolution(55019, &[ip("192.168.1.230")], 55019, &HashSet::new()));
3135 }
3136
3137 #[test]
3138 fn a_resolution_with_no_addresses_is_not_self() {
3139 assert!(!is_self_resolution(55019, &[], 55019, &own()));
3140 }
3141}
3142
3143/// `agent_names_refresh_loop`'s peer-response handling — real HTTP round
3144/// trips against a raw fake server, same style as
3145/// `server::tests::spawn_fake_browser_api`, because the behavior under test
3146/// (bailing out mid-body, not just rejecting on `Content-Length`) can't be
3147/// exercised through an in-process `tower::ServiceExt::oneshot` call.
3148#[cfg(test)]
3149mod agent_names_refresh_tests {
3150 use super::{LanDiscovery, LAN_AGENT_NAMES_MAX_COUNT, LAN_AGENT_NAMES_MAX_NAME_LEN};
3151 use tokio::io::{AsyncReadExt, AsyncWriteExt};
3152
3153 /// Starts a raw TCP server that always answers
3154 /// `/agentmux/reactive/agent-names` with `body` verbatim (caller supplies
3155 /// correct headers/framing), and returns its `http://127.0.0.1:<port>`
3156 /// base URL. One-shot: serves exactly one connection then stops, which is
3157 /// all a single `fetch_peer_agent_names` call makes.
3158 async fn spawn_fake_agent_names_server(response: &'static str) -> String {
3159 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3160 let port = listener.local_addr().unwrap().port();
3161 tokio::spawn(async move {
3162 let Ok((mut stream, _)) = listener.accept().await else { return };
3163 let mut buf = [0u8; 4096];
3164 let _ = stream.read(&mut buf).await;
3165 let _ = stream.write_all(response.as_bytes()).await;
3166 });
3167 format!("http://127.0.0.1:{port}")
3168 }
3169
3170 /// Codex P1 on PR #3102: the `lan_key` a peer advertises authenticates
3171 /// THIS client to that peer, not the other way round — anything able to
3172 /// fake an mDNS advertisement gets into `self.instances` and is polled.
3173 /// A response larger than the cap must be rejected outright, not
3174 /// buffered in full and then discarded.
3175 #[tokio::test]
3176 async fn oversized_response_is_rejected_not_buffered() {
3177 // 4x the cap, filled with a repeating byte inside a JSON string so a
3178 // parser that ignored the cap would otherwise succeed.
3179 let oversized_len = 4 * super::LAN_AGENT_NAMES_MAX_RESPONSE_BYTES;
3180 let filler = "a".repeat(oversized_len);
3181 let json_body = format!(r#"{{"agents":["{filler}"]}}"#);
3182 let response = format!(
3183 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
3184 json_body.len(),
3185 json_body
3186 );
3187 let leaked: &'static str = Box::leak(response.into_boxed_str());
3188 let base_url = spawn_fake_agent_names_server(leaked).await;
3189 let http = reqwest::Client::new();
3190
3191 let names = LanDiscovery::fetch_peer_agent_names(&http, &base_url, "irrelevant").await;
3192 assert!(
3193 names.is_none(),
3194 "an oversized response must be discarded entirely, not truncated into a result"
3195 );
3196 }
3197
3198 /// The happy path, plus defense-in-depth entry capping: a valid response
3199 /// with a name that's too long and more names than the count cap allows
3200 /// should keep the good names and drop the rest, not fail outright.
3201 #[tokio::test]
3202 async fn valid_response_is_parsed_and_excess_entries_are_capped() {
3203 let too_long_name = "x".repeat(LAN_AGENT_NAMES_MAX_NAME_LEN + 1);
3204 let mut names_json: Vec<String> = (0..LAN_AGENT_NAMES_MAX_COUNT + 10)
3205 .map(|i| format!("\"agent-{i}\""))
3206 .collect();
3207 names_json.push(format!("\"{too_long_name}\""));
3208 let json_body = format!(r#"{{"agents":[{}]}}"#, names_json.join(","));
3209 let response = format!(
3210 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
3211 json_body.len(),
3212 json_body
3213 );
3214 let leaked: &'static str = Box::leak(response.into_boxed_str());
3215 let base_url = spawn_fake_agent_names_server(leaked).await;
3216 let http = reqwest::Client::new();
3217
3218 let names = LanDiscovery::fetch_peer_agent_names(&http, &base_url, "irrelevant")
3219 .await
3220 .expect("a well-formed, appropriately-sized response must parse")
3221 .names;
3222
3223 assert_eq!(names.len(), LAN_AGENT_NAMES_MAX_COUNT, "must truncate to the count cap");
3224 assert!(names.contains(&"agent-0".to_string()));
3225 assert!(
3226 !names.iter().any(|n| n.len() > LAN_AGENT_NAMES_MAX_NAME_LEN),
3227 "an over-length name must never survive into the result"
3228 );
3229 }
3230
3231 /// Codex P2 on PR #3102: sequential polling lets N stale/unreachable
3232 /// peers each burn their full timeout in turn. Two peers that each take
3233 /// ~300ms to answer must complete in ~300ms total, not ~600ms — proving
3234 /// the fan-out is real concurrency, not just non-blocking syntax that
3235 /// still executes one after another.
3236 #[tokio::test]
3237 async fn peers_are_polled_concurrently_not_sequentially() {
3238 async fn spawn_slow_agent_names_server(delay_ms: u64) -> String {
3239 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3240 let port = listener.local_addr().unwrap().port();
3241 tokio::spawn(async move {
3242 let Ok((mut stream, _)) = listener.accept().await else { return };
3243 let mut buf = [0u8; 4096];
3244 let _ = stream.read(&mut buf).await;
3245 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
3246 let body = r#"{"agents":["slow-agent"]}"#;
3247 let resp = format!(
3248 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
3249 body.len(),
3250 body
3251 );
3252 let _ = stream.write_all(resp.as_bytes()).await;
3253 });
3254 format!("http://127.0.0.1:{port}")
3255 }
3256
3257 let delay_ms = 300;
3258 let url_a = spawn_slow_agent_names_server(delay_ms).await;
3259 let url_b = spawn_slow_agent_names_server(delay_ms).await;
3260 let http = reqwest::Client::new();
3261
3262 let started = std::time::Instant::now();
3263 use futures_util::StreamExt as _;
3264 let mut inflight = futures_util::stream::FuturesUnordered::new();
3265 inflight.push(LanDiscovery::fetch_peer_agent_names(&http, &url_a, "k"));
3266 inflight.push(LanDiscovery::fetch_peer_agent_names(&http, &url_b, "k"));
3267 while inflight.next().await.is_some() {}
3268 let elapsed = started.elapsed();
3269
3270 assert!(
3271 elapsed < std::time::Duration::from_millis(delay_ms * 2 - 100),
3272 "two {delay_ms}ms peers took {elapsed:?} — looks sequential, not concurrent"
3273 );
3274 }
3275}