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