Add guided node enrollment

This commit is contained in:
Eric Wendland 2026-07-18 16:48:35 +02:00
commit ce3e029b3d
8 changed files with 490 additions and 18 deletions

View file

@ -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,

View file

@ -81,6 +81,25 @@ async fn route_peer_family(
request_id,
path,
} => node_enrollment_submit_to_peer(node, &owner_node, request_id, path).await,
ControlRequest::NodeEnrollJoin {
peer_card_path,
admin_key_path,
node_name,
capabilities,
reason,
out,
} => {
node_enrollment_join_owner(
node,
peer_card_path,
admin_key_path,
node_name,
capabilities,
reason,
out,
)
.await
}
ControlRequest::NodeEnrollSync { owner_node } => {
node_enrollment_sync_from_owner(node, &owner_node).await
}
@ -266,6 +285,9 @@ pub(crate) fn control_request_trace_fields(request: &ControlRequest) -> ControlT
trace.peer_node = node.clone();
trace.stream = Some("all".to_owned());
}
ControlRequest::NodeEnrollJoin { .. } => {
trace.stream = Some("node-enrollment".to_owned());
}
ControlRequest::NodeGrant {
node,
resource,
@ -450,6 +472,7 @@ fn control_request_command(request: &ControlRequest) -> &'static str {
ControlRequest::ResourceCreate { .. } => "resource.create",
ControlRequest::KeychainSync { .. } => "keychain.sync",
ControlRequest::AuthSync { .. } => "auth.sync",
ControlRequest::NodeEnrollJoin { .. } => "node.enroll.join",
ControlRequest::SyncStatus => "sync.status",
ControlRequest::SyncNow { .. } => "sync.now",
ControlRequest::AuthExplain { .. } => "auth.explain",