From d5a548182b37cec232023c42dadac39f2cb6bb27 Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Mon, 18 May 2026 17:05:43 +0200 Subject: [PATCH] Advertise signed peer cards on LAN --- AGENTS.md | 5 +- Cargo.lock | 3 + Cargo.toml | 1 + README.md | 3 + crates/geth-discovery/Cargo.toml | 2 + crates/geth-discovery/src/lib.rs | 143 ++++++++++++++++++++++++++++++ crates/geth-node/Cargo.toml | 1 + crates/geth-node/src/lib.rs | 145 ++++++++++++++++++++++++++++++- docs/architecture.md | 6 +- docs/roadmap.md | 4 +- 10 files changed, 306 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ee9b9cb..d2ecc5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,8 +106,9 @@ Roadmap items should be actionable and checkable: certificate metadata, revocation metadata, user service definitions, and a pinned `geth-iroh` endpoint wrapper with protocol-router scaffold, peer-card types, manual signed peer-card export/import/list commands, `geth peer ping` - and `geth peer auth-check` over Iroh, untrusted discovery-backend trait, - custom relay-map config, and Iroh local-network discovery toggle exist. + and `geth peer auth-check` over Iroh, signed peer-card LAN discovery payloads, + untrusted discovery-backend trait, custom relay-map config, and Iroh + local-network discovery toggle exist. - Canonical signed-operation envelopes exist for keychain/auth signature payloads. The keychain reducer builds an active identity view for admin keys, users, devices, nodes, agents, and endpoint bindings. diff --git a/Cargo.lock b/Cargo.lock index 29c5d51..3fbb41f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1172,10 +1172,12 @@ dependencies = [ name = "geth-discovery" version = "0.1.0" dependencies = [ + "base64", "geth-crypto", "geth-types", "hex", "serde", + "serde_json", "thiserror 2.0.18", ] @@ -1247,6 +1249,7 @@ dependencies = [ "geth-types", "iroh", "serde_json", + "swarm-discovery", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 3227d93..a5e0772 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ rand_core = { version = "0.6", features = ["getrandom"] } rusqlite = { version = "0.32", features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +swarm-discovery = "0.4.1" tempfile = "3" thiserror = "2" time = { version = "0.3", features = ["formatting", "serde"] } diff --git a/README.md b/README.md index 10cf8a4..83e8ff5 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,9 @@ imported peer card and exchange a signed candidate-only peer-card ping. Iroh control request: the remote daemon verifies that the caller's signed peer card binds the actual Iroh EndpointID before reducing resource-local auth ops. Importing or pinging a peer card never grants capabilities by itself. +When `[iroh].local_discovery = true`, the daemon also advertises and discovers +signed peer cards on LAN using a geth-specific mDNS TXT payload. That payload is +candidate metadata only; all geth node-to-node requests still run over Iroh. Other command groups exist as explicit stubs: `ssh proxy`. diff --git a/crates/geth-discovery/Cargo.toml b/crates/geth-discovery/Cargo.toml index baad906..a2bb485 100644 --- a/crates/geth-discovery/Cargo.toml +++ b/crates/geth-discovery/Cargo.toml @@ -6,7 +6,9 @@ rust-version.workspace = true license.workspace = true [dependencies] +base64.workspace = true serde.workspace = true +serde_json.workspace = true thiserror.workspace = true hex.workspace = true geth-crypto = { path = "../geth-crypto" } diff --git a/crates/geth-discovery/src/lib.rs b/crates/geth-discovery/src/lib.rs index f2fde72..20d5a49 100644 --- a/crates/geth-discovery/src/lib.rs +++ b/crates/geth-discovery/src/lib.rs @@ -1,8 +1,16 @@ +use base64::Engine; use geth_crypto::AgentKey; use geth_types::{AgentId, NodeId, UnixMillis}; use serde::{Deserialize, Serialize}; pub const PEER_CARD_SIGNATURE_NAMESPACE: &str = "geth.peer-card.v1@geth.local"; +pub const PEER_CARD_LAN_DISCOVERY_SERVICE: &str = "geth-peer-card"; +pub const PEER_CARD_TXT_VERSION_KEY: &str = "geth"; +pub const PEER_CARD_TXT_VERSION: &str = "peer-card-v1"; +pub const PEER_CARD_TXT_CHUNKS_KEY: &str = "card-chunks"; +pub const PEER_CARD_TXT_CHUNK_PREFIX: &str = "card-"; +const PEER_CARD_TXT_CHUNK_BYTES: usize = 200; +const PEER_CARD_TXT_MAX_CHUNKS: usize = 32; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PeerCard { @@ -164,6 +172,86 @@ pub trait DiscoveryBackend { fn candidates(&self) -> Result, DiscoveryError>; } +pub fn peer_card_txt_attributes( + card: &PeerCard, +) -> Result)>, DiscoveryError> { + card.validate_candidate()?; + let json = serde_json::to_vec(card)?; + let encoded = base64::engine::general_purpose::STANDARD_NO_PAD.encode(json); + let chunks = encoded + .as_bytes() + .chunks(PEER_CARD_TXT_CHUNK_BYTES) + .map(|chunk| { + std::str::from_utf8(chunk) + .expect("base64 is utf-8") + .to_owned() + }) + .collect::>(); + if chunks.is_empty() || chunks.len() > PEER_CARD_TXT_MAX_CHUNKS { + return Err(DiscoveryError::PeerCardTxtTooLarge { + chunks: chunks.len(), + max_chunks: PEER_CARD_TXT_MAX_CHUNKS, + }); + } + + let mut attributes = vec![ + ( + PEER_CARD_TXT_VERSION_KEY.to_owned(), + Some(PEER_CARD_TXT_VERSION.to_owned()), + ), + ( + PEER_CARD_TXT_CHUNKS_KEY.to_owned(), + Some(chunks.len().to_string()), + ), + ]; + for (index, chunk) in chunks.into_iter().enumerate() { + attributes.push(( + format!("{PEER_CARD_TXT_CHUNK_PREFIX}{index:02}"), + Some(chunk), + )); + } + Ok(attributes) +} + +pub fn peer_card_from_txt_attributes<'a>( + attributes: impl IntoIterator)>, +) -> Result { + let attributes = attributes.into_iter().collect::>(); + let version = txt_value(&attributes, PEER_CARD_TXT_VERSION_KEY) + .ok_or_else(|| DiscoveryError::MissingTxtAttribute(PEER_CARD_TXT_VERSION_KEY.to_owned()))?; + if version != PEER_CARD_TXT_VERSION { + return Err(DiscoveryError::UnsupportedTxtVersion(version.to_owned())); + } + let chunk_count = txt_value(&attributes, PEER_CARD_TXT_CHUNKS_KEY) + .ok_or_else(|| DiscoveryError::MissingTxtAttribute(PEER_CARD_TXT_CHUNKS_KEY.to_owned()))? + .parse::() + .map_err(|_| DiscoveryError::InvalidTxtAttribute(PEER_CARD_TXT_CHUNKS_KEY.to_owned()))?; + if chunk_count == 0 || chunk_count > PEER_CARD_TXT_MAX_CHUNKS { + return Err(DiscoveryError::PeerCardTxtTooLarge { + chunks: chunk_count, + max_chunks: PEER_CARD_TXT_MAX_CHUNKS, + }); + } + + let mut encoded = String::new(); + for index in 0..chunk_count { + let key = format!("{PEER_CARD_TXT_CHUNK_PREFIX}{index:02}"); + let chunk = txt_value(&attributes, &key) + .ok_or_else(|| DiscoveryError::MissingTxtAttribute(key.clone()))?; + encoded.push_str(chunk); + } + let json = base64::engine::general_purpose::STANDARD_NO_PAD.decode(encoded)?; + let card: PeerCard = serde_json::from_slice(&json)?; + card.validate_candidate()?; + Ok(card) +} + +fn txt_value<'a>(attributes: &[(&'a str, Option<&'a str>)], key: &str) -> Option<&'a str> { + attributes + .iter() + .find_map(|(candidate, value)| (*candidate == key).then_some(*value).flatten()) +} + #[derive(Debug, thiserror::Error)] pub enum DiscoveryError { #[error("peer card has no endpoint candidates")] @@ -180,6 +268,18 @@ pub enum DiscoveryError { Hex(#[from] hex::FromHexError), #[error("peer card signature error: {0}")] Crypto(#[from] geth_crypto::CryptoError), + #[error("peer card TXT payload is too large: {chunks} chunks, max {max_chunks}")] + PeerCardTxtTooLarge { chunks: usize, max_chunks: usize }, + #[error("missing peer card TXT attribute: {0}")] + MissingTxtAttribute(String), + #[error("invalid peer card TXT attribute: {0}")] + InvalidTxtAttribute(String), + #[error("unsupported peer card TXT version: {0}")] + UnsupportedTxtVersion(String), + #[error("peer card TXT base64 error: {0}")] + Base64(#[from] base64::DecodeError), + #[error("peer card JSON error: {0}")] + Json(#[from] serde_json::Error), } #[must_use] @@ -241,4 +341,47 @@ mod tests { Err(DiscoveryError::Crypto(geth_crypto::CryptoError::Verify)) )); } + + #[test] + fn peer_card_txt_attributes_roundtrip_signed_payload() { + let card = signed_card(); + let attributes = peer_card_txt_attributes(&card).expect("txt attributes"); + assert_eq!( + attributes + .iter() + .find(|(key, _)| key == PEER_CARD_TXT_VERSION_KEY) + .and_then(|(_, value)| value.as_deref()), + Some(PEER_CARD_TXT_VERSION) + ); + assert!(attributes.iter().any(|(key, _)| key == "card-00")); + assert!( + attributes + .iter() + .all(|(key, value)| key.len() + value.as_deref().unwrap_or("").len() <= 254) + ); + + let decoded = peer_card_from_txt_attributes( + attributes + .iter() + .map(|(key, value)| (key.as_str(), value.as_deref())), + ) + .expect("decode txt attributes"); + + assert_eq!(decoded, card); + } + + #[test] + fn peer_card_txt_attributes_reject_missing_chunk() { + let card = signed_card(); + let attributes = peer_card_txt_attributes(&card).expect("txt attributes"); + let without_first_chunk = attributes + .iter() + .filter(|(key, _)| key != "card-00") + .map(|(key, value)| (key.as_str(), value.as_deref())); + + assert!(matches!( + peer_card_from_txt_attributes(without_first_chunk), + Err(DiscoveryError::MissingTxtAttribute(key)) if key == "card-00" + )); + } } diff --git a/crates/geth-node/Cargo.toml b/crates/geth-node/Cargo.toml index 557ad64..e71feeb 100644 --- a/crates/geth-node/Cargo.toml +++ b/crates/geth-node/Cargo.toml @@ -29,6 +29,7 @@ geth-ssh-identity = { path = "../geth-ssh-identity" } geth-store = { path = "../geth-store" } geth-types = { path = "../geth-types" } iroh.workspace = true +swarm-discovery.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 3e159a8..ab8d848 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -13,7 +13,8 @@ use geth_control::{ use geth_crypto::AgentKey; use geth_db::DbResource; use geth_discovery::{ - DiscoveredPeer, DiscoverySource, EndpointCandidate, PeerCard, discovery_is_untrusted_note, + DiscoveredPeer, DiscoverySource, EndpointCandidate, PEER_CARD_LAN_DISCOVERY_SERVICE, PeerCard, + discovery_is_untrusted_note, peer_card_from_txt_attributes, peer_card_txt_attributes, }; use geth_document::{DocumentResource, DocumentState}; use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode}; @@ -193,6 +194,7 @@ pub async fn run_daemon(paths: GethPaths) -> Result<(), NodeError> { { spawn_iroh_control_accept_loop(node.clone(), endpoint); } + let _peer_card_lan_discovery = start_peer_card_lan_discovery(&node).await; if Path::new(&paths.socket_path()).exists() { std::fs::remove_file(paths.socket_path())?; } @@ -259,6 +261,121 @@ async fn handle_stream(node: LocalNode, stream: UnixStream) -> Result<(), NodeEr Ok(()) } +async fn start_peer_card_lan_discovery(node: &LocalNode) -> Option { + if !node.iroh_status.local_discovery || !node.iroh_status.enabled { + return None; + } + + let card = match local_peer_card(node, DiscoverySource::Mdns, true).await { + Ok(card) => card, + Err(error) => { + tracing::warn!(%error, "signed peer-card LAN discovery disabled"); + return None; + } + }; + let attributes = match peer_card_txt_attributes(&card) { + Ok(attributes) => attributes, + Err(error) => { + tracing::warn!(%error, "could not encode peer-card LAN TXT payload"); + return None; + } + }; + let Some((port, addrs)) = lan_discovery_addrs(&card) else { + tracing::warn!( + "signed peer-card LAN discovery disabled because no direct Iroh address is known" + ); + return None; + }; + + let paths = node.paths.clone(); + let self_node_id = node.node_id.clone(); + let discoverer = match swarm_discovery::Discoverer::new_interactive( + PEER_CARD_LAN_DISCOVERY_SERVICE.to_owned(), + lan_discovery_peer_id(&node.agent_id), + ) + .with_addrs(port, addrs) + .with_txt_attributes(attributes) + { + Ok(discoverer) => discoverer, + Err(error) => { + tracing::warn!(%error, "could not build peer-card LAN discovery"); + return None; + } + } + .with_callback(move |_peer_id, peer| { + if peer.is_expiry() { + return; + } + let card = match peer_card_from_txt_attributes(peer.txt_attributes()) { + Ok(card) => card, + Err(error) => { + tracing::debug!(%error, "ignoring invalid peer-card LAN discovery payload"); + return; + } + }; + if card.node_id.as_str() == self_node_id { + return; + } + let discovered = match DiscoveredPeer::candidate( + card.clone(), + UnixMillis(geth_store::now_ms()), + DiscoverySource::Mdns, + ) { + Ok(discovered) => discovered, + Err(error) => { + tracing::debug!(%error, "ignoring invalid LAN peer-card candidate"); + return; + } + }; + match Store::open(&paths.metadata_db()).and_then(|store| { + store.upsert_peer_card(&StoredPeerCard { + peer_id: card.node_id.to_string(), + card_json: serde_json::to_string(&card).map_err(geth_store::StoreError::from)?, + updated_at_ms: discovered.discovered_at.0, + }) + }) { + Ok(()) => tracing::debug!(node = %card.node_id, "stored LAN peer-card candidate"), + Err(error) => tracing::warn!(%error, "could not store LAN peer-card candidate"), + } + }); + + match discoverer.spawn(&tokio::runtime::Handle::current()) { + Ok(guard) => { + tracing::info!( + service = PEER_CARD_LAN_DISCOVERY_SERVICE, + "signed peer-card LAN discovery running" + ); + Some(guard) + } + Err(error) => { + tracing::warn!(%error, "could not start signed peer-card LAN discovery"); + None + } + } +} + +fn lan_discovery_peer_id(agent_id: &str) -> String { + format!("geth-{}", geth_crypto::blake3_hex(agent_id.as_bytes())) +} + +fn lan_discovery_addrs(card: &PeerCard) -> Option<(u16, Vec)> { + let mut parsed = card + .endpoints + .iter() + .flat_map(|endpoint| endpoint.direct_addresses.iter()) + .filter_map(|addr| addr.parse::().ok()) + .collect::>(); + parsed.sort_unstable(); + parsed.dedup(); + let port = parsed.first()?.port(); + let addrs = parsed + .into_iter() + .filter(|addr| addr.port() == port) + .map(|addr| addr.ip()) + .collect::>(); + (!addrs.is_empty()).then_some((port, addrs)) +} + async fn export_peer_card( node: &LocalNode, out: Option, @@ -2143,6 +2260,32 @@ mod tests { .expect("write config"); } + #[test] + fn lan_discovery_address_selection_uses_iroh_direct_addresses() { + let key = AgentKey::generate(); + let card = PeerCard::signed( + "node:test".into(), + &key, + vec![EndpointCandidate { + endpoint_id: "endpoint:test".to_owned(), + relay_url: None, + direct_addresses: vec![ + "127.0.0.1:1111".to_owned(), + "127.0.0.2:1111".to_owned(), + "127.0.0.3:2222".to_owned(), + ], + source: DiscoverySource::Mdns, + }], + UnixMillis(1), + ) + .expect("peer card"); + + let (port, addrs) = lan_discovery_addrs(&card).expect("lan addresses"); + + assert_eq!(port, 1111); + assert_eq!(addrs.len(), 2); + } + #[tokio::test] async fn peer_ping_uses_signed_peer_card_over_iroh() { let left_home = tempfile::tempdir().expect("left home"); diff --git a/docs/architecture.md b/docs/architecture.md index 4aed18b..45d9134 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -49,8 +49,10 @@ them. `geth peer ping ` dials an imported peer card over Iroh and exchanges signed peer-card metadata. `geth peer auth-check ` sends a protected Iroh control request that validates the caller's signed peer card against the actual Iroh EndpointID before -evaluating resource-local capabilities. Automatic signed peer-card -advertisement over LAN discovery remains separate future work. +evaluating resource-local capabilities. When local discovery is enabled, the +daemon also advertises and discovers signed peer cards through a geth-specific +mDNS service. The LAN payload is TXT-encoded signed metadata only; remote geth +traffic still uses Iroh. Peer cards are the discovery payload. A peer card carries node ID, agent ID, endpoint candidates, timestamp, signing public key, and an Ed25519 signature diff --git a/docs/roadmap.md b/docs/roadmap.md index c8d04c6..8a3a723 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -94,13 +94,13 @@ geth-to-geth connections without granting trust from discovery alone. - 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. +- `[x]` Signed peer-card LAN discovery payloads. Acceptance criteria: - `[x]` Manual `geth peer export/import/list` can exchange signed peer cards and store them as untrusted candidates. - `[x]` Exported daemon peer cards include Iroh EndpointID plus available relay/direct address candidates. - - `[ ]` The daemon can advertise and discover signed geth peer cards over LAN + - `[x]` The daemon can advertise and discover signed geth peer cards over LAN discovery. - `[x]` Imported peer cards are stored only as untrusted peer candidates. - `[x]` Discovered EndpointIDs do not grant module access without keychain/auth