Fetch CAS blobs over Iroh
This commit is contained in:
parent
d5a548182b
commit
e4b788fec2
10 changed files with 425 additions and 19 deletions
17
AGENTS.md
17
AGENTS.md
|
|
@ -107,8 +107,9 @@ Roadmap items should be actionable and checkable:
|
||||||
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, `geth peer ping`
|
types, manual signed peer-card export/import/list commands, `geth peer ping`
|
||||||
and `geth peer auth-check` over Iroh, signed peer-card LAN discovery payloads,
|
and `geth peer auth-check` over Iroh, signed peer-card LAN discovery payloads,
|
||||||
untrusted discovery-backend trait, custom relay-map config, and Iroh
|
authorized `geth cas fetch` over the Iroh control ALPN, untrusted
|
||||||
local-network discovery toggle exist.
|
discovery-backend trait, custom relay-map config, and 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.
|
||||||
|
|
@ -120,7 +121,10 @@ Roadmap items should be actionable and checkable:
|
||||||
- The daemon persists local keychain init/admin-key ops and reduces them for
|
- The daemon persists local keychain init/admin-key ops and reduces them for
|
||||||
`keychain status`. SSH signature capture/verification is still roadmap work.
|
`keychain status`. SSH signature capture/verification is still roadmap work.
|
||||||
- Local CAS supports pin/unpin metadata, surfaced through `cas list`, and
|
- Local CAS supports pin/unpin metadata, surfaced through `cas list`, and
|
||||||
`cas cleanup` evicts unpinned blobs while retaining pinned blobs.
|
`cas cleanup` evicts unpinned blobs while retaining pinned blobs. The daemon
|
||||||
|
can fetch CAS blobs from an imported signed peer card over Iroh when the peer
|
||||||
|
grants `cas.fetch` on `resource:cas:local`; full `iroh-blobs` provider
|
||||||
|
integration is still roadmap work.
|
||||||
- The CAS crate can build deterministic tree objects for local file trees and
|
- The CAS crate can build deterministic tree objects for local file trees and
|
||||||
store those manifests as CAS blobs. The daemon can register and scan local
|
store those manifests as CAS blobs. The daemon can register and scan local
|
||||||
file roots, reporting create/update/delete/rename changes without writing back
|
file roots, reporting create/update/delete/rename changes without writing back
|
||||||
|
|
@ -154,7 +158,6 @@ Roadmap items should be actionable and checkable:
|
||||||
OpenSSH KRL files are not enumerable through OpenSSH tooling. Tests cover
|
OpenSSH KRL files are not enumerable through OpenSSH tooling. Tests cover
|
||||||
public-key and certificate binary KRL revocations when `ssh-keygen` is
|
public-key and certificate binary KRL revocations when `ssh-keygen` is
|
||||||
available.
|
available.
|
||||||
- Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite,
|
- cr-sqlite, iroh-docs, iroh-blobs provider/fetch, Automerge sync, broader auth
|
||||||
iroh-docs, iroh-blobs, Automerge sync, broader auth enforcement, and
|
enforcement, and Keyhive/BeeKEM-style authorization are future roadmap items
|
||||||
Keyhive/BeeKEM-style authorization are future roadmap items unless
|
unless implemented later.
|
||||||
implemented later.
|
|
||||||
|
|
|
||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1229,6 +1229,7 @@ dependencies = [
|
||||||
name = "geth-node"
|
name = "geth-node"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"geth-auth",
|
"geth-auth",
|
||||||
"geth-cas",
|
"geth-cas",
|
||||||
"geth-config",
|
"geth-config",
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -91,8 +91,8 @@ The bootstrap implementation provides:
|
||||||
- `geth auth explain <subject> <resource> <capability>`
|
- `geth auth explain <subject> <resource> <capability>`
|
||||||
- `geth auth grant <subject> <resource> <capability> [--grant-id <id>]`
|
- `geth auth grant <subject> <resource> <capability> [--grant-id <id>]`
|
||||||
- `geth auth revoke <resource> <grant-id>`
|
- `geth auth revoke <resource> <grant-id>`
|
||||||
- local filesystem CAS commands: `add`, `get`, `hash`, `has`, `pin`, `unpin`,
|
- local filesystem CAS commands: `add`, `get`, `fetch`, `hash`, `has`, `pin`,
|
||||||
`cleanup`, `list`
|
`unpin`, `cleanup`, `list`
|
||||||
- local CAS tree objects describe file trees and are stored as CAS blobs
|
- local CAS tree objects describe file trees and are stored as CAS blobs
|
||||||
- local file-root commands: `geth cas root add/list/scan`
|
- local file-root commands: `geth cas root add/list/scan`
|
||||||
- local file conflict metadata commands:
|
- local file conflict metadata commands:
|
||||||
|
|
@ -125,6 +125,12 @@ 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
|
||||||
|
request a blob from a peer. The remote daemon only returns bytes when the caller
|
||||||
|
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. This is the
|
||||||
|
bootstrap transfer path; future work will move provider/fetch behavior to
|
||||||
|
`iroh-blobs`.
|
||||||
Importing or pinging a peer card never grants capabilities by itself.
|
Importing or pinging a peer card never grants capabilities by itself.
|
||||||
When `[iroh].local_discovery = true`, the daemon also advertises and discovers
|
When `[iroh].local_discovery = true`, the daemon also advertises and discovers
|
||||||
signed peer cards on LAN using a geth-specific mDNS TXT payload. That payload is
|
signed peer cards on LAN using a geth-specific mDNS TXT payload. That payload is
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,14 @@ impl LocalCas {
|
||||||
std::fs::copy(path, out).map_err(CasError::from)
|
std::fs::copy(path, out).map_err(CasError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn read_bytes(&self, hash: &BlobHash) -> Result<Vec<u8>, CasError> {
|
||||||
|
let path = self.blob_path(hash)?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(CasError::NotFound(hash.to_string()));
|
||||||
|
}
|
||||||
|
Ok(std::fs::read(path)?)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn has(&self, hash: &BlobHash) -> Result<bool, CasError> {
|
pub fn has(&self, hash: &BlobHash) -> Result<bool, CasError> {
|
||||||
Ok(self.blob_path(hash)?.exists())
|
Ok(self.blob_path(hash)?.exists())
|
||||||
}
|
}
|
||||||
|
|
@ -547,6 +555,10 @@ mod tests {
|
||||||
let out = dir.path().join("out.txt");
|
let out = dir.path().join("out.txt");
|
||||||
cas.get_to_path(&info.hash, &out).expect("get");
|
cas.get_to_path(&info.hash, &out).expect("get");
|
||||||
assert_eq!(std::fs::read(out).expect("read"), b"hello geth");
|
assert_eq!(std::fs::read(out).expect("read"), b"hello geth");
|
||||||
|
assert_eq!(
|
||||||
|
cas.read_bytes(&info.hash).expect("read bytes"),
|
||||||
|
b"hello geth"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,10 @@ pub enum CasCommand {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
out: PathBuf,
|
out: PathBuf,
|
||||||
},
|
},
|
||||||
|
Fetch {
|
||||||
|
node: String,
|
||||||
|
hash: String,
|
||||||
|
},
|
||||||
Hash {
|
Hash {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
|
|
@ -548,6 +552,10 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
|
||||||
hash: hash.into(),
|
hash: hash.into(),
|
||||||
out,
|
out,
|
||||||
},
|
},
|
||||||
|
CasCommand::Fetch { node, hash } => ControlRequest::CasFetch {
|
||||||
|
node,
|
||||||
|
hash: hash.into(),
|
||||||
|
},
|
||||||
CasCommand::Hash { path } => ControlRequest::CasHash { path },
|
CasCommand::Hash { path } => ControlRequest::CasHash { path },
|
||||||
CasCommand::Has { hash } => ControlRequest::CasHas { hash: hash.into() },
|
CasCommand::Has { hash } => ControlRequest::CasHas { hash: hash.into() },
|
||||||
CasCommand::Pin { hash } => ControlRequest::CasPin { hash: hash.into() },
|
CasCommand::Pin { hash } => ControlRequest::CasPin { hash: hash.into() },
|
||||||
|
|
@ -879,6 +887,27 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
|
||||||
} => {
|
} => {
|
||||||
println!("wrote {hash} to {} ({size_bytes} bytes)", out.display());
|
println!("wrote {hash} to {} ({size_bytes} bytes)", out.display());
|
||||||
}
|
}
|
||||||
|
ControlResponse::CasFetched {
|
||||||
|
peer_node_id,
|
||||||
|
peer_agent_id,
|
||||||
|
endpoint_id,
|
||||||
|
hash,
|
||||||
|
size_bytes,
|
||||||
|
allowed,
|
||||||
|
reason,
|
||||||
|
note,
|
||||||
|
} => {
|
||||||
|
if allowed {
|
||||||
|
println!("fetched {hash} from {peer_node_id} ({size_bytes} bytes)");
|
||||||
|
} else {
|
||||||
|
println!("fetch denied for {hash} from {peer_node_id}");
|
||||||
|
}
|
||||||
|
println!("agent: {peer_agent_id}");
|
||||||
|
println!("endpoint: {endpoint_id}");
|
||||||
|
println!("allowed: {allowed}");
|
||||||
|
println!("reason: {reason}");
|
||||||
|
println!("note: {note}");
|
||||||
|
}
|
||||||
ControlResponse::CasHash { hash } => println!("{hash}"),
|
ControlResponse::CasHash { hash } => println!("{hash}"),
|
||||||
ControlResponse::CasHas { hash, present } => println!("{hash}: {present}"),
|
ControlResponse::CasHas { hash, present } => println!("{hash}: {present}"),
|
||||||
ControlResponse::CasPinned { hash, pinned } => {
|
ControlResponse::CasPinned { hash, pinned } => {
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,10 @@ pub enum ControlRequest {
|
||||||
hash: BlobHash,
|
hash: BlobHash,
|
||||||
out: PathBuf,
|
out: PathBuf,
|
||||||
},
|
},
|
||||||
|
CasFetch {
|
||||||
|
node: String,
|
||||||
|
hash: BlobHash,
|
||||||
|
},
|
||||||
CasHash {
|
CasHash {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
|
|
@ -269,6 +273,16 @@ pub enum ControlResponse {
|
||||||
out: PathBuf,
|
out: PathBuf,
|
||||||
size_bytes: u64,
|
size_bytes: u64,
|
||||||
},
|
},
|
||||||
|
CasFetched {
|
||||||
|
peer_node_id: String,
|
||||||
|
peer_agent_id: String,
|
||||||
|
endpoint_id: String,
|
||||||
|
hash: BlobHash,
|
||||||
|
size_bytes: u64,
|
||||||
|
allowed: bool,
|
||||||
|
reason: String,
|
||||||
|
note: String,
|
||||||
|
},
|
||||||
CasHash {
|
CasHash {
|
||||||
hash: BlobHash,
|
hash: BlobHash,
|
||||||
},
|
},
|
||||||
|
|
@ -467,6 +481,11 @@ pub enum PeerControlRequest {
|
||||||
capability: String,
|
capability: String,
|
||||||
nonce: String,
|
nonce: String,
|
||||||
},
|
},
|
||||||
|
CasFetch {
|
||||||
|
peer_card: PeerCard,
|
||||||
|
hash: BlobHash,
|
||||||
|
nonce: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
|
@ -494,6 +513,20 @@ pub enum PeerControlResponse {
|
||||||
nonce: String,
|
nonce: String,
|
||||||
note: String,
|
note: String,
|
||||||
},
|
},
|
||||||
|
CasFetched {
|
||||||
|
node_id: String,
|
||||||
|
agent_id: String,
|
||||||
|
endpoint_id: String,
|
||||||
|
remote_endpoint_id: String,
|
||||||
|
hash: BlobHash,
|
||||||
|
size_bytes: u64,
|
||||||
|
content_base64: Option<String>,
|
||||||
|
allowed: bool,
|
||||||
|
reason: String,
|
||||||
|
evaluated_ops: usize,
|
||||||
|
nonce: String,
|
||||||
|
note: String,
|
||||||
|
},
|
||||||
Error {
|
Error {
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
|
|
@ -568,6 +601,15 @@ mod tests {
|
||||||
response
|
response
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let request = ControlRequest::CasFetch {
|
||||||
|
node: "node:peer".to_owned(),
|
||||||
|
hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
let request = ControlRequest::SshRevocationExport {
|
let request = ControlRequest::SshRevocationExport {
|
||||||
out: PathBuf::from("revocations.krl-spec"),
|
out: PathBuf::from("revocations.krl-spec"),
|
||||||
format: "openssh-krl-spec".to_owned(),
|
format: "openssh-krl-spec".to_owned(),
|
||||||
|
|
@ -700,5 +742,25 @@ mod tests {
|
||||||
.expect("decode"),
|
.expect("decode"),
|
||||||
response
|
response
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let response = PeerControlResponse::CasFetched {
|
||||||
|
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(),
|
||||||
|
hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
|
||||||
|
size_bytes: 5,
|
||||||
|
content_base64: Some("aGVsbG8".to_owned()),
|
||||||
|
allowed: true,
|
||||||
|
reason: "direct grant".to_owned(),
|
||||||
|
evaluated_ops: 1,
|
||||||
|
nonce: "nonce".to_owned(),
|
||||||
|
note: "cas fetch".to_owned(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
decode_peer_response(&encode_peer_response(&response).expect("encode"))
|
||||||
|
.expect("decode"),
|
||||||
|
response
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ rust-version.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
base64.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
pub mod service;
|
pub mod service;
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
use geth_auth::{AuthExplanation, AuthOp, AuthOpKind};
|
use geth_auth::{AuthExplanation, AuthOp, AuthOpKind};
|
||||||
use geth_cas::{
|
use geth_cas::{
|
||||||
BlobInfoSummary, FileConflict, FileConflictKind, FileConflictResolution, FileConflictStatus,
|
BlobInfoSummary, FileConflict, FileConflictKind, FileConflictResolution, FileConflictStatus,
|
||||||
|
|
@ -36,8 +37,8 @@ use geth_store::{
|
||||||
StoredResourceSecret, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation,
|
StoredResourceSecret, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation,
|
||||||
};
|
};
|
||||||
use geth_types::{
|
use geth_types::{
|
||||||
AuthOpId, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind, ResourceName,
|
AuthOpId, BlobHash, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind,
|
||||||
SshCertId, SshCertRequestId, UnixMillis,
|
ResourceName, SshCertId, SshCertRequestId, UnixMillis,
|
||||||
};
|
};
|
||||||
use std::collections::{BTreeMap, VecDeque};
|
use std::collections::{BTreeMap, VecDeque};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
@ -239,6 +240,10 @@ pub async fn handle_request_async(
|
||||||
resource,
|
resource,
|
||||||
capability,
|
capability,
|
||||||
} => peer_auth_check(node, &peer_node, resource, capability).await,
|
} => peer_auth_check(node, &peer_node, resource, capability).await,
|
||||||
|
ControlRequest::CasFetch {
|
||||||
|
node: peer_node,
|
||||||
|
hash,
|
||||||
|
} => cas_fetch_from_peer(node, &peer_node, hash).await,
|
||||||
other => handle_request(node, other),
|
other => handle_request(node, other),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -512,6 +517,9 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result<ControlResponse,
|
||||||
PeerControlResponse::AuthChecked { .. } => Err(NodeError::IrohPeer(
|
PeerControlResponse::AuthChecked { .. } => Err(NodeError::IrohPeer(
|
||||||
"peer returned auth-check response to ping request".to_owned(),
|
"peer returned auth-check response to ping request".to_owned(),
|
||||||
)),
|
)),
|
||||||
|
PeerControlResponse::CasFetched { .. } => Err(NodeError::IrohPeer(
|
||||||
|
"peer returned CAS fetch response to ping request".to_owned(),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -614,6 +622,142 @@ async fn peer_auth_check(
|
||||||
PeerControlResponse::Pong { .. } => Err(NodeError::IrohPeer(
|
PeerControlResponse::Pong { .. } => Err(NodeError::IrohPeer(
|
||||||
"peer returned pong to auth-check request".to_owned(),
|
"peer returned pong to auth-check request".to_owned(),
|
||||||
)),
|
)),
|
||||||
|
PeerControlResponse::CasFetched { .. } => Err(NodeError::IrohPeer(
|
||||||
|
"peer returned CAS fetch response to auth-check request".to_owned(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cas_fetch_from_peer(
|
||||||
|
node: &LocalNode,
|
||||||
|
peer_node: &str,
|
||||||
|
hash: BlobHash,
|
||||||
|
) -> 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)?;
|
||||||
|
ensure_peer_card_matches_endpoint(&peer_card, &candidate.endpoint_id)?;
|
||||||
|
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{}\0{}",
|
||||||
|
node.node_id,
|
||||||
|
peer_node,
|
||||||
|
hash.as_str(),
|
||||||
|
geth_store::now_ms()
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
);
|
||||||
|
let request = PeerControlRequest::CasFetch {
|
||||||
|
peer_card: self_card,
|
||||||
|
hash: hash.clone(),
|
||||||
|
nonce: nonce.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let conn = endpoint
|
||||||
|
.endpoint()
|
||||||
|
.connect(node_addr, geth_iroh::ALPN_CONTROL)
|
||||||
|
.await
|
||||||
|
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||||
|
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 * 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::CasFetched {
|
||||||
|
node_id,
|
||||||
|
agent_id,
|
||||||
|
endpoint_id,
|
||||||
|
hash: response_hash,
|
||||||
|
size_bytes,
|
||||||
|
content_base64,
|
||||||
|
allowed,
|
||||||
|
reason,
|
||||||
|
nonce: response_nonce,
|
||||||
|
note,
|
||||||
|
..
|
||||||
|
} if response_nonce == nonce && response_hash == hash => {
|
||||||
|
if !allowed {
|
||||||
|
return Ok(ControlResponse::CasFetched {
|
||||||
|
peer_node_id: node_id,
|
||||||
|
peer_agent_id: agent_id,
|
||||||
|
endpoint_id,
|
||||||
|
hash: response_hash,
|
||||||
|
size_bytes: 0,
|
||||||
|
allowed,
|
||||||
|
reason,
|
||||||
|
note,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let content = content_base64
|
||||||
|
.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 {
|
||||||
|
return Err(NodeError::IrohPeer(format!(
|
||||||
|
"peer returned content hash {} for requested {}",
|
||||||
|
info.hash, response_hash
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
store.record_cas_object(
|
||||||
|
info.hash.as_str(),
|
||||||
|
info.size_bytes,
|
||||||
|
&info.path.to_string_lossy(),
|
||||||
|
)?;
|
||||||
|
Ok(ControlResponse::CasFetched {
|
||||||
|
peer_node_id: node_id,
|
||||||
|
peer_agent_id: agent_id,
|
||||||
|
endpoint_id,
|
||||||
|
hash: info.hash,
|
||||||
|
size_bytes: info.size_bytes,
|
||||||
|
allowed,
|
||||||
|
reason,
|
||||||
|
note,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
PeerControlResponse::CasFetched { .. } => Err(NodeError::IrohPeer(
|
||||||
|
"peer CAS fetch response did not match request".to_owned(),
|
||||||
|
)),
|
||||||
|
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
|
||||||
|
PeerControlResponse::Pong { .. } | PeerControlResponse::AuthChecked { .. } => Err(
|
||||||
|
NodeError::IrohPeer("peer returned wrong response type to CAS fetch".to_owned()),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -720,6 +864,74 @@ async fn handle_iroh_control_connection(
|
||||||
note: "protected peer request authenticated endpoint/card binding before resource capability evaluation".to_owned(),
|
note: "protected peer request authenticated endpoint/card binding before resource capability evaluation".to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
PeerControlRequest::CasFetch {
|
||||||
|
peer_card,
|
||||||
|
hash,
|
||||||
|
nonce,
|
||||||
|
} => {
|
||||||
|
peer_card.validate_candidate()?;
|
||||||
|
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
|
||||||
|
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,
|
||||||
|
})?;
|
||||||
|
let resource = "resource:cas:local".to_owned();
|
||||||
|
let capability = "cas.fetch".to_owned();
|
||||||
|
let explanation = geth_auth::explain_auth_ops(
|
||||||
|
&load_auth_ops_for_resource(&store, &resource)?,
|
||||||
|
PrincipalId::new(peer_card.node_id.to_string()),
|
||||||
|
ResourceId::new(resource),
|
||||||
|
Capability::new(capability),
|
||||||
|
);
|
||||||
|
if explanation.allowed {
|
||||||
|
match LocalCas::new(node.paths.cas_dir()).read_bytes(&hash) {
|
||||||
|
Ok(content) => {
|
||||||
|
let size_bytes = content.len() as u64;
|
||||||
|
PeerControlResponse::CasFetched {
|
||||||
|
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,
|
||||||
|
hash,
|
||||||
|
size_bytes,
|
||||||
|
content_base64: Some(
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(content),
|
||||||
|
),
|
||||||
|
allowed: true,
|
||||||
|
reason: explanation.reason,
|
||||||
|
evaluated_ops: explanation.evaluated_ops,
|
||||||
|
nonce,
|
||||||
|
note: "CAS fetch authenticated endpoint/card binding and required cas.fetch on resource:cas:local".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => PeerControlResponse::Error {
|
||||||
|
message: format!("CAS blob {hash} is not available: {error}"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PeerControlResponse::CasFetched {
|
||||||
|
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,
|
||||||
|
hash,
|
||||||
|
size_bytes: 0,
|
||||||
|
content_base64: None,
|
||||||
|
allowed: false,
|
||||||
|
reason: explanation.reason,
|
||||||
|
evaluated_ops: explanation.evaluated_ops,
|
||||||
|
nonce,
|
||||||
|
note: "CAS fetch authenticated endpoint/card binding and required cas.fetch on resource:cas:local".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
send.write_all(geth_control::encode_peer_response(&response)?.as_bytes())
|
send.write_all(geth_control::encode_peer_response(&response)?.as_bytes())
|
||||||
.await
|
.await
|
||||||
|
|
@ -855,6 +1067,7 @@ pub fn handle_request(
|
||||||
}
|
}
|
||||||
ControlRequest::PeerPing { .. } => Err(NodeError::IrohEndpointUnavailable),
|
ControlRequest::PeerPing { .. } => Err(NodeError::IrohEndpointUnavailable),
|
||||||
ControlRequest::PeerAuthCheck { .. } => Err(NodeError::IrohEndpointUnavailable),
|
ControlRequest::PeerAuthCheck { .. } => Err(NodeError::IrohEndpointUnavailable),
|
||||||
|
ControlRequest::CasFetch { .. } => Err(NodeError::IrohEndpointUnavailable),
|
||||||
ControlRequest::ResourceList => Ok(ControlResponse::ResourceList {
|
ControlRequest::ResourceList => Ok(ControlResponse::ResourceList {
|
||||||
resources: store
|
resources: store
|
||||||
.list_resources()?
|
.list_resources()?
|
||||||
|
|
@ -2330,6 +2543,9 @@ mod tests {
|
||||||
updated_at_ms: geth_store::now_ms(),
|
updated_at_ms: geth_store::now_ms(),
|
||||||
})
|
})
|
||||||
.expect("insert right peer");
|
.expect("insert right peer");
|
||||||
|
let right_blob = LocalCas::new(right.paths.cas_dir())
|
||||||
|
.add_bytes(b"remote cas bytes")
|
||||||
|
.expect("right cas add");
|
||||||
|
|
||||||
let ping = handle_request_async(
|
let ping = handle_request_async(
|
||||||
&left,
|
&left,
|
||||||
|
|
@ -2380,6 +2596,34 @@ mod tests {
|
||||||
other => panic!("unexpected denied auth response: {other:?}"),
|
other => panic!("unexpected denied auth response: {other:?}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let denied_fetch = handle_request_async(
|
||||||
|
&left,
|
||||||
|
ControlRequest::CasFetch {
|
||||||
|
node: right_card.node_id.to_string(),
|
||||||
|
hash: right_blob.hash.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("denied cas fetch");
|
||||||
|
match denied_fetch {
|
||||||
|
ControlResponse::CasFetched {
|
||||||
|
allowed,
|
||||||
|
reason,
|
||||||
|
size_bytes,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert!(!allowed);
|
||||||
|
assert_eq!(size_bytes, 0);
|
||||||
|
assert!(reason.contains("no active direct or group grant"));
|
||||||
|
assert!(
|
||||||
|
!LocalCas::new(left.paths.cas_dir())
|
||||||
|
.has(&right_blob.hash)
|
||||||
|
.expect("left cas has after denied fetch")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected denied CAS fetch response: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
handle_request(
|
handle_request(
|
||||||
&right,
|
&right,
|
||||||
ControlRequest::AuthGrant {
|
ControlRequest::AuthGrant {
|
||||||
|
|
@ -2415,6 +2659,39 @@ mod tests {
|
||||||
other => panic!("unexpected allowed auth response: {other:?}"),
|
other => panic!("unexpected allowed auth response: {other:?}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let fetched = handle_request_async(
|
||||||
|
&left,
|
||||||
|
ControlRequest::CasFetch {
|
||||||
|
node: right_card.node_id.to_string(),
|
||||||
|
hash: right_blob.hash.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("allowed cas fetch");
|
||||||
|
match fetched {
|
||||||
|
ControlResponse::CasFetched {
|
||||||
|
allowed,
|
||||||
|
hash,
|
||||||
|
size_bytes,
|
||||||
|
reason,
|
||||||
|
note,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert!(allowed);
|
||||||
|
assert_eq!(hash, right_blob.hash);
|
||||||
|
assert_eq!(size_bytes, right_blob.size_bytes);
|
||||||
|
assert!(reason.contains("direct grant"));
|
||||||
|
assert!(note.contains("required cas.fetch"));
|
||||||
|
assert_eq!(
|
||||||
|
LocalCas::new(left.paths.cas_dir())
|
||||||
|
.read_bytes(&right_blob.hash)
|
||||||
|
.expect("left cas read fetched blob"),
|
||||||
|
b"remote cas bytes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected allowed CAS fetch response: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
left_endpoint.shutdown().await;
|
left_endpoint.shutdown().await;
|
||||||
right_endpoint.shutdown().await;
|
right_endpoint.shutdown().await;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,9 +111,16 @@ into CAS tree objects while reporting create/update/delete/rename changes. These
|
||||||
scans are local metadata only and never overwrite the working tree. The daemon
|
scans are local metadata only and never overwrite the working tree. The daemon
|
||||||
also has durable local file-conflict records with explicit resolution choices;
|
also has durable local file-conflict records with explicit resolution choices;
|
||||||
future cross-node file sync will create those records automatically instead of
|
future cross-node file sync will create those records automatically instead of
|
||||||
silently applying ambiguous remote changes. Iroh-blobs, providers, encrypted
|
silently applying ambiguous remote changes.
|
||||||
blobs, richer cache policies, cross-node file roots, and automatic conflict
|
|
||||||
detection are future work.
|
As a bootstrap network path, `geth cas fetch <node-id> <hash>` dials an
|
||||||
|
imported signed peer card over the daemon-owned Iroh control ALPN. The serving
|
||||||
|
daemon validates the caller's peer-card signature and observed Iroh EndpointID,
|
||||||
|
then reduces local auth ops and requires `cas.fetch` on `resource:cas:local`
|
||||||
|
before returning blob bytes. The requester verifies that the returned bytes hash
|
||||||
|
to the requested BLAKE3 CAS hash before storing them. Iroh-blobs, provider
|
||||||
|
tracking, encrypted blobs, richer cache policies, cross-node file roots, and
|
||||||
|
automatic conflict detection are future work.
|
||||||
|
|
||||||
`geth-db` currently registers local SQLite paths as DB resources and reports
|
`geth-db` currently registers local SQLite paths as DB resources and reports
|
||||||
local-only sync status plus a read-only SQLite schema summary/hash. It also
|
local-only sync status plus a read-only SQLite schema summary/hash. It also
|
||||||
|
|
|
||||||
|
|
@ -218,11 +218,19 @@ resource-scoped capability decisions.
|
||||||
Goal: turn local CAS and stubs into Iroh-backed replicated modules while keeping
|
Goal: turn local CAS and stubs into Iroh-backed replicated modules while keeping
|
||||||
authorization and durable-state boundaries clear.
|
authorization and durable-state boundaries clear.
|
||||||
|
|
||||||
- `[ ]` Iroh-blobs CAS integration.
|
- `[~]` Iroh-blobs CAS integration.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- Local CAS can provide and fetch blobs over Iroh.
|
- `[x]` `geth cas fetch <node-id> <hash>` can fetch a blob from an imported
|
||||||
- Provider tracking is recorded locally.
|
peer over the daemon-owned Iroh control ALPN.
|
||||||
- Local add/get/hash/has/list behavior remains backward compatible.
|
- `[x]` The serving peer validates the caller's signed peer card against the
|
||||||
|
observed Iroh EndpointID before considering authorization.
|
||||||
|
- `[x]` Remote CAS fetch requires `cas.fetch` on `resource:cas:local`.
|
||||||
|
- `[x]` The requester verifies returned bytes against the requested BLAKE3
|
||||||
|
CAS hash before storing them locally.
|
||||||
|
- `[x]` Local add/get/hash/has/list behavior remains backward compatible.
|
||||||
|
- `[ ]` Replace the bootstrap control-ALPN transfer with `iroh-blobs`
|
||||||
|
provider/fetch behavior.
|
||||||
|
- `[ ]` Provider tracking is recorded locally.
|
||||||
|
|
||||||
- `[x]` CAS pin and cache policy.
|
- `[x]` CAS pin and cache policy.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue