use std::fmt;
use iroh::address_lookup::{PkarrPublisher, PkarrResolver};
use iroh::endpoint::presets::{self, Preset};
use crate::Error;
const ENV_RELAY_HOSTS: &str = "IROH_RELAY_HOSTS";
const ENV_PKARR_URL: &str = "IROH_PKARR_URL";
const DEFAULT_RELAY_HOSTS: &str = "eu-1.relay.iroh.radicle.garden";
const DEFAULT_PKARR_URL: &str = "https://dns.iroh.radicle.garden/pkarr";
#[derive(Debug, Clone)]
pub struct EndpointConfig {
relay_urls: Vec<iroh::RelayUrl>,
pkarr_url: url::Url,
}
impl Default for EndpointConfig {
fn default() -> Self {
Self {
relay_urls: parse_relay_hosts(DEFAULT_RELAY_HOSTS, "DEFAULT_RELAY_HOSTS")
.expect("valid DEFAULT_RELAY_HOSTS"),
pkarr_url: DEFAULT_PKARR_URL.parse().expect("valid DEFAULT_PKARR_URL"),
}
}
}
impl EndpointConfig {
pub fn from_env() -> Result<Self, Error> {
Ok(Self {
relay_urls: parse_relay_hosts(
&env_or(ENV_RELAY_HOSTS, DEFAULT_RELAY_HOSTS),
ENV_RELAY_HOSTS,
)?,
pkarr_url: parse_env(ENV_PKARR_URL, DEFAULT_PKARR_URL)?,
})
}
}
fn env_or(name: &str, default: &str) -> String {
match std::env::var(name) {
Ok(value) if !value.is_empty() => value,
_ => default.to_owned(),
}
}
fn parse_relay_hosts(value: &str, name: &str) -> Result<Vec<iroh::RelayUrl>, Error> {
value
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|host| {
format!("https://{host}")
.parse()
.map_err(|e| Error::Iroh(format!("invalid {name} value {host:?}: {e}")))
})
.collect()
}
fn parse_env<T>(name: &str, default: &str) -> Result<T, Error>
where
T: std::str::FromStr,
T::Err: fmt::Display,
{
let value = env_or(name, default);
value
.parse()
.map_err(|e| Error::Iroh(format!("invalid {name} value {value:?}: {e}")))
}
impl fmt::Display for EndpointConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let relay_urls = self
.relay_urls
.iter()
.map(|u| u.to_string())
.collect::<Vec<_>>()
.join(",");
write!(f, "relay={} pkarr={}", relay_urls, self.pkarr_url)
}
}
impl Preset for EndpointConfig {
fn apply(self, builder: iroh::endpoint::Builder) -> iroh::endpoint::Builder {
presets::Minimal
.apply(builder)
.address_lookup(PkarrPublisher::builder(self.pkarr_url.clone()))
.address_lookup(PkarrResolver::builder(self.pkarr_url))
.relay_mode(iroh::RelayMode::custom(self.relay_urls))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_uses_radicle_endpoints() {
let config = EndpointConfig::default();
assert_eq!(
config.relay_urls,
vec!["https://eu-1.relay.iroh.radicle.garden"
.parse::<iroh::RelayUrl>()
.unwrap()]
);
assert_eq!(config.pkarr_url, DEFAULT_PKARR_URL.parse().unwrap());
}
#[test]
fn display_lists_relays_and_pkarr() {
let config = EndpointConfig::default();
assert_eq!(
config.to_string(),
"relay=https://eu-1.relay.iroh.radicle.garden/ pkarr=https://dns.iroh.radicle.garden/pkarr"
);
}
#[test]
fn parse_relay_hosts_comma_separated() {
let urls =
parse_relay_hosts("relay1.example.org,relay2.example.org", "IROH_RELAY_HOSTS").unwrap();
assert_eq!(urls.len(), 2);
assert_eq!(
urls[0],
"https://relay1.example.org"
.parse::<iroh::RelayUrl>()
.unwrap()
);
assert_eq!(
urls[1],
"https://relay2.example.org"
.parse::<iroh::RelayUrl>()
.unwrap()
);
}
#[test]
fn parse_relay_hosts_trims_and_skips_empty() {
let urls = parse_relay_hosts(" a.org , , b.org ,", "IROH_RELAY_HOSTS").unwrap();
assert_eq!(
urls,
vec![
"https://a.org".parse::<iroh::RelayUrl>().unwrap(),
"https://b.org".parse::<iroh::RelayUrl>().unwrap(),
]
);
}
#[test]
fn parse_relay_hosts_rejects_malformed() {
let result = parse_relay_hosts("not a host", "IROH_RELAY_HOSTS");
assert!(matches!(result, Err(Error::Iroh(_))));
}
#[test]
fn parse_env_rejects_malformed_value() {
let result = parse_env::<url::Url>("IROH_UNSET_TEST_VAR", "not a url");
assert!(matches!(result, Err(Error::Iroh(_))));
}
}