pitchfork_cli/proxy/mdns.rs
1//! mDNS address record publishing for LAN mode.
2//!
3//! Registers each slug hostname as an mDNS service so that other devices on
4//! the LAN can resolve `<slug>.local` to the host's LAN IP address.
5//!
6//! Because `mdns-sd` does not support registering bare A/AAAA records, we
7//! register a virtual service for each slug. The service type is
8//! `_pitchfork._tcp`, and each slug gets its own instance with the hostname
9//! set to `<slug>.local.`. This causes the mDNS responder to include the
10//! correct A record in its response, making `<slug>.local` resolvable on
11//! the network.
12
13use mdns_sd::ServiceDaemon;
14use std::collections::HashMap;
15use std::net::Ipv4Addr;
16
17/// The virtual service type used to publish slug hostnames via mDNS.
18/// The service name "pitchfork" is exactly 10 chars, well under the RFC 6763
19/// limit of 15 chars.
20const SERVICE_TYPE: &str = "_pitchfork._tcp.local.";
21
22/// Manages mDNS address records for slug hostnames on the LAN.
23pub struct MdnsPublisher {
24 daemon: ServiceDaemon,
25 /// Registered slug hostname → full instance name (for unregister).
26 registrations: HashMap<String, String>,
27 /// Current LAN IP (used when re-publishing on IP change).
28 lan_ip: Ipv4Addr,
29 /// Whether the publisher has been shut down.
30 shutdown: bool,
31}
32
33impl MdnsPublisher {
34 /// Create a new mDNS publisher.
35 ///
36 /// Returns `None` if the mDNS daemon could not be started (e.g. Avahi
37 /// not running on Linux, or Bonjour unavailable on macOS).
38 pub fn new(lan_ip: Ipv4Addr) -> Option<Self> {
39 let daemon = ServiceDaemon::new().ok()?;
40 log::info!("mDNS publisher started with LAN IP {lan_ip}");
41 Some(Self {
42 daemon,
43 registrations: HashMap::new(),
44 lan_ip,
45 shutdown: false,
46 })
47 }
48
49 /// Publish an mDNS address record for a slug hostname.
50 ///
51 /// `hostname` should be the fully qualified name (e.g. `"myapp.local"`).
52 /// If the hostname is already registered, this is a no-op.
53 pub fn publish(&mut self, hostname: &str, port: u16) {
54 if self.shutdown {
55 return;
56 }
57 if self.registrations.contains_key(hostname) {
58 return;
59 }
60
61 // Derive instance name from hostname: "myapp.local" → "myapp"
62 let instance_name = hostname.strip_suffix(".local").unwrap_or(hostname);
63
64 // ServiceInfo requires host_name to end with ".local."
65 let host_name = format!("{hostname}.");
66
67 let service = match mdns_sd::ServiceInfo::new(
68 SERVICE_TYPE,
69 instance_name,
70 &host_name,
71 self.lan_ip.to_string(),
72 port,
73 &[] as &[(&str, &str)],
74 ) {
75 Ok(s) => s,
76 Err(e) => {
77 log::warn!("mDNS: failed to build ServiceInfo for {hostname}: {e}");
78 return;
79 }
80 };
81
82 let fullname = service.get_fullname().to_string();
83
84 match self.daemon.register(service) {
85 Ok(_) => {
86 log::info!("mDNS: published {hostname} → {lan}", lan = self.lan_ip);
87 self.registrations.insert(hostname.to_string(), fullname);
88 }
89 Err(e) => {
90 log::warn!("mDNS: failed to register {hostname}: {e}");
91 }
92 }
93 }
94
95 /// Unpublish an mDNS address record for a slug hostname.
96 pub fn unpublish(&mut self, hostname: &str) {
97 if self.shutdown {
98 return;
99 }
100 if let Some(fullname) = self.registrations.remove(hostname) {
101 if let Err(e) = self.daemon.unregister(&fullname) {
102 log::warn!("mDNS: failed to unregister {hostname}: {e}");
103 } else {
104 log::info!("mDNS: unpublished {hostname}");
105 }
106 }
107 }
108
109 /// Re-publish all registered hostnames with a new LAN IP.
110 ///
111 /// Called when the LAN IP changes (detected by polling). Unregisters all
112 /// existing records and re-registers them with the new IP.
113 pub fn republish_all(&mut self, new_ip: Ipv4Addr, port: u16) {
114 if self.shutdown || new_ip == self.lan_ip {
115 return;
116 }
117
118 let hostnames: Vec<String> = self.registrations.keys().cloned().collect();
119
120 // Unregister all existing records.
121 for hostname in &hostnames {
122 if let Some(fullname) = self.registrations.remove(hostname) {
123 let _ = self.daemon.unregister(&fullname);
124 }
125 }
126
127 let old_ip = self.lan_ip;
128 self.lan_ip = new_ip;
129 log::info!(
130 "mDNS: LAN IP changed {old_ip} → {new_ip}, re-publishing {} records",
131 hostnames.len()
132 );
133
134 // Re-register with new IP.
135 for hostname in &hostnames {
136 self.publish(hostname, port);
137 }
138 }
139
140 /// The current LAN IP used for mDNS publishing.
141 #[allow(dead_code)]
142 pub fn lan_ip(&self) -> Ipv4Addr {
143 self.lan_ip
144 }
145
146 /// Return the list of currently registered hostnames.
147 pub fn registered_hostnames(&self) -> Vec<String> {
148 self.registrations.keys().cloned().collect()
149 }
150
151 /// Check if a hostname is currently registered.
152 pub fn is_published(&self, hostname: &str) -> bool {
153 self.registrations.contains_key(hostname)
154 }
155
156 /// Shutdown the mDNS daemon gracefully.
157 ///
158 /// Sends goodbye packets and stops the mDNS responder.
159 pub fn shutdown(&mut self) {
160 if self.shutdown {
161 return;
162 }
163 self.shutdown = true;
164 // Clear registrations so they aren't used after shutdown.
165 self.registrations.clear();
166 if let Err(e) = self.daemon.shutdown() {
167 log::warn!("mDNS: shutdown error: {e}");
168 } else {
169 log::info!("mDNS: publisher shut down");
170 }
171 }
172}