1use 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
19pub const SERVICE_TYPE: &str = "_uptrakit._tcp.local.";
21
22pub const TXT_KEY_CA_FP: &str = "ca_fp";
24pub const TXT_KEY_URL: &str = "url";
26pub const TXT_KEY_PKI_ADDR: &str = "pki_addr";
28
29#[derive(Debug, Error)]
31#[non_exhaustive]
32pub enum ZeroconfError {
33 #[error("mDNS daemon error: {0}")]
35 Daemon(mdns_sd::Error),
36}
37
38pub type Result<T> = std::result::Result<T, Report<ZeroconfError>>;
40
41impl_report_conversion!(mdns_sd::Error => ZeroconfError::Daemon);
42
43#[non_exhaustive]
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct DiscoveredController {
47 pub url: String,
49 pub pki_addr: Option<String>,
51 pub ca_fingerprint: Option<String>,
53}
54
55impl DiscoveredController {
56 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
66fn 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
71pub 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 let url = if let Some(url_override) = get_txt_property(properties, TXT_KEY_URL) {
86 url_override.to_string()
87 } else {
88 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
103pub 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}