Add authorized file-root sync
This commit is contained in:
parent
0e3ca7e80a
commit
1caaedda90
7 changed files with 489 additions and 11 deletions
|
|
@ -257,6 +257,11 @@ pub async fn handle_request_async(
|
|||
hash,
|
||||
bearer_secret,
|
||||
} => cas_fetch_from_peer(node, &peer_node, hash, bearer_secret).await,
|
||||
ControlRequest::CasRootSync {
|
||||
node: peer_node,
|
||||
name,
|
||||
bearer_secret,
|
||||
} => cas_root_sync_from_peer(node, &peer_node, &name, bearer_secret).await,
|
||||
ControlRequest::SshCertSync {
|
||||
node: peer_node,
|
||||
bearer_secret,
|
||||
|
|
@ -581,6 +586,7 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result<ControlResponse,
|
|||
)),
|
||||
PeerControlResponse::SyncStatus { .. }
|
||||
| PeerControlResponse::CasFetched { .. }
|
||||
| PeerControlResponse::CasRootSynced { .. }
|
||||
| PeerControlResponse::SshCertSynced { .. }
|
||||
| PeerControlResponse::SshRevocationSynced { .. }
|
||||
| PeerControlResponse::KvSynced { .. }
|
||||
|
|
@ -697,6 +703,7 @@ async fn peer_auth_check(
|
|||
)),
|
||||
PeerControlResponse::SyncStatus { .. }
|
||||
| PeerControlResponse::CasFetched { .. }
|
||||
| PeerControlResponse::CasRootSynced { .. }
|
||||
| PeerControlResponse::SshCertSynced { .. }
|
||||
| PeerControlResponse::SshRevocationSynced { .. }
|
||||
| PeerControlResponse::KvSynced { .. }
|
||||
|
|
@ -845,6 +852,7 @@ async fn cas_fetch_from_peer(
|
|||
PeerControlResponse::Pong { .. }
|
||||
| PeerControlResponse::AuthChecked { .. }
|
||||
| PeerControlResponse::SyncStatus { .. }
|
||||
| PeerControlResponse::CasRootSynced { .. }
|
||||
| PeerControlResponse::SshCertSynced { .. }
|
||||
| PeerControlResponse::SshRevocationSynced { .. }
|
||||
| PeerControlResponse::KvSynced { .. }
|
||||
|
|
@ -860,6 +868,137 @@ async fn cas_fetch_from_peer(
|
|||
}
|
||||
}
|
||||
|
||||
async fn cas_root_sync_from_peer(
|
||||
node: &LocalNode,
|
||||
peer_node: &str,
|
||||
name: &str,
|
||||
bearer_secret: Option<String>,
|
||||
) -> Result<ControlResponse, NodeError> {
|
||||
geth_cas::validate_file_root_name(name)?;
|
||||
let response = request_peer_control(node, peer_node, "cas-root-sync", |peer_card, nonce| {
|
||||
PeerControlRequest::CasRootSync {
|
||||
peer_card,
|
||||
name: name.to_owned(),
|
||||
nonce: nonce.clone(),
|
||||
bearer_proof: bearer_proof(
|
||||
bearer_secret,
|
||||
&format!("resource:cas-tree:{name}"),
|
||||
"cas.fetch",
|
||||
&nonce,
|
||||
),
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
match response {
|
||||
PeerControlResponse::CasRootSynced {
|
||||
node_id,
|
||||
agent_id,
|
||||
endpoint_id,
|
||||
name: response_name,
|
||||
root,
|
||||
tree_content_base64,
|
||||
allowed,
|
||||
reason,
|
||||
note,
|
||||
..
|
||||
} if response_name == name => {
|
||||
if !allowed {
|
||||
return Ok(ControlResponse::CasRootSynced {
|
||||
peer_node_id: node_id,
|
||||
peer_agent_id: agent_id,
|
||||
endpoint_id,
|
||||
name: response_name,
|
||||
root: None,
|
||||
tree_bytes_imported: false,
|
||||
allowed,
|
||||
reason,
|
||||
note,
|
||||
});
|
||||
}
|
||||
let store = Store::open(&node.paths.metadata_db())?;
|
||||
let mut tree_bytes_imported = false;
|
||||
let root = root
|
||||
.map(|remote_root| {
|
||||
let stored_name = remote_file_root_name(&node_id, &remote_root.name);
|
||||
let stored_path = format!("remote:{node_id}:{}", remote_root.name);
|
||||
if let Some(existing) = store.get_file_root_by_name(&stored_name)? {
|
||||
if existing.path != stored_path {
|
||||
return Err(NodeError::IrohPeer(format!(
|
||||
"refusing to overwrite existing file root {} at {}",
|
||||
existing.name, existing.path
|
||||
)));
|
||||
}
|
||||
}
|
||||
if let (Some(hash), Some(content)) = (
|
||||
remote_root.latest_tree.clone(),
|
||||
tree_content_base64.as_ref(),
|
||||
) {
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(content)
|
||||
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
|
||||
let info = LocalCas::new(node.paths.cas_dir()).add_bytes(&decoded)?;
|
||||
if info.hash != hash {
|
||||
return Err(NodeError::IrohPeer(format!(
|
||||
"peer returned file-root tree bytes hash {} for announced {}",
|
||||
info.hash, hash
|
||||
)));
|
||||
}
|
||||
store.record_cas_object(
|
||||
info.hash.as_str(),
|
||||
info.size_bytes,
|
||||
&info.path.to_string_lossy(),
|
||||
)?;
|
||||
tree_bytes_imported = true;
|
||||
}
|
||||
let stored = StoredFileRoot {
|
||||
root_id: format!("file-root:remote:{node_id}:{}", remote_root.name),
|
||||
resource_id: remote_root.resource.clone(),
|
||||
name: stored_name,
|
||||
path: stored_path,
|
||||
latest_tree_hash: remote_root.latest_tree.as_ref().map(ToString::to_string),
|
||||
latest_tree_json: None,
|
||||
updated_at_ms: remote_root.updated_at_ms,
|
||||
};
|
||||
store.upsert_file_root(&stored)?;
|
||||
Ok(file_root_from_stored(&stored))
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(ControlResponse::CasRootSynced {
|
||||
peer_node_id: node_id,
|
||||
peer_agent_id: agent_id,
|
||||
endpoint_id,
|
||||
name: response_name,
|
||||
root,
|
||||
tree_bytes_imported,
|
||||
allowed,
|
||||
reason,
|
||||
note,
|
||||
})
|
||||
}
|
||||
PeerControlResponse::CasRootSynced { .. } => Err(NodeError::IrohPeer(
|
||||
"peer file-root sync response did not match request".to_owned(),
|
||||
)),
|
||||
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
|
||||
_ => Err(NodeError::IrohPeer(
|
||||
"peer returned wrong response type to file-root sync".to_owned(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_file_root_name(peer_node_id: &str, remote_name: &str) -> String {
|
||||
let peer = peer_node_id
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
|
||||
ch
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
format!("remote-{peer}-{remote_name}")
|
||||
}
|
||||
|
||||
async fn ssh_cert_sync_from_peer(
|
||||
node: &LocalNode,
|
||||
peer_node: &str,
|
||||
|
|
@ -1932,6 +2071,10 @@ async fn request_peer_control(
|
|||
nonce: response_nonce,
|
||||
..
|
||||
}
|
||||
| PeerControlResponse::CasRootSynced {
|
||||
nonce: response_nonce,
|
||||
..
|
||||
}
|
||||
| PeerControlResponse::SshProxyConnected {
|
||||
nonce: response_nonce,
|
||||
..
|
||||
|
|
@ -2253,6 +2396,73 @@ async fn handle_iroh_control_connection(
|
|||
}
|
||||
}
|
||||
}
|
||||
PeerControlRequest::CasRootSync {
|
||||
peer_card,
|
||||
name,
|
||||
nonce,
|
||||
bearer_proof,
|
||||
} => {
|
||||
geth_cas::validate_file_root_name(&name)?;
|
||||
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 = format!("resource:cas-tree:{name}");
|
||||
let capability = "cas.fetch".to_owned();
|
||||
let explanation = explain_peer_or_bearer(
|
||||
&store,
|
||||
peer_card.node_id.as_str(),
|
||||
&resource,
|
||||
&capability,
|
||||
&nonce,
|
||||
bearer_proof.as_ref(),
|
||||
)?;
|
||||
let (root, tree_content_base64) = if explanation.allowed {
|
||||
if let Some(root) = store.get_file_root_by_name(&name)? {
|
||||
let root_view = file_root_from_stored(&root);
|
||||
let tree_content_base64 = root
|
||||
.latest_tree_hash
|
||||
.as_ref()
|
||||
.map(|hash| -> Result<Option<String>, NodeError> {
|
||||
let hash = BlobHash::new(hash.clone());
|
||||
let content = LocalCas::new(node.paths.cas_dir()).read_bytes(&hash)?;
|
||||
Ok(Some(
|
||||
base64::engine::general_purpose::STANDARD.encode(content),
|
||||
))
|
||||
})
|
||||
.transpose()?
|
||||
.flatten();
|
||||
(Some(root_view), tree_content_base64)
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
PeerControlResponse::CasRootSynced {
|
||||
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,
|
||||
name,
|
||||
root,
|
||||
tree_content_base64,
|
||||
allowed: explanation.allowed,
|
||||
reason: explanation.reason,
|
||||
evaluated_ops: explanation.evaluated_ops,
|
||||
nonce,
|
||||
note: "file-root sync authenticated endpoint/card binding and required cas.fetch on the remote cas-tree resource; it imports tree metadata and CAS tree bytes but never writes files".to_owned(),
|
||||
}
|
||||
}
|
||||
PeerControlRequest::SshCertSync {
|
||||
peer_card,
|
||||
since_ms,
|
||||
|
|
@ -2952,6 +3162,7 @@ pub fn handle_request(
|
|||
ControlRequest::PeerPing { .. } => Err(NodeError::IrohEndpointUnavailable),
|
||||
ControlRequest::PeerAuthCheck { .. } => Err(NodeError::IrohEndpointUnavailable),
|
||||
ControlRequest::CasFetch { .. } => Err(NodeError::IrohEndpointUnavailable),
|
||||
ControlRequest::CasRootSync { .. } => Err(NodeError::IrohEndpointUnavailable),
|
||||
ControlRequest::SshCertSync { .. } => Err(NodeError::IrohEndpointUnavailable),
|
||||
ControlRequest::SshRevocationSync { .. } => Err(NodeError::IrohEndpointUnavailable),
|
||||
ControlRequest::KvSync { .. } => Err(NodeError::IrohEndpointUnavailable),
|
||||
|
|
@ -5246,6 +5457,39 @@ mod tests {
|
|||
let right_blob = LocalCas::new(right.paths.cas_dir())
|
||||
.add_bytes(b"remote cas bytes")
|
||||
.expect("right cas add");
|
||||
let left_file_root = left_home.path().join("files");
|
||||
std::fs::create_dir_all(&left_file_root).expect("create left file root");
|
||||
std::fs::write(left_file_root.join("note.txt"), b"local tree").expect("write left file");
|
||||
handle_request(
|
||||
&left,
|
||||
ControlRequest::CasRootAdd {
|
||||
name: "shared".to_owned(),
|
||||
path: left_file_root.clone(),
|
||||
},
|
||||
)
|
||||
.expect("left file root add");
|
||||
let right_file_root = right_home.path().join("files");
|
||||
std::fs::create_dir_all(&right_file_root).expect("create right file root");
|
||||
std::fs::write(right_file_root.join("note.txt"), b"synced tree").expect("write right file");
|
||||
handle_request(
|
||||
&right,
|
||||
ControlRequest::CasRootAdd {
|
||||
name: "shared".to_owned(),
|
||||
path: right_file_root,
|
||||
},
|
||||
)
|
||||
.expect("right file root add");
|
||||
let right_tree_hash = match handle_request(
|
||||
&right,
|
||||
ControlRequest::CasRootScan {
|
||||
name: "shared".to_owned(),
|
||||
},
|
||||
)
|
||||
.expect("right file root scan")
|
||||
{
|
||||
ControlResponse::CasRootScanned { scan } => scan.tree.hash,
|
||||
other => panic!("unexpected right file root scan response: {other:?}"),
|
||||
};
|
||||
let right_pubkey = right_home.path().join("request.pub");
|
||||
std::fs::write(
|
||||
&right_pubkey,
|
||||
|
|
@ -5423,6 +5667,30 @@ mod tests {
|
|||
other => panic!("unexpected denied CAS fetch response: {other:?}"),
|
||||
}
|
||||
|
||||
let denied_root_sync = handle_request_async(
|
||||
&left,
|
||||
ControlRequest::CasRootSync {
|
||||
node: right_card.node_id.to_string(),
|
||||
name: "shared".to_owned(),
|
||||
bearer_secret: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("denied file root sync");
|
||||
match denied_root_sync {
|
||||
ControlResponse::CasRootSynced {
|
||||
allowed,
|
||||
root,
|
||||
reason,
|
||||
..
|
||||
} => {
|
||||
assert!(!allowed);
|
||||
assert!(root.is_none());
|
||||
assert!(reason.contains("no active direct or group grant"));
|
||||
}
|
||||
other => panic!("unexpected denied file root sync response: {other:?}"),
|
||||
}
|
||||
|
||||
let denied_kv_sync = handle_request_async(
|
||||
&left,
|
||||
ControlRequest::KvSync {
|
||||
|
|
@ -5672,6 +5940,16 @@ mod tests {
|
|||
},
|
||||
)
|
||||
.expect("grant left peer");
|
||||
handle_request(
|
||||
&right,
|
||||
ControlRequest::AuthGrant {
|
||||
subject: left.node_id.clone(),
|
||||
resource: "resource:cas-tree:shared".to_owned(),
|
||||
capability: "cas.fetch".to_owned(),
|
||||
grant_id: Some("grant:left-cas-tree-fetch".to_owned()),
|
||||
},
|
||||
)
|
||||
.expect("grant left file root fetch");
|
||||
handle_request(
|
||||
&right,
|
||||
ControlRequest::AuthGrant {
|
||||
|
|
@ -5817,6 +6095,56 @@ mod tests {
|
|||
assert_eq!(providers.len(), 1);
|
||||
assert_eq!(providers[0].peer_node_id, right.node_id);
|
||||
|
||||
let root_sync = handle_request_async(
|
||||
&left,
|
||||
ControlRequest::CasRootSync {
|
||||
node: right_card.node_id.to_string(),
|
||||
name: "shared".to_owned(),
|
||||
bearer_secret: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("allowed file root sync");
|
||||
match root_sync {
|
||||
ControlResponse::CasRootSynced {
|
||||
allowed,
|
||||
root,
|
||||
tree_bytes_imported,
|
||||
reason,
|
||||
note,
|
||||
..
|
||||
} => {
|
||||
assert!(allowed);
|
||||
assert!(tree_bytes_imported);
|
||||
let root = root.expect("synced root");
|
||||
assert_ne!(root.name, "shared");
|
||||
assert!(root.name.starts_with("remote-"));
|
||||
assert!(root.name.ends_with("-shared"));
|
||||
assert_eq!(root.latest_tree, Some(right_tree_hash.clone()));
|
||||
assert!(root.path.starts_with("remote:"));
|
||||
assert!(reason.contains("direct grant"));
|
||||
assert!(note.contains("never writes files"));
|
||||
}
|
||||
other => panic!("unexpected allowed file root sync response: {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
LocalCas::new(left.paths.cas_dir())
|
||||
.has(&right_tree_hash)
|
||||
.expect("left has synced tree object")
|
||||
);
|
||||
let local_shared = match handle_request(
|
||||
&left,
|
||||
ControlRequest::CasRootScan {
|
||||
name: "shared".to_owned(),
|
||||
},
|
||||
)
|
||||
.expect("left local shared root remains scannable")
|
||||
{
|
||||
ControlResponse::CasRootScanned { scan } => scan.root,
|
||||
other => panic!("unexpected left local file root scan response: {other:?}"),
|
||||
};
|
||||
assert_eq!(local_shared.path, left_file_root.to_string_lossy());
|
||||
|
||||
let kv_synced = handle_request_async(
|
||||
&left,
|
||||
ControlRequest::KvSync {
|
||||
|
|
|
|||
Loading…
Reference in a new issue