Enable Iroh local network discovery

This commit is contained in:
Eric Wendland 2026-05-16 14:33:45 +02:00
commit bfc5df698c
12 changed files with 136 additions and 16 deletions

View file

@ -105,8 +105,9 @@ 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, untrusted discovery-backend trait, and custom relay-map config exist. types, untrusted discovery-backend trait, custom relay-map config, and Iroh
- mDNS discovery transport, peer auth over Iroh, cr-sqlite, iroh-docs, local-network discovery toggle exist.
iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement, OpenSSH KRL - Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite,
generation, and Keyhive/BeeKEM-style authorization are future roadmap items iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement,
unless implemented later. OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future
roadmap items unless implemented later.

36
Cargo.lock generated
View file

@ -2,6 +2,20 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 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]] [[package]]
name = "addr2line" name = "addr2line"
version = "0.25.1" version = "0.25.1"
@ -1871,6 +1885,7 @@ dependencies = [
"strum", "strum",
"stun-rs", "stun-rs",
"surge-ping", "surge-ping",
"swarm-discovery",
"time", "time",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
@ -3428,6 +3443,12 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smol_str"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fad6c857cbab2627dcf01ec85a623ca4e7dcb5691cbaa3d7fb7653671f0d09c9"
[[package]] [[package]]
name = "snafu" name = "snafu"
version = "0.8.9" version = "0.8.9"
@ -3575,6 +3596,21 @@ dependencies = [
"tracing", "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]] [[package]]
name = "syn" name = "syn"
version = "2.0.117" version = "2.0.117"

View file

@ -44,7 +44,7 @@ directories = "5"
ed25519-dalek = { version = "2", features = ["rand_core"] } ed25519-dalek = { version = "2", features = ["rand_core"] }
futures = "0.3" futures = "0.3"
hex = "0.4" hex = "0.4"
iroh = "0.90.0" iroh = { version = "0.90.0", features = ["discovery-local-network"] }
postcard = { version = "1", features = ["alloc"] } postcard = { version = "1", features = ["alloc"] }
rand_core = { version = "0.6", features = ["getrandom"] } rand_core = { version = "0.6", features = ["getrandom"] }
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }

View file

@ -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 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. Named custom relay maps can be selected with development. Named custom relay maps can be selected with
`relay_mode = "custom"` and `relay_map = "<name>"`. `relay_mode = "custom"` and `relay_map = "<name>"`. 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. 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

@ -508,6 +508,14 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
status.endpoint_id.as_deref().unwrap_or("not started") status.endpoint_id.as_deref().unwrap_or("not started")
); );
println!("iroh relay: {}", status.iroh_relay_mode); 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); println!("iroh: {}", status.iroh);
} }
ControlResponse::NodeId(node) => { ControlResponse::NodeId(node) => {

View file

@ -8,6 +8,7 @@ pub const DEFAULT_CONFIG_TOML: &str = r#"# geth local node config
# Values: "default", "staging", "disabled", "custom". # Values: "default", "staging", "disabled", "custom".
[iroh] [iroh]
relay_mode = "default" relay_mode = "default"
local_discovery = true
# relay_map = "home" # relay_map = "home"
# #
# [iroh.relay_maps.home] # [iroh.relay_maps.home]
@ -101,6 +102,8 @@ 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("invalid iroh local_discovery value: {0}")]
InvalidLocalDiscovery(String),
#[error("custom iroh relay_mode requires iroh.relay_map")] #[error("custom iroh relay_mode requires iroh.relay_map")]
MissingRelayMapSelection, MissingRelayMapSelection,
#[error("selected iroh relay map does not exist: {0}")] #[error("selected iroh relay map does not exist: {0}")]
@ -136,6 +139,7 @@ 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_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")) { let relay_mode = match document.get("iroh").and_then(|iroh| iroh.get("relay_mode")) {
Some(item) => { Some(item) => {
let value = item let value = item
@ -150,15 +154,27 @@ impl GethConfig {
iroh: IrohConfig { iroh: IrohConfig {
relay_mode, relay_mode,
relay_maps, relay_maps,
local_discovery,
}, },
}) })
} }
} }
#[derive(Clone, Debug, Default, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct IrohConfig { pub struct IrohConfig {
pub relay_mode: RelayMode, pub relay_mode: RelayMode,
pub relay_maps: BTreeMap<String, RelayMapConfig>, pub relay_maps: BTreeMap<String, RelayMapConfig>,
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)] #[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<bool, ConfigError> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -277,6 +305,7 @@ mod tests {
fn default_config_uses_iroh_relays() { fn default_config_uses_iroh_relays() {
let config = GethConfig::parse(GethConfig::default_toml()).expect("parse config"); let config = GethConfig::parse(GethConfig::default_toml()).expect("parse config");
assert_eq!(config.iroh.relay_mode, RelayMode::Default); assert_eq!(config.iroh.relay_mode, RelayMode::Default);
assert!(config.iroh.local_discovery);
} }
#[test] #[test]
@ -345,4 +374,16 @@ mod tests {
.expect_err("error"); .expect_err("error");
assert!(matches!(error, ConfigError::UnknownRelayMap(map) if map == "home")); 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(_)));
}
} }

View file

@ -149,6 +149,7 @@ pub struct StatusResponse {
pub iroh_enabled: bool, pub iroh_enabled: bool,
pub endpoint_id: Option<String>, pub endpoint_id: Option<String>,
pub iroh_relay_mode: String, pub iroh_relay_mode: String,
pub iroh_local_discovery: bool,
pub iroh: String, pub iroh: String,
} }

View file

@ -16,6 +16,7 @@ pub const ALPN_SSH_PROXY: &[u8] = b"/geth/ssh-proxy/1";
pub struct GethIrohConfig { pub struct GethIrohConfig {
pub secret_key_path: PathBuf, pub secret_key_path: PathBuf,
pub relay_mode: GethRelayMode, pub relay_mode: GethRelayMode,
pub local_discovery: bool,
pub bind_ipv4: Option<SocketAddrV4>, pub bind_ipv4: Option<SocketAddrV4>,
pub bind_ipv6: Option<SocketAddrV6>, pub bind_ipv6: Option<SocketAddrV6>,
pub alpns: Vec<Vec<u8>>, pub alpns: Vec<Vec<u8>>,
@ -35,6 +36,7 @@ impl GethIrohConfig {
Self { Self {
secret_key_path: secret_key_path.into(), secret_key_path: secret_key_path.into(),
relay_mode, relay_mode,
local_discovery: true,
bind_ipv4: None, bind_ipv4: None,
bind_ipv6: None, bind_ipv6: None,
alpns: default_protocol_router().alpns(), alpns: default_protocol_router().alpns(),
@ -229,6 +231,10 @@ pub async fn start_endpoint(config: &GethIrohConfig) -> Result<GethIrohEndpoint,
.relay_mode(config.relay_mode.to_iroh()?) .relay_mode(config.relay_mode.to_iroh()?)
.alpns(config.alpns.clone()); .alpns(config.alpns.clone());
if config.local_discovery {
builder = builder.discovery_local_network();
}
if let Some(bind_ipv4) = config.bind_ipv4 { if let Some(bind_ipv4) = config.bind_ipv4 {
builder = builder.bind_addr_v4(bind_ipv4); builder = builder.bind_addr_v4(bind_ipv4);
} }
@ -241,9 +247,10 @@ pub async fn start_endpoint(config: &GethIrohConfig) -> Result<GethIrohEndpoint,
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.label(), relay_mode: config.relay_mode.label(),
local_discovery: config.local_discovery,
note: format!( note: format!(
"Iroh endpoint started with iroh 0.90.0; relay mode: {:?}", "Iroh endpoint started with iroh 0.90.0; relay mode: {:?}; local discovery: {}",
config.relay_mode config.relay_mode, config.local_discovery
), ),
}; };
Ok(GethIrohEndpoint { endpoint, status }) Ok(GethIrohEndpoint { endpoint, status })
@ -283,6 +290,7 @@ pub struct EndpointStatus {
pub enabled: bool, pub enabled: bool,
pub endpoint_id: Option<String>, pub endpoint_id: Option<String>,
pub relay_mode: String, pub relay_mode: String,
pub local_discovery: bool,
pub note: String, pub note: String,
} }
@ -293,6 +301,7 @@ impl EndpointStatus {
enabled: false, enabled: false,
endpoint_id: None, endpoint_id: None,
relay_mode: "not-started".to_owned(), relay_mode: "not-started".to_owned(),
local_discovery: false,
note: "Iroh endpoint is not running outside daemon mode".to_owned(), note: "Iroh endpoint is not running outside daemon mode".to_owned(),
} }
} }
@ -416,16 +425,18 @@ mod tests {
#[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");
let config = GethIrohConfig::local_with_relay( let mut config = GethIrohConfig::local_with_relay(
dir.path().join("iroh.ed25519"), dir.path().join("iroh.ed25519"),
GethRelayMode::Disabled, GethRelayMode::Disabled,
); );
config.local_discovery = false;
match start_endpoint(&config).await { match start_endpoint(&config).await {
Ok(endpoint) => { Ok(endpoint) => {
let status = endpoint.status(); let status = endpoint.status();
assert!(status.enabled); assert!(status.enabled);
assert_eq!(status.endpoint_id, Some(endpoint.node_id())); assert_eq!(status.endpoint_id, Some(endpoint.node_id()));
assert_eq!(status.relay_mode, "disabled"); assert_eq!(status.relay_mode, "disabled");
assert!(!status.local_discovery);
endpoint.shutdown().await; endpoint.shutdown().await;
} }
Err(IrohError::Bind(error)) => { Err(IrohError::Bind(error)) => {

View file

@ -161,6 +161,7 @@ pub fn handle_request(
iroh_enabled: node.iroh_status.enabled, iroh_enabled: node.iroh_status.enabled,
endpoint_id: node.iroh_status.endpoint_id.clone(), endpoint_id: node.iroh_status.endpoint_id.clone(),
iroh_relay_mode: node.iroh_status.relay_mode.clone(), iroh_relay_mode: node.iroh_status.relay_mode.clone(),
iroh_local_discovery: node.iroh_status.local_discovery,
iroh: node.iroh_status.note.clone(), iroh: node.iroh_status.note.clone(),
})), })),
ControlRequest::NodeId => Ok(ControlResponse::NodeId(NodeIdResponse { 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 node_config = GethConfig::load(&node.paths.config_file())?;
let relay_mode = node_config.iroh.relay_mode.clone(); let relay_mode = node_config.iroh.relay_mode.clone();
let relay_mode_label = relay_mode.label(); 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 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 { match geth_iroh::start_endpoint(&config).await {
Ok(endpoint) => { Ok(endpoint) => {
let status = endpoint.status(); let status = endpoint.status();
@ -473,6 +476,7 @@ async fn start_daemon_iroh_endpoint(
enabled: false, enabled: false,
endpoint_id: None, endpoint_id: None,
relay_mode: relay_mode_label, relay_mode: relay_mode_label,
local_discovery,
note: format!("Iroh endpoint failed to start: {error}"), note: format!("Iroh endpoint failed to start: {error}"),
}; };
Ok(None) Ok(None)

View file

@ -64,6 +64,11 @@ fn geth_init_in_temp_home() {
.expect("read config") .expect("read config")
.contains("relay_mode = \"default\"") .contains("relay_mode = \"default\"")
); );
assert!(
std::fs::read_to_string(home.path().join("config.toml"))
.expect("read config")
.contains("local_discovery = true")
);
} }
#[test] #[test]
@ -75,7 +80,7 @@ fn geth_status_against_running_daemon() {
assert!(run_geth(home.path(), &["init"]).status.success()); assert!(run_geth(home.path(), &["init"]).status.success());
std::fs::write( std::fs::write(
home.path().join("config.toml"), home.path().join("config.toml"),
"[iroh]\nrelay_mode = \"disabled\"\n", "[iroh]\nrelay_mode = \"disabled\"\nlocal_discovery = false\n",
) )
.expect("write config"); .expect("write config");
let mut daemon = spawn_daemon(home.path()); let mut daemon = spawn_daemon(home.path());
@ -95,6 +100,7 @@ fn geth_status_against_running_daemon() {
assert!(stdout.contains("agent:")); assert!(stdout.contains("agent:"));
assert!(stdout.contains("endpoint:")); assert!(stdout.contains("endpoint:"));
assert!(stdout.contains("iroh relay: disabled")); assert!(stdout.contains("iroh relay: disabled"));
assert!(stdout.contains("iroh discovery: local-network disabled"));
} }
#[test] #[test]

View file

@ -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 connectivity and mDNS/LAN discovery for local networks. These are connectivity
and candidate-discovery mechanisms only. They do not grant trust, mutate and candidate-discovery mechanisms only. They do not grant trust, mutate
authorization state, or make EndpointID knowledge sufficient for access. 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, Peer cards are the discovery payload. A peer card carries node ID, agent ID,
endpoint candidates, timestamp, and signature metadata. The current scaffold endpoint candidates, timestamp, and signature metadata. The current scaffold

View file

@ -88,10 +88,17 @@ geth-to-geth connections without granting trust from discovery alone.
- Status output identifies the selected custom relay map without exposing - Status output identifies the selected custom relay map without exposing
unrelated config. unrelated config.
- `[ ]` LAN mDNS discovery. - `[x]` Iroh LAN address discovery.
Acceptance criteria: Acceptance criteria:
- The daemon can advertise and discover local geth peer cards over mDNS. - Config can enable or disable Iroh local-network discovery.
- mDNS results are stored only as untrusted peer candidates. - 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 - Discovered EndpointIDs do not grant module access without keychain/auth
validation. validation.