Skip to main content

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}