From 0f2ad755d9f6063a3a9c8a1cb83560ab39ff3282 Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Sat, 16 May 2026 03:17:45 +0200 Subject: [PATCH] Add Iroh relay mode config --- AGENTS.md | 8 +-- Cargo.lock | 8 +++ Cargo.toml | 1 + README.md | 6 +- crates/geth-cli/src/lib.rs | 1 + crates/geth-config/Cargo.toml | 1 + crates/geth-config/src/lib.rs | 111 +++++++++++++++++++++++++++++++++ crates/geth-control/src/lib.rs | 1 + crates/geth-iroh/src/lib.rs | 38 ++++++++++- crates/geth-node/src/lib.rs | 24 ++++--- crates/geth/tests/bootstrap.rs | 11 ++++ docs/architecture.md | 9 +-- docs/roadmap.md | 12 +++- 13 files changed, 210 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3ee2254..71dd309 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,10 +102,10 @@ Roadmap items should be actionable and checkable: ## Current Feature Boundaries - Local daemon, local control socket, local identity, local store, local CAS, - daemon-owned Iroh endpoint startup, SSH certificate metadata, revocation - metadata, user service definitions, and a pinned `geth-iroh` endpoint wrapper - exist. -- Relay policy, mDNS discovery, peer auth over Iroh, cr-sqlite, iroh-docs, + 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 exist. +- Custom relay maps, mDNS discovery, 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 2dcc1d9..a4afa17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1090,6 +1090,7 @@ dependencies = [ "directories", "serde", "thiserror 2.0.18", + "toml_edit", ] [[package]] @@ -3860,6 +3861,7 @@ dependencies = [ "indexmap", "toml_datetime", "toml_parser", + "toml_writer", "winnow", ] @@ -3872,6 +3874,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index 0051d68..6f156b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ tempfile = "3" thiserror = "2" time = { version = "0.3", features = ["formatting", "serde"] } tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "signal", "time"] } +toml_edit = "0.25.11" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } diff --git a/README.md b/README.md index 8b5ff94..72e923d 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ geth resource list geth cas add ./file ``` -The daemon owns local identity, the future Iroh endpoint, trust state, resource +The daemon owns local identity, the Iroh endpoint, trust state, resource registry, module router, local metadata store, and synchronized data structures. Most non-daemon commands talk to the daemon through a local Unix socket at `$GETH_HOME/run/geth.sock`. @@ -43,6 +43,9 @@ services, not system services. 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. SSH keys are used as admin trust anchors and ecosystem integration points. OpenSSH, FIDO, and YubiKey-backed keys can sign geth trust objects through @@ -110,6 +113,7 @@ $GETH_HOME/ geth.sqlite config.toml identity/agent.ed25519 + identity/iroh.ed25519 cas/blobs/ run/geth.sock ``` diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index f450396..d965e00 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -507,6 +507,7 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { "endpoint: {}", status.endpoint_id.as_deref().unwrap_or("not started") ); + println!("iroh relay: {}", status.iroh_relay_mode); println!("iroh: {}", status.iroh); } ControlResponse::NodeId(node) => { diff --git a/crates/geth-config/Cargo.toml b/crates/geth-config/Cargo.toml index f2ed90b..e2ccd9f 100644 --- a/crates/geth-config/Cargo.toml +++ b/crates/geth-config/Cargo.toml @@ -9,3 +9,4 @@ license.workspace = true directories.workspace = true serde.workspace = true thiserror.workspace = true +toml_edit.workspace = true diff --git a/crates/geth-config/src/lib.rs b/crates/geth-config/src/lib.rs index 73ada73..4eafd63 100644 --- a/crates/geth-config/src/lib.rs +++ b/crates/geth-config/src/lib.rs @@ -1,5 +1,14 @@ 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". +[iroh] +relay_mode = "default" +"#; + #[derive(Clone, Debug)] pub struct GethPaths { home: PathBuf, @@ -83,4 +92,106 @@ pub enum ConfigError { NoDataDirectory, #[error("io error: {0}")] Io(#[from] std::io::Error), + #[error("toml parse error: {0}")] + TomlParse(#[from] toml_edit::TomlError), + #[error("invalid iroh relay_mode: {0}")] + InvalidRelayMode(String), +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct GethConfig { + pub iroh: IrohConfig, +} + +impl GethConfig { + #[must_use] + pub fn default_toml() -> &'static str { + DEFAULT_CONFIG_TOML + } + + pub fn load(path: &Path) -> Result { + if !path.exists() { + return Ok(Self::default()); + } + Self::parse(&std::fs::read_to_string(path)?) + } + + pub fn parse(text: &str) -> Result { + let document = text.parse::()?; + 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)? + } + None => RelayMode::default(), + }; + + Ok(Self { + iroh: IrohConfig { relay_mode }, + }) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct IrohConfig { + pub relay_mode: RelayMode, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RelayMode { + Disabled, + #[default] + Default, + Staging, +} + +impl RelayMode { + pub fn parse(value: &str) -> Result { + match value { + "disabled" => Ok(Self::Disabled), + "default" => Ok(Self::Default), + "staging" => Ok(Self::Staging), + other => Err(ConfigError::InvalidRelayMode(other.to_owned())), + } + } + + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Disabled => "disabled", + Self::Default => "default", + Self::Staging => "staging", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_uses_iroh_relays() { + let config = GethConfig::parse(GethConfig::default_toml()).expect("parse config"); + assert_eq!(config.iroh.relay_mode, RelayMode::Default); + } + + #[test] + fn config_parses_disabled_relay_mode() { + let config = GethConfig::parse("[iroh]\nrelay_mode = \"disabled\"\n").expect("parse"); + assert_eq!(config.iroh.relay_mode, RelayMode::Disabled); + } + + #[test] + fn config_rejects_unknown_relay_mode() { + let error = GethConfig::parse("[iroh]\nrelay_mode = \"magic\"\n").expect_err("error"); + assert!(matches!(error, ConfigError::InvalidRelayMode(value) if value == "magic")); + } + + #[test] + fn config_rejects_non_string_relay_mode() { + let error = GethConfig::parse("[iroh]\nrelay_mode = true\n").expect_err("error"); + assert!(matches!(error, ConfigError::InvalidRelayMode(_))); + } } diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 94f75e1..a014ddb 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -148,6 +148,7 @@ pub struct StatusResponse { pub node_id: String, pub iroh_enabled: bool, pub endpoint_id: Option, + pub iroh_relay_mode: String, pub iroh: String, } diff --git a/crates/geth-iroh/src/lib.rs b/crates/geth-iroh/src/lib.rs index 4d5541b..952c47e 100644 --- a/crates/geth-iroh/src/lib.rs +++ b/crates/geth-iroh/src/lib.rs @@ -23,9 +23,17 @@ pub struct GethIrohConfig { impl GethIrohConfig { #[must_use] pub fn local(secret_key_path: impl Into) -> Self { + Self::local_with_relay(secret_key_path, GethRelayMode::Default) + } + + #[must_use] + pub fn local_with_relay( + secret_key_path: impl Into, + relay_mode: GethRelayMode, + ) -> Self { Self { secret_key_path: secret_key_path.into(), - relay_mode: GethRelayMode::Disabled, + relay_mode, bind_ipv4: None, bind_ipv6: None, alpns: all_alpns(), @@ -49,6 +57,15 @@ impl GethRelayMode { Self::Staging => iroh::RelayMode::Staging, } } + + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::Disabled => "disabled", + Self::Default => "default", + Self::Staging => "staging", + } + } } pub struct GethIrohEndpoint { @@ -90,6 +107,7 @@ pub async fn start_endpoint(config: &GethIrohConfig) -> Result Vec> { pub struct EndpointStatus { pub enabled: bool, pub endpoint_id: Option, + pub relay_mode: String, pub note: String, } @@ -152,6 +171,7 @@ impl EndpointStatus { Self { enabled: false, endpoint_id: None, + relay_mode: "not-started".to_owned(), note: "Iroh endpoint is not running outside daemon mode".to_owned(), } } @@ -197,15 +217,29 @@ mod tests { assert_eq!(first.public(), second.public()); } + #[test] + fn local_config_selects_relay_mode() { + let config = GethIrohConfig::local_with_relay("iroh.ed25519", GethRelayMode::Staging); + assert_eq!(config.relay_mode, GethRelayMode::Staging); + assert!(matches!( + config.relay_mode.to_iroh(), + iroh::RelayMode::Staging + )); + } + #[tokio::test] async fn endpoint_status_tracks_node_id_when_bind_is_available() { let dir = tempfile::tempdir().expect("tempdir"); - let config = GethIrohConfig::local(dir.path().join("iroh.ed25519")); + let config = GethIrohConfig::local_with_relay( + dir.path().join("iroh.ed25519"), + GethRelayMode::Disabled, + ); match start_endpoint(&config).await { Ok(endpoint) => { let status = endpoint.status(); assert!(status.enabled); assert_eq!(status.endpoint_id, Some(endpoint.node_id())); + assert_eq!(status.relay_mode, "disabled"); endpoint.shutdown().await; } Err(IrohError::Bind(error)) => { diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 6448e0b..fc7cc03 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -2,13 +2,13 @@ pub mod service; use geth_auth::AuthExplanation; use geth_cas::{LocalCas, hash_path}; -use geth_config::GethPaths; +use geth_config::{GethConfig, GethPaths, RelayMode}; use geth_control::{ CasBlob, ControlRequest, ControlResponse, KeychainStatusResponse, NodeIdResponse, StatusResponse, }; use geth_crypto::AgentKey; -use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint}; +use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode}; use geth_resource::ResourceDescriptor; use geth_ssh_identity::{ SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord, @@ -68,10 +68,7 @@ pub struct LocalNode { pub fn init_node(paths: &GethPaths) -> Result { paths.ensure_base_dirs()?; if !paths.config_file().exists() { - std::fs::write( - paths.config_file(), - "# geth local node config\n# Remote node-to-node communication is Iroh-only.\n", - )?; + std::fs::write(paths.config_file(), GethConfig::default_toml())?; } let key = AgentKey::load_or_create(&paths.agent_key())?; let agent_id = key.agent_id().to_string(); @@ -163,6 +160,7 @@ pub fn handle_request( node_id: node.node_id.clone(), iroh_enabled: node.iroh_status.enabled, endpoint_id: node.iroh_status.endpoint_id.clone(), + iroh_relay_mode: node.iroh_status.relay_mode.clone(), iroh: node.iroh_status.note.clone(), })), ControlRequest::NodeId => Ok(ControlResponse::NodeId(NodeIdResponse { @@ -447,7 +445,10 @@ fn stable_node_id(agent_id: &str) -> String { async fn start_daemon_iroh_endpoint( node: &mut LocalNode, ) -> Result, NodeError> { - let config = GethIrohConfig::local(node.paths.iroh_key()); + 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 config = GethIrohConfig::local_with_relay(node.paths.iroh_key(), iroh_relay_mode); match geth_iroh::start_endpoint(&config).await { Ok(endpoint) => { let status = endpoint.status(); @@ -462,6 +463,7 @@ async fn start_daemon_iroh_endpoint( node.iroh_status = EndpointStatus { enabled: false, endpoint_id: None, + relay_mode: relay_mode.as_str().to_owned(), note: format!("Iroh endpoint failed to start: {error}"), }; Ok(None) @@ -469,6 +471,14 @@ async fn start_daemon_iroh_endpoint( } } +fn config_relay_mode_to_iroh(mode: RelayMode) -> GethRelayMode { + match mode { + RelayMode::Disabled => GethRelayMode::Disabled, + RelayMode::Default => GethRelayMode::Default, + RelayMode::Staging => GethRelayMode::Staging, + } +} + fn expected_openssh_cert_path(public_key_path: &Path) -> String { let text = public_key_path.display().to_string(); if let Some(prefix) = text.strip_suffix(".pub") { diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 07d59c6..2b6af15 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -59,6 +59,11 @@ fn geth_init_in_temp_home() { assert!(home.path().join("geth.sqlite").exists()); assert!(home.path().join("identity/agent.ed25519").exists()); assert!(home.path().join("config.toml").exists()); + assert!( + std::fs::read_to_string(home.path().join("config.toml")) + .expect("read config") + .contains("relay_mode = \"default\"") + ); } #[test] @@ -68,6 +73,11 @@ fn geth_status_against_running_daemon() { return; } assert!(run_geth(home.path(), &["init"]).status.success()); + std::fs::write( + home.path().join("config.toml"), + "[iroh]\nrelay_mode = \"disabled\"\n", + ) + .expect("write config"); let mut daemon = spawn_daemon(home.path()); wait_for_socket(&home.path().join("run/geth.sock")); @@ -84,6 +94,7 @@ fn geth_status_against_running_daemon() { assert!(stdout.contains("geth daemon: running")); assert!(stdout.contains("agent:")); assert!(stdout.contains("endpoint:")); + assert!(stdout.contains("iroh relay: disabled")); } #[test] diff --git a/docs/architecture.md b/docs/architecture.md index 9b61979..6dc3627 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,8 +2,8 @@ `geth` is a single-binary local-first mesh runtime. One executable provides both daemon mode and control mode. The daemon owns local identity, metadata storage, -the future shared Iroh endpoint, resource registry, module routing, and local -control socket. Control commands connect to the Unix socket and send typed JSONL +the shared Iroh endpoint, resource registry, module routing, and local control +socket. Control commands connect to the Unix socket and send typed JSONL requests. Service management is also exposed through the single binary. `geth daemon @@ -23,8 +23,9 @@ Rust-1.85-compatible candidates in the 0.93-0.95 range failed to compile through a transitive `ed25519-dalek` prerelease dependency. `geth-iroh` wraps `iroh::Endpoint::builder()`, configures geth ALPNs with `Builder::alpns`, uses `Builder::relay_mode`, persists an `iroh::SecretKey` as hex-encoded 32-byte key -material, and shuts down through `Endpoint::close().await`. Relay mode defaults -to disabled until daemon policy and discovery are implemented. +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"`. The target product should use Iroh relay support for practical internet connectivity and mDNS/LAN discovery for local networks. These are connectivity diff --git a/docs/roadmap.md b/docs/roadmap.md index ab823e9..38d6327 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -74,14 +74,20 @@ geth-to-geth connections without granting trust from discovery alone. - Endpoint identity is bound to agent/node identity in local metadata. - Restarting the daemon preserves higher-level node identity. -- `[ ]` Relay policy and configuration. +- `[x]` Built-in relay policy and configuration. Acceptance criteria: - - Config can select disabled, default Iroh relays, staging relays, and future - custom relay maps. + - Config can select disabled, default Iroh relays, and staging relays. - The intended product default uses relays unless explicitly disabled. - `geth status --json` reports the selected relay mode. - Tests cover config parsing and endpoint builder relay-mode selection. +- `[ ]` Custom relay maps. + Acceptance criteria: + - Config can define and select named custom relay maps. + - Invalid relay URLs fail config validation with clear errors. + - Status output identifies the selected custom relay map without exposing + unrelated config. + - `[ ]` LAN mDNS discovery. Acceptance criteria: - The daemon can advertise and discover local geth peer cards over mDNS.