1use std::net::{IpAddr, SocketAddr};
13use std::sync::OnceLock;
14use std::time::{Duration, Instant};
15
16use mdns_sd::{ResolvedService, ServiceDaemon, ServiceEvent, ServiceInfo};
17
18use crate::protocol::NodeResources;
19
20pub const MDNS_SERVICE_TYPE: &str = "_ghostlink._tcp.local.";
22
23static ADVERTISER: OnceLock<Option<ServiceDaemon>> = OnceLock::new();
24
25pub fn ensure_advertised(node: &NodeResources, port: u16) {
31 ADVERTISER.get_or_init(|| match advertise(node, port) {
32 Ok(daemon) => Some(daemon),
33 Err(err) => {
34 tracing::warn!("mdns: failed to advertise local node, continuing without it: {err}");
35 None
36 }
37 });
38}
39
40fn advertise(node: &NodeResources, port: u16) -> Result<ServiceDaemon, String> {
41 let daemon = ServiceDaemon::new().map_err(|e| format!("failed to start mDNS daemon: {e}"))?;
42
43 let properties = node_to_txt_properties(node);
44 let borrowed_properties: Vec<(&str, &str)> = properties
45 .iter()
46 .map(|(k, v)| (k.as_str(), v.as_str()))
47 .collect();
48
49 let instance_name = sanitize_instance_name(&node.id);
50 let host_name = format!("{instance_name}.local.");
51
52 let service = ServiceInfo::new(
55 MDNS_SERVICE_TYPE,
56 &instance_name,
57 &host_name,
58 "",
59 port,
60 borrowed_properties.as_slice(),
61 )
62 .map_err(|e| format!("failed to build mDNS service info: {e}"))?
63 .enable_addr_auto();
64
65 daemon
66 .register(service)
67 .map_err(|e| format!("failed to register mDNS service: {e}"))?;
68
69 Ok(daemon)
70}
71
72pub fn discover(timeout: Duration) -> Result<Vec<(NodeResources, SocketAddr)>, String> {
78 let daemon = ServiceDaemon::new().map_err(|e| format!("failed to start mDNS daemon: {e}"))?;
79 let receiver = daemon
80 .browse(MDNS_SERVICE_TYPE)
81 .map_err(|e| format!("failed to browse {MDNS_SERVICE_TYPE}: {e}"))?;
82
83 let mut peers = std::collections::HashMap::new();
87 let deadline = Instant::now() + timeout;
88
89 while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
90 match receiver.recv_timeout(remaining) {
91 Ok(ServiceEvent::ServiceResolved(info)) => {
92 if let (Some(node), Some(addr)) =
93 (node_from_service_info(&info), first_socket_addr(&info))
94 {
95 peers.insert(node.id.clone(), (node, addr));
96 }
97 }
98 Ok(_) => {}
99 Err(_) => break, }
101 }
102
103 let _ = daemon.stop_browse(MDNS_SERVICE_TYPE);
104 let _ = daemon.shutdown();
105
106 Ok(peers.into_values().collect())
107}
108
109fn first_socket_addr(info: &ResolvedService) -> Option<SocketAddr> {
110 info.get_addresses_v4()
111 .into_iter()
112 .next()
113 .map(|ip| SocketAddr::new(IpAddr::V4(ip), info.get_port()))
114}
115
116fn node_from_service_info(info: &ResolvedService) -> Option<NodeResources> {
117 node_from_txt_lookup(|key| info.get_property_val_str(key).map(str::to_owned))
118}
119
120fn node_to_txt_properties(node: &NodeResources) -> Vec<(String, String)> {
121 let mut props = vec![
122 ("id".to_string(), node.id.clone()),
123 ("vram_gb".to_string(), node.vram_gb.to_string()),
124 (
125 "system_memory_gb".to_string(),
126 node.system_memory_gb.to_string(),
127 ),
128 (
129 "compute_capability".to_string(),
130 node.compute_capability.clone(),
131 ),
132 ];
133 if let Some(gpu_name) = &node.gpu_name {
134 props.push(("gpu_name".to_string(), gpu_name.clone()));
135 }
136 if let Some(rpc_port) = node.rpc_port {
137 props.push(("rpc_port".to_string(), rpc_port.to_string()));
138 }
139 if let Some(rpc_build_id) = &node.rpc_build_id {
140 props.push(("rpc_build_id".to_string(), rpc_build_id.clone()));
141 }
142 props
143}
144
145fn node_from_txt_lookup(lookup: impl Fn(&str) -> Option<String>) -> Option<NodeResources> {
146 let id = lookup("id")?;
147 let vram_gb = lookup("vram_gb")?.parse().ok()?;
148 let system_memory_gb = lookup("system_memory_gb")?.parse().ok()?;
149 let compute_capability = lookup("compute_capability").unwrap_or_default();
150 let gpu_name = lookup("gpu_name");
151 let rpc_port = lookup("rpc_port").and_then(|v| v.parse().ok());
152 let rpc_build_id = lookup("rpc_build_id");
153 Some(NodeResources {
154 id,
155 vram_gb,
156 system_memory_gb,
157 compute_capability,
158 gpu_name,
159 rpc_port,
160 rpc_build_id,
161 })
162}
163
164fn sanitize_instance_name(id: &str) -> String {
168 id.replace('.', "-")
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 fn lookup_from(props: &[(String, String)]) -> impl Fn(&str) -> Option<String> + '_ {
176 move |key: &str| props.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())
177 }
178
179 #[test]
180 fn txt_roundtrip_preserves_node_fields() {
181 let node = NodeResources::new("node-a", 24.0, 64.0, "8.9", Some("RTX 4090".to_string()));
182 let props = node_to_txt_properties(&node);
183 let decoded = node_from_txt_lookup(lookup_from(&props)).expect("decode");
184
185 assert_eq!(decoded.id, node.id);
186 assert_eq!(decoded.vram_gb, node.vram_gb);
187 assert_eq!(decoded.system_memory_gb, node.system_memory_gb);
188 assert_eq!(decoded.compute_capability, node.compute_capability);
189 assert_eq!(decoded.gpu_name, node.gpu_name);
190 }
191
192 #[test]
193 fn txt_roundtrip_without_gpu_name() {
194 let node = NodeResources::new("node-b", 8.0, 16.0, "7.5", None);
195 let props = node_to_txt_properties(&node);
196 let decoded = node_from_txt_lookup(lookup_from(&props)).expect("decode");
197
198 assert_eq!(decoded.gpu_name, None);
199 }
200
201 #[test]
202 fn txt_roundtrip_preserves_rpc_port() {
203 let node = NodeResources::new("node-c", 12.0, 32.0, "8.6", None).with_rpc_port(50052);
204 let props = node_to_txt_properties(&node);
205 let decoded = node_from_txt_lookup(lookup_from(&props)).expect("decode");
206
207 assert_eq!(decoded.rpc_port, Some(50052));
208 }
209
210 #[test]
211 fn txt_roundtrip_without_rpc_port() {
212 let node = NodeResources::new("node-d", 8.0, 16.0, "7.5", None);
213 let props = node_to_txt_properties(&node);
214 let decoded = node_from_txt_lookup(lookup_from(&props)).expect("decode");
215
216 assert_eq!(decoded.rpc_port, None);
217 }
218
219 #[test]
220 fn txt_roundtrip_preserves_rpc_build_id() {
221 let node =
222 NodeResources::new("node-e", 12.0, 32.0, "8.6", None).with_rpc_build_id("da296d6");
223 let props = node_to_txt_properties(&node);
224 let decoded = node_from_txt_lookup(lookup_from(&props)).expect("decode");
225
226 assert_eq!(decoded.rpc_build_id, Some("da296d6".to_string()));
227 }
228
229 #[test]
230 fn txt_roundtrip_without_rpc_build_id() {
231 let node = NodeResources::new("node-f", 8.0, 16.0, "7.5", None);
232 let props = node_to_txt_properties(&node);
233 let decoded = node_from_txt_lookup(lookup_from(&props)).expect("decode");
234
235 assert_eq!(decoded.rpc_build_id, None);
236 }
237
238 #[test]
239 fn decode_fails_gracefully_on_missing_required_field() {
240 let props = vec![("vram_gb".to_string(), "8.0".to_string())];
242 assert!(node_from_txt_lookup(lookup_from(&props)).is_none());
243 }
244
245 #[test]
246 fn sanitize_instance_name_collapses_dots() {
247 assert_eq!(
248 sanitize_instance_name("workstation.local"),
249 "workstation-local"
250 );
251 assert_eq!(sanitize_instance_name("node-a"), "node-a");
252 }
253}