use std::net::IpAddr;
use rootcause::prelude::*;
use thiserror::Error;
use uptrakit_shared_macros::impl_report_conversion;
mod browse;
pub use browse::{browse_all, browse_first};
pub const SERVICE_TYPE: &str = "_uptrakit._tcp.local.";
pub const TXT_KEY_CA_FP: &str = "ca_fp";
pub const TXT_KEY_URL: &str = "url";
pub const TXT_KEY_PKI_ADDR: &str = "pki_addr";
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ZeroconfError {
#[error("mDNS daemon error: {0}")]
Daemon(mdns_sd::Error),
}
pub type Result<T> = std::result::Result<T, Report<ZeroconfError>>;
impl_report_conversion!(mdns_sd::Error => ZeroconfError::Daemon);
#[non_exhaustive]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DiscoveredController {
pub url: String,
pub pki_addr: Option<String>,
pub ca_fingerprint: Option<String>,
}
impl DiscoveredController {
pub fn new(url: String, pki_addr: Option<String>, ca_fingerprint: Option<String>) -> Self {
Self {
url,
pki_addr,
ca_fingerprint,
}
}
}
fn get_txt_property<'a>(properties: &'a [(&str, &str)], key: &str) -> Option<&'a str> {
properties.iter().find(|(k, _)| *k == key).map(|(_, v)| *v)
}
pub fn parse_txt(
addresses: &[IpAddr],
port: u16,
properties: &[(&str, &str)],
) -> Option<DiscoveredController> {
let ca_fingerprint = get_txt_property(properties, TXT_KEY_CA_FP).map(String::from);
let pki_addr = get_txt_property(properties, TXT_KEY_PKI_ADDR).map(String::from);
let url = if let Some(url_override) = get_txt_property(properties, TXT_KEY_URL) {
url_override.to_string()
} else {
let ip = addresses.iter().find(|ip| !ip.is_loopback())?;
match ip {
IpAddr::V4(v4) => format!("https://{v4}:{port}"),
IpAddr::V6(v6) => format!("https://[{v6}]:{port}"),
}
};
Some(DiscoveredController {
url,
pki_addr,
ca_fingerprint,
})
}
pub fn build_txt_properties(
ca_fingerprint: &str,
url: Option<&str>,
pki_addr: Option<&str>,
) -> Vec<(&'static str, String)> {
let mut properties = vec![(TXT_KEY_CA_FP, ca_fingerprint.to_string())];
if let Some(url) = url {
properties.push((TXT_KEY_URL, url.to_string()));
}
if let Some(pki_addr) = pki_addr {
properties.push((TXT_KEY_PKI_ADDR, pki_addr.to_string()));
}
properties
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_from_txt_override() {
let addresses = vec![IpAddr::from([192, 168, 1, 100])];
let properties = vec![
("ca_fp", "abcd1234"),
("url", "https://proxy.example.com:443"),
];
let controller = parse_txt(&addresses, 8443, &properties).unwrap();
assert_eq!(controller.url, "https://proxy.example.com:443");
assert_eq!(controller.ca_fingerprint.as_deref(), Some("abcd1234"));
}
#[test]
fn url_from_mdns_ip_port() {
let addresses = vec![IpAddr::from([192, 168, 1, 100])];
let properties = vec![("ca_fp", "abcd1234")];
let controller = parse_txt(&addresses, 8443, &properties).unwrap();
assert_eq!(controller.url, "https://192.168.1.100:8443");
}
#[test]
fn url_from_mdns_ipv6() {
let addresses = vec![IpAddr::from([0xfe80, 0, 0, 0, 0, 0, 0, 1])];
let properties = vec![("ca_fp", "abcd1234")];
let controller = parse_txt(&addresses, 8443, &properties).unwrap();
assert_eq!(controller.url, "https://[fe80::1]:8443");
}
#[test]
fn url_skips_loopback() {
let addresses = vec![
IpAddr::from([127, 0, 0, 1]),
IpAddr::from([192, 168, 1, 100]),
];
let properties = vec![];
let controller = parse_txt(&addresses, 8443, &properties).unwrap();
assert_eq!(controller.url, "https://192.168.1.100:8443");
}
#[test]
fn url_only_loopback_returns_none() {
let addresses = vec![IpAddr::from([127, 0, 0, 1])];
let properties = vec![];
assert!(parse_txt(&addresses, 8443, &properties).is_none());
}
#[test]
fn pki_addr_from_txt() {
let addresses = vec![IpAddr::from([192, 168, 1, 100])];
let properties = vec![("pki_addr", "http://192.168.1.100:8080")];
let controller = parse_txt(&addresses, 8443, &properties).unwrap();
assert_eq!(
controller.pki_addr.as_deref(),
Some("http://192.168.1.100:8080")
);
}
#[test]
fn get_txt_property_finds_key() {
let props = vec![("key1", "val1"), ("key2", "val2")];
assert_eq!(get_txt_property(&props, "key1"), Some("val1"));
assert_eq!(get_txt_property(&props, "key2"), Some("val2"));
assert_eq!(get_txt_property(&props, "missing"), None);
}
#[test]
fn txt_properties_basic() {
let props = build_txt_properties("abcd1234", None, None);
assert_eq!(props, vec![("ca_fp", "abcd1234".to_string())]);
}
#[test]
fn txt_properties_with_url_override() {
let props = build_txt_properties("abcd1234", Some("https://proxy.example.com:443"), None);
assert_eq!(
props,
vec![
("ca_fp", "abcd1234".to_string()),
("url", "https://proxy.example.com:443".to_string()),
]
);
}
#[test]
fn txt_properties_with_all_overrides() {
let props = build_txt_properties(
"abcd1234",
Some("https://proxy.example.com:443"),
Some("http://pki.local:8080"),
);
assert_eq!(
props,
vec![
("ca_fp", "abcd1234".to_string()),
("url", "https://proxy.example.com:443".to_string()),
("pki_addr", "http://pki.local:8080".to_string()),
]
);
}
}