Add SSH cert flows and user service installer

This commit is contained in:
Eric Wendland 2026-05-16 00:17:08 +02:00
commit f302342b1c
21 changed files with 2158 additions and 14 deletions

View file

@ -2,6 +2,7 @@ use anyhow::{Context, Result, bail};
use clap::{Args, Parser, Subcommand};
use geth_config::GethPaths;
use geth_control::{ControlRequest, ControlResponse};
use geth_node::service::{ServiceInstallOptions, ServiceManager, ServiceReport};
use std::path::PathBuf;
#[derive(Debug, Parser)]
@ -76,6 +77,44 @@ pub enum Command {
#[derive(Debug, Subcommand)]
pub enum DaemonCommand {
Run,
Service {
#[command(subcommand)]
command: ServiceCommand,
},
}
#[derive(Debug, Subcommand)]
pub enum ServiceCommand {
Install {
#[arg(long, default_value = "auto")]
manager: String,
#[arg(long)]
bin: Option<PathBuf>,
#[arg(long)]
start: bool,
},
Uninstall {
#[arg(long, default_value = "auto")]
manager: String,
},
Start {
#[arg(long, default_value = "auto")]
manager: String,
},
Stop {
#[arg(long, default_value = "auto")]
manager: String,
},
Status {
#[arg(long, default_value = "auto")]
manager: String,
},
Print {
#[arg(long, default_value = "auto")]
manager: String,
#[arg(long)]
bin: Option<PathBuf>,
},
}
#[derive(Debug, Subcommand)]
@ -171,7 +210,68 @@ pub enum DocumentCommand {
#[derive(Debug, Subcommand)]
pub enum SshCommand {
Proxy { node: String },
Proxy {
node: String,
},
Cert {
#[command(subcommand)]
command: SshCertCommand,
},
Revocation {
#[command(subcommand)]
command: SshRevocationCommand,
},
}
#[derive(Debug, Subcommand)]
pub enum SshCertCommand {
Request {
#[arg(long)]
public_key: PathBuf,
#[arg(long, default_value = "user")]
kind: String,
#[arg(long = "principal", required = true)]
principals: Vec<String>,
#[arg(long)]
valid_for: Option<String>,
#[arg(long)]
renewal_of: Option<String>,
#[arg(long)]
reason: Option<String>,
},
Requests,
Approve {
request_id: String,
#[arg(long)]
ca_key: PathBuf,
#[arg(long)]
valid_for: Option<String>,
#[arg(long)]
serial: Option<u64>,
#[arg(long)]
out: Option<PathBuf>,
},
Import {
request_id: String,
#[arg(long)]
cert: PathBuf,
},
List,
}
#[derive(Debug, Subcommand)]
pub enum SshRevocationCommand {
Add {
kind: String,
target: String,
#[arg(long)]
reason: Option<String>,
},
List,
Export {
#[arg(long)]
out: PathBuf,
},
}
#[derive(Debug, Args)]
@ -194,6 +294,13 @@ pub async fn run() -> Result<()> {
.await
.context("run geth daemon")?;
}
Command::Daemon {
command: DaemonCommand::Service { command },
} => {
let report =
run_service_command(&paths, command).context("manage geth user service")?;
print_service_report(report, cli.json || cli.jsonl)?;
}
command => {
let request = request_for_command(command)?;
let response = geth_node::send_control(&paths, request)
@ -277,14 +384,113 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
module: "document".to_owned(),
command: format!("{command:?}"),
},
Command::Ssh { command } => ControlRequest::ModuleStub {
module: "ssh-proxy".to_owned(),
command: format!("{command:?}"),
Command::Ssh { command } => match command {
SshCommand::Proxy { node } => ControlRequest::ModuleStub {
module: "ssh-proxy".to_owned(),
command: format!("proxy {node}"),
},
SshCommand::Cert { command } => match command {
SshCertCommand::Request {
public_key,
kind,
principals,
valid_for,
renewal_of,
reason,
} => ControlRequest::SshCertRequest {
public_key_path: public_key,
cert_kind: kind,
principals,
requested_validity: valid_for,
renewal_of,
reason,
},
SshCertCommand::Requests => ControlRequest::SshCertRequests,
SshCertCommand::Approve {
request_id,
ca_key,
valid_for,
serial,
out,
} => ControlRequest::SshCertApprove {
request_id,
ca_key_path: ca_key,
valid_for,
serial,
out,
},
SshCertCommand::Import { request_id, cert } => ControlRequest::SshCertImport {
request_id,
cert_path: cert,
},
SshCertCommand::List => ControlRequest::SshCertList,
},
SshCommand::Revocation { command } => match command {
SshRevocationCommand::Add {
kind,
target,
reason,
} => ControlRequest::SshRevocationAdd {
kind,
target,
reason,
},
SshRevocationCommand::List => ControlRequest::SshRevocationList,
SshRevocationCommand::Export { out } => ControlRequest::SshRevocationExport { out },
},
},
Command::Init | Command::Daemon { .. } => bail!("command is handled directly"),
})
}
fn run_service_command(paths: &GethPaths, command: ServiceCommand) -> Result<ServiceReport> {
Ok(match command {
ServiceCommand::Install {
manager,
bin,
start,
} => {
geth_node::init_node(paths).context("initialize geth home before service install")?;
let manager = manager.parse::<ServiceManager>()?;
let executable = service_executable(bin)?;
geth_node::service::install_user_service(
paths,
ServiceInstallOptions {
manager,
executable,
start,
},
)?
}
ServiceCommand::Uninstall { manager } => {
geth_node::service::uninstall_user_service(manager.parse::<ServiceManager>()?)?
}
ServiceCommand::Start { manager } => {
geth_node::service::start_user_service(manager.parse::<ServiceManager>()?)?
}
ServiceCommand::Stop { manager } => {
geth_node::service::stop_user_service(manager.parse::<ServiceManager>()?)?
}
ServiceCommand::Status { manager } => {
geth_node::service::status_user_service(manager.parse::<ServiceManager>()?)?
}
ServiceCommand::Print { manager, bin } => {
let executable = service_executable(bin)?;
geth_node::service::print_user_service(
paths,
manager.parse::<ServiceManager>()?,
&executable,
)?
}
})
}
fn service_executable(bin: Option<PathBuf>) -> Result<PathBuf> {
bin.map(Ok)
.unwrap_or_else(std::env::current_exe)
.context("resolve current geth executable")
}
fn print_response(response: ControlResponse, json: bool) -> Result<()> {
if json {
println!("{}", serde_json::to_string_pretty(&response)?);
@ -356,6 +562,103 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
println!("reason: {}", explain.reason);
println!("evaluated_ops: {}", explain.evaluated_ops);
}
ControlResponse::SshCertRequested { request } => {
println!("ssh cert request: {}", request.id);
println!("status: {}", request.status);
println!("kind: {}", request.cert_kind);
println!("principals: {}", request.principals.join(","));
println!("public_key_fingerprint: {}", request.public_key_fingerprint);
}
ControlResponse::SshCertRequests { requests } => {
if requests.is_empty() {
println!("no ssh certificate requests");
} else {
for request in requests {
println!(
"{}\t{}\t{}\t{}\t{}",
request.id,
request.status,
request.cert_kind,
request.principals.join(","),
request.public_key_fingerprint
);
}
}
}
ControlResponse::SshCertApproved { approval } => {
println!("approved ssh cert request: {}", approval.request_id);
println!("valid_for: {}", approval.valid_for);
if let Some(serial) = approval.serial {
println!("serial: {serial}");
}
if let Some(output_path) = approval.output_path {
println!("expected_certificate: {output_path}");
}
println!("signing_command:");
println!("{}", shell_quote_command(&approval.signing_command));
println!("note: {}", approval.note);
}
ControlResponse::SshCertImported { certificate } => {
println!("imported ssh certificate: {}", certificate.id);
println!("request: {}", certificate.request_id);
println!("fingerprint: {}", certificate.certificate_fingerprint);
}
ControlResponse::SshCertList {
requests,
certificates,
} => {
println!("requests:");
if requests.is_empty() {
println!(" none");
} else {
for request in requests {
println!(
" {}\t{}\t{}\t{}",
request.id,
request.status,
request.cert_kind,
request.principals.join(",")
);
}
}
println!("certificates:");
if certificates.is_empty() {
println!(" none");
} else {
for certificate in certificates {
println!(
" {}\t{}\t{}",
certificate.id, certificate.request_id, certificate.certificate_fingerprint
);
}
}
}
ControlResponse::SshRevocationAdded { revocation } => {
println!("added ssh revocation: {}", revocation.id);
println!("kind: {}", revocation.kind);
println!("target: {}", revocation.target);
if let Some(reason) = revocation.reason {
println!("reason: {reason}");
}
}
ControlResponse::SshRevocationList { revocations } => {
if revocations.is_empty() {
println!("no ssh revocations");
} else {
for revocation in revocations {
println!(
"{}\t{}\t{}\t{}",
revocation.id,
revocation.kind,
revocation.target,
revocation.reason.unwrap_or_default()
);
}
}
}
ControlResponse::SshRevocationExported { out, count } => {
println!("exported {count} ssh revocations to {}", out.display());
}
ControlResponse::NotImplemented { module, command } => {
println!("{module} {command}: not implemented yet");
}
@ -363,3 +666,57 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
}
Ok(())
}
fn print_service_report(report: ServiceReport, json: bool) -> Result<()> {
if json {
println!(
"{}",
serde_json::json!({
"manager": report.manager.to_string(),
"action": format!("{:?}", report.action),
"service_name": report.service_name,
"definition_path": report.definition_path,
"definition": report.definition,
"commands": report.commands,
"note": report.note,
})
);
return Ok(());
}
println!("service: {}", report.service_name);
println!("manager: {}", report.manager);
println!("action: {:?}", report.action);
if let Some(path) = report.definition_path {
println!("definition: {}", path.display());
}
if !report.commands.is_empty() {
println!("commands:");
for command in report.commands {
println!(" {}", shell_quote_command(&command));
}
}
if let Some(definition) = report.definition {
println!("definition_body:");
print!("{definition}");
}
println!("note: {}", report.note);
Ok(())
}
fn shell_quote_command(command: &[String]) -> String {
command
.iter()
.map(|arg| {
if arg
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"-_./:=+@,".contains(&byte))
{
arg.clone()
} else {
format!("'{}'", arg.replace('\'', "'\\''"))
}
})
.collect::<Vec<_>>()
.join(" ")
}