From 280472cecb4d71ca32f6a70f1747724f5f82b7a1 Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Sat, 16 May 2026 14:28:38 +0200 Subject: [PATCH] Add custom Iroh relay maps --- AGENTS.md | 10 +- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 3 +- crates/geth-config/Cargo.toml | 1 + crates/geth-config/src/lib.rs | 169 ++++++++++++++++++++++++++++++++-- crates/geth-iroh/src/lib.rs | 60 +++++++++--- crates/geth-node/src/lib.rs | 21 ++++- docs/architecture.md | 5 +- docs/roadmap.md | 2 +- 10 files changed, 241 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 65fd762..430b15b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,8 +105,8 @@ Roadmap items should be actionable and checkable: daemon-owned Iroh endpoint startup, built-in relay-mode config, SSH certificate metadata, revocation metadata, user service definitions, and a pinned `geth-iroh` endpoint wrapper with protocol-router scaffold, peer-card - types, and untrusted discovery-backend trait exist. -- Custom relay maps, mDNS discovery transport, peer auth over Iroh, cr-sqlite, - iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement, - OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future - roadmap items unless implemented later. + types, untrusted discovery-backend trait, and custom relay-map config exist. +- mDNS discovery transport, peer auth over Iroh, cr-sqlite, iroh-docs, + iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement, OpenSSH KRL + generation, and Keyhive/BeeKEM-style authorization are future roadmap items + unless implemented later. diff --git a/Cargo.lock b/Cargo.lock index 2baf345..5ff248d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1092,6 +1092,7 @@ dependencies = [ "serde", "thiserror 2.0.18", "toml_edit", + "url", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6f156b8..81af30d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt-multi toml_edit = "0.25.11" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +url = "2" [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index 72e923d..47f1181 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ All remote node-to-node geth communication is designed to happen over Iroh only. SSH is not a geth transport backend, and there is no SSH fallback transport. The default node config uses Iroh's default relay policy for practical connectivity; set `[iroh].relay_mode = "disabled"` for local-only/offline -development. +development. Named custom relay maps can be selected with +`relay_mode = "custom"` and `relay_map = ""`. SSH keys are used as admin trust anchors and ecosystem integration points. OpenSSH, FIDO, and YubiKey-backed keys can sign geth trust objects through diff --git a/crates/geth-config/Cargo.toml b/crates/geth-config/Cargo.toml index e2ccd9f..0c8bd17 100644 --- a/crates/geth-config/Cargo.toml +++ b/crates/geth-config/Cargo.toml @@ -10,3 +10,4 @@ directories.workspace = true serde.workspace = true thiserror.workspace = true toml_edit.workspace = true +url.workspace = true diff --git a/crates/geth-config/src/lib.rs b/crates/geth-config/src/lib.rs index 4eafd63..9d10e46 100644 --- a/crates/geth-config/src/lib.rs +++ b/crates/geth-config/src/lib.rs @@ -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 { let document = text.parse::()?; + 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, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RelayMapConfig { + pub urls: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub enum RelayMode { Disabled, #[default] Default, Staging, + Custom { + map: String, + }, } impl RelayMode { - pub fn parse(value: &str) -> Result { + fn parse( + value: &str, + document: &toml_edit::DocumentMut, + relay_maps: &BTreeMap, + ) -> Result { 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, 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, 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")); + } } diff --git a/crates/geth-iroh/src/lib.rs b/crates/geth-iroh/src/lib.rs index d11aefa..e817426 100644 --- a/crates/geth-iroh/src/lib.rs +++ b/crates/geth-iroh/src/lib.rs @@ -160,23 +160,43 @@ pub enum GethRelayMode { Disabled, Default, Staging, + Custom { + name: String, + relay_urls: Vec, + }, } impl GethRelayMode { - fn to_iroh(&self) -> iroh::RelayMode { + fn to_iroh(&self) -> Result { 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::() + .map_err(|error| IrohError::InvalidRelayUrl { + url: url.clone(), + message: error.to_string(), + }) + }) + .collect::, _>>()?; + 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 Result), } @@ -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"); diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 248bc76..2c51b1d 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -454,8 +454,9 @@ async fn start_daemon_iroh_endpoint( node: &mut LocalNode, ) -> Result, 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, +) -> 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(), + } + } } } diff --git a/docs/architecture.md b/docs/architecture.md index ec84d45..3fb50b3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,7 +25,10 @@ a transitive `ed25519-dalek` prerelease dependency. `geth-iroh` wraps `Builder::relay_mode`, persists an `iroh::SecretKey` as hex-encoded 32-byte key material, and shuts down through `Endpoint::close().await`. The default config uses Iroh's default relay policy; local-only/offline development can set -`[iroh].relay_mode = "disabled"`. +`[iroh].relay_mode = "disabled"`. Named custom relay maps are configured under +`[iroh.relay_maps.]`, selected with `relay_mode = "custom"` plus +`relay_map = ""`, validated at config load, and reported in status as +`custom:` without exposing relay URLs. Module ALPNs are registered through `geth-iroh`'s protocol router scaffold. The router owns the default protocol descriptors, rejects duplicate ALPN diff --git a/docs/roadmap.md b/docs/roadmap.md index 44f820c..2d22f3e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -81,7 +81,7 @@ geth-to-geth connections without granting trust from discovery alone. - `geth status --json` reports the selected relay mode. - Tests cover config parsing and endpoint builder relay-mode selection. -- `[ ]` Custom relay maps. +- `[x]` Custom relay maps. Acceptance criteria: - Config can define and select named custom relay maps. - Invalid relay URLs fail config validation with clear errors.