Use native iroh-blobs for CAS fetches

This commit is contained in:
Eric Wendland 2026-05-22 16:10:40 +02:00
commit 72f28224ce
7 changed files with 199 additions and 39 deletions

1
Cargo.lock generated
View file

@ -1621,6 +1621,7 @@ dependencies = [
"geth-types", "geth-types",
"hex", "hex",
"iroh", "iroh",
"iroh-blobs",
"rusqlite", "rusqlite",
"serde", "serde",
"serde_json", "serde_json",

View file

@ -192,18 +192,17 @@ imported peer card and exchange a signed candidate-only peer-card ping.
`geth peer auth-check <node-id> <resource> <capability>` sends a protected `geth peer auth-check <node-id> <resource> <capability>` sends a protected
Iroh control request: the remote daemon verifies that the caller's signed peer Iroh control request: the remote daemon verifies that the caller's signed peer
card binds the actual Iroh EndpointID before reducing resource-local auth ops. card binds the actual Iroh EndpointID before reducing resource-local auth ops.
`geth cas fetch <node-id> <hash>` uses the same protected Iroh control path to `geth cas fetch <node-id> <hash>` uses the same protected Iroh control path as
request a blob from a peer. The remote daemon only returns bytes when the caller an authorization preflight. The remote daemon verifies the caller's signed peer
has `cas.fetch` on `resource:cas:local`, and the caller verifies that the bytes card against the observed Iroh EndpointID and requires `cas.fetch` on
hash to the requested BLAKE3 CAS hash before storing them locally. Successful `resource:cas:local`. After that preflight succeeds, the requester fetches the
fetches record the serving peer as a local provider, visible with blob payload over native `iroh-blobs` (`/iroh-bytes/4`) on the same daemon-owned
`geth cas providers <hash>`. This is the bootstrap transfer path; future work Iroh endpoint, verifies the BLAKE3 hash, stores it in local CAS, and records the
will move provider/fetch behavior to `iroh-blobs`. `geth-iroh` is now pinned to serving peer as a provider visible with `geth cas providers <hash>`.
`iroh 0.95.1` and compiles the native backend libraries `iroh-blobs 0.97.0`, `geth-iroh` is pinned to `iroh 0.95.1` and compiles the native backend
`iroh-docs 0.95.0`, and `iroh-gossip 0.95.0` against the same daemon-owned libraries `iroh-blobs 0.97.0`, `iroh-docs 0.95.0`, and `iroh-gossip 0.95.0`
endpoint generation. `geth status` reports those backends as ready to wire; the against the same daemon-owned endpoint generation. KV and pubsub still use their
module implementations still use the explicit bootstrap control path until the documented bootstrap equivalents until their native protocol migrations land.
module-specific migrations replace it.
Remote resource commands that accept `--bearer-secret` can also authorize with a Remote resource commands that accept `--bearer-secret` can also authorize with a
resource-scoped bearer proof generated from the private bearer token returned at 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 creation time. The persisted auth log stores a public bearer id and token

View file

@ -44,7 +44,7 @@ impl GethIrohConfig {
local_discovery: true, local_discovery: true,
bind_ipv4: None, bind_ipv4: None,
bind_ipv6: None, bind_ipv6: None,
alpns: default_protocol_router().alpns(), alpns: default_endpoint_alpns(),
} }
} }
} }
@ -161,6 +161,17 @@ pub fn default_protocol_descriptors() -> Vec<ProtocolDescriptor> {
] ]
} }
#[must_use]
pub fn default_endpoint_alpns() -> Vec<Vec<u8>> {
let mut alpns = default_protocol_router().alpns();
alpns.push(iroh_blobs::ALPN.to_vec());
alpns.push(iroh_docs::ALPN.to_vec());
alpns.push(iroh_gossip::ALPN.to_vec());
alpns.sort();
alpns.dedup();
alpns
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NativeIrohLibrary { pub struct NativeIrohLibrary {
pub module: String, pub module: String,
@ -447,6 +458,15 @@ mod tests {
})); }));
} }
#[test]
fn default_endpoint_alpns_include_native_libraries() {
let alpns = default_endpoint_alpns();
assert!(alpns.contains(&ALPN_CONTROL.to_vec()));
assert!(alpns.contains(&iroh_blobs::ALPN.to_vec()));
assert!(alpns.contains(&iroh_docs::ALPN.to_vec()));
assert!(alpns.contains(&iroh_gossip::ALPN.to_vec()));
}
#[test] #[test]
fn default_router_registers_all_protocols() { fn default_router_registers_all_protocols() {
let router = default_protocol_router(); let router = default_protocol_router();

View file

@ -34,6 +34,7 @@ geth-store = { path = "../geth-store" }
geth-types = { path = "../geth-types" } geth-types = { path = "../geth-types" }
hex.workspace = true hex.workspace = true
iroh.workspace = true iroh.workspace = true
iroh-blobs.workspace = true
swarm-discovery.workspace = true swarm-discovery.workspace = true
[dev-dependencies] [dev-dependencies]

View file

@ -51,6 +51,7 @@ use geth_types::{
AuthOpId, BlobHash, Capability, DeviceId, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind, AuthOpId, BlobHash, Capability, DeviceId, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind,
ResourceName, SshCertId, SshCertRequestId, UnixMillis, UserId, ResourceName, SshCertId, SshCertRequestId, UnixMillis, UserId,
}; };
use iroh::protocol::ProtocolHandler;
use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@ -157,6 +158,7 @@ pub struct LocalNode {
pub node_id: String, pub node_id: String,
pub iroh_status: EndpointStatus, pub iroh_status: EndpointStatus,
iroh_endpoint: Arc<Mutex<Option<GethIrohEndpoint>>>, iroh_endpoint: Arc<Mutex<Option<GethIrohEndpoint>>>,
iroh_blob_store: Arc<Mutex<Option<iroh_blobs::store::fs::FsStore>>>,
runtime: Arc<NodeRuntime>, runtime: Arc<NodeRuntime>,
} }
@ -265,6 +267,7 @@ pub fn init_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
node_id, node_id,
iroh_status: EndpointStatus::scaffolded(), iroh_status: EndpointStatus::scaffolded(),
iroh_endpoint: Arc::new(Mutex::new(None)), iroh_endpoint: Arc::new(Mutex::new(None)),
iroh_blob_store: 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()),
@ -748,7 +751,19 @@ pub async fn handle_request_async(
name, name,
bearer_secret, bearer_secret,
} => document_sync_from_peer(node, &peer_node, &name, bearer_secret).await, } => document_sync_from_peer(node, &peer_node, &name, bearer_secret).await,
other => handle_request(node, other), other => {
let response = handle_request(node, other)?;
match &response {
ControlResponse::CasAdded { hash, .. } => {
mirror_local_blob_to_iroh_blobs(node, hash).await?;
}
ControlResponse::CasPrivateAdded { encrypted_hash, .. } => {
mirror_local_blob_to_iroh_blobs(node, encrypted_hash).await?;
}
_ => {}
}
Ok(response)
}
} }
} }
@ -1275,7 +1290,7 @@ async fn cas_fetch_from_peer(
let conn = endpoint let conn = endpoint
.endpoint() .endpoint()
.connect(node_addr, geth_iroh::ALPN_CONTROL) .connect(node_addr.clone(), geth_iroh::ALPN_CONTROL)
.await .await
.map_err(|error| NodeError::IrohPeer(error.to_string()))?; .map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let (mut send, mut recv) = conn let (mut send, mut recv) = conn
@ -1300,7 +1315,7 @@ async fn cas_fetch_from_peer(
endpoint_id, endpoint_id,
hash: response_hash, hash: response_hash,
size_bytes, size_bytes,
content_base64, content_base64: _,
allowed, allowed,
reason, reason,
nonce: response_nonce, nonce: response_nonce,
@ -1319,27 +1334,19 @@ async fn cas_fetch_from_peer(
note, note,
}); });
} }
let content = content_base64 let info = fetch_blob_via_iroh_blobs(node, node_addr, response_hash.clone()).await?;
.ok_or_else(|| NodeError::IrohPeer("peer omitted CAS content".to_owned()))
.and_then(|content| {
base64::engine::general_purpose::STANDARD
.decode(content)
.map_err(|error| NodeError::IrohPeer(error.to_string()))
})?;
if content.len() as u64 != size_bytes {
return Err(NodeError::IrohPeer(format!(
"peer announced {size_bytes} CAS bytes but returned {} bytes",
content.len()
)));
}
let cas = LocalCas::new(node.paths.cas_dir());
let info = cas.add_bytes(&content)?;
if info.hash != response_hash { if info.hash != response_hash {
return Err(NodeError::IrohPeer(format!( return Err(NodeError::IrohPeer(format!(
"peer returned content hash {} for requested {}", "peer returned content hash {} for requested {}",
info.hash, response_hash info.hash, response_hash
))); )));
} }
if info.size_bytes != size_bytes {
return Err(NodeError::IrohPeer(format!(
"peer authorized {size_bytes} CAS bytes but iroh-blobs fetched {} bytes",
info.size_bytes
)));
}
store.record_cas_object( store.record_cas_object(
info.hash.as_str(), info.hash.as_str(),
info.size_bytes, info.size_bytes,
@ -1384,6 +1391,105 @@ async fn cas_fetch_from_peer(
} }
} }
async fn fetch_blob_via_iroh_blobs(
node: &LocalNode,
node_addr: iroh::EndpointAddr,
hash: BlobHash,
) -> Result<geth_cas::BlobInfo, NodeError> {
let endpoint = node
.iroh_endpoint
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)?
.clone()
.ok_or(NodeError::IrohEndpointUnavailable)?;
let blob_store = iroh_blob_store(node)?.ok_or_else(|| {
NodeError::IrohPeer(
"iroh-blobs store is unavailable; restart the daemon with native Iroh enabled"
.to_owned(),
)
})?;
let iroh_hash = iroh_blob_hash(&hash)?;
let conn = endpoint
.endpoint()
.connect(node_addr, iroh_blobs::ALPN)
.await
.map_err(|error| NodeError::IrohPeer(format!("iroh-blobs connect failed: {error}")))?;
blob_store
.remote()
.fetch(conn, iroh_blobs::HashAndFormat::raw(iroh_hash))
.await
.map_err(|error| NodeError::IrohPeer(format!("iroh-blobs fetch failed: {error}")))?;
let bytes = blob_store
.get_bytes(iroh_hash)
.await
.map_err(|error| NodeError::IrohPeer(format!("iroh-blobs export failed: {error}")))?;
let cas = LocalCas::new(node.paths.cas_dir());
let info = cas.add_bytes(bytes.as_ref())?;
mirror_local_blob_to_iroh_blobs(node, &info.hash).await?;
Ok(info)
}
fn iroh_blob_store(node: &LocalNode) -> Result<Option<iroh_blobs::store::fs::FsStore>, NodeError> {
node.iroh_blob_store
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)
.map(|store| store.clone())
}
fn iroh_blob_hash(hash: &BlobHash) -> Result<iroh_blobs::Hash, NodeError> {
let bytes = hex::decode(hash.as_str()).map_err(|error| {
NodeError::IrohPeer(format!("invalid CAS hash for iroh-blobs: {error}"))
})?;
let bytes: [u8; 32] = bytes
.try_into()
.map_err(|_| NodeError::IrohPeer("invalid CAS hash length for iroh-blobs".to_owned()))?;
Ok(iroh_blobs::Hash::from_bytes(bytes))
}
async fn mirror_local_blob_to_iroh_blobs(
node: &LocalNode,
hash: &BlobHash,
) -> Result<(), NodeError> {
let Some(blob_store) = iroh_blob_store(node)? else {
return Ok(());
};
let content = LocalCas::new(node.paths.cas_dir()).read_bytes(hash)?;
let tag = blob_store
.add_slice(&content)
.await
.map_err(|error| NodeError::IrohPeer(format!("iroh-blobs import failed: {error}")))?;
if tag.hash.to_string() != hash.as_str() {
return Err(NodeError::IrohPeer(format!(
"iroh-blobs imported hash {} but local CAS hash is {}",
tag.hash, hash
)));
}
Ok(())
}
async fn mirror_local_cas_to_iroh_blobs(
paths: &GethPaths,
blob_store: &iroh_blobs::store::fs::FsStore,
) -> Result<usize, NodeError> {
let cas = LocalCas::new(paths.cas_dir());
let mut count = 0;
for blob in cas.list()? {
let content = cas.read_bytes(&blob.hash)?;
let tag = blob_store
.add_slice(&content)
.await
.map_err(|error| NodeError::IrohPeer(format!("iroh-blobs import failed: {error}")))?;
if tag.hash.to_string() != blob.hash.as_str() {
return Err(NodeError::IrohPeer(format!(
"iroh-blobs imported hash {} but local CAS hash is {}",
tag.hash, blob.hash
)));
}
count += 1;
}
Ok(count)
}
async fn cas_root_sync_from_peer( async fn cas_root_sync_from_peer(
node: &LocalNode, node: &LocalNode,
peer_node: &str, peer_node: &str,
@ -4442,6 +4548,16 @@ async fn handle_iroh_control_connection(
.map_err(|error| NodeError::IrohPeer(error.to_string()))?; .map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let remote_endpoint_id = conn.remote_id().to_string(); let remote_endpoint_id = conn.remote_id().to_string();
let alpn = display_alpn(conn.alpn()); let alpn = display_alpn(conn.alpn());
if alpn == display_alpn(iroh_blobs::ALPN) {
let blob_store = iroh_blob_store(&node)?.ok_or_else(|| {
NodeError::IrohPeer("iroh-blobs ALPN accepted but blob store is unavailable".to_owned())
})?;
let protocol = iroh_blobs::BlobsProtocol::new(&blob_store, None);
return protocol
.accept(conn)
.await
.map_err(|error| NodeError::IrohPeer(format!("iroh-blobs accept failed: {error}")));
}
let (mut send, mut recv) = conn let (mut send, mut recv) = conn
.accept_bi() .accept_bi()
.await .await
@ -9557,11 +9673,26 @@ async fn start_daemon_iroh_endpoint(
let store = Store::open(&node.paths.metadata_db())?; let store = Store::open(&node.paths.metadata_db())?;
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")?;
} }
let blob_store =
iroh_blobs::store::fs::FsStore::load(node.paths.cas_dir().join("iroh-blobs"))
.await
.map_err(|error| {
NodeError::IrohPeer(format!("failed to open iroh-blobs store: {error}"))
})?;
let mirrored = mirror_local_cas_to_iroh_blobs(&node.paths, &blob_store).await?;
tracing::info!(
mirrored_blobs = mirrored,
"native iroh-blobs store is ready"
);
node.iroh_status = status; node.iroh_status = status;
*node *node
.iroh_endpoint .iroh_endpoint
.lock() .lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)? = Some(endpoint.clone()); .map_err(|_| NodeError::RuntimeLockPoisoned)? = Some(endpoint.clone());
*node
.iroh_blob_store
.lock()
.map_err(|_| NodeError::RuntimeLockPoisoned)? = Some(blob_store);
Ok(Some(endpoint)) Ok(Some(endpoint))
} }
Err(error) => { Err(error) => {

View file

@ -31,17 +31,21 @@ uses Iroh's default relay policy; local-only/offline development can set
The native module-backend crates for the intended CAS, KV, and pubsub The native module-backend crates for the intended CAS, KV, and pubsub
replacements now compile against the same endpoint generation: 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` `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 exposes their native ALPNs without creating a second daemon endpoint. CAS now
creating a second daemon endpoint. `geth status` reports these backends as registers an `iroh-blobs` provider handler on `/iroh-bytes/4`; local CAS writes
ready to wire. The module implementations still use explicit bootstrap are mirrored into the native blob store, and remote `geth cas fetch` performs a
control-ALPN paths until each module is migrated to its native protocol. geth control-ALPN authorization preflight before transferring payload bytes over
`iroh-blobs`. `geth status` reports these native backend libraries. KV and
pubsub still use explicit documented bootstrap equivalents until each module is
migrated to its native protocol.
Module ALPNs are registered through `geth-iroh`'s protocol router scaffold. The Module ALPNs are registered through `geth-iroh`'s protocol router scaffold. The
router owns the default protocol descriptors, rejects duplicate ALPN router owns the default protocol descriptors, rejects duplicate ALPN
registrations, and returns explicit unknown-ALPN errors. The current daemon registrations, and returns explicit unknown-ALPN errors. The current daemon
accept loop dispatches geth control, pipe, and SSH-proxy streams directly; the accept loop dispatches geth control, pipe, SSH-proxy, and native CAS blob
next backend migrations should attach iroh-blobs, iroh-docs, and iroh-gossip streams directly; the next backend migrations should attach iroh-docs and
handlers to the same endpoint instead of creating parallel endpoints. iroh-gossip handlers to the same endpoint instead of creating parallel
endpoints.
The target product should use Iroh relay support for practical internet The target product should use Iroh relay support for practical internet
connectivity and mDNS/LAN discovery for local networks. These are connectivity connectivity and mDNS/LAN discovery for local networks. These are connectivity

View file

@ -458,9 +458,13 @@ authorization and durable-state boundaries clear.
the CAS hash. the CAS hash.
- `[x]` `geth cas providers <hash>` lists locally known providers. - `[x]` `geth cas providers <hash>` lists locally known providers.
- `[x]` Tests cover local provider metadata storage. - `[x]` Tests cover local provider metadata storage.
- `[ ]` Replace the bootstrap control-ALPN transfer with `iroh-blobs` - `[x]` Replace the bootstrap control-ALPN byte transfer with `iroh-blobs`
provider/fetch behavior using the daemon-owned `iroh 0.95.1` endpoint and provider/fetch behavior using the daemon-owned `iroh 0.95.1` endpoint and
pinned `iroh-blobs 0.97.0`. pinned `iroh-blobs 0.97.0`.
- `[x]` CAS fetch keeps the geth control-ALPN authorization preflight before
opening the native `iroh-blobs` payload transfer.
- `[x]` Local CAS adds and daemon startup mirror available blobs into the
native `iroh-blobs` store so peers can fetch them through `/iroh-bytes/4`.
- `[x]` CAS pin and cache policy. - `[x]` CAS pin and cache policy.
Acceptance criteria: Acceptance criteria: