geth/crates/geth-cli/src/lib.rs

727 lines
22 KiB
Rust
Raw Normal View History

2026-05-15 15:08:20 +02:00
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};
2026-05-15 15:08:20 +02:00
use std::path::PathBuf;
#[derive(Debug, Parser)]
#[command(name = "geth", about = "Personal local-first Iroh mesh runtime")]
pub struct Cli {
#[arg(long, global = true)]
pub json: bool,
#[arg(long, global = true)]
pub jsonl: bool,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Init,
Daemon {
#[command(subcommand)]
command: DaemonCommand,
},
Status,
Node {
#[command(subcommand)]
command: NodeCommand,
},
Resource {
#[command(subcommand)]
command: ResourceCommand,
},
Keychain {
#[command(subcommand)]
command: KeychainCommand,
},
Auth {
#[command(subcommand)]
command: AuthCommand,
},
Secret {
#[command(subcommand)]
command: SecretCommand,
},
Cas {
#[command(subcommand)]
command: CasCommand,
},
Kv {
#[command(subcommand)]
command: KvCommand,
},
Pubsub {
#[command(subcommand)]
command: PubsubCommand,
},
Pipe {
#[command(subcommand)]
command: PipeCommand,
},
Db {
#[command(subcommand)]
command: DbCommand,
},
Document {
#[command(subcommand)]
command: DocumentCommand,
},
Ssh {
#[command(subcommand)]
command: SshCommand,
},
}
#[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>,
},
2026-05-15 15:08:20 +02:00
}
#[derive(Debug, Subcommand)]
pub enum NodeCommand {
Id,
Status,
}
#[derive(Debug, Subcommand)]
pub enum ResourceCommand {
List,
Create { kind: String, name: String },
}
#[derive(Debug, Subcommand)]
pub enum KeychainCommand {
Init,
Status,
}
#[derive(Debug, Subcommand)]
pub enum AuthCommand {
Explain {
subject: String,
resource: String,
capability: String,
},
}
#[derive(Debug, Subcommand)]
pub enum SecretCommand {
Status,
}
#[derive(Debug, Subcommand)]
pub enum CasCommand {
Add {
path: PathBuf,
},
Get {
hash: String,
#[arg(long)]
out: PathBuf,
},
Hash {
path: PathBuf,
},
Has {
hash: String,
},
List,
}
#[derive(Debug, Subcommand)]
pub enum KvCommand {
Create {
name: String,
},
Set {
name: String,
key: String,
value: String,
},
Get {
name: String,
key: String,
},
}
#[derive(Debug, Subcommand)]
pub enum PubsubCommand {
Pub { topic: String, message: String },
Sub { topic: String },
}
#[derive(Debug, Subcommand)]
pub enum PipeCommand {
Listen { name: String },
Connect { target: String },
}
#[derive(Debug, Subcommand)]
pub enum DbCommand {
Add { name: String, path: PathBuf },
Status { name: String },
}
#[derive(Debug, Subcommand)]
pub enum DocumentCommand {
Create { name: String },
Status { name: String },
}
#[derive(Debug, Subcommand)]
pub enum SshCommand {
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,
},
2026-05-15 15:08:20 +02:00
}
#[derive(Debug, Args)]
pub struct EmptyArgs {}
pub async fn run() -> Result<()> {
let cli = Cli::parse();
let paths = GethPaths::resolve().context("resolve geth paths")?;
match cli.command {
Command::Init => {
let node = geth_node::init_node(&paths).context("initialize geth node")?;
println!("initialized geth home: {}", node.paths.home().display());
println!("agent: {}", node.agent_id);
println!("node: {}", node.node_id);
}
Command::Daemon {
command: DaemonCommand::Run,
} => {
geth_node::run_daemon(paths)
.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)?;
}
2026-05-15 15:08:20 +02:00
command => {
let request = request_for_command(command)?;
let response = geth_node::send_control(&paths, request)
.await
.with_context(|| {
format!("connect to daemon at {}", paths.socket_path().display())
})?;
print_response(response, cli.json || cli.jsonl)?;
}
}
Ok(())
}
fn request_for_command(command: Command) -> Result<ControlRequest> {
Ok(match command {
Command::Status => ControlRequest::Status,
Command::Node {
command: NodeCommand::Id,
} => ControlRequest::NodeId,
Command::Node {
command: NodeCommand::Status,
} => ControlRequest::Status,
Command::Resource {
command: ResourceCommand::List,
} => ControlRequest::ResourceList,
Command::Resource {
command: ResourceCommand::Create { kind, name },
} => ControlRequest::ResourceCreate { kind, name },
Command::Keychain {
command: KeychainCommand::Init,
} => ControlRequest::ModuleStub {
module: "keychain".to_owned(),
command: "init".to_owned(),
},
Command::Keychain {
command: KeychainCommand::Status,
} => ControlRequest::KeychainStatus,
Command::Auth {
command:
AuthCommand::Explain {
subject,
resource,
capability,
},
} => ControlRequest::AuthExplain {
subject,
resource,
capability,
},
Command::Secret { command } => ControlRequest::ModuleStub {
module: "secret".to_owned(),
command: format!("{command:?}"),
},
Command::Cas { command } => match command {
CasCommand::Add { path } => ControlRequest::CasAdd { path },
CasCommand::Get { hash, out } => ControlRequest::CasGet {
hash: hash.into(),
out,
},
CasCommand::Hash { path } => ControlRequest::CasHash { path },
CasCommand::Has { hash } => ControlRequest::CasHas { hash: hash.into() },
CasCommand::List => ControlRequest::CasList,
},
Command::Kv { command } => ControlRequest::ModuleStub {
module: "kv".to_owned(),
command: format!("{command:?}"),
},
Command::Pubsub { command } => ControlRequest::ModuleStub {
module: "pubsub".to_owned(),
command: format!("{command:?}"),
},
Command::Pipe { command } => ControlRequest::ModuleStub {
module: "pipe".to_owned(),
command: format!("{command:?}"),
},
Command::Db { command } => ControlRequest::ModuleStub {
module: "db".to_owned(),
command: format!("{command:?}"),
},
Command::Document { command } => ControlRequest::ModuleStub {
module: "document".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 },
},
2026-05-15 15:08:20 +02:00
},
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")
}
2026-05-15 15:08:20 +02:00
fn print_response(response: ControlResponse, json: bool) -> Result<()> {
if json {
println!("{}", serde_json::to_string_pretty(&response)?);
return Ok(());
}
match response {
ControlResponse::Status(status) => {
println!("geth daemon: running");
println!("home: {}", status.home.display());
println!("socket: {}", status.socket.display());
println!("agent: {}", status.agent_id);
println!("node: {}", status.node_id);
2026-05-16 01:54:00 +02:00
println!(
"endpoint: {}",
status.endpoint_id.as_deref().unwrap_or("not started")
);
2026-05-16 03:17:45 +02:00
println!("iroh relay: {}", status.iroh_relay_mode);
2026-05-15 15:08:20 +02:00
println!("iroh: {}", status.iroh);
}
ControlResponse::NodeId(node) => {
println!("agent: {}", node.agent_id);
println!("node: {}", node.node_id);
println!(
"endpoint: {}",
node.endpoint_id
.as_deref()
.unwrap_or("not started in bootstrap")
);
}
ControlResponse::ResourceList { resources } => {
if resources.is_empty() {
println!("no resources");
} else {
for resource in resources {
println!("{}\t{}\t{}", resource.kind, resource.name, resource.id);
}
}
}
ControlResponse::ResourceCreated { resource } => {
println!(
"created resource: {} {} ({})",
resource.kind, resource.name, resource.id
);
}
ControlResponse::CasAdded { hash, size_bytes } => {
println!("{hash} {size_bytes} bytes");
}
ControlResponse::CasGot {
hash,
out,
size_bytes,
} => {
println!("wrote {hash} to {} ({size_bytes} bytes)", out.display());
}
ControlResponse::CasHash { hash } => println!("{hash}"),
ControlResponse::CasHas { hash, present } => println!("{hash}: {present}"),
ControlResponse::CasList { blobs } => {
for blob in blobs {
println!("{}\t{} bytes", blob.hash, blob.size_bytes);
}
}
ControlResponse::KeychainStatus(status) => {
println!("initialized: {}", status.initialized);
println!("admin_keys: {}", status.admin_keys);
println!("users: {}", status.users);
println!("devices: {}", status.devices);
println!("nodes: {}", status.nodes);
}
ControlResponse::AuthExplain(explain) => {
println!("allowed: {}", explain.allowed);
println!("subject: {}", explain.subject);
println!("resource: {}", explain.resource);
println!("capability: {}", explain.capability);
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());
}
2026-05-15 15:08:20 +02:00
ControlResponse::NotImplemented { module, command } => {
println!("{module} {command}: not implemented yet");
}
ControlResponse::Error { message } => bail!(message),
}
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(" ")
}