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_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/// Iroh endpoint configuration.
17///
18/// Controls the relay server and discovery services for the endpoint. Each
19/// value defaults to the Radicle infrastructure but can be overridden via
20/// environment variable:
21///
22/// - `IROH_RELAY_HOSTS` (default `eu-1.relay.iroh.radicle.garden`) — comma-separated list of relay hosts, each served over `https://`
23/// - `IROH_PKARR_URL` (default `https://dns.iroh.radicle.garden/pkarr`)
24#[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        // Parsing the compile-time defaults is infallible.
33        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    /// Build an [`EndpointConfig`] from the `IROH_RELAY_HOSTS` and
43    /// `IROH_PKARR_URL` environment variables, falling back to the Radicle
44    /// defaults when a variable is unset or empty. A malformed value fails here
45    /// so [`Preset::apply`] can consume the parsed values directly.
46    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
57/// Read an environment variable, falling back to `default` when unset or empty.
58fn 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
65/// Parse a comma-separated list of relay hosts into URLs, serving each over
66/// `https://`. Listing bare hosts avoids repeating the scheme per entry, which
67/// is error-prone to maintain. Parse errors are attributed to `name` (the
68/// source environment variable or constant).
69fn 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
82/// Read and parse an environment variable, falling back to `default` when unset
83/// or empty. Returns [`Error::Iroh`] if the value fails to parse.
84fn 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            // Resolve peers over HTTPS to the pkarr server, rather than
113            // unencrypted DNS. Relay and pkarr hostnames still use system DNS.
114            .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        // Also exercises the host-to-URL formatting in `Default`, guarding
126        // against a typo'd default that would otherwise panic at startup.
127        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        // This format is logged at startup to confirm what the node bound to.
140        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        // Whitespace around hosts is trimmed and empty entries (from leading,
169        // trailing, or doubled commas) are dropped rather than parsed.
170        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}