Skip to main content

clawft_kernel/
mesh_mdns.rs

1//! mDNS LAN discovery for WeftOS mesh networking (K6.2).
2//!
3//! Implements DNS-SD service discovery over multicast UDP (224.0.0.251:5353).
4//! Advertises `_weftos._tcp.local` service and discovers peers on the LAN.
5
6use std::collections::HashSet;
7use std::net::{Ipv4Addr, SocketAddrV4};
8
9use async_trait::async_trait;
10use serde::{Deserialize, Serialize};
11use tokio::net::UdpSocket;
12
13use crate::mesh_discovery::{DiscoveredPeer, DiscoveryBackend, DiscoveryError, DiscoverySource};
14
15/// Multicast group for mDNS.
16const MDNS_MULTICAST_ADDR: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 251);
17
18/// Standard mDNS port.
19const MDNS_PORT: u16 = 5353;
20
21/// WeftOS service name for DNS-SD.
22pub const WEFTOS_SERVICE_NAME: &str = "_weftos._tcp.local";
23
24/// mDNS service announcement payload.
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
26pub struct MdnsAnnouncement {
27    /// Service type (always `_weftos._tcp.local`).
28    pub service: String,
29    /// Node identifier.
30    pub node_id: String,
31    /// Listen address (IP).
32    pub address: String,
33    /// Listen port.
34    pub port: u16,
35    /// Platform (linux, darwin, wasm, etc.).
36    pub platform: String,
37    /// Governance genesis hash — used to scope clusters.
38    pub governance_genesis: String,
39}
40
41/// mDNS discovery backend.
42///
43/// Sends and receives JSON-encoded [`MdnsAnnouncement`] packets over
44/// UDP multicast. Filters out self-announcements and duplicates.
45pub struct MdnsDiscovery {
46    /// Our node's announcement template.
47    local_announcement: MdnsAnnouncement,
48    /// Discovered peers pending delivery.
49    pending: Vec<DiscoveredPeer>,
50    /// Multicast socket.
51    socket: Option<UdpSocket>,
52    /// Whether discovery is active.
53    active: bool,
54    /// Known node IDs for deduplication.
55    known: HashSet<String>,
56}
57
58impl MdnsDiscovery {
59    /// Create a new mDNS discovery backend.
60    pub fn new(
61        node_id: String,
62        listen_addr: String,
63        port: u16,
64        platform: String,
65        governance_genesis: String,
66    ) -> Self {
67        Self {
68            local_announcement: MdnsAnnouncement {
69                service: WEFTOS_SERVICE_NAME.to_string(),
70                node_id,
71                address: listen_addr,
72                port,
73                platform,
74                governance_genesis,
75            },
76            pending: Vec::new(),
77            socket: None,
78            active: false,
79            known: HashSet::new(),
80        }
81    }
82
83    /// Send our announcement to the multicast group.
84    async fn announce(&self) -> Result<(), DiscoveryError> {
85        if let Some(ref socket) = self.socket {
86            let payload = serde_json::to_vec(&self.local_announcement)
87                .map_err(|e| DiscoveryError::Backend(e.to_string()))?;
88            let dest = SocketAddrV4::new(MDNS_MULTICAST_ADDR, MDNS_PORT);
89            socket
90                .send_to(&payload, dest)
91                .await
92                .map_err(|e| DiscoveryError::Backend(e.to_string()))?;
93        }
94        Ok(())
95    }
96
97    /// Try to receive announcements from peers (non-blocking with short timeout).
98    async fn try_receive(&mut self) -> Vec<DiscoveredPeer> {
99        let mut peers = Vec::new();
100        let Some(ref socket) = self.socket else {
101            return peers;
102        };
103
104        let mut buf = [0u8; 4096];
105        // Short timeout so poll never blocks the coordinator.
106        // Timeout or recv error → no peers this round; only the happy
107        // path does work.
108        if let Ok(Ok((len, _addr))) = tokio::time::timeout(
109            std::time::Duration::from_millis(10),
110            socket.recv_from(&mut buf),
111        )
112        .await
113            && let Ok(ann) = serde_json::from_slice::<MdnsAnnouncement>(&buf[..len])
114            && ann.node_id != self.local_announcement.node_id
115            && !self.known.contains(&ann.node_id)
116        {
117            self.known.insert(ann.node_id.clone());
118            peers.push(DiscoveredPeer {
119                node_id: ann.node_id,
120                address: format!("{}:{}", ann.address, ann.port),
121                platform: ann.platform,
122                source: DiscoverySource::Mdns,
123            });
124        }
125        peers
126    }
127
128    /// Access the local announcement (for testing / introspection).
129    pub fn local_announcement(&self) -> &MdnsAnnouncement {
130        &self.local_announcement
131    }
132}
133
134#[async_trait]
135impl DiscoveryBackend for MdnsDiscovery {
136    fn name(&self) -> &str {
137        "mdns"
138    }
139
140    async fn start(&mut self) -> Result<(), DiscoveryError> {
141        // Bind to INADDR_ANY on the mDNS port (or any port as fallback).
142        let socket =
143            match UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, MDNS_PORT)).await {
144                Ok(s) => s,
145                Err(_) => {
146                    // Port 5353 may be in use (systemd-resolved, avahi, etc.).
147                    UdpSocket::bind("0.0.0.0:0")
148                        .await
149                        .map_err(|e| DiscoveryError::Backend(format!("bind failed: {e}")))?
150                }
151            };
152
153        // Join the multicast group — best effort (may fail in containers).
154        let _ = socket.join_multicast_v4(MDNS_MULTICAST_ADDR, Ipv4Addr::UNSPECIFIED);
155
156        self.socket = Some(socket);
157        self.active = true;
158
159        // Send initial announcement.
160        self.announce().await?;
161        Ok(())
162    }
163
164    async fn poll(&mut self) -> Vec<DiscoveredPeer> {
165        if !self.active {
166            return vec![];
167        }
168        let mut peers = self.try_receive().await;
169        peers.append(&mut self.pending);
170        peers
171    }
172
173    async fn stop(&mut self) -> Result<(), DiscoveryError> {
174        self.active = false;
175        self.socket = None;
176        self.pending.clear();
177        Ok(())
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn mdns_announcement_serde_roundtrip() {
187        let ann = MdnsAnnouncement {
188            service: WEFTOS_SERVICE_NAME.to_string(),
189            node_id: "node-abc".into(),
190            address: "192.168.1.42".into(),
191            port: 9489,
192            platform: "linux".into(),
193            governance_genesis: "deadbeef01234567".into(),
194        };
195        let json = serde_json::to_string(&ann).unwrap();
196        let back: MdnsAnnouncement = serde_json::from_str(&json).unwrap();
197        assert_eq!(ann, back);
198    }
199
200    #[tokio::test]
201    async fn mdns_discovery_creates_and_stops() {
202        let mut disc = MdnsDiscovery::new(
203            "test-node".into(),
204            "127.0.0.1".into(),
205            9489,
206            "linux".into(),
207            "0000000000000000".into(),
208        );
209        assert_eq!(disc.name(), "mdns");
210        assert!(!disc.active);
211
212        disc.start().await.unwrap();
213        assert!(disc.active);
214        assert!(disc.socket.is_some());
215
216        disc.stop().await.unwrap();
217        assert!(!disc.active);
218        assert!(disc.socket.is_none());
219    }
220
221    #[tokio::test]
222    async fn mdns_self_announcement_filtered() {
223        // Simulate receiving our own announcement — it should be ignored.
224        let mut disc = MdnsDiscovery::new(
225            "self-node".into(),
226            "127.0.0.1".into(),
227            9489,
228            "linux".into(),
229            "aabbccdd".into(),
230        );
231        disc.active = true;
232
233        // Manually inject a "self" announcement into the known set check path.
234        // The filter is: ann.node_id != self.local_announcement.node_id
235        // We verify by checking that the local announcement node_id is "self-node".
236        assert_eq!(disc.local_announcement().node_id, "self-node");
237
238        // Create a fake announcement from ourselves.
239        let self_ann = disc.local_announcement.clone();
240        let payload = serde_json::to_vec(&self_ann).unwrap();
241
242        // Parse it the way try_receive would.
243        let parsed: MdnsAnnouncement = serde_json::from_slice(&payload).unwrap();
244        assert_eq!(parsed.node_id, "self-node");
245        // The filter would reject this because parsed.node_id == local node_id.
246        assert_eq!(parsed.node_id, disc.local_announcement.node_id);
247    }
248
249    #[tokio::test]
250    async fn mdns_duplicate_filtered() {
251        let mut disc = MdnsDiscovery::new(
252            "local".into(),
253            "127.0.0.1".into(),
254            9489,
255            "linux".into(),
256            "aabb".into(),
257        );
258
259        // Simulate discovering the same node twice.
260        disc.known.insert("remote-1".to_string());
261
262        // Second insert returns false — already known.
263        assert!(!disc.known.insert("remote-1".to_string()));
264        // New node is accepted.
265        assert!(disc.known.insert("remote-2".to_string()));
266    }
267
268    #[tokio::test]
269    async fn mdns_poll_when_inactive_returns_empty() {
270        let mut disc = MdnsDiscovery::new(
271            "n".into(),
272            "127.0.0.1".into(),
273            9489,
274            "linux".into(),
275            "aa".into(),
276        );
277        // Not started — should return empty.
278        let peers = disc.poll().await;
279        assert!(peers.is_empty());
280    }
281
282    #[tokio::test]
283    async fn mdns_pending_drained_on_poll() {
284        let mut disc = MdnsDiscovery::new(
285            "n".into(),
286            "127.0.0.1".into(),
287            9489,
288            "linux".into(),
289            "aa".into(),
290        );
291        disc.active = true;
292        disc.pending.push(DiscoveredPeer {
293            node_id: "injected".into(),
294            address: "10.0.0.1:9489".into(),
295            platform: "darwin".into(),
296            source: DiscoverySource::Mdns,
297        });
298
299        let peers = disc.poll().await;
300        // The pending peer should be drained (try_receive will time out with no socket,
301        // but pending is appended).
302        assert!(peers.iter().any(|p| p.node_id == "injected"));
303
304        // Second poll should be empty.
305        let second = disc.poll().await;
306        assert!(second.is_empty());
307    }
308}