Add guided node enrollment
This commit is contained in:
parent
678865bfdc
commit
ce3e029b3d
8 changed files with 490 additions and 18 deletions
|
|
@ -3516,6 +3516,142 @@ async fn node_enrollment_submit_to_peer(
|
|||
}
|
||||
}
|
||||
|
||||
async fn node_enrollment_join_owner(
|
||||
node: &LocalNode,
|
||||
peer_card_path: PathBuf,
|
||||
admin_key_path: PathBuf,
|
||||
node_name: String,
|
||||
capabilities: Vec<String>,
|
||||
reason: Option<String>,
|
||||
out: Option<PathBuf>,
|
||||
) -> Result<ControlResponse, NodeError> {
|
||||
let (discovered, request, admin_key_id) = prepare_node_enrollment_join(
|
||||
node,
|
||||
&peer_card_path,
|
||||
&admin_key_path,
|
||||
node_name,
|
||||
capabilities,
|
||||
reason,
|
||||
out.as_deref(),
|
||||
)?;
|
||||
let owner_node = discovered.card.node_id.to_string();
|
||||
let submission = node_enrollment_submit_to_peer(
|
||||
node,
|
||||
&owner_node,
|
||||
Some(request.id.to_string()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
NodeError::IrohPeer(format!(
|
||||
"imported owner candidate and created enrollment request {}, but submission failed: {error}\nnext: retry with `geth node enroll submit {owner_node} --request-id {}`",
|
||||
request.id, request.id
|
||||
))
|
||||
})?;
|
||||
let ControlResponse::NodeEnrollmentSubmitted {
|
||||
owner_node_id,
|
||||
accepted,
|
||||
note,
|
||||
..
|
||||
} = submission
|
||||
else {
|
||||
return Err(NodeError::IrohPeer(
|
||||
"owner returned an unexpected enrollment response".to_owned(),
|
||||
));
|
||||
};
|
||||
Ok(ControlResponse::NodeEnrollmentJoined {
|
||||
peer: discovered,
|
||||
request,
|
||||
out,
|
||||
owner_node_id,
|
||||
admin_key_id,
|
||||
accepted,
|
||||
note: format!(
|
||||
"{}; {note}; owner approval with an admin signing key is still required",
|
||||
discovery_is_untrusted_note()
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn prepare_node_enrollment_join(
|
||||
node: &LocalNode,
|
||||
peer_card_path: &Path,
|
||||
admin_key_path: &Path,
|
||||
node_name: String,
|
||||
capabilities: Vec<String>,
|
||||
reason: Option<String>,
|
||||
out: Option<&Path>,
|
||||
) -> Result<(DiscoveredPeer, NodeEnrollmentRequest, String), NodeError> {
|
||||
let admin_public_key = std::fs::read_to_string(admin_key_path)?;
|
||||
if admin_public_key.trim().is_empty() {
|
||||
return Err(NodeError::IrohPeer(
|
||||
"owner admin public key file is empty".to_owned(),
|
||||
));
|
||||
}
|
||||
validate_openssh_public_key_text(&admin_public_key)?;
|
||||
let admin_key_id = ssh_public_key_fingerprint(&admin_public_key);
|
||||
let card_json = std::fs::read_to_string(peer_card_path)?;
|
||||
let card: PeerCard = serde_json::from_str(&card_json)?;
|
||||
card.validate_candidate()?;
|
||||
let discovered = DiscoveredPeer::candidate(
|
||||
card.clone(),
|
||||
UnixMillis(geth_store::now_ms()),
|
||||
DiscoverySource::Imported,
|
||||
)?;
|
||||
let store = Store::open(&node.paths.metadata_db())?;
|
||||
let admin_key = KeyId::new(admin_key_id.clone());
|
||||
let keychain = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?);
|
||||
if !keychain.admin_keys.contains(&admin_key) {
|
||||
handle_request(
|
||||
node,
|
||||
ControlRequest::KeychainInit {
|
||||
admin_key_path: Some(admin_key_path.to_path_buf()),
|
||||
signing_key_path: None,
|
||||
},
|
||||
)?;
|
||||
}
|
||||
store.upsert_peer_card(&StoredPeerCard {
|
||||
peer_id: card.node_id.to_string(),
|
||||
card_json: serde_json::to_string(&card)?,
|
||||
updated_at_ms: discovered.discovered_at.0,
|
||||
})?;
|
||||
|
||||
let mut request = create_node_enrollment_request(node, node_name, capabilities, reason)?;
|
||||
sign_node_enrollment_request(node, &mut request)?;
|
||||
store_node_enrollment_request(&store, &request)?;
|
||||
if let Some(path) = out {
|
||||
write_node_enrollment_request_file(path, &request)?;
|
||||
}
|
||||
Ok((discovered, request, admin_key_id))
|
||||
}
|
||||
|
||||
fn validate_openssh_public_key_text(public_key: &str) -> Result<(), NodeError> {
|
||||
let mut fields = public_key.split_whitespace();
|
||||
let key_type = fields.next().unwrap_or_default();
|
||||
let encoded = fields.next().unwrap_or_default();
|
||||
let recognized_type = key_type.starts_with("ssh-")
|
||||
|| key_type.starts_with("ecdsa-sha2-")
|
||||
|| key_type.starts_with("sk-");
|
||||
if !recognized_type || encoded.is_empty() {
|
||||
return Err(NodeError::IrohPeer(
|
||||
"owner admin key must be an OpenSSH public key, for example `ssh-ed25519 AAAA...`"
|
||||
.to_owned(),
|
||||
));
|
||||
}
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(encoded))
|
||||
.map_err(|_| {
|
||||
NodeError::IrohPeer("owner admin key contains invalid base64 key data".to_owned())
|
||||
})?;
|
||||
if decoded.is_empty() {
|
||||
return Err(NodeError::IrohPeer(
|
||||
"owner admin key contains empty key data".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn node_enrollment_sync_from_owner(
|
||||
node: &LocalNode,
|
||||
owner_node: &str,
|
||||
|
|
@ -7571,6 +7707,7 @@ pub fn handle_request(
|
|||
| ControlRequest::SshAdminShell { .. }
|
||||
| ControlRequest::KeychainSync { .. }
|
||||
| ControlRequest::AuthSync { .. }
|
||||
| ControlRequest::NodeEnrollJoin { .. }
|
||||
| ControlRequest::NodeEnrollSubmit { .. }
|
||||
| ControlRequest::NodeEnrollSync { .. } => Err(NodeError::IrohEndpointUnavailable),
|
||||
ControlRequest::ModuleStub { module, command } => {
|
||||
|
|
@ -13823,6 +13960,176 @@ mod tests {
|
|||
right_endpoint.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guided_enrollment_imports_creates_and_submits_in_one_request() {
|
||||
if skip_iroh_integration_tests() {
|
||||
eprintln!("skipping Iroh integration test because GETH_TEST_SKIP_IROH is set");
|
||||
return;
|
||||
}
|
||||
let requester_home = tempfile::tempdir().expect("requester home");
|
||||
let owner_home = tempfile::tempdir().expect("owner home");
|
||||
let requester_paths = GethPaths::from_home(requester_home.path());
|
||||
let owner_paths = GethPaths::from_home(owner_home.path());
|
||||
let mut requester = init_node(&requester_paths).expect("init requester");
|
||||
let mut owner = init_node(&owner_paths).expect("init owner");
|
||||
write_offline_iroh_config(&requester_paths);
|
||||
write_offline_iroh_config(&owner_paths);
|
||||
|
||||
let Some(requester_endpoint) = start_daemon_iroh_endpoint(&mut requester)
|
||||
.await
|
||||
.expect("requester iroh")
|
||||
else {
|
||||
eprintln!("skipping guided enrollment; requester endpoint unavailable");
|
||||
return;
|
||||
};
|
||||
let Some(owner_endpoint) = start_daemon_iroh_endpoint(&mut owner)
|
||||
.await
|
||||
.expect("owner iroh")
|
||||
else {
|
||||
eprintln!("skipping guided enrollment; owner endpoint unavailable");
|
||||
requester_endpoint.shutdown().await;
|
||||
return;
|
||||
};
|
||||
spawn_iroh_control_accept_loop(owner.clone(), owner_endpoint.clone());
|
||||
|
||||
let exported = handle_request_async(&owner, ControlRequest::PeerCardExport { out: None })
|
||||
.await
|
||||
.expect("export owner peer card");
|
||||
let owner_card = match exported {
|
||||
ControlResponse::PeerCardExported { card, .. } => card,
|
||||
other => panic!("unexpected peer export response: {other:?}"),
|
||||
};
|
||||
let card_path = requester_home.path().join("owner.peer.json");
|
||||
std::fs::write(
|
||||
&card_path,
|
||||
serde_json::to_string_pretty(&owner_card).expect("card json"),
|
||||
)
|
||||
.expect("write owner peer card");
|
||||
let admin_key_path = requester_home.path().join("owner-admin.pub");
|
||||
std::fs::write(
|
||||
&admin_key_path,
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGV0aA== owner\n",
|
||||
)
|
||||
.expect("write owner admin public key");
|
||||
|
||||
let joined = handle_request_async(
|
||||
&requester,
|
||||
ControlRequest::NodeEnrollJoin {
|
||||
peer_card_path: card_path,
|
||||
admin_key_path,
|
||||
node_name: "workstation".to_owned(),
|
||||
capabilities: vec!["resource:kv:preferences=kv.read".to_owned()],
|
||||
reason: Some("new workstation".to_owned()),
|
||||
out: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("guided enrollment");
|
||||
let request_id = match joined {
|
||||
ControlResponse::NodeEnrollmentJoined {
|
||||
request,
|
||||
owner_node_id,
|
||||
admin_key_id,
|
||||
accepted,
|
||||
peer,
|
||||
..
|
||||
} => {
|
||||
assert!(accepted);
|
||||
assert_eq!(owner_node_id, owner.node_id.to_string());
|
||||
assert_eq!(peer.card.node_id.to_string(), owner.node_id);
|
||||
assert!(admin_key_id.starts_with("ssh:blake3:"));
|
||||
request.id.to_string()
|
||||
}
|
||||
other => panic!("unexpected guided enrollment response: {other:?}"),
|
||||
};
|
||||
|
||||
let received = handle_request(&owner, ControlRequest::NodeEnrollList { status: None })
|
||||
.expect("list owner requests");
|
||||
match received {
|
||||
ControlResponse::NodeEnrollmentList { requests, .. } => {
|
||||
assert!(
|
||||
requests
|
||||
.iter()
|
||||
.any(|request| request.id.to_string() == request_id)
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected enrollment list response: {other:?}"),
|
||||
}
|
||||
|
||||
requester_endpoint.shutdown().await;
|
||||
owner_endpoint.shutdown().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guided_enrollment_preparation_records_explicit_trust_peer_and_request() {
|
||||
assert!(validate_openssh_public_key_text("not-a-public-key").is_err());
|
||||
let home = tempfile::tempdir().expect("requester home");
|
||||
let paths = GethPaths::from_home(home.path());
|
||||
let requester = init_node(&paths).expect("init requester");
|
||||
let owner_key = AgentKey::generate();
|
||||
let owner_card = PeerCard::signed(
|
||||
"node:owner".into(),
|
||||
&owner_key,
|
||||
vec![EndpointCandidate {
|
||||
endpoint_id: "endpoint:owner".to_owned(),
|
||||
relay_url: None,
|
||||
direct_addresses: Vec::new(),
|
||||
source: DiscoverySource::Manual,
|
||||
}],
|
||||
UnixMillis(1),
|
||||
)
|
||||
.expect("owner peer card");
|
||||
let card_path = home.path().join("owner.peer.json");
|
||||
std::fs::write(
|
||||
&card_path,
|
||||
serde_json::to_string_pretty(&owner_card).expect("card json"),
|
||||
)
|
||||
.expect("write card");
|
||||
let admin_key_path = home.path().join("owner-admin.pub");
|
||||
std::fs::write(
|
||||
&admin_key_path,
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGV0aA== owner\n",
|
||||
)
|
||||
.expect("write admin key");
|
||||
let request_path = home.path().join("request.json");
|
||||
|
||||
let (peer, request, admin_key_id) = prepare_node_enrollment_join(
|
||||
&requester,
|
||||
&card_path,
|
||||
&admin_key_path,
|
||||
"workstation".to_owned(),
|
||||
vec!["resource:kv:preferences=kv.read".to_owned()],
|
||||
Some("new workstation".to_owned()),
|
||||
Some(&request_path),
|
||||
)
|
||||
.expect("prepare guided enrollment");
|
||||
assert_eq!(peer.card.node_id.to_string(), "node:owner");
|
||||
assert!(admin_key_id.starts_with("ssh:blake3:"));
|
||||
assert_eq!(request.requested_node_name, "workstation");
|
||||
assert!(request.provenance.is_some());
|
||||
assert!(request_path.is_file());
|
||||
|
||||
let store = Store::open(&paths.metadata_db()).expect("open store");
|
||||
assert!(
|
||||
store
|
||||
.get_peer_card("node:owner")
|
||||
.expect("get peer")
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_node_enrollment_request(request.id.as_str())
|
||||
.expect("get request")
|
||||
.is_some()
|
||||
);
|
||||
let status =
|
||||
handle_request(&requester, ControlRequest::KeychainStatus).expect("keychain status");
|
||||
match status {
|
||||
ControlResponse::KeychainStatus(status) => assert_eq!(status.admin_keys, 1),
|
||||
other => panic!("unexpected keychain status: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_tree(entries: Vec<geth_cas::CasTreeEntry>) -> CasTreeObject {
|
||||
CasTreeObject {
|
||||
version: geth_cas::CAS_TREE_OBJECT_VERSION,
|
||||
|
|
|
|||
Loading…
Reference in a new issue