Upgrade Iroh native backend foundation

This commit is contained in:
Eric Wendland 2026-05-22 15:28:52 +02:00
commit 9d46dd4d0d
11 changed files with 1588 additions and 712 deletions

1991
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -42,13 +42,19 @@ blake3 = "1"
bytes = "1"
clap = { version = "4", features = ["derive", "env"] }
directories = "5"
# Compatibility pins for iroh 0.95's ed25519-dalek prerelease dependency.
ed25519 = "=3.0.0-rc.1"
ed25519-dalek = { version = "2", features = ["rand_core"] }
pkcs8 = "=0.11.0-rc.11"
futures = "0.3"
hex = "0.4"
hexane = "=0.1.5"
iroh = { version = "0.90.0", features = ["discovery-local-network"] }
n0-watcher = "0.2"
iroh = { version = "0.95.1", features = ["discovery-local-network"] }
iroh-blobs = "0.97.0"
iroh-docs = "0.95.0"
iroh-gossip = "0.95.0"
postcard = { version = "1", features = ["alloc"] }
rand = "0.9"
rand_core = { version = "0.6", features = ["getrandom"] }
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }

View file

@ -198,13 +198,12 @@ has `cas.fetch` on `resource:cas:local`, and the caller verifies that the bytes
hash to the requested BLAKE3 CAS hash before storing them locally. Successful
fetches record the serving peer as a local provider, visible with
`geth cas providers <hash>`. This is the bootstrap transfer path; future work
will move provider/fetch behavior to `iroh-blobs`. `geth status` currently
reports the native backend blocker for CAS, KV, and pubsub: the daemon endpoint
is pinned to `iroh 0.90.0`, while the Rust-1.85-compatible backend crates
resolved from crates.io are `iroh-blobs 0.97.0`, `iroh-docs 0.95.0`, and
`iroh-gossip 0.95.0`, all of which require `iroh 0.95`. These cannot be wired
to the daemon-owned endpoint until the endpoint wrapper is upgraded in one
coordinated step.
will move provider/fetch behavior to `iroh-blobs`. `geth-iroh` is now pinned to
`iroh 0.95.1` and compiles the native backend libraries `iroh-blobs 0.97.0`,
`iroh-docs 0.95.0`, and `iroh-gossip 0.95.0` against the same daemon-owned
endpoint generation. `geth status` reports those backends as ready to wire; the
module implementations still use the explicit bootstrap control path until the
module-specific migrations replace it.
Remote resource commands that accept `--bearer-secret` can also authorize with a
resource-scoped bearer proof generated from the private bearer token returned at
creation time. The persisted auth log stores a public bearer id and token

View file

