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_RELAYS: &str = "IROH_RELAYS";
11const ENV_PKARR_URLS: &str = "IROH_PKARR_URLS";
12
13const DEFAULT_RELAYS: &str = "eu-1.relay.iroh.radicle.garden,\
14 1.eu.relay.iroh.radicle.network,\
15 1.us.relay.iroh.radicle.network";
16const DEFAULT_PKARR_URLS: &str = "https://dns.iroh.radicle.garden/pkarr,\
17 https://1.eu.dns.iroh.radicle.network/pkarr,\
18 https://1.us.dns.iroh.radicle.network/pkarr";
19
20#[derive(Debug, Clone)]
37pub struct EndpointConfig {
38 relay_urls: Vec<iroh::RelayUrl>,
39 pkarr_urls: Vec<url::Url>,
40}
41
42impl Default for EndpointConfig {
43 fn default() -> Self {
44 Self {
46 relay_urls: parse_relays(DEFAULT_RELAYS, "DEFAULT_RELAYS")
47 .expect("valid DEFAULT_RELAYS"),
48 pkarr_urls: parse_urls(DEFAULT_PKARR_URLS, "DEFAULT_PKARR_URLS")
49 .expect("valid DEFAULT_PKARR_URLS"),
50 }
51 }
52}
53
54impl EndpointConfig {
55 pub fn pkarr_urls(&self) -> &[url::Url] {
57 &self.pkarr_urls
58 }
59
60 pub fn from_env() -> Result<Self, Error> {
66 Ok(Self {
67 relay_urls: parse_relays(&env_or(ENV_RELAYS, DEFAULT_RELAYS), ENV_RELAYS)?,
68 pkarr_urls: parse_urls(&env_or(ENV_PKARR_URLS, DEFAULT_PKARR_URLS), ENV_PKARR_URLS)?,
69 })
70 }
71}
72
73fn env_or(name: &str, default: &str) -> String {
75 match std::env::var(name) {
76 Ok(value) if !value.is_empty() => value,
77 _ => default.to_owned(),
78 }
79}
80
81fn entries(value: &str) -> impl Iterator<Item = &str> {
83 value.split(',').map(str::trim).filter(|s| !s.is_empty())
84}
85
86fn parse_relays(value: &str, name: &str) -> Result<Vec<iroh::RelayUrl>, Error> {
91 let urls = entries(value)
92 .map(|host| parse_relay_host(host, name))
93 .collect::<Result<Vec<_>, _>>()?;
94
95 non_empty(urls, name)
96}
97
98fn parse_relay_host(host: &str, name: &str) -> Result<iroh::RelayUrl, Error> {
102 if host.contains("://") {
103 return Err(Error::Iroh(format!(
104 "invalid {name} value {host:?}: expected a host without a scheme"
105 )));
106 }
107
108 format!("https://{host}")
109 .parse()
110 .map_err(|e| Error::Iroh(format!("invalid {name} value {host:?}: {e}")))
111}
112
113fn parse_urls(value: &str, name: &str) -> Result<Vec<url::Url>, Error> {
116 let urls = entries(value)
117 .map(|entry| parse_http_url(entry, name))
118 .collect::<Result<Vec<_>, _>>()?;
119
120 non_empty(urls, name)
121}
122
123fn parse_http_url(entry: &str, name: &str) -> Result<url::Url, Error> {
128 let url: url::Url = entry
129 .parse()
130 .map_err(|e| Error::Iroh(format!("invalid {name} value {entry:?}: {e}")))?;
131
132 match url.scheme() {
133 "https" | "http" => Ok(url),
134 scheme => Err(Error::Iroh(format!(
135 "invalid {name} value {entry:?}: expected scheme https or http, found {scheme}"
136 ))),
137 }
138}
139
140fn non_empty<T>(values: Vec<T>, name: &str) -> Result<Vec<T>, Error> {
144 if values.is_empty() {
145 return Err(Error::Iroh(format!("{name} lists no entries")));
146 }
147 Ok(values)
148}
149
150impl fmt::Display for EndpointConfig {
151 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152 write!(
153 f,
154 "relay={} pkarr={}",
155 join(&self.relay_urls),
156 join(&self.pkarr_urls)
157 )
158 }
159}
160
161fn join<T: fmt::Display>(values: &[T]) -> String {
162 values
163 .iter()
164 .map(|v| v.to_string())
165 .collect::<Vec<_>>()
166 .join(",")
167}
168
169impl Preset for EndpointConfig {
170 fn apply(self, builder: iroh::endpoint::Builder) -> iroh::endpoint::Builder {
171 let mut builder = presets::Minimal.apply(builder);
172 for url in self.pkarr_urls {
175 builder = builder
176 .address_lookup(PkarrPublisher::builder(url.clone()))
177 .address_lookup(PkarrResolver::builder(url));
180 }
181 builder.relay_mode(iroh::RelayMode::custom(self.relay_urls))
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn default_parses_radicle_endpoints() {
191 let config = EndpointConfig::default();
197 assert_eq!(
198 config.relay_urls,
199 [
200 "https://eu-1.relay.iroh.radicle.garden",
201 "https://1.eu.relay.iroh.radicle.network",
202 "https://1.us.relay.iroh.radicle.network",
203 ]
204 .map(|u| u.parse::<iroh::RelayUrl>().unwrap())
205 );
206 assert_eq!(
207 config.pkarr_urls,
208 [
209 "https://dns.iroh.radicle.garden/pkarr",
210 "https://1.eu.dns.iroh.radicle.network/pkarr",
211 "https://1.us.dns.iroh.radicle.network/pkarr",
212 ]
213 .map(|u| u.parse::<url::Url>().unwrap())
214 );
215 }
216
217 #[test]
218 fn parse_relays_trims_and_skips_empty() {
219 let urls = parse_relays(" a.org , , b.org ,", "IROH_RELAYS").unwrap();
220 assert_eq!(
221 urls,
222 vec![
223 "https://a.org".parse::<iroh::RelayUrl>().unwrap(),
224 "https://b.org".parse::<iroh::RelayUrl>().unwrap(),
225 ]
226 );
227 }
228
229 #[test]
230 fn parse_urls_keeps_scheme_and_path() {
231 let urls = parse_urls(
232 "https://a.example.org/pkarr, https://b.example.org/pkarr",
233 "IROH_PKARR_URLS",
234 )
235 .unwrap();
236 assert_eq!(
237 urls,
238 ["https://a.example.org/pkarr", "https://b.example.org/pkarr"]
239 .map(|u| u.parse::<url::Url>().unwrap())
240 );
241 }
242
243 #[test]
244 fn parse_rejects_malformed() {
245 assert!(matches!(
246 parse_relays("not a host", "IROH_RELAYS"),
247 Err(Error::Iroh(_))
248 ));
249 assert!(matches!(
250 parse_urls("not a url", "IROH_PKARR_URLS"),
251 Err(Error::Iroh(_))
252 ));
253 }
254
255 #[test]
256 fn parse_relays_rejects_entries_with_a_scheme() {
257 for value in ["https://a.org", "http://a.org"] {
262 assert!(matches!(
263 parse_relays(value, "IROH_RELAYS"),
264 Err(Error::Iroh(_))
265 ));
266 }
267 }
268
269 #[test]
270 fn parse_urls_rejects_other_schemes() {
271 for value in ["htp://a.example.org/pkarr", "ftp://a.example.org/pkarr"] {
274 assert!(matches!(
275 parse_urls(value, "IROH_PKARR_URLS"),
276 Err(Error::Iroh(_))
277 ));
278 }
279 }
280
281 #[test]
282 fn parse_rejects_lists_without_entries() {
283 for value in [" ", ","] {
287 assert!(matches!(
288 parse_relays(value, "IROH_RELAYS"),
289 Err(Error::Iroh(_))
290 ));
291 assert!(matches!(
292 parse_urls(value, "IROH_PKARR_URLS"),
293 Err(Error::Iroh(_))
294 ));
295 }
296 }
297}