Add daemon-owned Iroh endpoint

This commit is contained in:
Eric Wendland 2026-05-16 01:54:00 +02:00
commit ebc40ed208
14 changed files with 3930 additions and 431 deletions

View file

@ -102,8 +102,9 @@ Roadmap items should be actionable and checkable:
## Current Feature Boundaries ## Current Feature Boundaries
- Local daemon, local control socket, local identity, local store, local CAS, - Local daemon, local control socket, local identity, local store, local CAS,
SSH certificate metadata, revocation metadata, and user service definitions daemon-owned Iroh endpoint startup, SSH certificate metadata, revocation
metadata, user service definitions, and a pinned `geth-iroh` endpoint wrapper
exist. exist.
- Iroh, cr-sqlite, iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth - Peer auth over Iroh, cr-sqlite, iroh-docs, iroh-gossip, iroh-blobs, Automerge
enforcement, OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are sync, real auth enforcement, OpenSSH KRL generation, and Keyhive/BeeKEM-style
future roadmap items unless implemented later. authorization are future roadmap items unless implemented later.

3259
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -44,6 +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"
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

@ -503,6 +503,10 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
println!("socket: {}", status.socket.display()); println!("socket: {}", status.socket.display());
println!("agent: {}", status.agent_id); println!("agent: {}", status.agent_id);
println!("node: {}", status.node_id); println!("node: {}", status.node_id);
println!(
"endpoint: {}",
status.endpoint_id.as_deref().unwrap_or("not started")
);
println!("iroh: {}", status.iroh); println!("iroh: {}", status.iroh);
} }
ControlResponse::NodeId(node) => { ControlResponse::NodeId(node) => {

View file

@ -49,6 +49,11 @@ impl GethPaths {
self.identity_dir().join("agent.ed25519") self.identity_dir().join("agent.ed25519")
} }
#[must_use]
pub fn iroh_key(&self) -> PathBuf {
self.identity_dir().join("iroh.ed25519")
}
#[must_use] #[must_use]
pub fn cas_dir(&self) -> PathBuf { pub fn cas_dir(&self) -> PathBuf {
self.home.join("cas") self.home.join("cas")

View file

@ -146,6 +146,8 @@ pub struct StatusResponse {
pub socket: PathBuf, pub socket: PathBuf,
pub agent_id: String, pub agent_id: String,
pub node_id: String, pub node_id: String,
pub iroh_enabled: bool,
pub endpoint_id: Option<String>,
pub iroh: String, pub iroh: String,
} }

View file

@ -6,4 +6,12 @@ rust-version.workspace = true
license.workspace = true license.workspace = true
[dependencies] [dependencies]
hex.workspace = true
iroh.workspace = true
rand_core.workspace = true
serde.workspace = true serde.workspace = true
thiserror.workspace = true
[dev-dependencies]
tempfile.workspace = true
tokio.workspace = true

View file

@ -1,4 +1,6 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::net::{SocketAddrV4, SocketAddrV6};
use std::path::{Path, PathBuf};
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";
@ -9,6 +11,134 @@ pub const ALPN_DB: &[u8] = b"/geth/db/1";
pub const ALPN_DOCUMENT: &[u8] = b"/geth/document/1"; pub const ALPN_DOCUMENT: &[u8] = b"/geth/document/1";
pub const ALPN_SSH_PROXY: &[u8] = b"/geth/ssh-proxy/1"; pub const ALPN_SSH_PROXY: &[u8] = b"/geth/ssh-proxy/1";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct GethIrohConfig {
pub secret_key_path: PathBuf,
pub relay_mode: GethRelayMode,
pub bind_ipv4: Option<SocketAddrV4>,
pub bind_ipv6: Option<SocketAddrV6>,
pub alpns: Vec<Vec<u8>>,
}
impl GethIrohConfig {
#[must_use]
pub fn local(secret_key_path: impl Into<PathBuf>) -> Self {
Self {
secret_key_path: secret_key_path.into(),
relay_mode: GethRelayMode::Disabled,
bind_ipv4: None,
bind_ipv6: None,
alpns: all_alpns(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum GethRelayMode {
Disabled,
Default,
Staging,
}
impl GethRelayMode {
fn to_iroh(&self) -> iroh::RelayMode {
match self {
Self::Disabled => iroh::RelayMode::Disabled,
Self::Default => iroh::RelayMode::Default,
Self::Staging => iroh::RelayMode::Staging,
}
}
}
pub struct GethIrohEndpoint {
endpoint: iroh::Endpoint,
status: EndpointStatus,
}
impl GethIrohEndpoint {
#[must_use]
pub fn status(&self) -> EndpointStatus {
self.status.clone()
}
#[must_use]
pub fn node_id(&self) -> String {
self.endpoint.node_id().to_string()
}
pub async fn shutdown(&self) {
self.endpoint.close().await;
}
}
pub async fn start_endpoint(config: &GethIrohConfig) -> Result<GethIrohEndpoint, IrohError> {
let secret_key = load_or_create_secret_key(&config.secret_key_path)?;
let mut builder = iroh::Endpoint::builder()
.secret_key(secret_key)
.relay_mode(config.relay_mode.to_iroh())
.alpns(config.alpns.clone());
if let Some(bind_ipv4) = config.bind_ipv4 {
builder = builder.bind_addr_v4(bind_ipv4);
}
if let Some(bind_ipv6) = config.bind_ipv6 {
builder = builder.bind_addr_v6(bind_ipv6);
}
let endpoint = builder.bind().await.map_err(IrohError::from)?;
let status = EndpointStatus {
enabled: true,
endpoint_id: Some(endpoint.node_id().to_string()),
note: format!(
"Iroh endpoint started with iroh 0.90.0; relay mode: {:?}",
config.relay_mode
),
};
Ok(GethIrohEndpoint { endpoint, status })
}
pub fn load_or_create_secret_key(path: &Path) -> Result<iroh::SecretKey, IrohError> {
if path.exists() {
return load_secret_key(path);
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let secret_key = iroh::SecretKey::generate(rand_core::OsRng);
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, hex::encode(secret_key.to_bytes()))?;
std::fs::rename(tmp, path)?;
Ok(secret_key)
}
pub fn load_secret_key(path: &Path) -> Result<iroh::SecretKey, IrohError> {
let encoded = std::fs::read_to_string(path)?;
let bytes = hex::decode(encoded.trim())?;
let bytes: [u8; 32] = bytes
.try_into()
.map_err(|_| IrohError::InvalidSecretKeyLength)?;
Ok(iroh::SecretKey::from_bytes(&bytes))
}
#[must_use]
pub fn all_alpns() -> Vec<Vec<u8>> {
[
ALPN_CONTROL,
ALPN_KV,
ALPN_CAS,
ALPN_PUBSUB,
ALPN_PIPE,
ALPN_DB,
ALPN_DOCUMENT,
ALPN_SSH_PROXY,
]
.into_iter()
.map(<[u8]>::to_vec)
.collect()
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EndpointStatus { pub struct EndpointStatus {
pub enabled: bool, pub enabled: bool,
@ -22,7 +152,66 @@ impl EndpointStatus {
Self { Self {
enabled: false, enabled: false,
endpoint_id: None, endpoint_id: None,
note: "Iroh endpoint integration is scaffolded for a later pinned API pass".to_owned(), note: "Iroh endpoint is not running outside daemon mode".to_owned(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum IrohError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("invalid hex iroh secret key: {0}")]
Hex(#[from] hex::FromHexError),
#[error("invalid iroh secret key length")]
InvalidSecretKeyLength,
#[error("failed to bind iroh endpoint: {0}")]
Bind(Box<iroh::endpoint::BindError>),
}
impl From<iroh::endpoint::BindError> for IrohError {
fn from(error: iroh::endpoint::BindError) -> Self {
Self::Bind(Box::new(error))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_alpns_contains_expected_protocols() {
let alpns = all_alpns();
assert!(alpns.contains(&ALPN_CONTROL.to_vec()));
assert!(alpns.contains(&ALPN_CAS.to_vec()));
assert!(alpns.contains(&ALPN_SSH_PROXY.to_vec()));
assert_eq!(alpns.len(), 8);
}
#[test]
fn iroh_secret_key_persists() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("iroh.ed25519");
let first = load_or_create_secret_key(&path).expect("create key");
let second = load_or_create_secret_key(&path).expect("load key");
assert_eq!(first.public(), second.public());
}
#[tokio::test]
async fn endpoint_status_tracks_node_id_when_bind_is_available() {
let dir = tempfile::tempdir().expect("tempdir");
let config = GethIrohConfig::local(dir.path().join("iroh.ed25519"));
match start_endpoint(&config).await {
Ok(endpoint) => {
let status = endpoint.status();
assert!(status.enabled);
assert_eq!(status.endpoint_id, Some(endpoint.node_id()));
endpoint.shutdown().await;
}
Err(IrohError::Bind(error)) => {
eprintln!("skipping endpoint bind assertion; UDP unavailable: {error}");
}
Err(error) => panic!("unexpected iroh startup error: {error}"),
} }
} }
} }

View file

@ -15,6 +15,7 @@ geth-cas = { path = "../geth-cas" }
geth-config = { path = "../geth-config" } geth-config = { path = "../geth-config" }
geth-control = { path = "../geth-control" } geth-control = { path = "../geth-control" }
geth-crypto = { path = "../geth-crypto" } geth-crypto = { path = "../geth-crypto" }
geth-iroh = { path = "../geth-iroh" }
geth-resource = { path = "../geth-resource" } geth-resource = { path = "../geth-resource" }
geth-ssh-identity = { path = "../geth-ssh-identity" } geth-ssh-identity = { path = "../geth-ssh-identity" }
geth-store = { path = "../geth-store" } geth-store = { path = "../geth-store" }

View file

@ -8,6 +8,7 @@ use geth_control::{
StatusResponse, StatusResponse,
}; };
use geth_crypto::AgentKey; use geth_crypto::AgentKey;
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint};
use geth_resource::ResourceDescriptor; use geth_resource::ResourceDescriptor;
use geth_ssh_identity::{ use geth_ssh_identity::{
SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord, SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord,
@ -61,6 +62,7 @@ pub struct LocalNode {
pub paths: GethPaths, pub paths: GethPaths,
pub agent_id: String, pub agent_id: String,
pub node_id: String, pub node_id: String,
pub iroh_status: EndpointStatus,
} }
pub fn init_node(paths: &GethPaths) -> Result<LocalNode, NodeError> { pub fn init_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
@ -87,6 +89,7 @@ pub fn init_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
paths: paths.clone(), paths: paths.clone(),
agent_id, agent_id,
node_id, node_id,
iroh_status: EndpointStatus::scaffolded(),
}) })
} }
@ -95,7 +98,8 @@ 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 node = init_node(&paths)?; let mut node = init_node(&paths)?;
let _iroh_endpoint = start_daemon_iroh_endpoint(&mut node).await?;
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())?;
} }
@ -157,12 +161,14 @@ pub fn handle_request(
socket: node.paths.socket_path(), socket: node.paths.socket_path(),
agent_id: node.agent_id.clone(), agent_id: node.agent_id.clone(),
node_id: node.node_id.clone(), node_id: node.node_id.clone(),
iroh: "scaffolded; no remote endpoint is started in bootstrap".to_owned(), iroh_enabled: node.iroh_status.enabled,
endpoint_id: node.iroh_status.endpoint_id.clone(),
iroh: node.iroh_status.note.clone(),
})), })),
ControlRequest::NodeId => Ok(ControlResponse::NodeId(NodeIdResponse { ControlRequest::NodeId => Ok(ControlResponse::NodeId(NodeIdResponse {
agent_id: node.agent_id.clone(), agent_id: node.agent_id.clone(),
node_id: node.node_id.clone(), node_id: node.node_id.clone(),
endpoint_id: None, endpoint_id: node.iroh_status.endpoint_id.clone(),
})), })),
ControlRequest::ResourceList => Ok(ControlResponse::ResourceList { ControlRequest::ResourceList => Ok(ControlResponse::ResourceList {
resources: store resources: store
@ -438,6 +444,31 @@ fn stable_node_id(agent_id: &str) -> String {
format!("node:{agent_id}") format!("node:{agent_id}")
} }
async fn start_daemon_iroh_endpoint(
node: &mut LocalNode,
) -> Result<Option<GethIrohEndpoint>, NodeError> {
let config = GethIrohConfig::local(node.paths.iroh_key());
match geth_iroh::start_endpoint(&config).await {
Ok(endpoint) => {
let status = endpoint.status();
if let Some(endpoint_id) = &status.endpoint_id {
let store = Store::open(&node.paths.metadata_db())?;
store.upsert_node_endpoint(endpoint_id, &node.node_id, &node.agent_id, "iroh")?;
}
node.iroh_status = status;
Ok(Some(endpoint))
}
Err(error) => {
node.iroh_status = EndpointStatus {
enabled: false,
endpoint_id: None,
note: format!("Iroh endpoint failed to start: {error}"),
};
Ok(None)
}
}
}
fn expected_openssh_cert_path(public_key_path: &Path) -> String { fn expected_openssh_cert_path(public_key_path: &Path) -> String {
let text = public_key_path.display().to_string(); let text = public_key_path.display().to_string();
if let Some(prefix) = text.strip_suffix(".pub") { if let Some(prefix) = text.strip_suffix(".pub") {

View file

@ -47,6 +47,13 @@ impl Store {
agent_id TEXT, agent_id TEXT,
created_at_ms INTEGER NOT NULL created_at_ms INTEGER NOT NULL
); );
CREATE TABLE IF NOT EXISTS node_endpoints (
endpoint_id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
transport TEXT NOT NULL,
created_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS resources ( CREATE TABLE IF NOT EXISTS resources (
resource_id TEXT PRIMARY KEY, resource_id TEXT PRIMARY KEY,
kind TEXT NOT NULL, kind TEXT NOT NULL,
@ -168,6 +175,20 @@ impl Store {
Ok(()) Ok(())
} }
pub fn upsert_node_endpoint(
&self,
endpoint_id: &str,
node_id: &str,
agent_id: &str,
transport: &str,
) -> Result<(), StoreError> {
self.conn.execute(
"INSERT OR IGNORE INTO node_endpoints(endpoint_id, node_id, agent_id, transport, created_at_ms) VALUES (?1, ?2, ?3, ?4, ?5)",
params![endpoint_id, node_id, agent_id, transport, now_ms()],
)?;
Ok(())
}
pub fn list_resources(&self) -> Result<Vec<StoredResource>, StoreError> { pub fn list_resources(&self) -> Result<Vec<StoredResource>, StoreError> {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
"SELECT resource_id, kind, name, status FROM resources ORDER BY kind, name, resource_id", "SELECT resource_id, kind, name, status FROM resources ORDER BY kind, name, resource_id",

View file

@ -83,6 +83,7 @@ fn geth_status_against_running_daemon() {
let stdout = String::from_utf8_lossy(&output.stdout); let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("geth daemon: running")); assert!(stdout.contains("geth daemon: running"));
assert!(stdout.contains("agent:")); assert!(stdout.contains("agent:"));
assert!(stdout.contains("endpoint:"));
} }
#[test] #[test]

View file

@ -18,6 +18,20 @@ Remote geth node-to-node communication is Iroh-only. The daemon will own one
shared Iroh endpoint and register module protocols on ALPNs such as shared Iroh endpoint and register module protocols on ALPNs such as
`/geth/cas/1`, `/geth/kv/1`, `/geth/pipe/1`, and `/geth/ssh-proxy/1`. `/geth/cas/1`, `/geth/kv/1`, `/geth/pipe/1`, and `/geth/ssh-proxy/1`.
The first pinned Iroh integration uses `iroh = 0.90.0`, because newer
Rust-1.85-compatible candidates in the 0.93-0.95 range failed to compile through
a transitive `ed25519-dalek` prerelease dependency. `geth-iroh` wraps
`iroh::Endpoint::builder()`, configures geth ALPNs with `Builder::alpns`, uses
`Builder::relay_mode`, persists an `iroh::SecretKey` as hex-encoded 32-byte key
material, and shuts down through `Endpoint::close().await`. Relay mode defaults
to disabled until daemon policy and discovery are implemented.
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
recorded as a transport binding for the stable geth node identity. If local UDP
binding is unavailable, the daemon keeps local control running and reports the
Iroh startup error through status output.
SSH keys are not transport keys. They are admin trust anchors and signing SSH keys are not transport keys. They are admin trust anchors and signing
identities for keychain and authorization operations. SSH proxying, when added, identities for keychain and authorization operations. SSH proxying, when added,
will carry SSH bytes over an authorized Iroh stream and will not make SSH a geth will carry SSH bytes over an authorized Iroh stream and will not make SSH a geth

View file

@ -60,14 +60,14 @@ control, local CAS, service installation, and written architecture decisions.
Goal: make the daemon own a real Iroh endpoint and establish authenticated Goal: make the daemon own a real Iroh endpoint and establish authenticated
geth-to-geth connections without granting trust from discovery alone. geth-to-geth connections without granting trust from discovery alone.
- `[ ]` Pin and compile Iroh dependencies. - `[x]` Pin and compile Iroh dependencies.
Acceptance criteria: Acceptance criteria:
- `iroh` is added only after APIs are pinned and `cargo check --workspace` - `iroh` is added only after APIs are pinned and `cargo check --workspace`
passes. passes.
- `geth-iroh` exposes endpoint startup/shutdown wrappers. - `geth-iroh` exposes endpoint startup/shutdown wrappers.
- Docs note exact crate versions and any API assumptions. - Docs note exact crate versions and any API assumptions.
- `[ ]` Daemon-owned Iroh endpoint. - `[x]` Daemon-owned Iroh endpoint.
Acceptance criteria: Acceptance criteria:
- The daemon creates one shared Iroh endpoint on startup. - The daemon creates one shared Iroh endpoint on startup.
- `geth status --json` reports endpoint status and EndpointID. - `geth status --json` reports endpoint status and EndpointID.