Skip to main content

uptrakit_zeroconf/
lib.rs

1//! Uptrakit zeroconf contract — the single home for the mDNS/DNS-SD service
2//! type, the TXT record keys, and their build/parse logic, plus the browse
3//! primitives used by services and the CLI to locate a controller.
4//!
5//! The advertised contract (`SERVICE_TYPE` + TXT keys) lives here and nowhere
6//! else: the controller-runtime advertiser builds its TXT records through
7//! [`build_txt_properties`] and every browser parses through [`parse_txt`].
8
9use std::net::IpAddr;
10
11use rootcause::prelude::*;
12use thiserror::Error;
13use uptrakit_shared_macros::impl_report_conversion;
14
15mod browse;
16
17pub use browse::{browse_all, browse_first};
18
19/// mDNS service type advertised by the controller and browsed by clients.
20pub const SERVICE_TYPE: &str = "_uptrakit._tcp.local.";
21
22/// TXT record key: SHA-256 fingerprint of the controller's active CA certificate.
23pub const TXT_KEY_CA_FP: &str = "ca_fp";
24/// TXT record key: optional HTTPS URL override (reverse proxy deployments).
25pub const TXT_KEY_URL: &str = "url";
26/// TXT record key: optional PKI endpoint address.
27pub const TXT_KEY_PKI_ADDR: &str = "pki_addr";
28
29/// Errors produced by the zeroconf browse primitives.
30#[derive(Debug, Error)]
31#[non_exhaustive]
32pub enum ZeroconfError {
33    /// The mDNS daemon could not be started, browsed, or shut down.
34    #[error("mDNS daemon error: {0}")]
35    Daemon(mdns_sd::Error),
36}
37
38/// Boundary result alias covering all fallible functions in this crate.
39pub type Result<T> = std::result::Result<T, Report<ZeroconfError>>;
40
41impl_report_conversion!(mdns_sd::Error => ZeroconfError::Daemon);
42
43/// A controller discovered via mDNS.
44#[non_exhaustive]
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct DiscoveredController {
47    /// The discovered (or TXT-override) HTTPS URL of the controller.
48    pub url: String,
49    /// Optional PKI endpoint address from the TXT record.
50    pub pki_addr: Option<String>,
51    /// CA fingerprint from the TXT record (for TOFU verification).
52    pub ca_fingerprint: Option<String>,
53}
54
55impl DiscoveredController {
56    /// Constructor for external callers (`#[non_exhaustive]` blocks struct literals).
57    pub fn new(url: String, pki_addr: Option<String>, ca_fingerprint: Option<String>) -> Self {
58        Self {
59            url,
60            pki_addr,
61            ca_fingerprint,
62        }
63    }
64}
65
66/// Extract a named property value from mDNS TXT record properties.
67fn get_txt_property<'a>(properties: &'a [(&str, &str)], key: &str) -> Option<&'a str> {
68    properties.iter().find(|(k, _)| *k == key).map(|(_, v)| *v)
69}
70
71/// Build a [`DiscoveredController`] from resolved mDNS service info.
72///
73/// A TXT `url` override wins (reverse proxy mode); otherwise the URL is built
74/// from the first non-loopback address and the advertised port. Loopback-only
75/// address sets yield `None`.
76pub fn parse_txt(
77    addresses: &[IpAddr],
78    port: u16,
79    properties: &[(&str, &str)],
80) -> Option<DiscoveredController> {
81    let ca_fingerprint = get_txt_property(properties, TXT_KEY_CA_FP).map(String::from);
82    let pki_addr = get_txt_property(properties, TXT_KEY_PKI_ADDR).map(String::from);
83
84    // If a URL override is in the TXT record, use it directly (reverse proxy mode)
85    let url = if let Some(url_override) = get_txt_property(properties, TXT_KEY_URL) {
86        url_override.to_string()
87    } else {
88        // Construct URL from the mDNS-resolved address
89        let ip = addresses.iter().find(|ip| !ip.is_loopback())?;
90        match ip {
91            IpAddr::V4(v4) => format!("https://{v4}:{port}"),
92            IpAddr::V6(v6) => format!("https://[{v6}]:{port}"),
93        }
94    };
95
96    Some(DiscoveredController {
97        url,
98        pki_addr,
99        ca_fingerprint,
100    })
101}
102
103/// Build the TXT record property list the controller advertises.
104///
105/// Order is part of the contract: `ca_fp` first, then optional `url`, then
106/// optional `pki_addr`.
107pub fn build_txt_properties(
108    ca_fingerprint: &str,
109    url: Option<&str>,
110    pki_addr: Option<&str>,
111) -> Vec<(&'static str, String)> {
112    let mut properties = vec![(TXT_KEY_CA_FP, ca_fingerprint.to_string())];
113
114    if let Some(url) = url {
115        properties.push((TXT_KEY_URL, url.to_string()));
116    }
117
118    if let Some(pki_addr) = pki_addr {
119        properties.push((TXT_KEY_PKI_ADDR, pki_addr.to_string()));
120    }
121
122    properties
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn url_from_txt_override() {
131        let addresses = vec![IpAddr::from([192, 168, 1, 100])];
132        let properties = vec![
133            ("ca_fp", "abcd1234"),
134            ("url", "https://proxy.example.com:443"),
135        ];
136        let controller = parse_txt(&addresses, 8443, &properties).unwrap();
137        assert_eq!(controller.url, "https://proxy.example.com:443");
138        assert_eq!(controller.ca_fingerprint.as_deref(), Some("abcd1234"));
139    }
140
141    #[test]
142    fn url_from_mdns_ip_port() {
143        let addresses = vec![IpAddr::from([192, 168, 1, 100])];
144        let properties = vec![("ca_fp", "abcd1234")];
145        let controller = parse_txt(&addresses, 8443, &properties).unwrap();
146        assert_eq!(controller.url, "https://192.168.1.100:8443");
147    }
148
149    #[test]
150    fn url_from_mdns_ipv6() {
151        let addresses = vec![IpAddr::from([0xfe80, 0, 0, 0, 0, 0, 0, 1])];
152        let properties = vec![("ca_fp", "abcd1234")];
153        let controller = parse_txt(&addresses, 8443, &properties).unwrap();
154        assert_eq!(controller.url, "https://[fe80::1]:8443");
155    }
156
157    #[test]
158    fn url_skips_loopback() {
159        let addresses = vec![
160            IpAddr::from([127, 0, 0, 1]),
161            IpAddr::from([192, 168, 1, 100]),
162        ];
163        let properties = vec![];
164        let controller = parse_txt(&addresses, 8443, &properties).unwrap();
165        assert_eq!(controller.url, "https://192.168.1.100:8443");
166    }
167
168    #[test]
169    fn url_only_loopback_returns_none() {
170        let addresses = vec![IpAddr::from([127, 0, 0, 1])];
171        let properties = vec![];
172        assert!(parse_txt(&addresses, 8443, &properties).is_none());
173    }
174
175    #[test]
176    fn pki_addr_from_txt() {
177        let addresses = vec![IpAddr::from([192, 168, 1, 100])];
178        let properties = vec![("pki_addr", "http://192.168.1.100:8080")];
179        let controller = parse_txt(&addresses, 8443, &properties).unwrap();
180        assert_eq!(
181            controller.pki_addr.as_deref(),
182            Some("http://192.168.1.100:8080")
183        );
184    }
185
186    #[test]
187    fn get_txt_property_finds_key() {
188        let props = vec![("key1", "val1"), ("key2", "val2")];
189        assert_eq!(get_txt_property(&props, "key1"), Some("val1"));
190        assert_eq!(get_txt_property(&props, "key2"), Some("val2"));
191        assert_eq!(get_txt_property(&props, "missing"), None);
192    }
193
194    #[test]
195    fn txt_properties_basic() {
196        let props = build_txt_properties("abcd1234", None, None);
197        assert_eq!(props, vec![("ca_fp", "abcd1234".to_string())]);
198    }
199
200    #[test]
201    fn txt_properties_with_url_override() {
202        let props = build_txt_properties("abcd1234", Some("https://proxy.example.com:443"), None);
203        assert_eq!(
204            props,
205            vec![
206                ("ca_fp", "abcd1234".to_string()),
207                ("url", "https://proxy.example.com:443".to_string()),
208            ]
209        );
210    }
211
212    #[test]
213    fn txt_properties_with_all_overrides() {
214        let props = build_txt_properties(
215            "abcd1234",
216            Some("https://proxy.example.com:443"),
217            Some("http://pki.local:8080"),
218        );
219        assert_eq!(
220            props,
221            vec![
222                ("ca_fp", "abcd1234".to_string()),
223                ("url", "https://proxy.example.com:443".to_string()),
224                ("pki_addr", "http://pki.local:8080".to_string()),
225            ]
226        );
227    }
228}