diff --git a/AGENTS.md b/AGENTS.md index 1fe6c60..9d352b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,9 +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, manual signed peer-card export/import/list commands, untrusted - discovery-backend trait, custom relay-map config, and Iroh local-network - discovery toggle exist. + types, manual signed peer-card export/import/list commands, `geth peer ping` + over Iroh, 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 6d0944e..29c5d51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1052,6 +1052,7 @@ dependencies = [ "geth-store", "geth-types", "rusqlite", + "serde_json", "tempfile", "tokio", "tracing-subscriber", @@ -1194,6 +1195,7 @@ version = "0.1.0" dependencies = [ "hex", "iroh", + "n0-watcher", "rand_core 0.6.4", "serde", "tempfile", @@ -1243,7 +1245,9 @@ dependencies = [ "geth-ssh-identity", "geth-store", "geth-types", + "iroh", "serde_json", + "tempfile", "thiserror 2.0.18", "tokio", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 4d7e45d..3227d93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ ed25519-dalek = { version = "2", features = ["rand_core"] } futures = "0.3" hex = "0.4" iroh = { version = "0.90.0", features = ["discovery-local-network"] } +n0-watcher = "0.2" 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 a41dd34..d2c497e 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ The bootstrap implementation provides: - `geth peer export [--out ]` - `geth peer import ` - `geth peer list` +- `geth peer ping ` - `geth resource list` - `geth resource create ` - `geth keychain init [--admin-key ]` @@ -116,9 +117,11 @@ The bootstrap implementation provides: - `geth ssh revocation import [--format jsonl|openssh-krl-spec]` - local pipe registry commands: `geth pipe listen/connect` -`geth peer export/import/list` is for untrusted peer-card exchange while live -LAN discovery and authenticated Iroh dialing are still being built. Importing a -peer card never grants capabilities by itself. +`geth peer export/import/list` is for untrusted peer-card exchange. Peer cards +include the Iroh EndpointID plus currently known relay/direct addresses. +`geth peer ping ` uses the local daemon's Iroh endpoint to dial an +imported peer card and exchange a signed candidate-only peer-card ping. +Importing or pinging a peer card never grants capabilities by itself. Other command groups exist as explicit stubs: `ssh proxy`. diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 667d3c4..f80cce8 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -137,6 +137,9 @@ pub enum PeerCommand { path: PathBuf, }, List, + Ping { + node: String, + }, } #[derive(Debug, Subcommand)] @@ -460,6 +463,7 @@ fn request_for_command(command: Command) -> Result { PeerCommand::Export { out } => ControlRequest::PeerCardExport { out }, PeerCommand::Import { path } => ControlRequest::PeerCardImport { path }, PeerCommand::List => ControlRequest::PeerCardList, + PeerCommand::Ping { node } => ControlRequest::PeerPing { node }, }, Command::Resource { command: ResourceCommand::List, @@ -802,6 +806,19 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { } println!("note: {note}"); } + ControlResponse::PeerPinged { + peer_node_id, + peer_agent_id, + endpoint_id, + alpn, + note, + } => { + println!("peer pong: {peer_node_id}"); + println!("agent: {peer_agent_id}"); + println!("endpoint: {endpoint_id}"); + println!("alpn: {alpn}"); + println!("note: {note}"); + } ControlResponse::ResourceList { resources } => { if resources.is_empty() { println!("no resources"); diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 71ed244..b74aea5 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -28,6 +28,9 @@ pub enum ControlRequest { path: PathBuf, }, PeerCardList, + PeerPing { + node: String, + }, ResourceList, ResourceCreate { kind: String, @@ -228,6 +231,13 @@ pub enum ControlResponse { peers: Vec, note: String, }, + PeerPinged { + peer_node_id: String, + peer_agent_id: String, + endpoint_id: String, + alpn: String, + note: String, + }, ResourceList { resources: Vec, }, @@ -428,6 +438,29 @@ pub struct CasBlob { pub pinned: bool, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum PeerControlRequest { + Ping { peer_card: PeerCard, nonce: String }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum PeerControlResponse { + Pong { + node_id: String, + agent_id: String, + endpoint_id: String, + remote_endpoint_id: String, + alpn: String, + nonce: String, + note: String, + }, + Error { + message: String, + }, +} + #[derive(Debug, thiserror::Error)] pub enum ControlError { #[error("json error: {0}")] @@ -454,6 +487,26 @@ pub fn decode_response(line: &str) -> Result { serde_json::from_str(line).map_err(ControlError::from) } +pub fn encode_peer_request(request: &PeerControlRequest) -> Result { + let mut line = serde_json::to_string(request)?; + line.push('\n'); + Ok(line) +} + +pub fn decode_peer_request(line: &str) -> Result { + serde_json::from_str(line).map_err(ControlError::from) +} + +pub fn encode_peer_response(response: &PeerControlResponse) -> Result { + let mut line = serde_json::to_string(response)?; + line.push('\n'); + Ok(line) +} + +pub fn decode_peer_response(line: &str) -> Result { + serde_json::from_str(line).map_err(ControlError::from) +} + #[cfg(test)] mod tests { use super::*; @@ -557,5 +610,28 @@ mod tests { decode_request(&encode_request(&request).expect("encode")).expect("decode"), request ); + + let request = ControlRequest::PeerPing { + node: "node:peer".to_owned(), + }; + assert_eq!( + decode_request(&encode_request(&request).expect("encode")).expect("decode"), + request + ); + + let response = PeerControlResponse::Pong { + node_id: "node:peer".to_owned(), + agent_id: "agent:peer".to_owned(), + endpoint_id: "endpoint:peer".to_owned(), + remote_endpoint_id: "endpoint:caller".to_owned(), + alpn: "/geth/control/1".to_owned(), + nonce: "nonce".to_owned(), + note: "candidate only".to_owned(), + }; + assert_eq!( + decode_peer_response(&encode_peer_response(&response).expect("encode")) + .expect("decode"), + response + ); } } diff --git a/crates/geth-discovery/src/lib.rs b/crates/geth-discovery/src/lib.rs index 5f520ec..f2fde72 100644 --- a/crates/geth-discovery/src/lib.rs +++ b/crates/geth-discovery/src/lib.rs @@ -107,6 +107,8 @@ struct PeerCardSigningPayload { pub struct EndpointCandidate { pub endpoint_id: String, pub relay_url: Option, + #[serde(default)] + pub direct_addresses: Vec, pub source: DiscoverySource, } @@ -198,6 +200,7 @@ mod tests { vec![EndpointCandidate { endpoint_id: "endpoint:iroh".to_owned(), relay_url: None, + direct_addresses: vec!["127.0.0.1:12345".to_owned()], source: DiscoverySource::Manual, }], UnixMillis(1), diff --git a/crates/geth-iroh/Cargo.toml b/crates/geth-iroh/Cargo.toml index d989adb..21addaa 100644 --- a/crates/geth-iroh/Cargo.toml +++ b/crates/geth-iroh/Cargo.toml @@ -8,9 +8,11 @@ license.workspace = true [dependencies] hex.workspace = true iroh.workspace = true +n0-watcher.workspace = true rand_core.workspace = true serde.workspace = true thiserror.workspace = true +tokio.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/geth-iroh/src/lib.rs b/crates/geth-iroh/src/lib.rs index d430ed2..dc8f9af 100644 --- a/crates/geth-iroh/src/lib.rs +++ b/crates/geth-iroh/src/lib.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::net::{SocketAddrV4, SocketAddrV6}; use std::path::{Path, PathBuf}; +use std::time::Duration; pub const ALPN_CONTROL: &[u8] = b"/geth/control/1"; pub const ALPN_KV: &[u8] = b"/geth/kv/1"; @@ -203,6 +204,7 @@ impl GethRelayMode { } } +#[derive(Clone, Debug)] pub struct GethIrohEndpoint { endpoint: iroh::Endpoint, status: EndpointStatus, @@ -219,11 +221,42 @@ impl GethIrohEndpoint { self.endpoint.node_id().to_string() } + #[must_use] + pub fn endpoint(&self) -> iroh::Endpoint { + self.endpoint.clone() + } + + pub async fn node_addr_snapshot(&self) -> Result { + use n0_watcher::Watcher; + + let mut watcher = self.endpoint.node_addr(); + let node_addr = tokio::time::timeout(Duration::from_secs(2), watcher.initialized()) + .await + .map_err(|_| IrohError::NodeAddrTimeout)? + .map_err(|error| IrohError::NodeAddrUnavailable(error.to_string()))?; + Ok(GethNodeAddr { + endpoint_id: node_addr.node_id.to_string(), + relay_url: node_addr.relay_url.map(|url| url.to_string()), + direct_addresses: node_addr + .direct_addresses + .into_iter() + .map(|addr| addr.to_string()) + .collect(), + }) + } + pub async fn shutdown(&self) { self.endpoint.close().await; } } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct GethNodeAddr { + pub endpoint_id: String, + pub relay_url: Option, + pub direct_addresses: Vec, +} + pub async fn start_endpoint(config: &GethIrohConfig) -> Result { let secret_key = load_or_create_secret_key(&config.secret_key_path)?; let mut builder = iroh::Endpoint::builder() @@ -319,6 +352,10 @@ pub enum IrohError { InvalidRelayUrl { url: String, message: String }, #[error("failed to bind iroh endpoint: {0}")] Bind(Box), + #[error("timed out waiting for iroh node address")] + NodeAddrTimeout, + #[error("iroh node address watcher is unavailable: {0}")] + NodeAddrUnavailable(String), } #[derive(Debug, thiserror::Error)] @@ -437,6 +474,9 @@ mod tests { assert_eq!(status.endpoint_id, Some(endpoint.node_id())); assert_eq!(status.relay_mode, "disabled"); assert!(!status.local_discovery); + let node_addr = endpoint.node_addr_snapshot().await.expect("node addr"); + assert_eq!(node_addr.endpoint_id, endpoint.node_id()); + assert!(!node_addr.direct_addresses.is_empty()); endpoint.shutdown().await; } Err(IrohError::Bind(error)) => { diff --git a/crates/geth-node/Cargo.toml b/crates/geth-node/Cargo.toml index 58df197..557ad64 100644 --- a/crates/geth-node/Cargo.toml +++ b/crates/geth-node/Cargo.toml @@ -28,3 +28,7 @@ geth-secrets = { path = "../geth-secrets" } geth-ssh-identity = { path = "../geth-ssh-identity" } geth-store = { path = "../geth-store" } geth-types = { path = "../geth-types" } +iroh.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 88c7778..42afa34 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -8,7 +8,7 @@ use geth_cas::{ use geth_config::{GethConfig, GethPaths, RelayMode}; use geth_control::{ CasBlob, ControlRequest, ControlResponse, KeychainStatusResponse, NodeIdResponse, - StatusResponse, + PeerControlRequest, PeerControlResponse, StatusResponse, }; use geth_crypto::AgentKey; use geth_db::DbResource; @@ -112,6 +112,10 @@ pub enum NodeError { Discovery(#[from] geth_discovery::DiscoveryError), #[error("cannot export peer card before the daemon has an Iroh EndpointID")] IrohEndpointUnavailable, + #[error("peer candidate not found: {0}")] + PeerNotFound(String), + #[error("iroh peer error: {0}")] + IrohPeer(String), } #[derive(Clone, Debug)] @@ -120,6 +124,7 @@ pub struct LocalNode { pub agent_id: String, pub node_id: String, pub iroh_status: EndpointStatus, + iroh_endpoint: Arc>>, runtime: Arc, } @@ -165,6 +170,7 @@ pub fn init_node(paths: &GethPaths) -> Result { agent_id, node_id, iroh_status: EndpointStatus::scaffolded(), + iroh_endpoint: Arc::new(Mutex::new(None)), runtime: Arc::new(NodeRuntime { pubsub: Mutex::new(PubsubRuntime::default()), pipes: Mutex::new(PipeRuntime::default()), @@ -179,6 +185,14 @@ pub fn open_node(paths: &GethPaths) -> Result { pub async fn run_daemon(paths: GethPaths) -> Result<(), NodeError> { let mut node = init_node(&paths)?; let _iroh_endpoint = start_daemon_iroh_endpoint(&mut node).await?; + if let Some(endpoint) = node + .iroh_endpoint + .lock() + .map_err(|_| NodeError::RuntimeLockPoisoned)? + .clone() + { + spawn_iroh_control_accept_loop(node.clone(), endpoint); + } if Path::new(&paths.socket_path()).exists() { std::fs::remove_file(paths.socket_path())?; } @@ -211,12 +225,23 @@ pub async fn send_control( Ok(geth_control::decode_response(&line)?) } +pub async fn handle_request_async( + node: &LocalNode, + request: ControlRequest, +) -> Result { + match request { + ControlRequest::PeerCardExport { out } => export_peer_card(node, out, true).await, + ControlRequest::PeerPing { node: peer_node } => peer_ping(node, &peer_node).await, + other => handle_request(node, other), + } +} + async fn handle_stream(node: LocalNode, stream: UnixStream) -> Result<(), NodeError> { let mut reader = BufReader::new(stream); let mut line = String::new(); reader.read_line(&mut line).await?; let request = geth_control::decode_request(&line)?; - let response = match handle_request(&node, request) { + let response = match handle_request_async(&node, request).await { Ok(response) => response, Err(error) => ControlResponse::Error { message: error.to_string(), @@ -229,6 +254,243 @@ async fn handle_stream(node: LocalNode, stream: UnixStream) -> Result<(), NodeEr Ok(()) } +async fn export_peer_card( + node: &LocalNode, + out: Option, + include_node_addr: bool, +) -> Result { + let card = local_peer_card(node, DiscoverySource::Manual, include_node_addr).await?; + if let Some(path) = &out { + std::fs::write(path, serde_json::to_string_pretty(&card)?)?; + } + Ok(ControlResponse::PeerCardExported { + card, + out, + note: discovery_is_untrusted_note().to_owned(), + }) +} + +async fn local_peer_card( + node: &LocalNode, + source: DiscoverySource, + include_node_addr: bool, +) -> Result { + let endpoint_id = node + .iroh_status + .endpoint_id + .clone() + .ok_or(NodeError::IrohEndpointUnavailable)?; + let endpoint = node + .iroh_endpoint + .lock() + .map_err(|_| NodeError::RuntimeLockPoisoned)? + .clone(); + let node_addr = if include_node_addr { + match endpoint { + Some(endpoint) => endpoint.node_addr_snapshot().await.ok(), + None => None, + } + } else { + None + }; + let candidate = EndpointCandidate { + endpoint_id, + relay_url: node_addr.as_ref().and_then(|addr| addr.relay_url.clone()), + direct_addresses: node_addr + .map(|addr| addr.direct_addresses) + .unwrap_or_default(), + source, + }; + let key = AgentKey::load(&node.paths.agent_key())?; + PeerCard::signed( + NodeId::new(node.node_id.clone()), + &key, + vec![candidate], + UnixMillis(geth_store::now_ms()), + ) + .map_err(NodeError::from) +} + +async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result { + let store = Store::open(&node.paths.metadata_db())?; + let stored = store + .get_peer_card(peer_node)? + .ok_or_else(|| NodeError::PeerNotFound(peer_node.to_owned()))?; + let peer_card: PeerCard = serde_json::from_str(&stored.card_json)?; + peer_card.validate_candidate()?; + let candidate = peer_card + .endpoints + .first() + .ok_or(geth_discovery::DiscoveryError::MissingEndpoint)?; + let node_addr = iroh_node_addr_from_candidate(candidate)?; + let endpoint = node + .iroh_endpoint + .lock() + .map_err(|_| NodeError::RuntimeLockPoisoned)? + .clone() + .ok_or(NodeError::IrohEndpointUnavailable)?; + let self_card = local_peer_card(node, DiscoverySource::PeerExchange, true).await?; + let nonce = geth_crypto::blake3_hex( + format!("{}\0{}\0{}", node.node_id, peer_node, geth_store::now_ms()).as_bytes(), + ); + let request = PeerControlRequest::Ping { + peer_card: self_card, + nonce: nonce.clone(), + }; + + let conn = endpoint + .endpoint() + .connect(node_addr, geth_iroh::ALPN_CONTROL) + .await + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + let alpn = conn + .alpn() + .map(display_alpn) + .unwrap_or_else(|| "unknown".to_owned()); + let (mut send, mut recv) = conn + .open_bi() + .await + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + send.write_all(geth_control::encode_peer_request(&request)?.as_bytes()) + .await + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + send.finish() + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + let bytes = recv + .read_to_end(64 * 1024) + .await + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + let text = + std::str::from_utf8(&bytes).map_err(|error| NodeError::IrohPeer(error.to_string()))?; + match geth_control::decode_peer_response(text)? { + PeerControlResponse::Pong { + node_id, + agent_id, + endpoint_id, + alpn: remote_alpn, + nonce: response_nonce, + note, + .. + } if response_nonce == nonce => Ok(ControlResponse::PeerPinged { + peer_node_id: node_id, + peer_agent_id: agent_id, + endpoint_id, + alpn: if remote_alpn == "unknown" { + alpn + } else { + remote_alpn + }, + note, + }), + PeerControlResponse::Pong { .. } => Err(NodeError::IrohPeer( + "peer ping response nonce did not match request".to_owned(), + )), + PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)), + } +} + +fn spawn_iroh_control_accept_loop(node: LocalNode, endpoint: GethIrohEndpoint) { + let raw_endpoint = endpoint.endpoint(); + tokio::spawn(async move { + while let Some(incoming) = raw_endpoint.accept().await { + let node = node.clone(); + tokio::spawn(async move { + if let Err(error) = handle_iroh_control_connection(node, incoming).await { + tracing::warn!(%error, "iroh control request failed"); + } + }); + } + }); +} + +async fn handle_iroh_control_connection( + node: LocalNode, + incoming: iroh::endpoint::Incoming, +) -> Result<(), NodeError> { + let conn = incoming + .await + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + let remote_endpoint_id = conn + .remote_node_id() + .map(|node_id| node_id.to_string()) + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + let alpn = conn + .alpn() + .map(display_alpn) + .unwrap_or_else(|| "unknown".to_owned()); + let (mut send, mut recv) = conn + .accept_bi() + .await + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + let bytes = recv + .read_to_end(64 * 1024) + .await + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + let text = + std::str::from_utf8(&bytes).map_err(|error| NodeError::IrohPeer(error.to_string()))?; + let response = match geth_control::decode_peer_request(text)? { + PeerControlRequest::Ping { peer_card, nonce } => { + peer_card.validate_candidate()?; + let discovered = DiscoveredPeer::candidate( + peer_card.clone(), + UnixMillis(geth_store::now_ms()), + DiscoverySource::PeerExchange, + )?; + let store = Store::open(&node.paths.metadata_db())?; + store.upsert_peer_card(&StoredPeerCard { + peer_id: peer_card.node_id.to_string(), + card_json: serde_json::to_string(&peer_card)?, + updated_at_ms: discovered.discovered_at.0, + })?; + PeerControlResponse::Pong { + node_id: node.node_id.clone(), + agent_id: node.agent_id.clone(), + endpoint_id: node.iroh_status.endpoint_id.clone().unwrap_or_default(), + remote_endpoint_id, + alpn, + nonce, + note: "peer endpoint authenticated by Iroh and peer-card signature; candidate status does not grant resource capabilities".to_owned(), + } + } + }; + send.write_all(geth_control::encode_peer_response(&response)?.as_bytes()) + .await + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + send.finish() + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + Ok(()) +} + +fn iroh_node_addr_from_candidate( + candidate: &EndpointCandidate, +) -> Result { + let node_id = candidate + .endpoint_id + .parse::() + .map_err(|error| NodeError::IrohPeer(error.to_string()))?; + let direct_addresses = candidate + .direct_addresses + .iter() + .map(|addr| { + addr.parse::() + .map_err(|error| NodeError::IrohPeer(error.to_string())) + }) + .collect::, _>>()?; + let mut node_addr = iroh::NodeAddr::new(node_id).with_direct_addresses(direct_addresses); + if let Some(relay_url) = &candidate.relay_url { + node_addr = node_addr.with_relay_url( + relay_url + .parse::() + .map_err(|error| NodeError::IrohPeer(error.to_string()))?, + ); + } + Ok(node_addr) +} + +fn display_alpn(alpn: Vec) -> String { + String::from_utf8_lossy(&alpn).into_owned() +} + pub fn handle_request( node: &LocalNode, request: ControlRequest, @@ -264,6 +526,7 @@ pub fn handle_request( vec![EndpointCandidate { endpoint_id, relay_url: None, + direct_addresses: Vec::new(), source: DiscoverySource::Manual, }], UnixMillis(geth_store::now_ms()), @@ -307,6 +570,7 @@ pub fn handle_request( note: discovery_is_untrusted_note().to_owned(), }) } + ControlRequest::PeerPing { .. } => Err(NodeError::IrohEndpointUnavailable), ControlRequest::ResourceList => Ok(ControlResponse::ResourceList { resources: store .list_resources()? @@ -1564,6 +1828,10 @@ async fn start_daemon_iroh_endpoint( store.upsert_node_endpoint(endpoint_id, &node.node_id, &node.agent_id, "iroh")?; } node.iroh_status = status; + *node + .iroh_endpoint + .lock() + .map_err(|_| NodeError::RuntimeLockPoisoned)? = Some(endpoint.clone()); Ok(Some(endpoint)) } Err(error) => { @@ -1695,3 +1963,90 @@ fn ssh_revocation_from_stored( published: stored.published, }) } + +#[cfg(test)] +mod tests { + use super::*; + + fn write_offline_iroh_config(paths: &GethPaths) { + std::fs::write( + paths.config_file(), + "[iroh]\nrelay_mode = \"disabled\"\nlocal_discovery = false\n", + ) + .expect("write config"); + } + + #[tokio::test] + async fn peer_ping_uses_signed_peer_card_over_iroh() { + let left_home = tempfile::tempdir().expect("left home"); + let right_home = tempfile::tempdir().expect("right home"); + let left_paths = GethPaths::from_home(left_home.path()); + let right_paths = GethPaths::from_home(right_home.path()); + let mut left = init_node(&left_paths).expect("init left"); + let mut right = init_node(&right_paths).expect("init right"); + write_offline_iroh_config(&left_paths); + write_offline_iroh_config(&right_paths); + + let Some(left_endpoint) = start_daemon_iroh_endpoint(&mut left) + .await + .expect("left iroh") + else { + eprintln!("skipping peer ping assertion; left Iroh endpoint unavailable"); + return; + }; + let Some(right_endpoint) = start_daemon_iroh_endpoint(&mut right) + .await + .expect("right iroh") + else { + eprintln!("skipping peer ping assertion; right Iroh endpoint unavailable"); + left_endpoint.shutdown().await; + return; + }; + spawn_iroh_control_accept_loop(right.clone(), right_endpoint.clone()); + + let exported = handle_request_async(&right, ControlRequest::PeerCardExport { out: None }) + .await + .expect("export right peer card"); + let right_card = match exported { + ControlResponse::PeerCardExported { card, .. } => card, + other => panic!("unexpected export response: {other:?}"), + }; + assert!(!right_card.endpoints[0].direct_addresses.is_empty()); + Store::open(&left_paths.metadata_db()) + .expect("open left store") + .upsert_peer_card(&StoredPeerCard { + peer_id: right_card.node_id.to_string(), + card_json: serde_json::to_string(&right_card).expect("card json"), + updated_at_ms: geth_store::now_ms(), + }) + .expect("insert right peer"); + + let ping = handle_request_async( + &left, + ControlRequest::PeerPing { + node: right_card.node_id.to_string(), + }, + ) + .await + .expect("peer ping"); + + match ping { + ControlResponse::PeerPinged { + peer_node_id, + peer_agent_id, + alpn, + note, + .. + } => { + assert_eq!(peer_node_id, right.node_id); + assert_eq!(peer_agent_id, right.agent_id); + assert_eq!(alpn, "/geth/control/1"); + assert!(note.contains("does not grant resource capabilities")); + } + other => panic!("unexpected ping response: {other:?}"), + } + + left_endpoint.shutdown().await; + right_endpoint.shutdown().await; + } +} diff --git a/crates/geth/Cargo.toml b/crates/geth/Cargo.toml index 019a651..7329970 100644 --- a/crates/geth/Cargo.toml +++ b/crates/geth/Cargo.toml @@ -26,4 +26,5 @@ geth-ssh-identity = { path = "../geth-ssh-identity" } geth-store = { path = "../geth-store" } geth-types = { path = "../geth-types" } rusqlite.workspace = true +serde_json.workspace = true tempfile.workspace = true diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 7a55eab..9eb020c 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -103,6 +103,87 @@ fn geth_status_against_running_daemon() { assert!(stdout.contains("iroh discovery: local-network disabled")); } +#[test] +fn peer_ping_uses_daemon_owned_iroh_endpoint() { + let left_home = tempfile::tempdir().expect("left tempdir"); + let right_home = tempfile::tempdir().expect("right tempdir"); + if !unix_sockets_available(left_home.path()) || !unix_sockets_available(right_home.path()) { + return; + } + assert!(run_geth(left_home.path(), &["init"]).status.success()); + assert!(run_geth(right_home.path(), &["init"]).status.success()); + for home in [left_home.path(), right_home.path()] { + std::fs::write( + home.join("config.toml"), + "[iroh]\nrelay_mode = \"disabled\"\nlocal_discovery = false\n", + ) + .expect("write config"); + } + + let mut left_daemon = spawn_daemon(left_home.path()); + let mut right_daemon = spawn_daemon(right_home.path()); + wait_for_socket(&left_home.path().join("run/geth.sock")); + wait_for_socket(&right_home.path().join("run/geth.sock")); + + let left_card = left_home.path().join("left-peer-card.json"); + let right_card = right_home.path().join("right-peer-card.json"); + let left_export = run_geth( + left_home.path(), + &["peer", "export", "--out", left_card.to_str().unwrap()], + ); + let right_export = run_geth( + right_home.path(), + &["peer", "export", "--out", right_card.to_str().unwrap()], + ); + assert!( + left_export.status.success(), + "left export stderr: {}", + String::from_utf8_lossy(&left_export.stderr) + ); + assert!( + right_export.status.success(), + "right export stderr: {}", + String::from_utf8_lossy(&right_export.stderr) + ); + let import = run_geth( + left_home.path(), + &["peer", "import", right_card.to_str().unwrap()], + ); + assert!( + import.status.success(), + "import stderr: {}", + String::from_utf8_lossy(&import.stderr) + ); + let right_card_json = + std::fs::read_to_string(&right_card).expect("read right exported peer card"); + let right_card: geth_discovery::PeerCard = + serde_json::from_str(&right_card_json).expect("decode right peer card"); + assert!( + right_card.endpoints[0] + .direct_addresses + .iter() + .any(|addr| addr.contains("127.0.0.1") || addr.contains("[::1]")) + ); + + let ping = run_geth( + left_home.path(), + &["peer", "ping", right_card.node_id.as_str()], + ); + let _ = left_daemon.kill(); + let _ = right_daemon.kill(); + let _ = left_daemon.wait(); + let _ = right_daemon.wait(); + + assert!( + ping.status.success(), + "ping stderr: {}", + String::from_utf8_lossy(&ping.stderr) + ); + let stdout = String::from_utf8_lossy(&ping.stdout); + assert!(stdout.contains("peer pong:")); + assert!(stdout.contains("candidate status does not grant resource capabilities")); +} + #[test] fn peer_card_export_import_and_list_are_candidate_only() { let source_home = tempfile::tempdir().expect("source tempdir"); diff --git a/docs/architecture.md b/docs/architecture.md index 4fa8367..214cbe4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,14 +43,19 @@ 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. `geth peer export/import/list` supports manual -exchange of signed peer cards as untrusted candidates. Automatic signed -peer-card advertisement over LAN discovery remains separate future work. +exchange of signed peer cards as untrusted candidates. Peer cards include the +Iroh EndpointID plus relay/direct address candidates when the daemon can observe +them. `geth peer ping ` dials an imported peer card over Iroh and +exchanges signed peer-card metadata. Automatic signed peer-card advertisement +over LAN discovery remains separate future work. Peer cards are the discovery payload. A peer card carries node ID, agent ID, endpoint candidates, timestamp, signing public key, and an Ed25519 signature -over a canonical payload. Imported peer cards are stored as untrusted metadata -in `peer_cards`; trust reduction is future work. `auth explain` reports when a -subject is only a discovered peer candidate and denies access. +over a canonical payload. Imported and ping-discovered peer cards are stored as +untrusted metadata in `peer_cards`; trust reduction is future work. `auth +explain` reports when a subject is only a discovered peer candidate and denies +access. The peer ping path authenticates the Iroh endpoint and peer-card +signature, but it does not authorize any resource module. The daemon starts this endpoint during `geth daemon run` and keeps it alive for the daemon lifetime. When endpoint startup succeeds, the Iroh EndpointID is diff --git a/docs/roadmap.md b/docs/roadmap.md index 93fe307..5ab906d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -98,6 +98,8 @@ geth-to-geth connections without granting trust from discovery alone. 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 discovery. - `[x]` Imported peer cards are stored only as untrusted peer candidates. @@ -125,11 +127,18 @@ geth-to-geth connections without granting trust from discovery alone. - No discovery result grants capabilities or trust. - `auth explain` can distinguish "discovered" from "trusted". -- `[ ]` Basic authenticated peer connection. +- `[~]` Basic authenticated peer connection. Acceptance criteria: - - A node can dial another node over Iroh using an EndpointID from a peer card. - - The remote side proves an agent/node binding before module access. - - Knowing only an EndpointID is insufficient to access a protected module. + - `[x]` `geth peer ping ` dials another node over Iroh using an + imported signed peer card. + - `[x]` The remote side validates the caller's signed peer card and stores it + as a candidate only. + - `[x]` The ping response records negotiated ALPN and remote endpoint + identity. + - `[ ]` The remote side proves an agent/node binding before protected module + access. + - `[ ]` Protected module handlers reject requests that only know an + EndpointID and lack resource capabilities. ## Phase 2: Trust And Authorization