Skip to main content

mcpmesh_node/discovery/
local.rs

1//! LOCAL (mDNS) peer discovery (#68) — find peers on the same link with no internet at all.
2//!
3//! # Why this exists
4//!
5//! Peer resolution otherwise depends on external infrastructure: the pkarr publisher/resolver a
6//! relay provides, or a dialable address someone already handed over in an invite. Two machines on
7//! the same LAN with no uplink cannot find each other, though the network path between them is
8//! fine. That is the scenario where "peer to peer" earns its keep — a boat, a workshop, a failed
9//! uplink, a deliberately air-gapped network — and the commoner weak version too: a LAN where the
10//! internet is merely flaky, so peers that could talk directly fail to resolve because resolution
11//! goes out first.
12//!
13//! # This is a DEPENDENCY, not an implementation
14//!
15//! #68 concluded "there is no mDNS in iroh 1.0.3, so this needs an implementation", from a correct
16//! reading of `iroh-1.0.3/src/address_lookup/` — which contains exactly `dns.rs`, `memory.rs` and
17//! `pkarr.rs`. iroh 1.x did not drop mDNS; it moved it into a companion crate, and says so in the
18//! module docs of that same file:
19//!
20//! > mDNS-based and Mainline-DHT-based Address Lookup services live in separate crates:
21//! > `iroh-mdns-address-lookup` and `iroh-mainline-address-lookup`.
22//!
23//! So this module is thin on purpose. An mDNS responder is a multicast listener on every interface;
24//! one written here would be ours to get right and ours to keep right, against a transport whose
25//! address model it has to track. n0's stays version-matched to the iroh we pin.
26//!
27//! # What it discloses
28//!
29//! Advertising multicasts this node's endpoint id and its addresses to **every device on the link**,
30//! unprompted and repeatedly, including machines that had no idea it existed. "Its addresses" means
31//! the LAN address, the PUBLIC WAN IPv4 and global IPv6 — a café LAN learns your home/ISP address,
32//! not merely that you are there. That is why `[network].local_discovery` defaults to `"off"`, a
33//! deliberate departure from what #68 asked for.
34//!
35//! **`"resolve"` is quieter, not silent.** Resolving over mDNS means asking:
36//! `MdnsAddressLookup` builds a `Discoverer::new_interactive` unconditionally (τ = 700 ms) and
37//! `advertise` gates only `with_addrs`, so a resolving node multicasts a `_mcpmesh._udp.local`
38//! query roughly once a second for as long as it runs. It publishes no identity and no addresses —
39//! pinned on the wire — but the query itself says "an mcpmesh node is at this IP, right now". The
40//! docs said "listen only" until the 0.44.0 gate captured the packets.
41//!
42//! **`relay_only` does not restrain any of this on a stock build.** `AddrFilter::relay_only()` is
43//! installed only under the `unstable-relay-only` feature; without it the filter does not exist and
44//! the full direct address set goes out. Boot warns, naming which of the two builds the operator
45//! has. An earlier version of this comment asserted the filter always applied; it does not.
46
47use iroh::EndpointId;
48use iroh::address_lookup::AddressLookup;
49use iroh_mdns_address_lookup::MdnsAddressLookup;
50
51/// The mDNS service name mcpmesh nodes announce and listen on.
52///
53/// Deliberately NOT the crate's `irohv1` default, which is the SHARED iroh namespace: every iroh
54/// application on the link would advertise into it and be resolved out of it. That is not an
55/// authorization problem — resolution answers *where*, never *who may*, and a peer found this way
56/// still faces the trust gate — but it is a disclosure and a noise problem, announcing this node to
57/// unrelated applications and resolving endpoint ids that can never be ours.
58///
59/// Records take the form `<endpoint-id>._mcpmesh._udp.local`.
60pub const SERVICE_NAME: &str = "mcpmesh";
61
62/// Build the local-discovery lookup for `endpoint_id`.
63///
64/// **Takes the parsed [`LocalDiscovery`], not a bare `bool`, deliberately.** The 0.44.0 gate
65/// mutated the call site from `local_disc.advertise` to a literal `true` — putting a node told to
66/// listen only on the air, broadcasting its endpoint id — and every deterministic test stayed
67/// green. A bare bool makes that a one-character edit; a struct parsed from config makes the same
68/// lie require constructing a `LocalDiscovery` that disagrees with the operator's file, which is
69/// visible at review. It does not make the mistake impossible, and the wire test below is what
70/// actually pins it.
71///
72/// Fallible because a machine with no multicast-capable interface cannot run this; boot warns and
73/// continues rather than refusing to start, since that is a networking condition rather than a
74/// misconfiguration.
75///
76/// Must be called from within a tokio runtime: the crate's builder relies on
77/// `Handle::current()` and PANICS otherwise.
78pub fn build(
79    endpoint_id: EndpointId,
80    mode: crate::daemon::boot::LocalDiscovery,
81) -> anyhow::Result<impl AddressLookup> {
82    MdnsAddressLookup::builder()
83        .advertise(mode.advertise)
84        .service_name(SERVICE_NAME)
85        .build(endpoint_id)
86        .map_err(|e| anyhow::anyhow!("start local discovery: {e}"))
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    /// The service name must stay `mcpmesh`, not the crate's shared `irohv1` default.
94    ///
95    /// Asserted as a CONSTANT, which catches a change to the constant and NOTHING ELSE. The 0.44.0
96    /// gate deleted the `.service_name(..)` call entirely: this test stayed green, every boot test
97    /// stayed green, and even the real-multicast test passed — both nodes simply fell into iroh's
98    /// shared `irohv1` namespace together, with zero `_mcpmesh` packets on the wire. That is pinned
99    /// where it is observable: `local_discovery_announces_only_under_the_mcpmesh_service_name` in
100    /// `cli/tests/embedded_loopback.rs` reads the multicast group directly.
101    #[test]
102    fn the_service_name_is_ours_and_not_irohs_shared_default() {
103        assert_eq!(SERVICE_NAME, "mcpmesh");
104        assert_ne!(
105            SERVICE_NAME, "irohv1",
106            "the crate's default is the SHARED iroh namespace — using it would announce this node \
107             to every unrelated iroh app on the link"
108        );
109    }
110
111    /// Building must not panic inside a runtime, in either mode.
112    ///
113    /// **This is nearly all it proves, and the 0.44.0 gate said so.** It does not observe the
114    /// service name, and it does not observe whether `advertise` reached the socket — deleting
115    /// `.service_name(..)` or hard-coding `advertise: true` both leave it green. Those are pinned
116    /// on the wire, in `cli/tests/embedded_loopback.rs`'s `#[ignore]`d multicast tests, because
117    /// the crate exposes no way to read either back.
118    ///
119    /// What it does pin is real and load-bearing: the crate PANICS when built outside a tokio
120    /// runtime, which would take the whole daemon down at boot rather than warn.
121    #[tokio::test]
122    async fn both_modes_build_inside_a_runtime() {
123        let id = iroh::SecretKey::generate().public();
124        for advertise in [true, false] {
125            let mode = crate::daemon::boot::LocalDiscovery {
126                enabled: true,
127                advertise,
128            };
129            // A machine with no multicast interface legitimately fails; that is the case boot
130            // warns about, and it must be an Err rather than a panic.
131            match build(id, mode) {
132                Ok(_) => {}
133                Err(e) => {
134                    let msg = format!("{e:#}");
135                    assert!(
136                        msg.contains("start local discovery"),
137                        "a build failure must be contextualized so the boot warning is readable: \
138                         {msg}"
139                    );
140                }
141            }
142        }
143    }
144}