Add custom Iroh relay maps

This commit is contained in:
Eric Wendland 2026-05-16 14:28:38 +02:00
commit 280472cecb
10 changed files with 241 additions and 32 deletions

View file

@ -10,3 +10,4 @@ directories.workspace = true
serde.workspace = true
thiserror.workspace = true
toml_edit.workspace = true
url.workspace = true

View file

@ -1,12 +1,17 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const DEFAULT_CONFIG_TOML: &str = r#"# geth local node config
# Remote node-to-node communication is Iroh-only.
#
# relay_mode controls Iroh relay use for practical internet connectivity.
# Values: "default", "staging", "disabled".
# Values: "default", "staging", "disabled", "custom".
[iroh]
relay_mode = "default"
# relay_map = "home"
#
# [iroh.relay_maps.home]
# urls = ["https://relay.example.com"]
"#;
#[derive(Clone, Debug)]
@ -96,6 +101,18 @@ pub enum ConfigError {
TomlParse(#[from] toml_edit::TomlError),
#[error("invalid iroh relay_mode: {0}")]
InvalidRelayMode(String),
#[error("custom iroh relay_mode requires iroh.relay_map")]
MissingRelayMapSelection,
#[error("selected iroh relay map does not exist: {0}")]
UnknownRelayMap(String),
#[error("invalid iroh relay_maps table")]
InvalidRelayMapsTable,
#[error("invalid iroh relay map `{0}`")]
InvalidRelayMap(String),
#[error("iroh relay map `{0}` must include at least one URL")]
EmptyRelayMap(String),
#[error("invalid iroh relay URL in map `{map}`: {url}")]
InvalidRelayUrl { map: String, url: String },
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
@ -118,18 +135,22 @@ impl GethConfig {
pub fn parse(text: &str) -> Result<Self, ConfigError> {
let document = text.parse::<toml_edit::DocumentMut>()?;
let relay_maps = parse_relay_maps(&document)?;
let relay_mode = match document.get("iroh").and_then(|iroh| iroh.get("relay_mode")) {
Some(item) => {
let value = item
.as_str()
.ok_or_else(|| ConfigError::InvalidRelayMode(item.to_string()))?;
RelayMode::parse(value)?
RelayMode::parse(value, &document, &relay_maps)?
}
None => RelayMode::default(),
};
Ok(Self {
iroh: IrohConfig { relay_mode },
iroh: IrohConfig {
relay_mode,
relay_maps,
},
})
}
}
@ -137,36 +158,117 @@ impl GethConfig {
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct IrohConfig {
pub relay_mode: RelayMode,
pub relay_maps: BTreeMap<String, RelayMapConfig>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RelayMapConfig {
pub urls: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum RelayMode {
Disabled,
#[default]
Default,
Staging,
Custom {
map: String,
},
}
impl RelayMode {
pub fn parse(value: &str) -> Result<Self, ConfigError> {
fn parse(
value: &str,
document: &toml_edit::DocumentMut,
relay_maps: &BTreeMap<String, RelayMapConfig>,
) -> Result<Self, ConfigError> {
match value {
"disabled" => Ok(Self::Disabled),
"default" => Ok(Self::Default),
"staging" => Ok(Self::Staging),
"custom" => {
let map = document
.get("iroh")
.and_then(|iroh| iroh.get("relay_map"))
.and_then(toml_edit::Item::as_str)
.ok_or(ConfigError::MissingRelayMapSelection)?
.to_owned();
if !relay_maps.contains_key(&map) {
return Err(ConfigError::UnknownRelayMap(map));
}
Ok(Self::Custom { map })
}
other => Err(ConfigError::InvalidRelayMode(other.to_owned())),
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
pub fn label(&self) -> String {
match self {
Self::Disabled => "disabled",
Self::Default => "default",
Self::Staging => "staging",
Self::Disabled => "disabled".to_owned(),
Self::Default => "default".to_owned(),
Self::Staging => "staging".to_owned(),
Self::Custom { map } => format!("custom:{map}"),
}
}
}
fn parse_relay_maps(
document: &toml_edit::DocumentMut,
) -> Result<BTreeMap<String, RelayMapConfig>, ConfigError> {
let Some(relay_maps_item) = document.get("iroh").and_then(|iroh| iroh.get("relay_maps")) else {
return Ok(BTreeMap::new());
};
let relay_maps_table = relay_maps_item
.as_table_like()
.ok_or(ConfigError::InvalidRelayMapsTable)?;
let mut relay_maps = BTreeMap::new();
for (name, item) in relay_maps_table.iter() {
let urls = parse_relay_map_urls(name, item)?;
relay_maps.insert(name.to_owned(), RelayMapConfig { urls });
}
Ok(relay_maps)
}
fn parse_relay_map_urls(name: &str, item: &toml_edit::Item) -> Result<Vec<String>, ConfigError> {
let table = item
.as_table_like()
.ok_or_else(|| ConfigError::InvalidRelayMap(name.to_owned()))?;
let urls_item = table
.get("urls")
.ok_or_else(|| ConfigError::InvalidRelayMap(name.to_owned()))?;
let urls_array = urls_item
.as_array()
.ok_or_else(|| ConfigError::InvalidRelayMap(name.to_owned()))?;
let mut urls = Vec::new();
for value in urls_array.iter() {
let url = value
.as_str()
.ok_or_else(|| ConfigError::InvalidRelayMap(name.to_owned()))?;
validate_relay_url(name, url)?;
urls.push(url.to_owned());
}
if urls.is_empty() {
return Err(ConfigError::EmptyRelayMap(name.to_owned()));
}
Ok(urls)
}
fn validate_relay_url(map: &str, relay_url: &str) -> Result<(), ConfigError> {
let url = url::Url::parse(relay_url).map_err(|_| ConfigError::InvalidRelayUrl {
map: map.to_owned(),
url: relay_url.to_owned(),
})?;
match url.scheme() {
"http" | "https" => Ok(()),
_ => Err(ConfigError::InvalidRelayUrl {
map: map.to_owned(),
url: relay_url.to_owned(),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -183,6 +285,32 @@ mod tests {
assert_eq!(config.iroh.relay_mode, RelayMode::Disabled);
}
#[test]
fn config_parses_custom_relay_map() {
let config = GethConfig::parse(
r#"
[iroh]
relay_mode = "custom"
relay_map = "home"
[iroh.relay_maps.home]
urls = ["https://relay.example.com"]
"#,
)
.expect("parse");
assert_eq!(
config.iroh.relay_mode,
RelayMode::Custom {
map: "home".to_owned()
}
);
assert_eq!(
config.iroh.relay_maps["home"].urls,
vec!["https://relay.example.com".to_owned()]
);
assert_eq!(config.iroh.relay_mode.label(), "custom:home");
}
#[test]
fn config_rejects_unknown_relay_mode() {
let error = GethConfig::parse("[iroh]\nrelay_mode = \"magic\"\n").expect_err("error");
@ -194,4 +322,27 @@ mod tests {
let error = GethConfig::parse("[iroh]\nrelay_mode = true\n").expect_err("error");
assert!(matches!(error, ConfigError::InvalidRelayMode(_)));
}
#[test]
fn config_rejects_invalid_custom_relay_url() {
let error = GethConfig::parse(
r#"
[iroh]
relay_mode = "custom"
relay_map = "home"
[iroh.relay_maps.home]
urls = ["not a url"]
"#,
)
.expect_err("error");
assert!(matches!(error, ConfigError::InvalidRelayUrl { map, .. } if map == "home"));
}
#[test]
fn config_rejects_missing_custom_relay_map() {
let error = GethConfig::parse("[iroh]\nrelay_mode = \"custom\"\nrelay_map = \"home\"\n")
.expect_err("error");
assert!(matches!(error, ConfigError::UnknownRelayMap(map) if map == "home"));
}
}

View file

@ -160,23 +160,43 @@ pub enum GethRelayMode {
Disabled,
Default,
Staging,
Custom {
name: String,
relay_urls: Vec<String>,
},
}
impl GethRelayMode {
fn to_iroh(&self) -> iroh::RelayMode {
fn to_iroh(&self) -> Result<iroh::RelayMode, IrohError> {
match self {
Self::Disabled => iroh::RelayMode::Disabled,
Self::Default => iroh::RelayMode::Default,
Self::Staging => iroh::RelayMode::Staging,
Self::Disabled => Ok(iroh::RelayMode::Disabled),
Self::Default => Ok(iroh::RelayMode::Default),
Self::Staging => Ok(iroh::RelayMode::Staging),
Self::Custom { relay_urls, .. } => {
let relay_urls = relay_urls
.iter()
.map(|url| {
url.parse::<iroh::RelayUrl>()
.map_err(|error| IrohError::InvalidRelayUrl {
url: url.clone(),
message: error.to_string(),
})
})
.collect::<Result<Vec<_>, _>>()?;
Ok(iroh::RelayMode::Custom(iroh::RelayMap::from_iter(
relay_urls,
)))
}
}
}
#[must_use]
pub fn as_str(&self) -> &'static str {
pub fn label(&self) -> String {
match self {
Self::Disabled => "disabled",
Self::Default => "default",
Self::Staging => "staging",
Self::Disabled => "disabled".to_owned(),
Self::Default => "default".to_owned(),
Self::Staging => "staging".to_owned(),
Self::Custom { name, .. } => format!("custom:{name}"),
}
}
}
@ -206,7 +226,7 @@ pub async fn start_endpoint(config: &GethIrohConfig) -> Result<GethIrohEndpoint,
let secret_key = load_or_create_secret_key(&config.secret_key_path)?;
let mut builder = iroh::Endpoint::builder()
.secret_key(secret_key)
.relay_mode(config.relay_mode.to_iroh())
.relay_mode(config.relay_mode.to_iroh()?)
.alpns(config.alpns.clone());
if let Some(bind_ipv4) = config.bind_ipv4 {
@ -220,7 +240,7 @@ pub async fn start_endpoint(config: &GethIrohConfig) -> Result<GethIrohEndpoint,
let status = EndpointStatus {
enabled: true,
endpoint_id: Some(endpoint.node_id().to_string()),
relay_mode: config.relay_mode.as_str().to_owned(),
relay_mode: config.relay_mode.label(),
note: format!(
"Iroh endpoint started with iroh 0.90.0; relay mode: {:?}",
config.relay_mode
@ -286,6 +306,8 @@ pub enum IrohError {
Hex(#[from] hex::FromHexError),
#[error("invalid iroh secret key length")]
InvalidSecretKeyLength,
#[error("invalid iroh relay URL `{url}`: {message}")]
InvalidRelayUrl { url: String, message: String },
#[error("failed to bind iroh endpoint: {0}")]
Bind(Box<iroh::endpoint::BindError>),
}
@ -370,11 +392,27 @@ mod tests {
let config = GethIrohConfig::local_with_relay("iroh.ed25519", GethRelayMode::Staging);
assert_eq!(config.relay_mode, GethRelayMode::Staging);
assert!(matches!(
config.relay_mode.to_iroh(),
config.relay_mode.to_iroh().expect("iroh relay mode"),
iroh::RelayMode::Staging
));
}
#[test]
fn custom_relay_mode_builds_iroh_relay_map() {
let mode = GethRelayMode::Custom {
name: "home".to_owned(),
relay_urls: vec!["https://relay.example.com".to_owned()],
};
let relay_mode = mode.to_iroh().expect("custom relay mode");
match relay_mode {
iroh::RelayMode::Custom(map) => {
assert_eq!(map.len(), 1);
assert_eq!(mode.label(), "custom:home");
}
other => panic!("unexpected relay mode: {other:?}"),
}
}
#[tokio::test]
async fn endpoint_status_tracks_node_id_when_bind_is_available() {
let dir = tempfile::tempdir().expect("tempdir");

View file

@ -454,8 +454,9 @@ async fn start_daemon_iroh_endpoint(
node: &mut LocalNode,
) -> Result<Option<GethIrohEndpoint>, NodeError> {
let node_config = GethConfig::load(&node.paths.config_file())?;
let relay_mode = node_config.iroh.relay_mode;
let iroh_relay_mode = config_relay_mode_to_iroh(relay_mode);
let relay_mode = node_config.iroh.relay_mode.clone();
let relay_mode_label = relay_mode.label();
let iroh_relay_mode = config_relay_mode_to_iroh(&relay_mode, &node_config.iroh.relay_maps);
let config = GethIrohConfig::local_with_relay(node.paths.iroh_key(), iroh_relay_mode);
match geth_iroh::start_endpoint(&config).await {
Ok(endpoint) => {
@ -471,7 +472,7 @@ async fn start_daemon_iroh_endpoint(
node.iroh_status = EndpointStatus {
enabled: false,
endpoint_id: None,
relay_mode: relay_mode.as_str().to_owned(),
relay_mode: relay_mode_label,
note: format!("Iroh endpoint failed to start: {error}"),
};
Ok(None)
@ -479,11 +480,23 @@ async fn start_daemon_iroh_endpoint(
}
}
fn config_relay_mode_to_iroh(mode: RelayMode) -> GethRelayMode {
fn config_relay_mode_to_iroh(
mode: &RelayMode,
relay_maps: &std::collections::BTreeMap<String, geth_config::RelayMapConfig>,
) -> GethRelayMode {
match mode {
RelayMode::Disabled => GethRelayMode::Disabled,
RelayMode::Default => GethRelayMode::Default,
RelayMode::Staging => GethRelayMode::Staging,
RelayMode::Custom { map } => {
let relay_map = relay_maps
.get(map)
.expect("custom relay map was validated during config load");
GethRelayMode::Custom {
name: map.clone(),
relay_urls: relay_map.urls.clone(),
}
}
}
}