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

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

View file

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

View file

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

View file

@ -6,4 +6,12 @@ rust-version.workspace = true
license.workspace = true
[dependencies]
hex.workspace = true
iroh.workspace = true
rand_core.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 std::net::{SocketAddrV4, SocketAddrV6};
use std::path::{Path, PathBuf};
pub const ALPN_CONTROL: &[u8] = b"/geth/control/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_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)]
pub struct EndpointStatus {
pub enabled: bool,
@ -22,7 +152,66 @@ impl EndpointStatus {
Self {
enabled: false,
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-control = { path = "../geth-control" }
geth-crypto = { path = "../geth-crypto" }
geth-iroh = { path = "../geth-iroh" }
geth-resource = { path = "../geth-resource" }
geth-ssh-identity = { path = "../geth-ssh-identity" }
geth-store = { path = "../geth-store" }

View file

@ -8,6 +8,7 @@ use geth_control::{
StatusResponse,
};
use geth_crypto::AgentKey;
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint};
use geth_resource::ResourceDescriptor;
use geth_ssh_identity::{
SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord,
@ -61,6 +62,7 @@ pub struct LocalNode {
pub paths: GethPaths,
pub agent_id: String,
pub node_id: String,
pub iroh_status: EndpointStatus,
}
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(),
agent_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> {
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() {
std::fs::remove_file(paths.socket_path())?;
}
@ -157,12 +161,14 @@ pub fn handle_request(
socket: node.paths.socket_path(),
agent_id: node.agent_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 {
agent_id: node.agent_id.clone(),
node_id: node.node_id.clone(),
endpoint_id: None,
endpoint_id: node.iroh_status.endpoint_id.clone(),
})),
ControlRequest::ResourceList => Ok(ControlResponse::ResourceList {
resources: store
@ -438,6 +444,31 @@ fn stable_node_id(agent_id: &str) -> String {
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 {
let text = public_key_path.display().to_string();
if let Some(prefix) = text.strip_suffix(".pub") {

View file

@ -47,6 +47,13 @@ impl Store {
agent_id TEXT,
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 (
resource_id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
@ -168,6 +175,20 @@ impl Store {
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> {
let mut stmt = self.conn.prepare(
"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);
assert!(stdout.contains("geth daemon: running"));
assert!(stdout.contains("agent:"));
assert!(stdout.contains("endpoint:"));
}
#[test]