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)] #[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, #[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, }, } #[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 { #[arg(long)] admin_key: Option, }, Status, } #[derive(Debug, Subcommand)] pub enum AuthCommand { Explain { subject: String, resource: String, capability: String, }, Grant { subject: String, resource: String, capability: String, #[arg(long)] grant_id: Option, }, Revoke { resource: String, grant_id: 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, #[arg(long)] valid_for: Option, #[arg(long)] renewal_of: Option, #[arg(long)] reason: Option, }, Requests, Approve { request_id: String, #[arg(long)] ca_key: PathBuf, #[arg(long)] valid_for: Option, #[arg(long)] serial: Option, #[arg(long)] out: Option, }, Import { request_id: String, #[arg(long)] cert: PathBuf, }, List, } #[derive(Debug, Subcommand)] pub enum SshRevocationCommand { Add { kind: String, target: String, #[arg(long)] reason: Option, }, List, Export { #[arg(long)] out: PathBuf, }, } #[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)?; } 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 { 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 { admin_key }, } => ControlRequest::KeychainInit { admin_key_path: admin_key, }, Command::Keychain { command: KeychainCommand::Status, } => ControlRequest::KeychainStatus, Command::Auth { command: AuthCommand::Explain { subject, resource, capability, }, } => ControlRequest::AuthExplain { subject, resource, capability, }, Command::Auth { command: AuthCommand::Grant { subject, resource, capability, grant_id, }, } => ControlRequest::AuthGrant { subject, resource, capability, grant_id, }, Command::Auth { command: AuthCommand::Revoke { resource, grant_id }, } => ControlRequest::AuthRevoke { resource, grant_id }, 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 }, }, }, Command::Init | Command::Daemon { .. } => bail!("command is handled directly"), }) } fn run_service_command(paths: &GethPaths, command: ServiceCommand) -> Result { Ok(match command { ServiceCommand::Install { manager, bin, start, } => { geth_node::init_node(paths).context("initialize geth home before service install")?; let manager = manager.parse::()?; 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::()?)? } ServiceCommand::Start { manager } => { geth_node::service::start_user_service(manager.parse::()?)? } ServiceCommand::Stop { manager } => { geth_node::service::stop_user_service(manager.parse::()?)? } ServiceCommand::Status { manager } => { geth_node::service::status_user_service(manager.parse::()?)? } ServiceCommand::Print { manager, bin } => { let executable = service_executable(bin)?; geth_node::service::print_user_service( paths, manager.parse::()?, &executable, )? } }) } fn service_executable(bin: Option) -> Result { 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)?); 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); println!( "endpoint: {}", status.endpoint_id.as_deref().unwrap_or("not started") ); println!("iroh relay: {}", status.iroh_relay_mode); println!( "iroh discovery: {}", if status.iroh_local_discovery { "local-network enabled" } else { "local-network disabled" } ); 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::KeychainInitialized { ops } => { println!("initialized keychain"); for op in ops { println!("recorded keychain op: {}", op.id); } } 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::AuthOpRecorded { op } => { println!("recorded auth op: {}", op.id); println!("resource: {}", op.resource); } 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"); } 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::>() .join(" ") }