radicle_artifact_node/
iroh.rs1use std::fmt;
4
5use iroh::address_lookup::{PkarrPublisher, PkarrResolver};
6use iroh::endpoint::presets::{self, Preset};
7
8use crate::Error;
9
10const ENV_RELAY_HOSTS: &str = "IROH_RELAY_HOSTS";
11const ENV_PKARR_URL: &str = "IROH_PKARR_URL";
12
13const DEFAULT_RELAY_HOSTS: &str = "eu-1.relay.iroh.radicle.garden";
14const DEFAULT_PKARR_URL: &str = "https://dns.iroh.radicle.garden/pkarr";
15
16#[derive(Debug, Clone)]
25pub struct EndpointConfig {
26 relay_urls: Vec<iroh::RelayUrl>,
27 pkarr_url: url::Url,
28}
29
30impl Default for EndpointConfig {
31 fn default() -> Self {
32 Self {
34 relay_urls: parse_relay_hosts(DEFAULT_RELAY_HOSTS, "DEFAULT_RELAY_HOSTS")
35 .expect("valid DEFAULT_RELAY_HOSTS"),
36 pkarr_url: DEFAULT_PKARR_URL.parse().expect("valid DEFAULT_PKARR_URL"),
37 }
38 }
39}
40
41impl EndpointConfig {
42 pub fn from_env() -> Result<Self, Error> {
47 Ok(Self {
48 relay_urls: parse_relay_hosts(
49 &env_or(ENV_RELAY_HOSTS, DEFAULT_RELAY_HOSTS),
50 ENV_RELAY_HOSTS,
51 )?,
52 pkarr_url: parse_env(ENV_PKARR_URL, DEFAULT_PKARR_URL)?,
53 })
54 }
55}
56
57fn env_or(name: &str, default: &str) -> String {
59 match std::env::var(name) {
60 Ok(value) if !value.is_empty() => value,
61 _ => default.to_owned(),
62 }
63}
64
65fn parse_relay_hosts(value: &str, name: &str) -> Result<Vec<iroh::RelayUrl>, Error> {
70 value
71 .split(',')
72 .map(str::trim)
73 .filter(|s| !s.is_empty())
74 .map(|host| {
75 format!("https://{host}")
76 .parse()
77 .map_err(|e| Error::Iroh(format!("invalid {name} value {host:?}: {e}")))
78 })
79 .collect()
80}
81
82fn parse_env<T>(name: &str, default: &str) -> Result<T, Error>
85where
86 T: std::str::FromStr,
87 T::Err: fmt::Display,
88{
89 let value = env_or(name, default);
90 value
91 .parse()
92 .map_err(|e| Error::Iroh(format!("invalid {name} value {value:?}: {e}")))
93}
94
95impl fmt::Display for EndpointConfig {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 let relay_urls = self
98 .relay_urls
99 .iter()
100 .map(|u| u.to_string())
101 .collect::<Vec<_>>()
102 .join(",");
103 write!(f, "relay={} pkarr={}", relay_urls, self.pkarr_url)
104 }
105}
106
107impl Preset for EndpointConfig {
108 fn apply(self, builder: iroh::endpoint::Builder) -> iroh::endpoint::Builder {
109 presets::Minimal
110 .apply(builder)
111 .address_lookup(PkarrPublisher::builder(self.pkarr_url.clone()))
112 .address_lookup(PkarrResolver::builder(self.pkarr_url))
115 .relay_mode(iroh::RelayMode::custom(self.relay_urls))
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 #[test]
124 fn default_uses_radicle_endpoints() {
125 let config = EndpointConfig::default();
128 assert_eq!(
129 config.relay_urls,
130 vec!["https://eu-1.relay.iroh.radicle.garden"
131 .parse::<iroh::RelayUrl>()
132 .unwrap()]
133 );
134 assert_eq!(config.pkarr_url, DEFAULT_PKARR_URL.parse().unwrap());
135 }
136
137 #[test]
138 fn display_lists_relays_and_pkarr() {
139 let config = EndpointConfig::default();
141 assert_eq!(
142 config.to_string(),
143 "relay=https://eu-1.relay.iroh.radicle.garden/ pkarr=https://dns.iroh.radicle.garden/pkarr"
144 );
145 }
146
147 #[test]
148 fn parse_relay_hosts_comma_separated() {
149 let urls =
150 parse_relay_hosts("relay1.example.org,relay2.example.org", "IROH_RELAY_HOSTS").unwrap();
151 assert_eq!(urls.len(), 2);
152 assert_eq!(
153 urls[0],
154 "https://relay1.example.org"
155 .parse::<iroh::RelayUrl>()
156 .unwrap()
157 );
158 assert_eq!(
159 urls[1],
160 "https://relay2.example.org"
161 .parse::<iroh::RelayUrl>()
162 .unwrap()
163 );
164 }
165
166 #[test]
167 fn parse_relay_hosts_trims_and_skips_empty() {
168 let urls = parse_relay_hosts(" a.org , , b.org ,", "IROH_RELAY_HOSTS").unwrap();
171 assert_eq!(
172 urls,
173 vec![
174 "https://a.org".parse::<iroh::RelayUrl>().unwrap(),
175 "https://b.org".parse::<iroh::RelayUrl>().unwrap(),
176 ]
177 );
178 }
179
180 #[test]
181 fn parse_relay_hosts_rejects_malformed() {
182 let result = parse_relay_hosts("not a host", "IROH_RELAY_HOSTS");
183 assert!(matches!(result, Err(Error::Iroh(_))));
184 }
185
186 #[test]
187 fn parse_env_rejects_malformed_value() {
188 let result = parse_env::<url::Url>("IROH_UNSET_TEST_VAR", "not a url");
189 assert!(matches!(result, Err(Error::Iroh(_))));
190 }
191}