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

@ -105,8 +105,8 @@ Roadmap items should be actionable and checkable:
daemon-owned Iroh endpoint startup, built-in relay-mode config, SSH daemon-owned Iroh endpoint startup, built-in relay-mode config, SSH
certificate metadata, revocation metadata, user service definitions, and a certificate metadata, revocation metadata, user service definitions, and a
pinned `geth-iroh` endpoint wrapper with protocol-router scaffold, peer-card pinned `geth-iroh` endpoint wrapper with protocol-router scaffold, peer-card
types, and untrusted discovery-backend trait exist. types, untrusted discovery-backend trait, and custom relay-map config exist.
- Custom relay maps, mDNS discovery transport, peer auth over Iroh, cr-sqlite, - mDNS discovery transport, peer auth over Iroh, cr-sqlite, iroh-docs,
iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement, OpenSSH KRL
OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future generation, and Keyhive/BeeKEM-style authorization are future roadmap items
roadmap items unless implemented later. unless implemented later.

1
Cargo.lock generated
View file

@ -1092,6 +1092,7 @@ dependencies = [
"serde", "serde",
"thiserror 2.0.18", "thiserror 2.0.18",
"toml_edit", "toml_edit",
"url",
] ]
[[package]] [[package]]

View file

@ -57,6 +57,7 @@ tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt-multi
toml_edit = "0.25.11" toml_edit = "0.25.11"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
url = "2"
[workspace.lints.rust] [workspace.lints.rust]
unsafe_code = "forbid" unsafe_code = "forbid"

View file

