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 from_env() -> Result<Self, Error> {
61 Ok(Self {
62 relay_urls: parse_relays(&env_or(ENV_RELAYS, DEFAULT_RELAYS), ENV_RELAYS)?,
63 pkarr_urls: parse_urls(&env_or(ENV_PKARR_URLS, DEFAULT_PKARR_URLS), ENV_PKARR_URLS)?,
64 })
65 }
66}
67
68fn env_or(name: &str, default: &str) -> String {
70 match std::env::var(name) {
71 Ok(value) if !value.is_empty() => value,
72 _ => default.to_owned(),
73 }
74}
75
76fn entries(value: &str) -> impl Iterator<Item = &str> {
78 value.split(',').map(str::trim).filter(|s| !s.is_empty())
79}
80
81fn parse_relays(value: &str, name: &str) -> Result<Vec<iroh::RelayUrl>, Error> {
86 let urls = entries(value)
87 .map(|host| parse_relay_host(host, name))
88 .collect::<Result<Vec<_>, _>>()?;
89
90 non_empty(urls, name)
91}
92
93fn parse_relay_host(host: &str, name: &str) -> Result<iroh::RelayUrl, Error> {
97 if host.contains("://") {
98 return Err(Error::Iroh(format!(
99 "invalid {name} value {host:?}: expected a host without a scheme"
100 )));
101 }
102
103 format!("https://{host}")
104 .parse()
105 .map_err(|e| Error::Iroh(format!("invalid {name} value {host:?}: {e}")))
106}
107
108fn parse_urls(value: &str, name: &str) -> Result<Vec<url::Url>, Error> {
111 let urls = entries(value)
112 .map(|entry| parse_http_url(entry, name))
113 .collect::<Result<Vec<_>, _>>()?;
114
115 non_empty(urls, name)
116}
117
118fn parse_http_url(entry: &str, name: &str) -> Result<url::Url, Error> {
123 let url: url::Url = entry
124 .parse()
125 .map_err(|e| Error::Iroh(format!("invalid {name} value {entry:?}: {e}")))?;
126
127 match url.scheme() {
128 "https" | "http" => Ok(url),
129 scheme => Err(Error::Iroh(format!(
130 "invalid {name} value {entry:?}: expected scheme https or http, found {scheme}"
131 ))),
132 }
133}
134
135fn non_empty<T>(values: Vec<T>, name: &str) -> Result<Vec<T>, Error> {
139 if values.is_empty() {
140 return Err(Error::Iroh(format!("{name} lists no entries")));
141 }
142 Ok(values)
143}
144
145impl fmt::Display for EndpointConfig {
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147 write!(
148 f,
149 "relay={} pkarr={}",
150 join(&self.relay_urls),
151 join(&self.pkarr_urls)
152 )
153 }
154}
155
156fn join<T: fmt::Display>(values: &[T]) -> String {
157 values
158 .iter()
159 .map(|v| v.to_string())
160 .collect::<Vec<_>>()
161 .join(",")
162}
163
164impl Preset for EndpointConfig {
165 fn apply(self, builder: iroh::endpoint::Builder) -> iroh::endpoint::Builder {
166 let mut builder = presets::Minimal.apply(builder);
167 for url in self.pkarr_urls {
170 builder = builder
171 .address_lookup(PkarrPublisher::builder(url.clone()))
172 .address_lookup(PkarrResolver::builder(url));
175 }
176 builder.relay_mode(iroh::RelayMode::custom(self.relay_urls))
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn default_parses_radicle_endpoints() {
186 let config = EndpointConfig::default();
192 assert_eq!(
193 config.relay_urls,
194 [
195 "https://eu-1.relay.iroh.radicle.garden",
196 "https://1.eu.relay.iroh.radicle.network",
197 "https://1.us.relay.iroh.radicle.network",
198 ]
199 .map(|u| u.parse::<iroh::RelayUrl>().unwrap())
200 );
201 assert_eq!(
202 config.pkarr_urls,
203 [
204 "https://dns.iroh.radicle.garden/pkarr",
205 "https://1.eu.dns.iroh.radicle.network/pkarr",
206 "https://1.us.dns.iroh.radicle.network/pkarr",
207 ]
208 .map(|u| u.parse::<url::Url>().unwrap())
209 );
210 }
211
212 #[test]
213 fn parse_relays_trims_and_skips_empty() {
214 let urls = parse_relays(" a.org , , b.org ,", "IROH_RELAYS").unwrap();
215 assert_eq!(
216 urls,
217 vec![
218 "https://a.org".parse::<iroh::RelayUrl>().unwrap(),
219 "https://b.org".parse::<iroh::RelayUrl>().unwrap(),
220 ]
221 );
222 }
223
224 #[test]
225 fn parse_urls_keeps_scheme_and_path() {
226 let urls = parse_urls(
227 "https://a.example.org/pkarr, https://b.example.org/pkarr",
228 "IROH_PKARR_URLS",
229 )
230 .unwrap();
231 assert_eq!(
232 urls,
233 ["https://a.example.org/pkarr", "https://b.example.org/pkarr"]
234 .map(|u| u.parse::<url::Url>().unwrap())
235 );
236 }
237
238 #[test]
239 fn parse_rejects_malformed() {
240 assert!(matches!(
241 parse_relays("not a host", "IROH_RELAYS"),
242 Err(Error::Iroh(_))
243 ));
244 assert!(matches!(
245 parse_urls("not a url", "IROH_PKARR_URLS"),
246 Err(Error::Iroh(_))
247 ));
248 }
249
250 #[test]
251 fn parse_relays_rejects_entries_with_a_scheme() {
252 for value in ["https://a.org", "http://a.org"] {
257 assert!(matches!(
258 parse_relays(value, "IROH_RELAYS"),
259 Err(Error::Iroh(_))
260 ));
261 }
262 }
263
264 #[test]
265 fn parse_urls_rejects_other_schemes() {
266 for value in ["htp://a.example.org/pkarr", "ftp://a.example.org/pkarr"] {
269 assert!(matches!(
270 parse_urls(value, "IROH_PKARR_URLS"),
271 Err(Error::Iroh(_))
272 ));
273 }
274 }
275
276 #[test]
277 fn parse_rejects_lists_without_entries() {
278 for value in [" ", ","] {
282 assert!(matches!(
283 parse_relays(value, "IROH_RELAYS"),
284 Err(Error::Iroh(_))
285 ));
286 assert!(matches!(
287 parse_urls(value, "IROH_PKARR_URLS"),
288 Err(Error::Iroh(_))
289 ));
290 }
291 }
292}