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([