@ -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. 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 The default node config uses Iroh's default relay policy for practical
connectivity; set `[iroh].relay_mode = "disabled"` for local-only/offline 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 = "<name>"`.
SSH keys are used as admin trust anchors and ecosystem integration points. SSH keys are used as admin trust anchors and ecosystem integration points.
OpenSSH, FIDO, and YubiKey-backed keys can sign geth trust objects through OpenSSH, FIDO, and YubiKey-backed keys can sign geth trust objects through

View file

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

View file

@ -1,12 +1,17 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
pub const DEFAULT_CONFIG_TOML: &str = r#"# geth local node config pub const DEFAULT_CONFIG_TOML: &str = r#"# geth local node config
# Remote node-to-node communication is Iroh-only. # Remote node-to-node communication is Iroh-only.
# #
# relay_mode controls Iroh relay use for practical internet connectivity. # relay_mode controls Iroh relay use for practical internet connectivity.
# Values: "default", "staging", "disabled". # Values: "default", "staging", "disabled", "custom".
[iroh] [iroh]
relay_mode = "default" relay_mode = "default"
# relay_map = "home"
#
# [iroh.relay_maps.home]
# urls = ["https://relay.example.com"]
"#; "#;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -96,6 +101,18 @@ pub enum ConfigError {
TomlParse(#[from] toml_edit::TomlError), TomlParse(#[from] toml_edit::TomlError),
#[error("invalid iroh relay_mode: {0}")] #[error("invalid iroh relay_mode: {0}")]
InvalidRelayMode(String), 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)] #[derive(Clone, Debug, Default, PartialEq, Eq)]
@ -118,18 +135,22 @@ impl GethConfig {
pub fn parse(text: &str) -> Result<Self, ConfigError> { pub fn parse(text: &str) -> Result<Self, ConfigError> {
let document = text.parse::<toml_edit::DocumentMut>()?; 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")) { let relay_mode = match document.get("iroh").and_then(|iroh| iroh.get("relay_mode")) {
Some(item) => { Some(item) => {
let value = item let value = item
.as_str() .as_str()
.ok_or_else(|| ConfigError::InvalidRelayMode(item.to_string()))?; .ok_or_else(|| ConfigError::InvalidRelayMode(item.to_string()))?;
RelayMode::parse(value)? RelayMode::parse(value, &document, &relay_maps)?
} }
None => RelayMode::default(), None => RelayMode::default(),
}; };
Ok(Self { Ok(Self {
iroh: IrohConfig { relay_mode }, iroh: IrohConfig {
relay_mode,
relay_maps,
},
}) })
} }
} }
@ -137,36 +158,117 @@ impl GethConfig {
#[derive(Clone, Debug, Default, PartialEq, Eq)] #[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct IrohConfig { pub struct IrohConfig {
pub relay_mode: RelayMode, 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 { pub enum RelayMode {
Disabled, Disabled,
#[default] #[default]
Default, Default,
Staging, Staging,
Custom {
map: String,
},
} }
impl RelayMode { 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 { match value {
"disabled" => Ok(Self::Disabled), "disabled" => Ok(Self::Disabled),
"default" => Ok(Self::Default), "default" => Ok(Self::Default),
"staging" => Ok(Self::Staging), "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())), other => Err(ConfigError::InvalidRelayMode(other.to_owned())),
} }
} }
#[must_use] #[must_use]
pub fn as_str(self) -> &'static str { pub fn label(&self) -> String {
match self { match self {
Self::Disabled => "disabled", Self::Disabled => "disabled".to_owned(),
Self::Default => "default", Self::Default => "default".to_owned(),
Self::Staging => "staging", 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -183,6 +285,32 @@ mod tests {
assert_eq!(config.iroh.relay_mode, RelayMode::Disabled); 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] #[test]
fn config_rejects_unknown_relay_mode() { fn config_rejects_unknown_relay_mode() {
let error = GethConfig::parse("[iroh]\nrelay_mode = \"magic\"\n").expect_err("error"); 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"); let error = GethConfig::parse("[iroh]\nrelay_mode = true\n").expect_err("error");
assert!(matches!(error, ConfigError::InvalidRelayMode(_))); 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, Disabled,
Default, Default,
Staging, Staging,
Custom {
name: String,
relay_urls: Vec<String>,
},
} }
impl GethRelayMode { impl GethRelayMode {
fn to_iroh(&self) -> iroh::RelayMode { fn to_iroh(&self) -> Result<iroh::RelayMode, IrohError> {
match self { match self {
Self::Disabled => iroh::RelayMode::Disabled, Self::Disabled => Ok(iroh::RelayMode::Disabled),
Self::Default => iroh::RelayMode::Default, Self::Default => Ok(iroh::RelayMode::Default),
Self::Staging => iroh::RelayMode::Staging, 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] #[must_use]
pub fn as_str(&self) -> &'static str { pub fn label(&self) -> String {
match self { match self {
Self::Disabled => "disabled", Self::Disabled => "disabled".to_owned(),
Self::Default => "default", Self::Default => "default".to_owned(),
Self::Staging => "staging", 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 secret_key = load_or_create_secret_key(&config.secret_key_path)?;
let mut builder = iroh::Endpoint::builder() let mut builder = iroh::Endpoint::builder()
.secret_key(secret_key) .secret_key(secret_key)
.relay_mode(config.relay_mode.to_iroh()) .relay_mode(config.relay_mode.to_iroh()?)
.alpns(config.alpns.clone()); .alpns(config.alpns.clone());
if let Some(bind_ipv4) = config.bind_ipv4 { if let Some(bind_ipv4) = config.bind_ipv4 {
@ -220,7 +240,7 @@ pub async fn start_endpoint(config: &GethIrohConfig) -> Result<GethIrohEndpoint,
let status = EndpointStatus { let status = EndpointStatus {
enabled: true, enabled: true,
endpoint_id: Some(endpoint.node_id().to_string()), 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!( note: format!(
"Iroh endpoint started with iroh 0.90.0; relay mode: {:?}", "Iroh endpoint started with iroh 0.90.0; relay mode: {:?}",
config.relay_mode config.relay_mode
@ -286,6 +306,8 @@ pub enum IrohError {
Hex(#[from] hex::FromHexError), Hex(#[from] hex::FromHexError),
#[error("invalid iroh secret key length")] #[error("invalid iroh secret key length")]
InvalidSecretKeyLength, InvalidSecretKeyLength,
#[error("invalid iroh relay URL `{url}`: {message}")]
InvalidRelayUrl { url: String, message: String },
#[error("failed to bind iroh endpoint: {0}")] #[error("failed to bind iroh endpoint: {0}")]
Bind(Box<iroh::endpoint::BindError>), Bind(Box<iroh::endpoint::BindError>),
} }
@ -370,11 +392,27 @@ mod tests {
let config = GethIrohConfig::local_with_relay("iroh.ed25519", GethRelayMode::Staging); let config = GethIrohConfig::local_with_relay("iroh.ed25519", GethRelayMode::Staging);
assert_eq!(config.relay_mode, GethRelayMode::Staging); assert_eq!(config.relay_mode, GethRelayMode::Staging);
assert!(matches!( assert!(matches!(
config.relay_mode.to_iroh(), config.relay_mode.to_iroh().expect("iroh relay mode"),
iroh::RelayMode::Staging 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] #[tokio::test]
async fn endpoint_status_tracks_node_id_when_bind_is_available() { async fn endpoint_status_tracks_node_id_when_bind_is_available() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");

View file

@ -454,8 +454,9 @@ async fn start_daemon_iroh_endpoint(
node: &mut LocalNode, node: &mut LocalNode,
) -> Result<Option<GethIrohEndpoint>, NodeError> { ) -> Result<Option<GethIrohEndpoint>, NodeError> {
let node_config = GethConfig::load(&node.paths.config_file())?; let node_config = GethConfig::load(&node.paths.config_file())?;
let relay_mode = node_config.iroh.relay_mode; let relay_mode = node_config.iroh.relay_mode.clone();
let iroh_relay_mode = config_relay_mode_to_iroh(relay_mode); 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); let config = GethIrohConfig::local_with_relay(node.paths.iroh_key(), iroh_relay_mode);
match geth_iroh::start_endpoint(&config).await { match geth_iroh::start_endpoint(&config).await {
Ok(endpoint) => { Ok(endpoint) => {
@ -471,7 +472,7 @@ async fn start_daemon_iroh_endpoint(
node.iroh_status = EndpointStatus { node.iroh_status = EndpointStatus {
enabled: false, enabled: false,
endpoint_id: None, endpoint_id: None,
relay_mode: relay_mode.as_str().to_owned(), relay_mode: relay_mode_label,
note: format!("Iroh endpoint failed to start: {error}"), note: format!("Iroh endpoint failed to start: {error}"),
}; };
Ok(None) 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 { match mode {
RelayMode::Disabled => GethRelayMode::Disabled, RelayMode::Disabled => GethRelayMode::Disabled,
RelayMode::Default => GethRelayMode::Default, RelayMode::Default => GethRelayMode::Default,
RelayMode::Staging => GethRelayMode::Staging, 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(),
}
}
} }
} }

View file

@ -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 `Builder::relay_mode`, persists an `iroh::SecretKey` as hex-encoded 32-byte key
material, and shuts down through `Endpoint::close().await`. The default config material, and shuts down through `Endpoint::close().await`. The default config
uses Iroh's default relay policy; local-only/offline development can set 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.<name>]`, selected with `relay_mode = "custom"` plus
`relay_map = "<name>"`, validated at config load, and reported in status as
`custom:<name>` without exposing relay URLs.
Module ALPNs are registered through `geth-iroh`'s protocol router scaffold. The Module ALPNs are registered through `geth-iroh`'s protocol router scaffold. The
router owns the default protocol descriptors, rejects duplicate ALPN router owns the default protocol descriptors, rejects duplicate ALPN

View file

@ -81,7 +81,7 @@ geth-to-geth connections without granting trust from discovery alone.
- `geth status --json` reports the selected relay mode. - `geth status --json` reports the selected relay mode.
- Tests cover config parsing and endpoint builder relay-mode selection. - Tests cover config parsing and endpoint builder relay-mode selection.
- `[ ]` Custom relay maps. - `[x]` Custom relay maps.
Acceptance criteria: Acceptance criteria:
- Config can define and select named custom relay maps. - Config can define and select named custom relay maps.
- Invalid relay URLs fail config validation with clear errors. - Invalid relay URLs fail config validation with clear errors.