Add Iroh peer ping

This commit is contained in:
Eric Wendland 2026-05-18 12:09:50 +02:00
commit 679eeb48a3
15 changed files with 619 additions and 18 deletions

View file

@ -105,9 +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, manual signed peer-card export/import/list commands, untrusted types, manual signed peer-card export/import/list commands, `geth peer ping`
discovery-backend trait, custom relay-map config, and Iroh local-network over Iroh, untrusted discovery-backend trait, custom relay-map config, and
discovery toggle exist. Iroh local-network discovery toggle exist.
- Canonical signed-operation envelopes exist for keychain/auth signature - Canonical signed-operation envelopes exist for keychain/auth signature
payloads. The keychain reducer builds an active identity view for admin keys, payloads. The keychain reducer builds an active identity view for admin keys,
users, devices, nodes, agents, and endpoint bindings. users, devices, nodes, agents, and endpoint bindings.

4
Cargo.lock generated
View file

@ -1052,6 +1052,7 @@ dependencies = [
"geth-store", "geth-store",
"geth-types", "geth-types",
"rusqlite", "rusqlite",
"serde_json",
"tempfile", "tempfile",
"tokio", "tokio",
"tracing-subscriber", "tracing-subscriber",
@ -1194,6 +1195,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"hex", "hex",
"iroh", "iroh",
"n0-watcher",
"rand_core 0.6.4", "rand_core 0.6.4",
"serde", "serde",
"tempfile", "tempfile",
@ -1243,7 +1245,9 @@ dependencies = [
"geth-ssh-identity", "geth-ssh-identity",
"geth-store", "geth-store",
"geth-types", "geth-types",
"iroh",
"serde_json", "serde_json",
"tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",

View file

@ -45,6 +45,7 @@ ed25519-dalek = { version = "2", features = ["rand_core"] }
futures = "0.3" futures = "0.3"
hex = "0.4" hex = "0.4"
iroh = { version = "0.90.0", features = ["discovery-local-network"] } iroh = { version = "0.90.0", features = ["discovery-local-network"] }
n0-watcher = "0.2"
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

@ -76,6 +76,7 @@ The bootstrap implementation provides:
- `geth peer export [--out <path>]` - `geth peer export [--out <path>]`
- `geth peer import <path>` - `geth peer import <path>`
- `geth peer list` - `geth peer list`
- `geth peer ping <node-id>`
- `geth resource list` - `geth resource list`
- `geth resource create <kind> <name>` - `geth resource create <kind> <name>`
- `geth keychain init [--admin-key <path>]` - `geth keychain init [--admin-key <path>]`
@ -116,9 +117,11 @@ The bootstrap implementation provides:
- `geth ssh revocation import <path> [--format jsonl|openssh-krl-spec]` - `geth ssh revocation import <path> [--format jsonl|openssh-krl-spec]`
- local pipe registry commands: `geth pipe listen/connect` - local pipe registry commands: `geth pipe listen/connect`
`geth peer export/import/list` is for untrusted peer-card exchange while live `geth peer export/import/list` is for untrusted peer-card exchange. Peer cards
LAN discovery and authenticated Iroh dialing are still being built. Importing a include the Iroh EndpointID plus currently known relay/direct addresses.
peer card never grants capabilities by itself. `geth peer ping <node-id>` 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`. Other command groups exist as explicit stubs: `ssh proxy`.

View file

@ -137,6 +137,9 @@ pub enum PeerCommand {
path: PathBuf, path: PathBuf,
}, },
List, List,
Ping {
node: String,
},
} }
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
@ -460,6 +463,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
PeerCommand::Export { out } => ControlRequest::PeerCardExport { out }, PeerCommand::Export { out } => ControlRequest::PeerCardExport { out },
PeerCommand::Import { path } => ControlRequest::PeerCardImport { path }, PeerCommand::Import { path } => ControlRequest::PeerCardImport { path },
PeerCommand::List => ControlRequest::PeerCardList, PeerCommand::List => ControlRequest::PeerCardList,
PeerCommand::Ping { node } => ControlRequest::PeerPing { node },
}, },
Command::Resource { Command::Resource {
command: ResourceCommand::List, command: ResourceCommand::List,
@ -802,6 +806,19 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
} }
println!("note: {note}"); 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 } => { ControlResponse::ResourceList { resources } => {
if resources.is_empty() { if resources.is_empty() {
println!("no resources"); println!("no resources");

View file

@ -28,6 +28,9 @@ pub enum ControlRequest {
path: PathBuf, path: PathBuf,
}, },
PeerCardList, PeerCardList,
PeerPing {
node: String,
},
ResourceList, ResourceList,
ResourceCreate { ResourceCreate {
kind: String, kind: String,
@ -228,6 +231,13 @@ pub enum ControlResponse {
peers: Vec<DiscoveredPeer>, peers: Vec<DiscoveredPeer>,
note: String, note: String,
}, },
PeerPinged {
peer_node_id: String,
peer_agent_id: String,
endpoint_id: String,
alpn: String,
note: String,
},
ResourceList { ResourceList {
resources: Vec<ResourceDescriptor>, resources: Vec<ResourceDescriptor>,
}, },
@ -428,6 +438,29 @@ pub struct CasBlob {
pub pinned: bool, 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)] #[derive(Debug, thiserror::Error)]
pub enum ControlError { pub enum ControlError {
#[error("json error: {0}")] #[error("json error: {0}")]
@ -454,6 +487,26 @@ pub fn decode_response(line: &str) -> Result<ControlResponse, ControlError> {
serde_json::from_str(line).map_err(ControlError::from) serde_json::from_str(line).map_err(ControlError::from)
} }
pub fn encode_peer_request(request: &PeerControlRequest) -> Result<String, ControlError> {
let mut line = serde_json::to_string(request)?;
line.push('\n');
Ok(line)
}
pub fn decode_peer_request(line: &str) -> Result<PeerControlRequest, ControlError> {
serde_json::from_str(line).map_err(ControlError::from)
}
pub fn encode_peer_response(response: &PeerControlResponse) -> Result<String, ControlError> {
let mut line = serde_json::to_string(response)?;
line.push('\n');
Ok(line)
}
pub fn decode_peer_response(line: &str) -> Result<PeerControlResponse, ControlError> {
serde_json::from_str(line).map_err(ControlError::from)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -557,5 +610,28 @@ mod tests {
decode_request(&encode_request(&request).expect("encode")).expect("decode"), decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request 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
);
} }
} }

View file

@ -107,6 +107,8 @@ struct PeerCardSigningPayload {
pub struct EndpointCandidate { pub struct EndpointCandidate {
pub endpoint_id: String, pub endpoint_id: String,
pub relay_url: Option<String>, pub relay_url: Option<String>,
#[serde(default)]
pub direct_addresses: Vec<String>,
pub source: DiscoverySource, pub source: DiscoverySource,
} }
@ -198,6 +200,7 @@ mod tests {
vec![EndpointCandidate { vec![EndpointCandidate {
endpoint_id: "endpoint:iroh".to_owned(), endpoint_id: "endpoint:iroh".to_owned(),
relay_url: None, relay_url: None,
direct_addresses: vec!["127.0.0.1:12345".to_owned()],
source: DiscoverySource::Manual, source: DiscoverySource::Manual,
}], }],
UnixMillis(1), UnixMillis(1),

View file

@ -8,9 +8,11 @@ license.workspace = true
[dependencies] [dependencies]
hex.workspace = true hex.workspace = true
iroh.workspace = true iroh.workspace = true
n0-watcher.workspace = true
rand_core.workspace = true rand_core.workspace = true
serde.workspace = true serde.workspace = true
thiserror.workspace = true thiserror.workspace = true
tokio.workspace = true
[dev-dependencies] [dev-dependencies]
tempfile.workspace = true tempfile.workspace = true

View file

@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::net::{SocketAddrV4, SocketAddrV6}; use std::net::{SocketAddrV4, SocketAddrV6};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration;
pub const ALPN_CONTROL: &[u8] = b"/geth/control/1"; pub const ALPN_CONTROL: &[u8] = b"/geth/control/1";
pub const ALPN_KV: &[u8] = b"/geth/kv/1"; pub const ALPN_KV: &[u8] = b"/geth/kv/1";
@ -203,6 +204,7 @@ impl GethRelayMode {
} }
} }
#[derive(Clone, Debug)]
pub struct GethIrohEndpoint { pub struct GethIrohEndpoint {
endpoint: iroh::Endpoint, endpoint: iroh::Endpoint,
status: EndpointStatus, status: EndpointStatus,
@ -219,11 +221,42 @@ impl GethIrohEndpoint {
self.endpoint.node_id().to_string() 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<GethNodeAddr, IrohError> {
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) { pub async fn shutdown(&self) {
self.endpoint.close().await; self.endpoint.close().await;
} }
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct GethNodeAddr {
pub endpoint_id: String,
pub relay_url: Option<String>,
pub direct_addresses: Vec<String>,
}
pub async fn start_endpoint(config: &GethIrohConfig) -> Result<GethIrohEndpoint, IrohError> { pub async fn start_endpoint(config: &GethIrohConfig) -> Result<GethIrohEndpoint, IrohError> {
let secret_key = load_or_create_secret_key(&config.secret_key_path)?; let secret_key = load_or_create_secret_key(&config.secret_key_path)?;
let mut builder = iroh::Endpoint::builder() let mut builder = iroh::Endpoint::builder()
@ -319,6 +352,10 @@ pub enum IrohError {
InvalidRelayUrl { url: String, message: String }, InvalidRelayUrl { url: String, message: String },
#[error("failed to bind iroh endpoint: {0}")] #[error("failed to bind iroh endpoint: {0}")]
Bind(Box<iroh::endpoint::BindError>), Bind(Box<iroh::endpoint::BindError>),
#[error("timed out waiting for iroh node address")]
NodeAddrTimeout,
#[error("iroh node address watcher is unavailable: {0}")]
NodeAddrUnavailable(String),
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@ -437,6 +474,9 @@ mod tests {
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); 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; endpoint.shutdown().await;
} }
Err(IrohError::Bind(error)) => { Err(IrohError::Bind(error)) => {

View file

@ -28,3 +28,7 @@ geth-secrets = { path = "../geth-secrets" }
geth-ssh-identity = { path = "../geth-ssh-identity" } geth-ssh-identity = { path = "../geth-ssh-identity" }
geth-store = { path = "../geth-store" } geth-store = { path = "../geth-store" }
geth-types = { path = "../geth-types" } geth-types = { path = "../geth-types" }
iroh.workspace = true
[dev-dependencies]
tempfile.workspace = true

View file

@ -8,7 +8,7 @@ use geth_cas::{
use geth_config::{GethConfig, GethPaths, RelayMode}; use geth_config::{GethConfig, GethPaths, RelayMode};
use geth_control::{ use geth_control::{
CasBlob, ControlRequest, ControlResponse, KeychainStatusResponse, NodeIdResponse, CasBlob, ControlRequest, ControlResponse, KeychainStatusResponse, NodeIdResponse,
StatusResponse, PeerControlRequest, PeerControlResponse, StatusResponse,
}; };
use geth_crypto::AgentKey; use geth_crypto::AgentKey;
use geth_db::DbResource; use geth_db::DbResource;
@ -112,6 +112,10 @@ pub enum NodeError {
Discovery(#[from] geth_discovery::DiscoveryError), Discovery(#[from] geth_discovery::DiscoveryError),
#[error("cannot export peer card before the daemon has an Iroh EndpointID")] #[error("cannot export peer card before the daemon has an Iroh EndpointID")]
IrohEndpointUnavailable, IrohEndpointUnavailable,
#[error("peer candidate not found: {0}")]
PeerNotFound(String),
#[error("iroh peer error: {0}")]
IrohPeer(String),
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -120,6 +124,7 @@ pub struct LocalNode {
pub agent_id: String, pub agent_id: String,
pub node_id: String, pub node_id: String,
pub iroh_status: EndpointStatus, pub iroh_status: EndpointStatus,
iroh_endpoint: Arc<Mutex<Option<GethIrohEndpoint>>>,
runtime: Arc<NodeRuntime>, runtime: Arc<NodeRuntime>,
} }
@ -165,6 +170,7 @@ pub fn init_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
agent_id, agent_id,
node_id, node_id,
iroh_status: EndpointStatus::scaffolded(), iroh_status: EndpointStatus::scaffolded(),
iroh_endpoint: Arc::new(Mutex::new(None)),
runtime: Arc::new(NodeRuntime { runtime: Arc::new(NodeRuntime {
pubsub: Mutex::new(PubsubRuntime::default()), pubsub: Mutex::new(PubsubRuntime::default()),
pipes: Mutex::new(PipeRuntime::default()), pipes: Mutex::new(PipeRuntime::default()),
@ -179,6 +185,14 @@ pub fn open_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
pub async fn run_daemon(paths: GethPaths) -> Result<(), NodeError> { pub async fn run_daemon(paths: GethPaths) -> Result<(), NodeError> {
let mut node = init_node(&paths)?; let mut node = init_node(&paths)?;
let _iroh_endpoint = start_daemon_iroh_endpoint(&mut node).await?; 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() { if Path::new(&paths.socket_path()).exists() {
std::fs::remove_file(paths.socket_path())?; std::fs::remove_file(paths.socket_path())?;
} }
@ -211,12 +225,23 @@ pub async fn send_control(
Ok(geth_control::decode_response(&line)?) Ok(geth_control::decode_response(&line)?)
} }
pub async fn handle_request_async(
node: &LocalNode,
request: ControlRequest,
) -> Result<ControlResponse, NodeError> {
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> { async fn handle_stream(node: LocalNode, stream: UnixStream) -> Result<(), NodeError> {
let mut reader = BufReader::new(stream); let mut reader = BufReader::new(stream);
let mut line = String::new(); let mut line = String::new();
reader.read_line(&mut line).await?; reader.read_line(&mut line).await?;
let request = geth_control::decode_request(&line)?; 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, Ok(response) => response,
Err(error) => ControlResponse::Error { Err(error) => ControlResponse::Error {
message: error.to_string(), message: error.to_string(),
@ -229,6 +254,243 @@ async fn handle_stream(node: LocalNode, stream: UnixStream) -> Result<(), NodeEr
Ok(()) Ok(())
} }
async fn export_peer_card(
node: &LocalNode,
out: Option<std::path::PathBuf>,
include_node_addr: bool,
) -> Result<ControlResponse, NodeError> {
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<PeerCard, NodeError> {
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<ControlResponse, NodeError> {
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<iroh::NodeAddr, NodeError> {
let node_id = candidate
.endpoint_id
.parse::<iroh::NodeId>()
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let direct_addresses = candidate
.direct_addresses
.iter()
.map(|addr| {
addr.parse::<std::net::SocketAddr>()
.map_err(|error| NodeError::IrohPeer(error.to_string()))
})
.collect::<Result<Vec<_>, _>>()?;
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::<iroh::RelayUrl>()
.map_err(|error| NodeError::IrohPeer(error.to_string()))?,
);
}
Ok(node_addr)
}
fn display_alpn(alpn: Vec<u8>) -> String {
String::from_utf8_lossy(&alpn).into_owned()
}
pub fn handle_request( pub fn handle_request(
node: &LocalNode, node: &LocalNode,
request: ControlRequest, request: ControlRequest,
@ -264,6 +526,7 @@ pub fn handle_request(
vec![EndpointCandidate { vec![EndpointCandidate {
endpoint_id, endpoint_id,
relay_url: None, relay_url: None,
direct_addresses: Vec::new(),
source: DiscoverySource::Manual, source: DiscoverySource::Manual,
}], }],
UnixMillis(geth_store::now_ms()), UnixMillis(geth_store::now_ms()),
@ -307,6 +570,7 @@ pub fn handle_request(
note: discovery_is_untrusted_note().to_owned(), note: discovery_is_untrusted_note().to_owned(),
}) })
} }
ControlRequest::PeerPing { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::ResourceList => Ok(ControlResponse::ResourceList { ControlRequest::ResourceList => Ok(ControlResponse::ResourceList {
resources: store resources: store
.list_resources()? .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")?; store.upsert_node_endpoint(endpoint_id, &node.node_id, &node.agent_id, "iroh")?;
} }
node.iroh_status = status; node.iroh_status = status;
*node
.iroh_endpoint
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)? = Some(endpoint.clone());
Ok(Some(endpoint)) Ok(Some(endpoint))
} }
Err(error) => { Err(error) => {
@ -1695,3 +1963,90 @@ fn ssh_revocation_from_stored(
published: stored.published, 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;
}
}

View file

@ -26,4 +26,5 @@ geth-ssh-identity = { path = "../geth-ssh-identity" }
geth-store = { path = "../geth-store" } geth-store = { path = "../geth-store" }
geth-types = { path = "../geth-types" } geth-types = { path = "../geth-types" }
rusqlite.workspace = true rusqlite.workspace = true
serde_json.workspace = true
tempfile.workspace = true tempfile.workspace = true

View file

@ -103,6 +103,87 @@ fn geth_status_against_running_daemon() {
assert!(stdout.contains("iroh discovery: local-network disabled")); 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] #[test]
fn peer_card_export_import_and_list_are_candidate_only() { fn peer_card_export_import_and_list_are_candidate_only() {
let source_home = tempfile::tempdir().expect("source tempdir"); let source_home = tempfile::tempdir().expect("source tempdir");

View file

@ -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 The current daemon can enable Iroh's local-network discovery service through
`[iroh].local_discovery = true`, which is the default. This publishes and `[iroh].local_discovery = true`, which is the default. This publishes and
discovers Iroh node addressing. `geth peer export/import/list` supports manual discovers Iroh node addressing. `geth peer export/import/list` supports manual
exchange of signed peer cards as untrusted candidates. Automatic signed exchange of signed peer cards as untrusted candidates. Peer cards include the
peer-card advertisement over LAN discovery remains separate future work. Iroh EndpointID plus relay/direct address candidates when the daemon can observe
them. `geth peer ping <node-id>` 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, Peer cards are the discovery payload. A peer card carries node ID, agent ID,
endpoint candidates, timestamp, signing public key, and an Ed25519 signature endpoint candidates, timestamp, signing public key, and an Ed25519 signature
over a canonical payload. Imported peer cards are stored as untrusted metadata over a canonical payload. Imported and ping-discovered peer cards are stored as
in `peer_cards`; trust reduction is future work. `auth explain` reports when a untrusted metadata in `peer_cards`; trust reduction is future work. `auth
subject is only a discovered peer candidate and denies access. 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 starts this endpoint during `geth daemon run` and keeps it alive for
the daemon lifetime. When endpoint startup succeeds, the Iroh EndpointID is the daemon lifetime. When endpoint startup succeeds, the Iroh EndpointID is

View file

@ -98,6 +98,8 @@ geth-to-geth connections without granting trust from discovery alone.
Acceptance criteria: Acceptance criteria:
- `[x]` Manual `geth peer export/import/list` can exchange signed peer cards - `[x]` Manual `geth peer export/import/list` can exchange signed peer cards
and store them as untrusted candidates. 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 - `[ ]` The daemon can advertise and discover signed geth peer cards over LAN
discovery. discovery.
- `[x]` Imported peer cards are stored only as untrusted peer candidates. - `[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. - No discovery result grants capabilities or trust.
- `auth explain` can distinguish "discovered" from "trusted". - `auth explain` can distinguish "discovered" from "trusted".
- `[ ]` Basic authenticated peer connection. - `[~]` Basic authenticated peer connection.
Acceptance criteria: Acceptance criteria:
- A node can dial another node over Iroh using an EndpointID from a peer card. - `[x]` `geth peer ping <node-id>` dials another node over Iroh using an
- The remote side proves an agent/node binding before module access. imported signed peer card.
- Knowing only an EndpointID is insufficient to access a protected module. - `[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 ## Phase 2: Trust And Authorization