Skip to main content

ipp_printer_app/
mdns.rs

1//! mDNS / DNS-SD advertising for IPP printers (`_ipp._tcp.local.`).
2//!
3//! Gated by the default-on `mdns` feature. [`Advertiser::register_all`]
4//! publishes one service instance per printer in the registry, with the TXT
5//! records CUPS / cups-browsed need for IPP-Everywhere auto-discovery
6//! (RFC 8011 + Bonjour for IPP + PWG 5100.14).
7
8use std::collections::HashMap;
9use std::net::IpAddr;
10
11use mdns_sd::{ServiceDaemon, ServiceInfo};
12use parking_lot::Mutex;
13
14use crate::printer::{PrinterRecord, PrinterRegistry};
15use crate::status::AdvertiserControl;
16
17const IPP_SERVICE: &str = "_ipp._tcp.local.";
18
19/// Interface name prefixes for container / VM virtual bridges and veth pairs.
20///
21/// Advertising on these is actively harmful next to a co-resident
22/// `cups-browsed`: it resolves our service over every Docker `veth*` /
23/// `br-*` link, and the duplicate / racy A-record answers on those links make
24/// avahi hand `cups-browsed` a *null* host name for some resolves. A null host
25/// name fails its `is_local_hostname()` check, so that resolve bypasses the
26/// `UUID=` dedup and `cups-browsed` builds a spurious `implicitclass://`
27/// duplicate queue. Restricting the advert to real interfaces removes those
28/// resolves at the source. (Plain `br0`/`tun0` are *not* matched — only the
29/// `br-`/container-style names — so genuine LAN bridges still advertise.)
30const VIRTUAL_IFACE_PREFIXES: &[&str] =
31    &["veth", "docker", "br-", "virbr", "vnet", "vmnet", "vboxnet"];
32
33/// Owns the [`ServiceDaemon`] and the bind-time host/addresses, and tracks the
34/// DNS-SD fullname registered for each printer so individual services can be
35/// withdrawn and republished as devices go offline / come back.
36pub struct Advertiser {
37    daemon: ServiceDaemon,
38    port: u16,
39    host: String,
40    addrs: Vec<IpAddr>,
41    /// printer logical name -> registered DNS-SD fullname.
42    registered: Mutex<HashMap<String, String>>,
43}
44
45impl Advertiser {
46    /// Start a daemon and register every printer in the registry.
47    pub fn register_all(registry: &PrinterRegistry, port: u16) -> mdns_sd::Result<Self> {
48        let me = Self {
49            daemon: ServiceDaemon::new()?,
50            port,
51            host: hostname(),
52            addrs: advertise_addrs(),
53            registered: Mutex::new(HashMap::new()),
54        };
55        for rec in registry.read().iter() {
56            me.register_one(rec)?;
57        }
58        Ok(me)
59    }
60
61    /// Build + register one printer's service, recording its fullname.
62    /// Replaces any existing registration for the same logical name.
63    fn register_one(&self, rec: &PrinterRecord) -> mdns_sd::Result<()> {
64        let info = service_info(
65            &self.host,
66            &self.addrs,
67            self.port,
68            rec.config.display_label(),
69            &rec.config.name,
70            &rec.config.make_and_model,
71            &rec.uuid,
72        )?;
73        let fullname = info.get_fullname().to_string();
74        self.daemon.register(info)?;
75        log::info!("mdns: registered {fullname}");
76        self.registered
77            .lock()
78            .insert(rec.config.name.clone(), fullname);
79        Ok(())
80    }
81}
82
83impl AdvertiserControl for Advertiser {
84    fn publish(&self, rec: &PrinterRecord) {
85        if !self.registered.lock().contains_key(&rec.config.name) {
86            if let Err(e) = self.register_one(rec) {
87                log::warn!("mdns: republish of {} failed: {e}", rec.config.name);
88            }
89        }
90    }
91
92    fn withdraw(&self, name: &str) {
93        if let Some(fullname) = self.registered.lock().remove(name) {
94            log::info!("mdns: withdrawing {fullname} (device offline)");
95            let _ = self.daemon.unregister(&fullname);
96        }
97    }
98
99    fn is_advertised(&self, name: &str) -> bool {
100        self.registered.lock().contains_key(name)
101    }
102}
103
104impl Drop for Advertiser {
105    fn drop(&mut self) {
106        for fullname in self.registered.lock().values() {
107            let _ = self.daemon.unregister(fullname);
108        }
109        let _ = self.daemon.shutdown();
110    }
111}
112
113/// Whether `name` looks like a container/VM virtual bridge or veth interface
114/// we must not advertise on (see [`VIRTUAL_IFACE_PREFIXES`]).
115fn is_virtual_iface(name: &str) -> bool {
116    VIRTUAL_IFACE_PREFIXES.iter().any(|p| name.starts_with(p))
117}
118
119/// The host addresses to advertise on: every up, non-loopback, non-link-local
120/// interface that isn't a container/VM virtual bridge. Replaces mdns-sd's
121/// `enable_addr_auto()` (which advertises on *all* interfaces, including the
122/// `veth*`/`br-*` links that defeat `cups-browsed` dedup — see
123/// [`VIRTUAL_IFACE_PREFIXES`]). Returns empty if enumeration fails or filters
124/// everything out, in which case the caller falls back to `enable_addr_auto()`.
125fn advertise_addrs() -> Vec<IpAddr> {
126    let ifaces = match if_addrs::get_if_addrs() {
127        Ok(i) => i,
128        Err(e) => {
129            log::warn!("mdns: interface enumeration failed ({e}); advertising on all interfaces");
130            return Vec::new();
131        }
132    };
133    let mut addrs = Vec::new();
134    for iface in ifaces {
135        if iface.is_loopback() || iface.is_link_local() || !iface.is_oper_up() {
136            continue;
137        }
138        if is_virtual_iface(&iface.name) {
139            log::debug!(
140                "mdns: skipping virtual interface {} ({})",
141                iface.name,
142                iface.ip()
143            );
144            continue;
145        }
146        log::debug!("mdns: advertising on {} ({})", iface.name, iface.ip());
147        addrs.push(iface.ip());
148    }
149    addrs
150}
151
152fn hostname() -> String {
153    let h = std::process::Command::new("hostname")
154        .output()
155        .ok()
156        .and_then(|o| String::from_utf8(o.stdout).ok())
157        .map(|s| s.trim().to_string())
158        .filter(|s| !s.is_empty())
159        .unwrap_or_else(|| "localhost".to_string());
160    // mdns-sd normalises trailing ".local." — pass bare hostname.
161    h
162}
163
164fn service_info(
165    host: &str,
166    addrs: &[IpAddr],
167    port: u16,
168    instance_name: &str,
169    logical_name: &str,
170    make_and_model: &str,
171    uuid: &str,
172) -> mdns_sd::Result<ServiceInfo> {
173    let mut txt: HashMap<String, String> = HashMap::new();
174    // `rp` is the IPP resource path on our server — always the logical name.
175    // The DNS-SD *instance* name (below) carries the human-readable label.
176    txt.insert("rp".into(), format!("ipp/print/{logical_name}"));
177    // `UUID=` lets a local cups-browsed dedupe this advert against a CUPS
178    // queue with the same `printer-uuid` and stand down (it's the same
179    // mechanism CUPS's own shared queues use). Advertise the bare value —
180    // cups-browsed strips `urn:uuid:` on the CUPS side before comparing.
181    let bare_uuid = uuid.strip_prefix("urn:uuid:").unwrap_or(uuid);
182    if !bare_uuid.is_empty() {
183        txt.insert("UUID".into(), bare_uuid.to_string());
184    }
185    txt.insert("ty".into(), make_and_model.to_string());
186    txt.insert("note".into(), make_and_model.to_string());
187    txt.insert("product".into(), format!("({make_and_model})"));
188    // Document formats CUPS asks for during driverless setup.
189    txt.insert(
190        "pdl".into(),
191        "image/pwg-raster,application/vnd.cups-raster,application/octet-stream".into(),
192    );
193    // IPP Everywhere advertises URF=…; CUPS reads this for the everywhere driver.
194    txt.insert("URF".into(), "W8,SRGB24,CP1,RS203".into());
195    txt.insert("Color".into(), "F".into());
196    txt.insert("Duplex".into(), "F".into());
197    txt.insert("adminurl".into(), format!("http://{host}.local:{port}/"));
198    txt.insert("priority".into(), "0".into());
199    txt.insert("qtotal".into(), "1".into());
200    // TXT version per PWG 5100.14.
201    txt.insert("txtvers".into(), "1".into());
202
203    // Advertise an explicit, filtered address list when we have one; otherwise
204    // fall back to mdns-sd's auto-detection (all interfaces). The filtered list
205    // excludes container/VM virtual bridges so a co-resident `cups-browsed`
206    // doesn't see us over `veth*`/`br-*` links (see `advertise_addrs`).
207    let info = if addrs.is_empty() {
208        ServiceInfo::new(
209            IPP_SERVICE,
210            instance_name,
211            &format!("{host}.local."),
212            "", // IPs filled by enable_addr_auto
213            port,
214            txt,
215        )?
216        .enable_addr_auto()
217    } else {
218        ServiceInfo::new(
219            IPP_SERVICE,
220            instance_name,
221            &format!("{host}.local."),
222            addrs,
223            port,
224            txt,
225        )?
226    };
227    Ok(info)
228}
229
230#[cfg(test)]
231mod tests {
232    use super::is_virtual_iface;
233
234    #[test]
235    fn flags_container_and_vm_interfaces() {
236        for name in [
237            "veth1a2b3c",   // Docker container veth pair (host side)
238            "docker0",      // Docker default bridge
239            "br-9f3c1d20a", // Docker user-defined bridge
240            "virbr0",       // libvirt bridge
241            "vnet3",        // libvirt VM tap
242            "vmnet8",       // VMware
243            "vboxnet0",     // VirtualBox
244        ] {
245            assert!(is_virtual_iface(name), "{name} should be filtered out");
246        }
247    }
248
249    #[test]
250    fn keeps_real_interfaces() {
251        // Real NICs and genuine LAN bridges/tunnels (no `-` / container prefix)
252        // must still be advertised.
253        for name in ["eth0", "enp3s0", "wlan0", "wlp2s0", "br0", "tun0", "lo"] {
254            assert!(!is_virtual_iface(name), "{name} should be kept");
255        }
256    }
257}