From bfc5df698c390120622923aea879beb998102956 Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Sat, 16 May 2026 14:33:45 +0200 Subject: [PATCH] Enable Iroh local network discovery --- AGENTS.md | 11 +++++---- Cargo.lock | 36 ++++++++++++++++++++++++++++ Cargo.toml | 2 +- README.md | 3 ++- crates/geth-cli/src/lib.rs | 8 +++++++ crates/geth-config/src/lib.rs | 43 +++++++++++++++++++++++++++++++++- crates/geth-control/src/lib.rs | 1 + crates/geth-iroh/src/lib.rs | 17 +++++++++++--- crates/geth-node/src/lib.rs | 6 ++++- crates/geth/tests/bootstrap.rs | 8 ++++++- docs/architecture.md | 4 ++++ docs/roadmap.md | 13 +++++++--- 12 files changed, 136 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 430b15b..3bb5ed3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,8 +105,9 @@ 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, 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. + types, untrusted discovery-backend trait, custom relay-map config, and Iroh + local-network discovery toggle exist. +- Signed peer-card LAN discovery payloads, 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 5ff248d..17cf2af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,20 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "acto" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a026259da4f1a13b4af60cda453c392de64c58c12d239c560923e0382f42f2b9" +dependencies = [ + "parking_lot", + "pin-project-lite", + "rustc_version", + "smol_str", + "tokio", + "tracing", +] + [[package]] name = "addr2line" version = "0.25.1" @@ -1871,6 +1885,7 @@ dependencies = [ "strum", "stun-rs", "surge-ping", + "swarm-discovery", "time", "tokio", "tokio-stream", @@ -3428,6 +3443,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smol_str" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fad6c857cbab2627dcf01ec85a623ca4e7dcb5691cbaa3d7fb7653671f0d09c9" + [[package]] name = "snafu" version = "0.8.9" @@ -3575,6 +3596,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "swarm-discovery" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790d8444f7db1e88f70aed3234cab8e42c48e05360bfc86ca7dce0d9a5d95d26" +dependencies = [ + "acto", + "hickory-proto", + "rand 0.9.4", + "socket2 0.5.10", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "syn" version = "2.0.117" diff --git a/Cargo.toml b/Cargo.toml index 81af30d..4d7e45d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,7 @@ directories = "5" ed25519-dalek = { version = "2", features = ["rand_core"] } futures = "0.3" hex = "0.4" -iroh = "0.90.0" +iroh = { version = "0.90.0", features = ["discovery-local-network"] } postcard = { version = "1", features = ["alloc"] } rand_core = { version = "0.6", features = ["getrandom"] } rusqlite = { version = "0.32", features = ["bundled"] } diff --git a/README.md b/README.md index 47f1181..8dda686 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ 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. Named custom relay maps can be selected with -`relay_mode = "custom"` and `relay_map = ""`. +`relay_mode = "custom"` and `relay_map = ""`. Iroh local-network +discovery is enabled by default with `[iroh].local_discovery = true`. 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-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index d965e00..c0d60e0 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -508,6 +508,14 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { status.endpoint_id.as_deref().unwrap_or("not started") ); println!("iroh relay: {}", status.iroh_relay_mode); + println!( + "iroh discovery: {}", + if status.iroh_local_discovery { + "local-network enabled" + } else { + "local-network disabled" + } + ); println!("iroh: {}", status.iroh); } ControlResponse::NodeId(node) => { diff --git a/crates/geth-config/src/lib.rs b/crates/geth-config/src/lib.rs index 9d10e46..45c7ea2 100644 --- a/crates/geth-config/src/lib.rs +++ b/crates/geth-config/src/lib.rs @@ -8,6 +8,7 @@ pub const DEFAULT_CONFIG_TOML: &str = r#"# geth local node config # Values: "default", "staging", "disabled", "custom". [iroh] relay_mode = "default" +local_discovery = true # relay_map = "home" # # [iroh.relay_maps.home] @@ -101,6 +102,8 @@ pub enum ConfigError { TomlParse(#[from] toml_edit::TomlError), #[error("invalid iroh relay_mode: {0}")] InvalidRelayMode(String), + #[error("invalid iroh local_discovery value: {0}")] + InvalidLocalDiscovery(String), #[error("custom iroh relay_mode requires iroh.relay_map")] MissingRelayMapSelection, #[error("selected iroh relay map does not exist: {0}")] @@ -136,6 +139,7 @@ impl GethConfig { pub fn parse(text: &str) -> Result { let document = text.parse::()?; let relay_maps = parse_relay_maps(&document)?; + let local_discovery = parse_local_discovery(&document)?; let relay_mode = match document.get("iroh").and_then(|iroh| iroh.get("relay_mode")) { Some(item) => { let value = item @@ -150,15 +154,27 @@ impl GethConfig { iroh: IrohConfig { relay_mode, relay_maps, + local_discovery, }, }) } } -#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct IrohConfig { pub relay_mode: RelayMode, pub relay_maps: BTreeMap, + pub local_discovery: bool, +} + +impl Default for IrohConfig { + fn default() -> Self { + Self { + relay_mode: RelayMode::Default, + relay_maps: BTreeMap::new(), + local_discovery: true, + } + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -269,6 +285,18 @@ fn validate_relay_url(map: &str, relay_url: &str) -> Result<(), ConfigError> { } } +fn parse_local_discovery(document: &toml_edit::DocumentMut) -> Result { + match document + .get("iroh") + .and_then(|iroh| iroh.get("local_discovery")) + { + Some(item) => item + .as_bool() + .ok_or_else(|| ConfigError::InvalidLocalDiscovery(item.to_string())), + None => Ok(true), + } +} + #[cfg(test)] mod tests { use super::*; @@ -277,6 +305,7 @@ mod tests { fn default_config_uses_iroh_relays() { let config = GethConfig::parse(GethConfig::default_toml()).expect("parse config"); assert_eq!(config.iroh.relay_mode, RelayMode::Default); + assert!(config.iroh.local_discovery); } #[test] @@ -345,4 +374,16 @@ mod tests { .expect_err("error"); assert!(matches!(error, ConfigError::UnknownRelayMap(map) if map == "home")); } + + #[test] + fn config_parses_local_discovery_toggle() { + let config = GethConfig::parse("[iroh]\nlocal_discovery = false\n").expect("parse"); + assert!(!config.iroh.local_discovery); + } + + #[test] + fn config_rejects_non_bool_local_discovery() { + let error = GethConfig::parse("[iroh]\nlocal_discovery = \"yes\"\n").expect_err("error"); + assert!(matches!(error, ConfigError::InvalidLocalDiscovery(_))); + } } diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index a014ddb..e5c8d16 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -149,6 +149,7 @@ pub struct StatusResponse { pub iroh_enabled: bool, pub endpoint_id: Option, pub iroh_relay_mode: String, + pub iroh_local_discovery: bool, pub iroh: String, } diff --git a/crates/geth-iroh/src/lib.rs b/crates/geth-iroh/src/lib.rs index e817426..d430ed2 100644 --- a/crates/geth-iroh/src/lib.rs +++ b/crates/geth-iroh/src/lib.rs @@ -16,6 +16,7 @@ pub const ALPN_SSH_PROXY: &[u8] = b"/geth/ssh-proxy/1"; pub struct GethIrohConfig { pub secret_key_path: PathBuf, pub relay_mode: GethRelayMode, + pub local_discovery: bool, pub bind_ipv4: Option, pub bind_ipv6: Option, pub alpns: Vec>, @@ -35,6 +36,7 @@ impl GethIrohConfig { Self { secret_key_path: secret_key_path.into(), relay_mode, + local_discovery: true, bind_ipv4: None, bind_ipv6: None, alpns: default_protocol_router().alpns(), @@ -229,6 +231,10 @@ pub async fn start_endpoint(config: &GethIrohConfig) -> Result Result, pub relay_mode: String, + pub local_discovery: bool, pub note: String, } @@ -293,6 +301,7 @@ impl EndpointStatus { enabled: false, endpoint_id: None, relay_mode: "not-started".to_owned(), + local_discovery: false, note: "Iroh endpoint is not running outside daemon mode".to_owned(), } } @@ -416,16 +425,18 @@ mod tests { #[tokio::test] async fn endpoint_status_tracks_node_id_when_bind_is_available() { let dir = tempfile::tempdir().expect("tempdir"); - let config = GethIrohConfig::local_with_relay( + let mut config = GethIrohConfig::local_with_relay( dir.path().join("iroh.ed25519"), GethRelayMode::Disabled, ); + config.local_discovery = false; 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"); + assert!(!status.local_discovery); endpoint.shutdown().await; } Err(IrohError::Bind(error)) => { diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 2c51b1d..48082cf 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -161,6 +161,7 @@ pub fn handle_request( iroh_enabled: node.iroh_status.enabled, endpoint_id: node.iroh_status.endpoint_id.clone(), iroh_relay_mode: node.iroh_status.relay_mode.clone(), + iroh_local_discovery: node.iroh_status.local_discovery, iroh: node.iroh_status.note.clone(), })), ControlRequest::NodeId => Ok(ControlResponse::NodeId(NodeIdResponse { @@ -456,8 +457,10 @@ async fn start_daemon_iroh_endpoint( let node_config = GethConfig::load(&node.paths.config_file())?; let relay_mode = node_config.iroh.relay_mode.clone(); let relay_mode_label = relay_mode.label(); + let local_discovery = node_config.iroh.local_discovery; 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 mut config = GethIrohConfig::local_with_relay(node.paths.iroh_key(), iroh_relay_mode); + config.local_discovery = local_discovery; match geth_iroh::start_endpoint(&config).await { Ok(endpoint) => { let status = endpoint.status(); @@ -473,6 +476,7 @@ async fn start_daemon_iroh_endpoint( enabled: false, endpoint_id: None, relay_mode: relay_mode_label, + local_discovery, note: format!("Iroh endpoint failed to start: {error}"), }; Ok(None) diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index c01d216..dad7a47 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -64,6 +64,11 @@ fn geth_init_in_temp_home() { .expect("read config") .contains("relay_mode = \"default\"") ); + assert!( + std::fs::read_to_string(home.path().join("config.toml")) + .expect("read config") + .contains("local_discovery = true") + ); } #[test] @@ -75,7 +80,7 @@ fn geth_status_against_running_daemon() { assert!(run_geth(home.path(), &["init"]).status.success()); std::fs::write( home.path().join("config.toml"), - "[iroh]\nrelay_mode = \"disabled\"\n", + "[iroh]\nrelay_mode = \"disabled\"\nlocal_discovery = false\n", ) .expect("write config"); let mut daemon = spawn_daemon(home.path()); @@ -95,6 +100,7 @@ fn geth_status_against_running_daemon() { assert!(stdout.contains("agent:")); assert!(stdout.contains("endpoint:")); assert!(stdout.contains("iroh relay: disabled")); + assert!(stdout.contains("iroh discovery: local-network disabled")); } #[test] diff --git a/docs/architecture.md b/docs/architecture.md index 3fb50b3..0b9fc3a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,6 +40,10 @@ The target product should use Iroh relay support for practical internet connectivity and mDNS/LAN discovery for local networks. These are connectivity and candidate-discovery mechanisms only. They do not grant trust, mutate authorization state, or make EndpointID knowledge sufficient for access. +The current daemon can enable Iroh's local-network discovery service through +`[iroh].local_discovery = true`, which is the default. This publishes and +discovers Iroh node addressing. Signed geth peer-card payloads over LAN +discovery remain separate future work. Peer cards are the discovery payload. A peer card carries node ID, agent ID, endpoint candidates, timestamp, and signature metadata. The current scaffold diff --git a/docs/roadmap.md b/docs/roadmap.md index 2d22f3e..5ab437b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -88,10 +88,17 @@ geth-to-geth connections without granting trust from discovery alone. - Status output identifies the selected custom relay map without exposing unrelated config. -- `[ ]` LAN mDNS discovery. +- `[x]` Iroh LAN address discovery. Acceptance criteria: - - The daemon can advertise and discover local geth peer cards over mDNS. - - mDNS results are stored only as untrusted peer candidates. + - Config can enable or disable Iroh local-network discovery. + - The daemon registers Iroh's local mDNS-like discovery service when enabled. + - `geth status --json` reports whether local-network discovery is enabled. + +- `[ ]` Signed peer-card LAN discovery payloads. + Acceptance criteria: + - The daemon can advertise and discover signed geth peer cards over LAN + discovery. + - LAN-discovered peer cards are stored only as untrusted peer candidates. - Discovered EndpointIDs do not grant module access without keychain/auth validation.