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    /// Build an [`EndpointConfig`] from the `IROH_RELAYS` and
56    /// `IROH_PKARR_URLS` environment variables, falling back to the Radicle
57    /// defaults when a variable is unset or empty. A value that is malformed, or
58    /// that lists no entries, fails here so [`Preset::apply`] can consume the
59    /// parsed values directly.
60    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
68/// Read an environment variable, falling back to `default` when unset or empty.
69fn 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
76/// Split a comma-separated list, trimming entries and dropping empty ones.
77fn entries(value: &str) -> impl Iterator<Item = &str> {
78    value.split(',').map(str::trim).filter(|s| !s.is_empty())
79}
80
81/// Parse a comma-separated list of relay hosts into URLs, serving each over
82/// `https://`. Listing bare hosts avoids repeating the scheme per entry, which
83/// is error-prone to maintain. Parse errors are attributed to `name` (the
84/// source environment variable or constant).
85fn 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
93/// Parse one bare host into an `https://` URL. An entry that spells out a
94/// scheme still parses, as `https://https//host`, which puts the node on a
95/// relay it can never reach, so reject it and name the expected form.
96fn 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
108/// Parse a comma-separated list of full URLs. Unlike relay hosts these carry a
109/// path (`/pkarr`), so each entry spells out its scheme.
110fn 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
118/// Parse one URL and require an `https` or `http` scheme. Any other scheme
119/// still parses, but iroh only logs a warning when it cannot publish, so the
120/// node would run on unreachable discovery. `http` stays allowed for a pkarr
121/// server on the local network.
122fn 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
135/// Reject a value that lists no entries, such as `" "` or `","`. Accepting it
136/// would leave the node without relays, or without discovery, and unreachable
137/// with no error to show why.
138fn 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        // Publish to and resolve from every pkarr server for redundancy; iroh
168        // combines the address lookup services.
169        for url in self.pkarr_urls {
170            builder = builder
171                .address_lookup(PkarrPublisher::builder(url.clone()))
172                // Resolve peers over HTTPS to the pkarr server, rather than
173                // unencrypted DNS. Relay and pkarr hostnames still use system DNS.
174                .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        // `Default` panics on a constant that fails to parse, so this guards
187        // startup. Spelling out every entry also catches a dropped comma and a
188        // typo'd host, both of which still parse: the node would then run on a
189        // dead relay or a pkarr server that never answers, and iroh only logs a
190        // warning when publishing fails.
191        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        // `https://https://a.org` parses, leaving the host as `https` and the
253        // rest as a path, so only this check stops a node from starting on a
254        // relay it can never reach. Pasting a full URL is the likely mistake
255        // after the rename from `IROH_RELAY_URL`.
256        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        // A scheme typo parses as a valid URL, so this check is what stops the
267        // node from starting with discovery it can never reach.
268        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        // `env_or` only falls back when the variable is exactly empty, so a
279        // value of whitespace or commas reaches the parsers and must be
280        // rejected instead of yielding no relays and no discovery.
281        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}