@ -1534,12 +1534,14 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
backend.target_version,
backend.status
);
if !backend.blocker.is_empty() {
println!(
"native backend {} blocker: {}",
"native backend {} note: {}",
backend.module, backend.blocker
);
}
}
}
ControlResponse::NodeId(node) => {
println!("agent: {}", node.agent_id);
println!("node: {}", node.node_id);

View file

@ -7,9 +7,13 @@ license.workspace = true
[dependencies]
hex.workspace = true
ed25519.workspace = true
pkcs8.workspace = true
iroh.workspace = true
n0-watcher.workspace = true
rand_core.workspace = true
iroh-blobs.workspace = true
iroh-docs.workspace = true
iroh-gossip.workspace = true
rand.workspace = true
serde.workspace = true
thiserror.workspace = true
tokio.workspace = true

View file

@ -2,7 +2,6 @@ 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";
@ -13,6 +12,11 @@ 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";
pub const IROH_VERSION: &str = "0.95.1";
pub const IROH_BLOBS_VERSION: &str = "0.97.0";
pub const IROH_DOCS_VERSION: &str = "0.95.0";
pub const IROH_GOSSIP_VERSION: &str = "0.95.0";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct GethIrohConfig {
pub secret_key_path: PathBuf,
@ -157,6 +161,38 @@ pub fn default_protocol_descriptors() -> Vec<ProtocolDescriptor> {
]
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NativeIrohLibrary {
pub module: String,
pub crate_name: String,
pub crate_version: String,
pub alpn: String,
}
#[must_use]
pub fn native_iroh_libraries() -> Vec<NativeIrohLibrary> {
vec![
NativeIrohLibrary {
module: "cas".to_owned(),
crate_name: "iroh-blobs".to_owned(),
crate_version: IROH_BLOBS_VERSION.to_owned(),
alpn: display_alpn(iroh_blobs::ALPN),
},
NativeIrohLibrary {
module: "kv".to_owned(),
crate_name: "iroh-docs".to_owned(),
crate_version: IROH_DOCS_VERSION.to_owned(),
alpn: display_alpn(iroh_docs::ALPN),
},
NativeIrohLibrary {
module: "pubsub".to_owned(),
crate_name: "iroh-gossip".to_owned(),
crate_version: IROH_GOSSIP_VERSION.to_owned(),
alpn: display_alpn(iroh_gossip::ALPN),
},
]
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum GethRelayMode {
@ -218,7 +254,7 @@ impl GethIrohEndpoint {
#[must_use]
pub fn node_id(&self) -> String {
self.endpoint.node_id().to_string()
self.endpoint.id().to_string()
}
#[must_use]
@ -227,19 +263,12 @@ impl GethIrohEndpoint {
}
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()))?;
let endpoint_addr = self.endpoint.addr();
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()
endpoint_id: endpoint_addr.id.to_string(),
relay_url: endpoint_addr.relay_urls().next().map(ToString::to_string),
direct_addresses: endpoint_addr
.ip_addrs()
.map(|addr| addr.to_string())
.collect(),
})
@ -265,7 +294,7 @@ pub async fn start_endpoint(config: &GethIrohConfig) -> Result<GethIrohEndpoint,
.alpns(config.alpns.clone());
if config.local_discovery {
builder = builder.discovery_local_network();
builder = builder.discovery(iroh::discovery::mdns::MdnsDiscovery::builder());
}
if let Some(bind_ipv4) = config.bind_ipv4 {
@ -278,12 +307,17 @@ pub async fn start_endpoint(config: &GethIrohConfig) -> Result<GethIrohEndpoint,
let endpoint = builder.bind().await.map_err(IrohError::from)?;
let status = EndpointStatus {
enabled: true,
endpoint_id: Some(endpoint.node_id().to_string()),
endpoint_id: Some(endpoint.id().to_string()),
relay_mode: config.relay_mode.label(),
local_discovery: config.local_discovery,
note: format!(
"Iroh endpoint started with iroh 0.90.0; relay mode: {:?}; local discovery: {}",
config.relay_mode, config.local_discovery
"Iroh endpoint started with iroh {}; relay mode: {:?}; local discovery: {}; native libraries: iroh-blobs {}, iroh-docs {}, iroh-gossip {}",
IROH_VERSION,
config.relay_mode,
config.local_discovery,
IROH_BLOBS_VERSION,
IROH_DOCS_VERSION,
IROH_GOSSIP_VERSION
),
};
Ok(GethIrohEndpoint { endpoint, status })
@ -297,7 +331,7 @@ pub fn load_or_create_secret_key(path: &Path) -> Result<iroh::SecretKey, IrohErr
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let secret_key = iroh::SecretKey::generate(rand_core::OsRng);
let secret_key = iroh::SecretKey::generate(&mut rand::rng());
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, hex::encode(secret_key.to_bytes()))?;
std::fs::rename(tmp, path)?;
@ -389,6 +423,30 @@ mod tests {
assert_eq!(alpns.len(), 8);
}
#[test]
fn native_iroh_libraries_are_pinned_and_expose_alpns() {
let libraries = native_iroh_libraries();
assert_eq!(libraries.len(), 3);
assert!(libraries.iter().any(|library| {
library.module == "cas"
&& library.crate_name == "iroh-blobs"
&& library.crate_version == IROH_BLOBS_VERSION
&& library.alpn == "/iroh-bytes/4"
}));
assert!(libraries.iter().any(|library| {
library.module == "kv"
&& library.crate_name == "iroh-docs"
&& library.crate_version == IROH_DOCS_VERSION
&& library.alpn == "/iroh-sync/1"
}));
assert!(libraries.iter().any(|library| {
library.module == "pubsub"
&& library.crate_name == "iroh-gossip"
&& library.crate_version == IROH_GOSSIP_VERSION
&& library.alpn == "/iroh-gossip/1"
}));
}
#[test]
fn default_router_registers_all_protocols() {
let router = default_protocol_router();

View file

@ -1045,10 +1045,7 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result<ControlResponse,
.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 alpn = display_alpn(conn.alpn());
let (mut send, mut recv) = conn
.open_bi()
.await
@ -2178,14 +2175,16 @@ async fn handle_local_pipe_tcp_stream(
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
remote_send
.finish()
.map_err(|error| NodeError::IrohPeer(error.to_string()))
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
Ok::<(), NodeError>(())
};
let mut remote_recv = remote_recv;
let download = async {
tokio::io::copy(&mut remote_recv, &mut local_write)
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
local_write.shutdown().await.map_err(NodeError::from)
local_write.shutdown().await.map_err(NodeError::from)?;
Ok::<(), NodeError>(())
};
tokio::try_join!(upload, download)?;
Ok(())
@ -2301,14 +2300,16 @@ async fn handle_local_pipe_unix_stream(
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
remote_send
.finish()
.map_err(|error| NodeError::IrohPeer(error.to_string()))
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
Ok::<(), NodeError>(())
};
let mut remote_recv = remote_recv;
let download = async {
tokio::io::copy(&mut remote_recv, &mut local_write)
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
local_write.shutdown().await.map_err(NodeError::from)
local_write.shutdown().await.map_err(NodeError::from)?;
Ok::<(), NodeError>(())
};
tokio::try_join!(upload, download)?;
Ok(())
@ -2511,14 +2512,16 @@ async fn handle_local_ssh_proxy_stream(
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
remote_send
.finish()
.map_err(|error| NodeError::IrohPeer(error.to_string()))
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
Ok::<(), NodeError>(())
};
let mut remote_recv = remote_recv;
let download = async {
tokio::io::copy(&mut remote_recv, &mut local_write)
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
local_write.shutdown().await.map_err(NodeError::from)
local_write.shutdown().await.map_err(NodeError::from)?;
Ok::<(), NodeError>(())
};
tokio::try_join!(upload, download)?;
Ok(())
@ -4437,22 +4440,16 @@ async fn handle_iroh_control_connection(
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 remote_endpoint_id = conn.remote_id().to_string();
let alpn = display_alpn(conn.alpn());
let (mut send, mut recv) = conn
.accept_bi()
.await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
if alpn == display_alpn(geth_iroh::ALPN_SSH_PROXY.to_vec()) {
if alpn == display_alpn(geth_iroh::ALPN_SSH_PROXY) {
return handle_ssh_proxy_wire_connection(node, remote_endpoint_id, send, recv).await;
}
if alpn == display_alpn(geth_iroh::ALPN_PIPE.to_vec()) {
if alpn == display_alpn(geth_iroh::ALPN_PIPE) {
return handle_pipe_wire_connection(node, remote_endpoint_id, send, recv).await;
}
let bytes = recv
@ -5946,10 +5943,10 @@ async fn handle_pipe_unix_wire_connection(
fn iroh_node_addr_from_candidate(
candidate: &EndpointCandidate,
) -> Result<iroh::NodeAddr, NodeError> {
let node_id = candidate
) -> Result<iroh::EndpointAddr, NodeError> {
let endpoint_id = candidate
.endpoint_id
.parse::<iroh::NodeId>()
.parse::<iroh::EndpointId>()
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let direct_addresses = candidate
.direct_addresses
@ -5959,7 +5956,10 @@ fn iroh_node_addr_from_candidate(
.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);
let mut node_addr = iroh::EndpointAddr::new(endpoint_id);
for address in direct_addresses {
node_addr = node_addr.with_ip_addr(address);
}
if let Some(relay_url) = &candidate.relay_url {
node_addr = node_addr.with_relay_url(
relay_url
@ -6005,8 +6005,8 @@ fn restricted_admin_shell_output(node: &LocalNode, command: &str) -> Result<Stri
}
}
fn display_alpn(alpn: Vec<u8>) -> String {
String::from_utf8_lossy(&alpn).into_owned()
fn display_alpn(alpn: &[u8]) -> String {
String::from_utf8_lossy(alpn).into_owned()
}
pub fn handle_request(
@ -7661,33 +7661,21 @@ pub fn handle_request(
}
fn native_backend_statuses() -> Vec<NativeBackendStatus> {
let blocker = "pinned blocker: current daemon endpoint uses iroh 0.90.0; the Rust-1.85-compatible backend crates resolved from crates.io require iroh 0.95, so they cannot share the daemon-owned endpoint until geth performs a coordinated Iroh endpoint upgrade".to_owned();
vec![
NativeBackendStatus {
module: "cas".to_owned(),
current_backend: "iroh-control-alpn-bootstrap".to_owned(),
target_crate: "iroh-blobs".to_owned(),
target_version: "0.97.0".to_owned(),
status: "blocked".to_owned(),
blocker: blocker.clone(),
},
NativeBackendStatus {
module: "kv".to_owned(),
current_backend: "iroh-control-alpn-bootstrap".to_owned(),
target_crate: "iroh-docs".to_owned(),
target_version: "0.95.0".to_owned(),
status: "blocked".to_owned(),
blocker: blocker.clone(),
},
NativeBackendStatus {
module: "pubsub".to_owned(),
current_backend: "iroh-control-alpn-bootstrap".to_owned(),
target_crate: "iroh-gossip".to_owned(),
target_version: "0.95.0".to_owned(),
status: "blocked".to_owned(),
blocker,
},
]
geth_iroh::native_iroh_libraries()
.into_iter()
.map(|library| NativeBackendStatus {
module: library.module,
current_backend: "shared-iroh-endpoint-ready".to_owned(),
target_crate: library.crate_name,
target_version: library.crate_version,
status: "ready-to-wire".to_owned(),
blocker: format!(
"none; native ALPN {} is compiled against the daemon-owned iroh {} endpoint, but the module still needs its bootstrap control-path implementation replaced",
library.alpn,
geth_iroh::IROH_VERSION
),
})
.collect()
}
fn stored_resource_to_descriptor(stored: StoredResource) -> Result<ResourceDescriptor, NodeError> {

View file

@ -155,13 +155,13 @@ fn geth_status_against_running_daemon() {
assert!(stdout.contains("iroh relay: disabled"));
assert!(stdout.contains("iroh discovery: local-network disabled"));
assert!(stdout.contains(
"native backend cas: iroh-control-alpn-bootstrap target iroh-blobs 0.97.0 (blocked)"
"native backend cas: shared-iroh-endpoint-ready target iroh-blobs 0.97.0 (ready-to-wire)"
));
assert!(stdout.contains(
"native backend kv: iroh-control-alpn-bootstrap target iroh-docs 0.95.0 (blocked)"
"native backend kv: shared-iroh-endpoint-ready target iroh-docs 0.95.0 (ready-to-wire)"
));
assert!(stdout.contains(
"native backend pubsub: iroh-control-alpn-bootstrap target iroh-gossip 0.95.0 (blocked)"
"native backend pubsub: shared-iroh-endpoint-ready target iroh-gossip 0.95.0 (ready-to-wire)"
));
}

View file

@ -12,4 +12,8 @@ prefix-scoped capabilities.
## Consequences
The MVP exposes CLI shape and types while deferring iroh-docs API pinning.
The prototype exposes CLI shape and durable local KV state. `iroh-docs 0.95.0`
is pinned and compiles against the daemon-owned `iroh 0.95.1` endpoint
generation, so the remaining work is replacing the bootstrap control-path KV
sync with an Iroh Documents namespace implementation and resource-scoped
authorization checks around namespace access.

View file

@ -18,9 +18,7 @@ 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
`/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
The pinned Iroh integration uses `iroh = 0.95.1`. `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`. The default config
@ -30,22 +28,20 @@ uses Iroh's default relay policy; local-only/offline development can set
`relay_map = "<name>"`, validated at config load, and reported in status as
`custom:<name>` without exposing relay URLs.
The native module-backend crates currently available for the intended CAS, KV,
and pubsub replacements are not wired in yet because they require a coordinated
endpoint upgrade. Crates.io metadata checked during this prototype pass resolved
`iroh-blobs 0.97.0`, `iroh-docs 0.95.0`, and `iroh-gossip 0.95.0` as
Rust-1.85-compatible candidates; those crates depend on `iroh 0.95` and cannot
share the daemon-owned `iroh 0.90.0` endpoint. Pulling them in beside the
current endpoint would create parallel Iroh stacks and violate the one-endpoint
daemon invariant. Until the endpoint wrapper upgrades as a unit, `geth status`
reports CAS, KV, and pubsub native backends as blocked and the bootstrap
control-ALPN paths remain explicit.
The native module-backend crates for the intended CAS, KV, and pubsub
replacements now compile against the same endpoint generation:
`iroh-blobs 0.97.0`, `iroh-docs 0.95.0`, and `iroh-gossip 0.95.0`. `geth-iroh`
exposes their native ALPNs so module migrations can register handlers without
creating a second daemon endpoint. `geth status` reports these backends as
ready to wire. The module implementations still use explicit bootstrap
control-ALPN paths until each module is migrated to its native protocol.
Module ALPNs are registered through `geth-iroh`'s protocol router scaffold. The
router owns the default protocol descriptors, rejects duplicate ALPN
registrations, and returns explicit unknown-ALPN errors. It does not yet accept
or dispatch remote streams; peer authentication and module handlers are later
Phase 1 work.
registrations, and returns explicit unknown-ALPN errors. The current daemon
accept loop dispatches geth control, pipe, and SSH-proxy streams directly; the
next backend migrations should attach iroh-blobs, iroh-docs, and iroh-gossip
handlers to the same endpoint instead of creating parallel endpoints.
The target product should use Iroh relay support for practical internet
connectivity and mDNS/LAN discovery for local networks. These are connectivity

View file

@ -102,7 +102,7 @@ Implementation order:
equivalent.
- `[x]` Fallback/stub behavior remains clearly marked where APIs are not yet
pinned.
- `[ ]` Upgrade `geth-iroh` from `iroh 0.90.0` to an endpoint version
- `[x]` Upgrade `geth-iroh` from `iroh 0.90.0` to an endpoint version
compatible with `iroh-blobs`, `iroh-docs`, and `iroh-gossip` without
introducing a second daemon endpoint.
@ -459,8 +459,8 @@ authorization and durable-state boundaries clear.
- `[x]` `geth cas providers <hash>` lists locally known providers.
- `[x]` Tests cover local provider metadata storage.
- `[ ]` Replace the bootstrap control-ALPN transfer with `iroh-blobs`
provider/fetch behavior after the daemon endpoint upgrades to an Iroh
version compatible with `iroh-blobs 0.97.0` or a newer pinned equivalent.
provider/fetch behavior using the daemon-owned `iroh 0.95.1` endpoint and
pinned `iroh-blobs 0.97.0`.
- `[x]` CAS pin and cache policy.
Acceptance criteria: