Skip to main content

radicle_artifact_node/
iroh.rs

1//! Iroh endpoint configuration via environment variables.
2
3use 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/// Iroh endpoint configuration.
21///
22/// Controls the relay servers and discovery services for the endpoint. Each
23/// value defaults to the Radicle infrastructure but can be overridden via
24/// environment variable. Both accept a comma-separated list, so a deployment
25/// can name more than one server for redundancy. Iroh relays through the
26/// fastest of the given relays and falls back to the others, but it publishes
27/// to and resolves from every pkarr server:
28///
29/// - `IROH_RELAYS` — relay hosts, each served over `https://`.
30///   Default: `eu-1.relay.iroh.radicle.garden`,
31///   `1.eu.relay.iroh.radicle.network`, `1.us.relay.iroh.radicle.network`
32/// - `IROH_PKARR_URLS` — pkarr server URLs, used for both publishing and
33///   resolving. Default: `https://dns.iroh.radicle.garden/pkarr`,
34///   `https://1.eu.dns.iroh.radicle.network/pkarr`,
35///   `https://1.us.dns.iroh.radicle.network/pkarr`
36#[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        // Parsing the compile-time defaults is infallible.
45        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    /// Pkarr server URLs the endpoint publishes to and resolves from.
56    pub fn pkarr_urls(&self) -> &[url::Url] {
57        &self.pkarr_urls
58    }
59
60    /// Build an [`EndpointConfig`] from the `IROH_RELAYS` and
61    /// `IROH_PKARR_URLS` environment variables, falling back to the Radicle
62    /// defaults when a variable is unset or empty. A value that is malformed, or
63    /// that lists no entries, fails here so [`Preset::apply`] can consume the
64    /// parsed values directly.
65    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
73/// Read an environment variable, falling back to `default` when unset or empty.
74fn 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
81/// Split a comma-separated list, trimming entries and dropping empty ones.
82fn entries(value: &str) -> impl Iterator<Item = &str> {
83    value.split(',').map(str::trim).filter(|s| !s.is_empty())
84}
85
86/// Parse a comma-separated list of relay hosts into URLs, serving each over
87/// `https://`. Listing bare hosts avoids repeating the scheme per entry, which
88/// is error-prone to maintain. Parse errors are attributed to `name` (the
89/// source environment variable or constant).
90fn 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
98/// Parse one bare host into an `https://` URL. An entry that spells out a
99/// scheme still parses, as `https://https//host`, which puts the node on a
100/// relay it can never reach, so reject it and name the expected form.
101fn 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
113/// Parse a comma-separated list of full URLs. Unlike relay hosts these carry a
114/// path (`/pkarr`), so each entry spells out its scheme.
115fn 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
123/// Parse one URL and require an `https` or `http` scheme. Any other scheme
124/// still parses, but iroh only logs a warning when it cannot publish, so the
125/// node would run on unreachable discovery. `http` stays allowed for a pkarr
126/// server on the local network.
127fn 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
140/// Reject a value that lists no entries, such as `" "` or `","`. Accepting it
141/// would leave the node without relays, or without discovery, and unreachable
142/// with no error to show why.
143fn 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        // Publish to and resolve from every pkarr server for redundancy; iroh
173        // combines the address lookup services.
174        for url in self.pkarr_urls {
175            builder = builder
176                .address_lookup(PkarrPublisher::builder(url.clone()))
177                // Resolve peers over HTTPS to the pkarr server, rather than
178                // unencrypted DNS. Relay and pkarr hostnames still use system DNS.
179                .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        // `Default` panics on a constant that fails to parse, so this guards
192        // startup. Spelling out every entry also catches a dropped comma and a
193        // typo'd host, both of which still parse: the node would then run on a
194        // dead relay or a pkarr server that never answers, and iroh only logs a
195        // warning when publishing fails.
196        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        // `https://https://a.org` parses, leaving the host as `https` and the
258        // rest as a path, so only this check stops a node from starting on a
259        // relay it can never reach. Pasting a full URL is the likely mistake
260        // after the rename from `IROH_RELAY_URL`.
261        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        // A scheme typo parses as a valid URL, so this check is what stops the
272        // node from starting with discovery it can never reach.
273        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        // `env_or` only falls back when the variable is exactly empty, so a
284        // value of whitespace or commas reaches the parsers and must be
285        // rejected instead of yielding no relays and no discovery.
286        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}