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

@ -174,21 +174,26 @@ const GUIDE_ENROLLMENT: &str = r#"Add another node/device:
On the new node:
geth init
geth keychain init --admin-key ~/.ssh/id_ed25519_sk.pub
geth peer import /tmp/owner.peer.json
geth node enroll request --node-name workstation --out /tmp/workstation-enrollment.json
geth node enroll submit owner --path /tmp/workstation-enrollment.json
geth daemon install
geth node enroll join /tmp/owner.peer.json \
--admin-key /tmp/owner-admin.pub \
--node-name workstation
On the owner/YubiKey machine:
geth node enroll list
geth node enroll approve <request-id> --signing-key ~/.ssh/id_ed25519_sk
Back on the new node:
geth sync now owner
geth node list
geth node enroll sync <owner-node-id>
geth keychain status
Enrollment approval records signed keychain/auth operations. Discovery and peer
cards alone never grant trust or capabilities.
cards alone never grant trust or capabilities. The join command combines peer
import, explicit admin public-key trust bootstrap, signed request creation, and
Iroh submission; it does not approve the request. Verify the admin public key
through a separate trusted channel before using it. For offline transfer or
recovery, the lower-level `request`, `submit`, and `import` commands remain
available.
"#;
const GUIDE_KEYS: &str = r#"Key terminology:
@ -819,6 +824,23 @@ pub enum NodeCommand {
#[derive(Debug, Subcommand)]
pub enum NodeEnrollCommand {
/// Import an owner peer card, create a request, and submit it in one step
#[command(
after_help = "Example:\n geth node enroll join owner.peer.json --admin-key owner-admin.pub --node-name workstation --capability resource:kv:preferences=kv.read\n\nThe explicitly supplied admin public key bootstraps trust for later approval sync. Peer-card import supplies candidate routing metadata only. Owner approval remains a separate, admin-signed action."
)]
Join {
peer_card_path: PathBuf,
#[arg(long)]
admin_key: PathBuf,
#[arg(long)]
node_name: String,
#[arg(long = "capability")]
capabilities: Vec<String>,
#[arg(long)]
reason: Option<String>,
#[arg(long)]
out: Option<PathBuf>,
},
/// Create a signed enrollment request on the new node
#[command(
after_help = "Examples:\n geth node enroll request --node-name workstation --out workstation.enroll.json\n geth node enroll request --node-name ci-runner --capability resource:kv:builds=kv.read"
@ -1639,6 +1661,18 @@ fn argument_help(path: &str, id: &str) -> Option<&'static str> {
("geth node enroll request", "capabilities") => {
Some("Requested RESOURCE=CAPABILITY pair; repeat to request more than one")
}
("geth node enroll join", "peer_card_path") => {
Some("Signed owner peer-card JSON file to import as an untrusted candidate")
}
("geth node enroll join", "admin_key") => {
Some("Owner OpenSSH admin public key used as the explicit trust anchor")
}
("geth node enroll join", "capabilities") => {
Some("Requested RESOURCE=CAPABILITY pair; repeat to request more than one")
}
("geth node enroll join" | "geth node enroll request", "out") => {
Some("Optional path for a portable enrollment-request JSON copy")
}
("geth kv create" | "geth kv set" | "geth kv get" | "geth kv sync", "name") => {
Some("Name of the KV store")
}
@ -2244,6 +2278,21 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
Command::Node {
command: NodeCommand::Enroll { command },
} => match command {
NodeEnrollCommand::Join {
peer_card_path,
admin_key,
node_name,
capabilities,
reason,
out,
} => ControlRequest::NodeEnrollJoin {
peer_card_path,
admin_key_path: admin_key,
node_name,
capabilities,
reason,
out,
},
NodeEnrollCommand::Request {
node_name,
capabilities,
@ -4397,6 +4446,28 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
}
println!("note: {note}");
}
ControlResponse::NodeEnrollmentJoined {
peer,
request,
out,
owner_node_id,
admin_key_id,
accepted,
note,
} => {
println!("imported owner candidate: {}", peer.card.node_id);
println!("trust: candidate-only");
println!("admin trust anchor: {admin_key_id}");
println!("node enrollment request: {}", request.id);
println!("requested_name: {}", request.requested_node_name);
if let Some(out) = out {
println!("written: {}", out.display());
}
println!("submitted_to: {owner_node_id}");
println!("accepted: {accepted}");
println!("next: the owner reviews and approves this request with an admin key");
println!("note: {note}");
}
ControlResponse::NodeEnrollmentSubmitted {
request_id,
owner_node_id,
@ -5349,6 +5420,32 @@ mod tests {
assert_eq!(config.sync.live_sync_interval_ms, 500);
}
#[test]
fn guided_enrollment_join_parses_the_common_new_node_workflow() {
let parsed = Cli::try_parse_from([
"geth",
"node",
"enroll",
"join",
"owner.peer.json",
"--admin-key",
"owner-admin.pub",
"--node-name",
"workstation",
"--capability",
"resource:kv:preferences=kv.read",
])
.expect("parse guided enrollment");
assert!(matches!(
parsed.command,
Command::Node {
command: NodeCommand::Enroll {
command: NodeEnrollCommand::Join { .. }
}
}
));
}
#[tokio::test]
async fn ephemeral_daemon_rejects_an_explicit_persistent_home() {
let parsed = Cli::try_parse_from([

View file

@ -203,6 +203,14 @@ pub enum ControlRequest {
reason: Option<String>,
out: Option<PathBuf>,
},
NodeEnrollJoin {
peer_card_path: PathBuf,
admin_key_path: PathBuf,
node_name: String,
capabilities: Vec<String>,
reason: Option<String>,
out: Option<PathBuf>,
},
NodeEnrollSubmit {
owner_node: String,
request_id: Option<String>,
@ -886,6 +894,15 @@ pub enum ControlResponse {
out: Option<PathBuf>,
note: String,
},
NodeEnrollmentJoined {
peer: DiscoveredPeer,
request: NodeEnrollmentRequest,
out: Option<PathBuf>,
owner_node_id: String,
admin_key_id: String,
accepted: bool,
note: String,
},
NodeEnrollmentSubmitted {
request_id: String,
owner_node_id: String,
@ -1817,8 +1834,8 @@ mod tests {
use super::*;
use serde_json::{Map, Value, json};
const CONTROL_REQUEST_VARIANTS: usize = 122;
const CONTROL_RESPONSE_VARIANTS: usize = 114;
const CONTROL_REQUEST_VARIANTS: usize = 123;
const CONTROL_RESPONSE_VARIANTS: usize = 115;
const PEER_CONTROL_REQUEST_VARIANTS: usize = 19;
const PEER_CONTROL_RESPONSE_VARIANTS: usize = 20;
const PIPE_WIRE_REQUEST_VARIANTS: usize = 3;
@ -2518,6 +2535,19 @@ mod tests {
request
);
let request = ControlRequest::NodeEnrollJoin {
peer_card_path: PathBuf::from("owner.peer.json"),
admin_key_path: PathBuf::from("owner-admin.pub"),
node_name: "workstation".to_owned(),
capabilities: vec!["resource:ssh-proxy:local=ssh_proxy.connect".to_owned()],
reason: Some("new machine".to_owned()),
out: Some(PathBuf::from("enrollment.json")),
};
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request
);
let request = ControlRequest::NodeEnrollSync {
owner_node: "owner-laptop".to_owned(),
};

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