use anyhow::{Context, Result, bail}; use base64::Engine; use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; use clap_complete::{Shell, generate}; use geth_config::{ConfigKey, GethConfig, GethPaths}; use geth_control::{ControlRequest, ControlResponse, SyncStreamStatus}; use geth_node::service::{ServiceInstallOptions, ServiceManager, ServiceReport}; use std::io::{Read, Write, stdout}; use std::path::PathBuf; use std::time::{Duration, Instant}; const TOP_LEVEL_AFTER_HELP: &str = r#"Common starts: geth daemon install # initialize, install, and start in background geth daemon run --ephemeral # disposable foreground daemon geth guide quickstart geth guide owner-setup geth init geth init --admin-key ~/.ssh/id_ed25519_sk.pub --signing-key ~/.ssh/id_ed25519_sk --node-name laptop geth daemon run geth status Use `geth --help` for details and `geth guide` for complete workflows."#; const DAEMON_AFTER_HELP: &str = r#"Examples: geth daemon install # persistent user service; starts immediately geth daemon status geth daemon stop geth daemon start geth daemon uninstall geth daemon run # foreground, persistent state geth daemon run --ephemeral # foreground, temporary state The service commands only use the current user's service manager. They never install a privileged system service."#; const SERVICE_MANAGER_HELP: &str = "Backend: auto, systemd-user, launchd-user, or windows-task"; const INIT_LONG_ABOUT: &str = r#"Initialize local geth state. With no owner options, `geth init` creates local directories, config, metadata store, and the daemon agent identity. This is enough for local CAS/KV/document testing and for later enrollment into an owner's mesh. Owner setup is SSH-admin-rooted. `--admin-key` is the OpenSSH public key that is recorded as an admin trust anchor. `--signing-key` is the matching private SSH key path used immediately through `ssh-keygen -Y sign` to sign the initial keychain/auth statements. For security-key/YubiKey keys, use the private key stub path such as `~/.ssh/id_ed25519_sk`; ssh-keygen/ssh-agent will trigger the hardware-backed signing flow. If any owner setup option is used (`--admin-key`, `--signing-key`, `--owner`, `--node-name`, or `--capability`), both `--admin-key` and `--signing-key` are required so geth never creates unsigned owner/device/node statements by accident."#; const INIT_AFTER_HELP: &str = r#"Examples: # Local-only node for development or later enrollment: geth init # Owner/admin node using an SSH or YubiKey-backed admin key: geth init \ --admin-key ~/.ssh/id_ed25519_sk.pub \ --signing-key ~/.ssh/id_ed25519_sk \ --owner eric \ --node-name laptop # Owner node with initial resource grants: geth init \ --admin-key ~/.ssh/id_ed25519.pub \ --signing-key ~/.ssh/id_ed25519 \ --node-name laptop \ --capability resource:cas:local=cas.fetch \ --capability resource:ssh-proxy:local=ssh_proxy.connect Key paths: --admin-key OpenSSH public key, usually *.pub. Stored as the trust anchor. --signing-key Matching private key or security-key stub. Used to sign init ops. Related: geth guide owner-setup geth keychain status geth node list"#; const GUIDE_INDEX: &str = r#"Usage: geth guide Topics: quickstart Choose disposable, foreground, or background startup. init Local-only init versus owner/admin init. owner-setup First owner node with SSH/YubiKey admin trust. enrollment Add another node/device to the owner mesh. keys Meaning of --admin-key and --signing-key. overlay Optional Iroh overlay network planning. service Install and manage geth as a user service. completions Shell completion installation examples. smoke-test Minimal commands to verify a node and daemon."#; const GUIDE_QUICKSTART: &str = r#"Choose the startup that matches what you are doing. Try geth without keeping state: geth daemon run --ephemeral The command prints its temporary home. In another terminal, use that path: geth --home status geth --home node id The state is removed after a normal daemon shutdown (Ctrl-C). Install and start a persistent background daemon: geth daemon install geth status `daemon install` creates the local home if necessary, installs a user-level service, enables it for future logins, and starts it immediately. Manage it with: geth daemon status geth daemon stop geth daemon start geth daemon uninstall Run a persistent daemon in the foreground instead: geth init geth daemon run Use `--home ` on any command to select an isolated home without exporting GETH_HOME. See `geth guide owner-setup` before adding other machines. "#; const GUIDE_INIT: &str = r#"geth init has two modes. Local-only: geth init Creates GETH_HOME, config.toml, geth.sqlite, CAS directories, and a local agent identity. Use this for local testing or for a node that will later request enrollment into an owner's mesh. Owner/admin: geth init --admin-key ~/.ssh/id_ed25519_sk.pub --signing-key ~/.ssh/id_ed25519_sk --node-name laptop This records signed owner, device, node, and agent bindings. Use it on the machine where you control the admin SSH/YubiKey key. Once initialized, inspect: geth keychain status geth node list "#; const GUIDE_OWNER_SETUP: &str = r#"Owner setup flow: 1. Pick or create an SSH admin key. Security-key/YubiKey-backed OpenSSH keys are supported through ssh-keygen: ssh-keygen -t ed25519-sk -f ~/.ssh/id_ed25519_sk 2. Initialize the owner node: geth init \ --admin-key ~/.ssh/id_ed25519_sk.pub \ --signing-key ~/.ssh/id_ed25519_sk \ --owner eric \ --node-name owner-laptop 3. Start the daemon and export a peer card: geth daemon run geth peer export --out /tmp/owner.peer.json `--admin-key` is public and replicated as the admin trust anchor. `--signing-key` is private and only used locally to sign canonical init ops. "#; 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 On the owner/YubiKey machine: geth node enroll list geth node enroll approve --signing-key ~/.ssh/id_ed25519_sk Back on the new node: geth sync now owner geth node list Enrollment approval records signed keychain/auth operations. Discovery and peer cards alone never grant trust or capabilities. "#; const GUIDE_KEYS: &str = r#"Key terminology: --admin-key OpenSSH public key path, usually ending in .pub. This key is recorded in the geth keychain as an admin trust anchor. It is safe to distribute. --signing-key Matching private key path, or the OpenSSH security-key/YubiKey stub path. geth shells out to ssh-keygen -Y sign with explicit namespaces to sign canonical geth keychain/auth operations. The private key is not copied into geth state. Signing sources: Local key file: --signing-key ~/.ssh/id_ed25519 --admin-key ~/.ssh/id_ed25519.pub Encrypted key file: Load it into ssh-agent with `ssh-add ~/.ssh/id_ed25519`, then sign through the agent by passing the public key path: --signing-key ~/.ssh/id_ed25519.pub FIDO/YubiKey OpenSSH key: Use the security-key stub or load it into ssh-agent: --signing-key ~/.ssh/id_ed25519_sk --admin-key ~/.ssh/id_ed25519_sk.pub PKCS#11: Direct ssh-keygen -Y signing does not expose a portable -D provider option. Load the token key into ssh-agent with `ssh-add -s `, then pass the public key path with --signing-key. Examples: Software key: --admin-key ~/.ssh/id_ed25519.pub --signing-key ~/.ssh/id_ed25519 YubiKey/FIDO OpenSSH key: --admin-key ~/.ssh/id_ed25519_sk.pub --signing-key ~/.ssh/id_ed25519_sk Generate the active OpenSSH allowed_signers projection: geth keychain allowed-signers --out ~/.config/geth/allowed_signers Sign an arbitrary authorized_keys snapshot with an active admin key: geth keychain sign-file --in ~/.ssh/authorized_keys --out ~/.ssh/authorized_keys.sig --signing-key ~/.ssh/id_ed25519_sk Verify the snapshot signature against the current keychain trust root: geth keychain verify-file --in ~/.ssh/authorized_keys --signature ~/.ssh/authorized_keys.sig If you use --owner, --node-name, or --capability during init, geth requires both key options because those fields create signed owner/device/node statements. "#; const GUIDE_SERVICE: &str = r#"Install geth as a user service: geth daemon install geth daemon status geth daemon stop geth daemon start geth daemon uninstall Service installation targets user service managers, not system services: Linux: systemd --user macOS: launchd user agent Windows: current-user scheduled task Preview definitions without installing: geth daemon service print The longer `geth daemon service ...` command family remains available for automation compatibility and for installing without immediately starting via `geth daemon service install`. "#; const GUIDE_COMPLETIONS: &str = r#"Shell completions: geth can print completions for bash, zsh, fish, PowerShell, and elvish. The generated scripts are produced from the same Clap command tree as `geth --help`, so subcommands and flags stay in sync with the executable. Bash: mkdir -p ~/.local/share/bash-completion/completions geth completions bash > ~/.local/share/bash-completion/completions/geth Zsh: mkdir -p ~/.zfunc geth completions zsh > ~/.zfunc/_geth # Ensure ~/.zfunc is in fpath, then run: compinit Fish: mkdir -p ~/.config/fish/completions geth completions fish > ~/.config/fish/completions/geth.fish PowerShell: geth completions powershell > geth.ps1 # Source geth.ps1 from your PowerShell profile. Elvish: mkdir -p ~/.elvish/lib geth completions elvish > ~/.elvish/lib/geth.elv "#; const GUIDE_OVERLAY: &str = r#"Optional overlay network: geth has an experimental overlay-network design inspired by iroh-lan. The intended future runtime is a private L3-style packet overlay carried over geth's daemon-owned Iroh endpoint. Current prototype commands: geth overlay status geth overlay plan home geth overlay plan home --cidr 172.22.0.0/24 geth overlay join home --secret geth overlay interface-plan home --platform linux geth overlay up home geth overlay down home geth overlay send home --packet-base64 geth overlay recv home geth overlay leave home Current limits: - overlay up creates a real TUN/Wintun-style L3 device and usually needs privileges or host network entitlements - host network changes are explicit opt-in only - discovery can suggest peers, but never grants overlay access - overlay access must be resource-authorized with overlay.join/overlay.route - all overlay packets must be carried over Iroh, not SSH or another transport - release/platform notes live in docs/overlay-platforms.md Bearer invite flow: geth resource create overlay home geth secret bearer create resource:overlay:home --capability overlay.join geth overlay join home --secret "#; const GUIDE_SMOKE_TEST: &str = r#"Minimal smoke test: Terminal 1 (prints a temporary home): geth daemon run --ephemeral Terminal 2: geth --home status geth --home node id echo "hello geth" > /tmp/hello-geth.txt geth --home cas add /tmp/hello-geth.txt geth --home cas list geth --home keychain status For two-node owner/enrollment testing, use: geth guide owner-setup geth guide enrollment "#; #[derive(Debug, Parser)] #[command( name = "geth", version, about = "Personal local-first Iroh mesh runtime", long_about = "Personal local-first Iroh mesh runtime for nodes, resources, and secure peer workflows.\n\nThis is the local-first geth project, not the Ethereum client. One executable provides both daemon and control commands.", after_long_help = TOP_LEVEL_AFTER_HELP, arg_required_else_help = true )] pub struct Cli { #[arg( long, global = true, value_name = "DIR", help = "Use DIR as geth home instead of GETH_HOME or the OS data directory" )] pub home: Option, #[arg(long, global = true, help = "Print machine-readable JSON output")] pub json: bool, #[arg( long, global = true, help = "Print newline-delimited JSON output for streaming commands" )] pub jsonl: bool, #[command(subcommand)] pub command: Command, } #[derive(Debug, Subcommand)] pub enum Command { /// Show task-oriented setup and workflow guides Guide { #[arg(value_enum)] topic: Option, }, /// Generate shell completion scripts Completions { #[arg(value_enum)] shell: Shell, }, #[command( about = "Initialize local state and identity", long_about = INIT_LONG_ABOUT, after_long_help = INIT_AFTER_HELP )] Init { #[arg( long, value_name = "OPENSSH_PUBLIC_KEY", help = "OpenSSH public key recorded as the owner/admin trust anchor", long_help = "Path to the OpenSSH public key recorded as the owner/admin trust anchor, usually ~/.ssh/.pub. This key is public and replicated in the geth keychain." )] admin_key: Option, #[arg( long, value_name = "OPENSSH_PRIVATE_KEY", help = "Matching private key or YubiKey/FIDO stub used to sign init statements", long_help = "Path to the matching private OpenSSH key, or security-key/YubiKey stub such as ~/.ssh/id_ed25519_sk. geth uses ssh-keygen -Y sign with explicit geth namespaces; it does not copy the private key into geth state." )] signing_key: Option, #[arg( long, default_value = "owner", help = "Owner/user display name recorded during owner init" )] owner: String, #[arg( long, default_value = "local", help = "Friendly node name recorded during owner init" )] node_name: String, #[arg( long = "capability", value_name = "RESOURCE=CAPABILITY", help = "Initial capability grant for this node; repeatable" )] capabilities: Vec, }, /// Run, install, and manage the daemon Daemon { #[command(subcommand)] command: DaemonCommand, }, /// Inspect, validate, and safely edit daemon configuration Config { #[command(subcommand)] command: ConfigCommand, }, /// Show daemon, storage, Iroh, and backend health Status, /// Inspect or trigger peer synchronization Sync { #[command(subcommand)] command: SyncCommand, }, /// Wait for daemon, peer, or sync readiness Wait { #[command(subcommand)] command: WaitCommand, }, /// Create or restore an offline local-state backup Backup { #[command(subcommand)] command: BackupCommand, }, /// Diagnose local configuration, daemon, trust, and dependency problems Doctor, /// Inspect and manage trusted nodes and enrollment Node { #[command(subcommand)] command: NodeCommand, }, /// Exchange peer cards and test peer connectivity Peer { #[command(subcommand)] command: PeerCommand, }, /// Plan and operate the experimental Iroh overlay network Overlay { #[command(subcommand)] command: OverlayCommand, }, /// List and create resource registrations Resource { #[command(subcommand)] command: ResourceCommand, }, /// Manage the SSH-admin-rooted identity keychain Keychain { #[command(subcommand)] command: KeychainCommand, }, /// Explain and manage resource-scoped authorization Auth { #[command(subcommand)] command: AuthCommand, }, /// Manage resource secret epochs and bearer access Secret { #[command(subcommand)] command: SecretCommand, }, /// Store, fetch, pin, and synchronize content-addressed data Cas { #[command(subcommand)] command: CasCommand, }, /// Operate synchronized key-value stores Kv { #[command(subcommand)] command: KvCommand, }, /// Publish or read daemon-lifetime topic messages Pubsub { #[command(subcommand)] command: PubsubCommand, }, /// Open authorized message and byte-stream pipes Pipe { #[command(subcommand)] command: PipeCommand, }, /// Register and synchronize SQLite/cr-sqlite databases Db { #[command(subcommand)] command: DbCommand, }, /// Create and synchronize document resources Document { #[command(subcommand)] command: DocumentCommand, }, /// Use geth-managed SSH proxy, certificate, and revocation workflows Ssh { #[command(subcommand)] command: SshCommand, }, } #[derive(Clone, Debug, ValueEnum)] pub enum GuideTopic { Quickstart, Init, OwnerSetup, Enrollment, Keys, Overlay, Service, Completions, SmokeTest, } #[derive(Debug, Subcommand)] pub enum ConfigCommand { /// Print the selected config.toml path Path, /// Print the selected config and its effective values Show, /// Parse and validate the complete selected config Validate, /// Safely update one supported setting and validate the result Set { #[arg(value_enum)] key: ConfigKeyArg, value: String, }, } #[derive(Clone, Copy, Debug, ValueEnum)] pub enum ConfigKeyArg { #[value(name = "iroh.relay_mode")] IrohRelayMode, #[value(name = "iroh.relay_map")] IrohRelayMap, #[value(name = "iroh.local_discovery")] IrohLocalDiscovery, #[value(name = "sync.live_sync_enabled")] SyncLiveSyncEnabled, #[value(name = "sync.live_sync_interval_ms")] SyncLiveSyncIntervalMs, } impl From for ConfigKey { fn from(value: ConfigKeyArg) -> Self { match value { ConfigKeyArg::IrohRelayMode => Self::IrohRelayMode, ConfigKeyArg::IrohRelayMap => Self::IrohRelayMap, ConfigKeyArg::IrohLocalDiscovery => Self::IrohLocalDiscovery, ConfigKeyArg::SyncLiveSyncEnabled => Self::SyncLiveSyncEnabled, ConfigKeyArg::SyncLiveSyncIntervalMs => Self::SyncLiveSyncIntervalMs, } } } #[derive(Debug, Subcommand)] #[command(after_long_help = DAEMON_AFTER_HELP)] pub enum DaemonCommand { /// Run the daemon in the foreground Run { #[arg( long, help = "Use a temporary home that is removed after normal shutdown" )] ephemeral: bool, }, /// Initialize, install, enable, and start a persistent user service Install { #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] manager: String, #[arg(long, help = "Executable path stored in the service definition")] bin: Option, }, /// Start the installed user service Start { #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] manager: String, }, /// Stop the installed user service Stop { #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] manager: String, }, /// Show the installed user service status Status { #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] manager: String, }, /// Stop and remove the installed user service Uninstall { #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] manager: String, }, /// Advanced and compatibility-preserving service controls Service { #[command(subcommand)] command: ServiceCommand, }, } #[derive(Debug, Subcommand)] pub enum ServiceCommand { /// Install and enable the user service Install { #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] manager: String, #[arg(long, help = "Executable path stored in the service definition")] bin: Option, #[arg(long, help = "Start the service immediately after installation")] start: bool, }, /// Stop and remove the user service Uninstall { #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] manager: String, }, /// Start the installed user service Start { #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] manager: String, }, /// Stop the installed user service Stop { #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] manager: String, }, /// Query the user service manager Status { #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] manager: String, }, /// Preview the service definition without installing it Print { #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] manager: String, #[arg(long, help = "Executable path used in the preview")] bin: Option, }, } #[derive(Debug, Subcommand)] pub enum SyncCommand { /// Show per-peer stream health, cursors, and retry state Status, /// Synchronize all known peers or one selected node immediately Now { node: Option }, } #[derive(Debug, Subcommand)] pub enum WaitCommand { /// Wait until the local control socket answers Daemon { #[arg(long, default_value_t = 30_000)] timeout_ms: u64, #[arg(long, default_value_t = 250)] interval_ms: u64, }, /// Wait until an imported peer answers over Iroh Peer { node: String, #[arg(long, default_value_t = 30_000)] timeout_ms: u64, #[arg(long, default_value_t = 500)] interval_ms: u64, }, /// Wait until a peer's sync streams are healthy or marked stale Sync { node: String, #[arg(long, default_value_t = 30_000)] timeout_ms: u64, #[arg(long, default_value_t = 500)] interval_ms: u64, }, } #[derive(Debug, Subcommand)] pub enum BackupCommand { /// Create an offline backup directory without private identity keys Create { #[arg(long, value_name = "DIR")] out: PathBuf, }, /// Restore a backup into a new, empty geth home Restore { backup_dir: PathBuf, #[arg(long, value_name = "DIR")] target_home: PathBuf, }, } #[derive(Debug, Subcommand)] pub enum NodeCommand { /// Print the local stable node, agent, and Iroh endpoint identifiers Id, /// Show the same local runtime health as `geth status` Status, /// List the active trusted-node view List, /// Request, review, approve, or synchronize node enrollment Enroll { #[command(subcommand)] command: NodeEnrollCommand, }, /// Rename a trusted node with an admin-signed keychain operation Rename { node: String, name: String, #[arg(long)] signing_key: Option, }, /// Revoke a trusted node with an admin-signed keychain operation Revoke { node: String, #[arg(long)] signing_key: Option, }, /// Grant a trusted node one resource capability Grant { node: String, resource: String, capability: String, #[arg(long)] grant_id: Option, #[arg(long)] signing_key: PathBuf, #[arg(long)] admin_key: Option, }, /// Revoke a previously issued node grant RevokeGrant { resource: String, grant_id: String, #[arg(long)] signing_key: PathBuf, #[arg(long)] admin_key: Option, }, /// Bind an Iroh endpoint to a trusted node EndpointAdd { node: String, endpoint: String, #[arg(long)] signing_key: PathBuf, }, /// Revoke an Iroh endpoint binding EndpointRevoke { node: String, endpoint: String, #[arg(long)] signing_key: PathBuf, }, } #[derive(Debug, Subcommand)] pub enum NodeEnrollCommand { /// 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" )] Request { #[arg(long)] node_name: String, #[arg(long = "capability")] capabilities: Vec, #[arg(long)] reason: Option, #[arg(long)] out: Option, }, /// Send an enrollment request to an imported owner peer Submit { owner_node: String, #[arg(long)] request_id: Option, #[arg(long)] path: Option, }, /// Import an enrollment request from a file Import { path: PathBuf }, /// List received enrollment requests List { #[arg(long)] status: Option, }, /// Approve a request with the owner's SSH admin key Approve { request_id: String, #[arg(long)] signing_key: PathBuf, #[arg(long)] admin_key: Option, #[arg(long)] node_name: Option, #[arg(long = "capability")] capabilities: Vec, }, /// Pull approved enrollment state from the owner node Sync { owner_node: String }, } #[derive(Debug, Subcommand)] pub enum PeerCommand { /// Export this daemon's signed peer card Export { #[arg(long)] out: Option, }, /// Import and verify a signed peer card Import { path: PathBuf }, /// List imported and discovered peer candidates List, /// Test protected Iroh connectivity to a peer Ping { node: String }, /// Ask a peer whether this node has a capability AuthCheck { node: String, resource: String, capability: String, }, } #[derive(Debug, Subcommand)] pub enum OverlayCommand { /// Show local overlay memberships and active interfaces Status, /// Preview deterministic overlay addressing without changing state Plan { name: String, #[arg(long)] cidr: Option, }, /// Join an overlay using a resource or bearer secret Join { name: String, #[arg(long)] secret: String, #[arg(long)] cidr: Option, }, /// Remove local overlay membership Leave { name: String }, /// Preview platform-specific interface setup InterfacePlan { name: String, #[arg(long)] platform: Option, }, /// Create the local TUN/Wintun-style overlay interface Up { name: String, #[arg(long)] bearer_secret: Option, #[arg(long)] mtu: Option, }, /// Stop the local overlay interface Down { name: String }, /// List peers visible to an overlay Peers { name: String }, /// Send one validated IPv4 packet over Iroh Send { name: String, node: String, #[arg(long)] packet_base64: String, #[arg(long)] bearer_secret: Option, }, /// Read locally received overlay packets Recv { name: String, #[arg(long)] peek: bool, }, } #[derive(Debug, Subcommand)] pub enum ResourceCommand { /// List registered resources List, /// Register a named resource Create { kind: String, name: String }, } #[derive(Debug, Subcommand)] pub enum KeychainCommand { /// Initialize local keychain metadata and optional admin trust Init { #[arg(long)] admin_key: Option, #[arg(long)] signing_key: Option, }, /// Show the reduced identity view and signature verification state Status, /// Add an SSH public key as an admin trust anchor AdminAdd { #[arg(long)] admin_key: PathBuf, #[arg(long)] signing_key: PathBuf, #[arg(long)] principal: Option, #[arg(long)] valid_after_ms: Option, #[arg(long)] valid_before_ms: Option, }, /// Revoke an admin trust anchor AdminRevoke { key: String, #[arg(long)] signing_key: PathBuf, #[arg(long)] admin_key: Option, }, /// Write the active OpenSSH allowed_signers projection AllowedSigners { #[arg(long)] out: Option, }, /// Sign an arbitrary file through an active SSH admin key SignFile { #[arg(long = "in")] input: PathBuf, #[arg(long)] out: Option, #[arg(long)] namespace: Option, #[arg(long)] signing_key: PathBuf, #[arg(long)] admin_key: Option, }, /// Verify a file signature against current keychain trust VerifyFile { #[arg(long = "in")] input: PathBuf, #[arg(long)] signature: PathBuf, #[arg(long)] namespace: Option, #[arg(long)] allowed_signers: Option, #[arg(long)] principal: Option, }, /// Export the canonical SSH signature chain Sigchain { #[arg(long)] out: Option, }, /// Build a signed static-publication bundle PublishBundle { #[arg(long)] out: PathBuf, #[arg(long, default_value = geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL)] base_url: String, #[arg(long)] signing_key: PathBuf, #[arg(long)] admin_key: Option, #[arg(long = "snapshot")] snapshots: Vec, }, /// Verify a signature chain without importing it VerifySigchain { #[arg(long = "in")] input: PathBuf, }, /// Verify and import a signature chain ImportSigchain { #[arg(long = "in")] input: PathBuf, }, /// Verify a published checkpoint and its discovery metadata VerifyCheckpoint { #[arg(long)] checkpoint: PathBuf, #[arg(long)] signature: PathBuf, #[arg(long)] sigchain: PathBuf, #[arg(long)] allowed_signers: PathBuf, #[arg(long)] base_url: Option, #[arg(long)] principal: Option, }, /// Fetch a published signature chain over HTTPS Fetch { #[arg(long, default_value = geth_keychain::DEFAULT_SSH_SIGCHAIN_DISCOVERY_URL)] url: String, #[arg(long)] out: Option, #[arg(long)] import: bool, }, /// Explain why one keychain operation was accepted or rejected Explain { op_id: String }, /// Explain the current trust state of one signer ExplainSigner { key: String }, /// Verify all locally stored keychain operations Verify, /// Pull verified keychain operations from a trusted peer Sync { node: String }, } #[derive(Debug, Subcommand)] pub enum AuthCommand { /// Explain an allow or deny decision for one subject and capability Explain { subject: String, resource: String, capability: String, }, /// Pull verified authorization operations from a trusted peer Sync { node: String }, /// Add an admin-signed resource capability grant #[command( after_help = "Example:\n geth auth grant resource:kv:preferences kv.read --signing-key ~/.ssh/id_ed25519" )] Grant { subject: String, resource: String, capability: String, #[arg(long)] grant_id: Option, #[arg(long)] signing_key: PathBuf, #[arg(long)] admin_key: Option, }, /// Revoke an admin-signed grant by id Revoke { resource: String, grant_id: String, #[arg(long)] signing_key: PathBuf, #[arg(long)] admin_key: Option, }, } #[derive(Debug, Subcommand)] pub enum SecretCommand { /// List resource secret epochs Status, /// Create the first local secret epoch for a resource Create { resource: String }, /// Rotate a resource to a new local secret epoch Rotate { resource: String }, /// Create, prove, verify, list, or revoke bearer access Bearer { #[command(subcommand)] command: SecretBearerCommand, }, } #[derive(Debug, Subcommand)] pub enum SecretBearerCommand { /// Create resource-scoped bearer access and print its token once Create { resource: String, #[arg(long = "capability", required = true)] capabilities: Vec, #[arg(long)] expires_at_ms: Option, }, /// List public bearer metadata without private tokens List, /// Issue a possession challenge for requested capabilities Challenge { resource: String, #[arg(long = "capability", required = true)] capabilities: Vec, }, /// Produce a challenge response from a bearer token Prove { token: String, resource: String, #[arg(long)] nonce: String, #[arg(long = "capability", required = true)] capabilities: Vec, }, /// Verify a bearer challenge response locally Verify { token: String, resource: String, #[arg(long)] nonce: String, #[arg(long)] response: String, #[arg(long = "capability", required = true)] capabilities: Vec, }, /// Revoke bearer access by its public id Revoke { resource: String, bearer_id: String }, } #[derive(Debug, Subcommand)] pub enum CasCommand { /// Add a file to the local content-addressed store Add { path: PathBuf }, /// Add a prototype resource-encrypted file envelope AddPrivate { resource: String, path: PathBuf }, /// Copy a local blob to a path Get { hash: String, #[arg(long)] out: PathBuf, }, /// Decrypt a prototype private blob to a path GetPrivate { resource: String, hash: String, #[arg(long)] out: PathBuf, }, /// Fetch an authorized blob from an imported peer over Iroh Fetch { node: String, hash: String, #[arg(long)] bearer_secret: Option, }, /// Hash a file without adding it Hash { path: PathBuf }, /// Check whether a blob exists locally Has { hash: String }, /// Protect a blob from cleanup Pin { hash: String }, /// Allow a blob to be removed by cleanup Unpin { hash: String }, /// Remove unpinned local blobs Cleanup { #[arg(long)] dry_run: bool, }, /// List known local and peer providers for a blob Providers { hash: String }, /// List local blobs and pin state List, /// Register, scan, synchronize, and safely apply file roots Root { #[command(subcommand)] command: CasRootCommand, }, /// Inspect and resolve durable file-root conflicts Conflict { #[command(subcommand)] command: CasConflictCommand, }, } #[derive(Debug, Subcommand)] pub enum CasRootCommand { /// Register a local directory as a named file root Add { name: String, path: PathBuf }, /// List local and peer-qualified file roots List, /// Scan a local root and store its deterministic CAS tree Scan { name: String }, /// Pull authorized tree metadata and bytes from a peer Sync { node: String, name: String, #[arg(long)] bearer_secret: Option, }, /// Materialize a tree without overwriting local edits #[command( after_help = "Examples:\n geth cas root apply photos --to ./restored-photos --dry-run\n geth cas root apply remote--photos --to ./restored-photos" )] Apply { source: String, #[arg(long)] to: PathBuf, #[arg(long)] dry_run: bool, }, } #[derive(Debug, Subcommand)] pub enum CasConflictCommand { /// Record a file-root conflict explicitly Record { root: String, path: String, kind: String, #[arg(long)] detail: String, #[arg(long)] base_tree: Option, #[arg(long)] local_tree: Option, #[arg(long)] remote_tree: Option, }, /// List unresolved and resolved conflicts List { #[arg(long)] root: Option, }, /// Record an operator-selected conflict resolution Resolve { conflict_id: String, resolution: String, #[arg(long)] note: Option, }, } #[derive(Debug, Subcommand)] pub enum KvCommand { /// Create a named local key-value store Create { name: String }, /// Set a local key, optionally checking a non-owner subject #[command( after_help = "Examples:\n geth kv set preferences theme dark\n geth kv set preferences theme dark --subject " )] Set { name: String, key: String, value: String, #[arg(long)] subject: Option, }, /// Read a local value Get { name: String, key: String }, /// Pull authorized values from a peer over Iroh Sync { node: String, name: String, #[arg(long)] bearer_secret: Option, }, } #[derive(Debug, Subcommand)] pub enum PubsubCommand { /// Publish a daemon-lifetime message locally or to a peer Pub { topic: String, message: String, #[arg(long)] node: Option, #[arg(long)] bearer_secret: Option, }, /// Read the current daemon-lifetime topic snapshot Sub { topic: String, #[arg(long)] node: Option, #[arg(long)] bearer_secret: Option, }, } #[derive(Debug, Subcommand)] pub enum PipeCommand { /// Register a daemon-lifetime listener locally or on a peer Listen { name: String, #[arg(long)] node: Option, #[arg(long)] bearer_secret: Option, }, /// Request an authorized pipe connection Connect { target: String, #[arg(long)] node: Option, #[arg(long)] bearer_secret: Option, }, /// Forward a local loopback TCP listener to a peer's loopback target ForwardTcp { #[arg(long)] listen: String, #[arg(long)] node: String, #[arg(long)] target: String, #[arg(long)] bearer_secret: Option, }, /// Forward a local Unix socket to an absolute socket path on a peer ForwardUnix { #[arg(long)] listen: PathBuf, #[arg(long)] node: String, #[arg(long)] target: PathBuf, #[arg(long)] bearer_secret: Option, }, /// Send text, a file, or stdin through a dedicated Iroh pipe Send { target: String, message: Option, #[arg(long = "in", value_name = "PATH")] input: Option, #[arg(long)] node: Option, #[arg(long)] bearer_secret: Option, }, /// Drain messages from a local daemon-lifetime listener Recv { name: String, #[arg(long)] peek: bool, }, } #[derive(Debug, Subcommand)] pub enum DbCommand { /// Register a local SQLite database without mutating its schema Add { name: String, path: PathBuf }, /// Show schema compatibility and cr-sqlite metadata Status { name: String }, /// Read a typed batch from crsql_changes Changes { name: String, #[arg(long)] after_db_version: Option, #[arg(long, default_value_t = 100)] limit: u32, }, /// Pull and apply an authorized compatible change batch Sync { node: String, name: String, #[arg(long, default_value_t = 100)] limit: u32, #[arg(long)] bearer_secret: Option, }, } #[derive(Debug, Subcommand)] pub enum DocumentCommand { /// Create a named local document resource Create { name: String }, /// Show local document metadata Status { name: String }, /// Replace local document state from validated JSON Set { name: String, state_json: String }, /// Read local document state Get { name: String }, /// Pull authorized document changes from a peer Sync { node: String, name: String, #[arg(long)] bearer_secret: Option, }, } #[derive(Debug, Subcommand)] pub enum SshCommand { /// Act as an OpenSSH ProxyCommand over an authorized Iroh stream Proxy { node: String, #[arg(long)] bearer_secret: Option, }, /// Run a restricted geth admin command on a peer AdminShell { node: String, command: String, #[arg(long)] bearer_secret: Option, }, /// Request, approve, import, list, or sync SSH certificates Cert { #[command(subcommand)] command: SshCertCommand, }, /// Add, export, import, list, or sync SSH revocations Revocation { #[command(subcommand)] command: SshRevocationCommand, }, } #[derive(Debug, Subcommand)] pub enum SshCertCommand { /// Create a signed SSH certificate request 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, #[arg(long)] subject: Option, }, /// List local certificate requests Requests { #[arg(long)] subject: Option, }, /// Build or execute the OpenSSH certificate signing command Approve { request_id: String, #[arg(long)] ca_key: PathBuf, #[arg(long)] valid_for: Option, #[arg(long)] serial: Option, #[arg(long)] out: Option, #[arg(long)] sign: bool, #[arg(long)] subject: Option, }, /// Attach a signed certificate to its request Import { request_id: String, #[arg(long)] cert: PathBuf, #[arg(long)] subject: Option, }, /// List stored SSH certificates List { #[arg(long)] subject: Option, }, /// Pull authorized certificate metadata from a peer Sync { node: String, #[arg(long)] bearer_secret: Option, }, } #[derive(Debug, Subcommand)] pub enum SshRevocationCommand { /// Record a key, certificate, serial, or key-id revocation Add { kind: String, target: String, #[arg(long)] reason: Option, #[arg(long)] subject: Option, }, /// List stored SSH revocations List { #[arg(long)] subject: Option, }, /// Export JSONL, OpenSSH KRL specification, or binary KRL data Export { #[arg(long)] out: PathBuf, #[arg(long, default_value = "jsonl")] format: String, #[arg(long)] ca_public: Option, #[arg(long)] subject: Option, }, /// Import JSONL or an enumerable OpenSSH KRL specification Import { path: PathBuf, #[arg(long, default_value = "jsonl")] format: String, #[arg(long)] subject: Option, }, /// Pull authorized revocation metadata from a peer Sync { node: String, #[arg(long)] bearer_secret: Option, }, } #[derive(Debug, Args)] pub struct EmptyArgs {} pub async fn run() -> Result<()> { let matches = documented_cli_command().get_matches(); let cli = Cli::from_arg_matches(&matches).context("parse geth command")?; let json = cli.json || cli.jsonl; match run_inner(cli).await { Ok(()) => Ok(()), Err(error) if json => { print_json_error(&error)?; std::process::exit(1); } Err(error) => Err(error), } } fn documented_cli_command() -> clap::Command { document_missing_arguments(Cli::command(), "geth") } fn document_missing_arguments(mut command: clap::Command, path: &str) -> clap::Command { let command_path = path.to_owned(); command = command.mut_args(|argument| { if argument.get_help().is_some() { argument } else if let Some(help) = argument_help(&command_path, argument.get_id().as_str()) { argument.help(help) } else { argument } }); command.mut_subcommands(|subcommand| { let child_path = format!("{path} {}", subcommand.get_name()); document_missing_arguments(subcommand, &child_path) }) } fn argument_help(path: &str, id: &str) -> Option<&'static str> { let contextual = match (path, id) { ("geth resource create", "kind") => { Some("Resource kind, such as cas, kv, document, db, pipe, or pubsub") } ("geth overlay interface-plan", "platform") => { Some("Target platform: linux, macos, or windows; defaults to this host") } ("geth ssh cert request", "kind") => Some("Certificate kind: user or host"), ("geth ssh revocation add", "kind") => { Some("Revocation kind: public-key, certificate, serial, or key-id") } ("geth cas conflict record", "kind") => { Some("Conflict kind, such as concurrent-edit, delete-edit, or divergent-rename") } ("geth pipe connect" | "geth pipe send", "target") => Some("Registered pipe listener name"), ("geth pipe forward-tcp", "target") => { Some("Remote loopback TCP target, for example 127.0.0.1:5432") } ("geth pipe forward-unix", "target") => Some("Absolute Unix socket path on the peer"), ("geth ssh revocation add", "target") => { Some("Key, certificate, serial, or key-id selected by KIND") } ("geth ssh admin-shell", "command") => Some("Restricted command: help, status, or node-id"), ("geth node enroll list", "status") => { Some("Optional request status filter: pending, approved, or rejected") } ("geth ssh revocation export" | "geth ssh revocation import", "format") => { Some("Format: jsonl, openssh, or krl where supported") } ("geth cas root apply", "source") => { Some("Local or peer-qualified file-root name to materialize") } ("geth cas conflict resolve", "resolution") => { Some("Resolution: keep-local, accept-remote, keep-both, or manual") } ("geth config set", "key") => Some("Supported dotted configuration key"), ("geth config set", "value") => Some("New value for the selected setting"), ("geth node enroll request", "capabilities") => { Some("Requested RESOURCE=CAPABILITY pair; repeat to request more than one") } ("geth kv create" | "geth kv set" | "geth kv get" | "geth kv sync", "name") => { Some("Name of the KV store") } ("geth kv set" | "geth kv get", "key") => Some("Key within the named KV store"), ("geth db add" | "geth db status" | "geth db changes" | "geth db sync", "name") => { Some("Name of the database resource") } ( "geth document create" | "geth document set" | "geth document get" | "geth document sync", "name", ) => Some("Name of the document resource"), ("geth cas root add" | "geth cas root scan" | "geth cas root sync", "name") => { Some("Name of the file root") } _ => None, }; contextual.or(match id { "topic" => Some("Embedded guide topic; omit it to list available topics"), "shell" => Some("Shell whose completion script should be generated"), "node" | "owner_node" => Some("Trusted node id or friendly node name"), "timeout_ms" => Some("Maximum time to wait, in milliseconds"), "interval_ms" => Some("Delay between readiness checks, in milliseconds"), "out" => Some("Output file or directory; command output is used when omitted if supported"), "backup_dir" => Some("Backup directory containing manifest.json and the home payload"), "target_home" => Some("New empty directory that will receive the restored home"), "node_name" => Some("Human-friendly name for the node"), "capabilities" | "capability" => { Some("Resource capability name; repeat the flag where supported") } "reason" => Some("Optional operator-readable reason recorded with the operation"), "request_id" => Some("Enrollment or certificate request identifier"), "path" => Some("Local input file or directory path"), "signing_key" => { Some("Private OpenSSH key, public agent key, or FIDO/YubiKey key stub used to sign") } "admin_key" => Some("Matching OpenSSH admin public key; inferred where possible"), "name" => Some("Name of the command-specific local object"), "resource" => Some("Canonical resource id, for example resource:kv:preferences"), "grant_id" => Some("Stable grant identifier; generated when omitted where supported"), "endpoint" => Some("Iroh EndpointID to bind to or revoke from the node"), "kind" => Some("Command-specific object kind"), "cidr" => Some("Overlay IPv4 CIDR, for example 172.22.0.0/24"), "secret" => Some("Private resource or bearer token; it is never stored in command logs"), "platform" => Some("Target operating-system platform"), "bearer_secret" => Some("Optional resource-scoped bearer token for remote authorization"), "mtu" => Some("Overlay interface MTU in bytes"), "packet_base64" => Some("Complete IPv4 packet encoded as base64"), "peek" => Some("Read current data without draining it"), "principal" | "principals" => { Some("OpenSSH signer or certificate principal; repeat where supported") } "valid_after_ms" => Some("Earliest validity time as Unix milliseconds"), "valid_before_ms" => Some("Latest validity time as Unix milliseconds"), "input" => Some("Input file path"), "namespace" => Some("SSH signature namespace; defaults to the relevant geth namespace"), "signature" => Some("OpenSSH signature file path"), "allowed_signers" => Some("OpenSSH allowed_signers file used for verification"), "base_url" => Some("Publication base URL recorded in signed discovery metadata"), "snapshots" => Some("Named snapshot mapping NAME=PATH; repeatable"), "checkpoint" => Some("Signed publication checkpoint file"), "sigchain" => Some("Canonical keychain signature-chain JSONL file"), "url" => Some("HTTPS URL to fetch"), "import" => Some("Import the verified result into local state"), "op_id" => Some("Canonical keychain operation identifier"), "subject" => Some("Principal evaluated or authorized instead of the local owner"), "expires_at_ms" => Some("Optional bearer expiration as Unix milliseconds"), "token" => Some("Private bearer token returned when access was created"), "nonce" => Some("Challenge nonce returned by the challenge command"), "response" => Some("Bearer possession proof response"), "bearer_id" => Some("Public bearer-access identifier, not the private token"), "hash" => Some("BLAKE3 CAS blob hash"), "dry_run" => Some("Preview changes without mutating local state or files"), "source" => Some("Command-specific source object"), "to" => Some("Destination directory"), "root" => Some("File-root name; omit the filter to include all roots"), "detail" => Some("Operator-readable conflict detail"), "base_tree" => Some("Optional common-ancestor CAS tree hash"), "local_tree" => Some("Optional local CAS tree hash"), "remote_tree" => Some("Optional remote CAS tree hash"), "conflict_id" => Some("Durable file-conflict identifier"), "resolution" => Some("Operator-selected conflict resolution"), "note" => Some("Optional operator note recorded with the action"), "value" => Some("UTF-8 value to store"), "key" => Some("Key name, signer id, or fingerprint selected by the command"), "message" => Some("UTF-8 message payload"), "target" => Some("Command-specific destination or target"), "listen" => Some("Local loopback address or absolute Unix socket path to listen on"), "after_db_version" => Some("Return changes strictly after this cr-sqlite db_version"), "limit" => Some("Maximum number of change rows to read or synchronize"), "state_json" => Some("Complete JSON object or value used as the new document state"), "command" => Some("Restricted command name"), "public_key" => Some("OpenSSH public key to certify"), "valid_for" => Some("OpenSSH validity interval, for example 8h or 7d"), "renewal_of" => Some("Certificate id this request renews"), "serial" => Some("Explicit OpenSSH certificate serial number"), "sign" => Some("Run ssh-keygen now and import the generated certificate"), "cert" => Some("Signed OpenSSH certificate file"), "format" => Some("Input or output format selected by the command"), "ca_public" => Some("OpenSSH CA public key used for KRL generation"), "ca_key" => Some("OpenSSH CA private key or hardware-key stub used to sign"), _ => None, }) } async fn run_inner(cli: Cli) -> Result<()> { if let Command::Completions { shell } = &cli.command { print_completions(*shell); return Ok(()); } if cli.home.is_some() && matches!( &cli.command, Command::Daemon { command: DaemonCommand::Run { ephemeral: true } } ) { bail!( "--home cannot be combined with --ephemeral; omit --ephemeral for persistent state or omit --home for an automatically managed temporary home" ); } let paths = cli .home .clone() .map(GethPaths::from_home) .map_or_else(GethPaths::resolve, Ok) .context("resolve geth paths")?; match cli.command { Command::Guide { topic } => { print_guide(topic, cli.json || cli.jsonl)?; } Command::Config { command } => { run_config_command(&paths, command, cli.json || cli.jsonl)?; } Command::Init { admin_key, signing_key, owner, node_name, capabilities, } => { let node = geth_node::init_owned_node( &paths, geth_node::InitOwnerOptions { admin_key_path: admin_key, signing_key_path: signing_key, owner_name: owner, node_name, capabilities, }, ) .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 { ephemeral: true }, } => { run_ephemeral_daemon(cli.json || cli.jsonl).await?; } Command::Daemon { command: DaemonCommand::Run { ephemeral: false }, } => { geth_node::run_daemon(paths) .await .context("run geth daemon")?; } Command::Daemon { command: DaemonCommand::Install { manager, bin }, } => { let report = run_service_command( &paths, ServiceCommand::Install { manager, bin, start: true, }, ) .context("install and start geth user service")?; print_service_report(report, cli.json || cli.jsonl)?; } Command::Daemon { command: DaemonCommand::Start { manager }, } => { let report = run_service_command(&paths, ServiceCommand::Start { manager }) .context("start geth user service; install it first with `geth daemon install`")?; print_service_report(report, cli.json || cli.jsonl)?; } Command::Daemon { command: DaemonCommand::Stop { manager }, } => { let report = run_service_command(&paths, ServiceCommand::Stop { manager }) .context("stop geth user service")?; print_service_report(report, cli.json || cli.jsonl)?; } Command::Daemon { command: DaemonCommand::Status { manager }, } => { let report = run_service_command(&paths, ServiceCommand::Status { manager }) .context("query geth user service; install it with `geth daemon install`")?; print_service_report(report, cli.json || cli.jsonl)?; } Command::Daemon { command: DaemonCommand::Uninstall { manager }, } => { let report = run_service_command(&paths, ServiceCommand::Uninstall { manager }) .context("uninstall geth user service")?; print_service_report(report, cli.json || cli.jsonl)?; } 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::Wait { command } => { let report = run_wait_command(&paths, command).await?; print_wait_report(&report, cli.json || cli.jsonl)?; if !report.ready { std::process::exit(1); } } Command::Backup { command } => { run_backup_command(&paths, command, cli.json || cli.jsonl) .context("run backup command")?; } Command::Doctor => { let report = geth_node::doctor::run_doctor(&paths) .await .context("run doctor")?; print_doctor_report(&report, cli.json || cli.jsonl)?; if !report.ok { std::process::exit(1); } } Command::Ssh { command: SshCommand::Proxy { node, bearer_secret, }, } if !cli.json && !cli.jsonl => { geth_node::stream_ssh_proxy(&paths, node, bearer_secret) .await .context("stream SSH proxy through geth daemon")?; } Command::Pipe { command: PipeCommand::ForwardTcp { listen, node, target, bearer_secret, }, } if !cli.json && !cli.jsonl => { println!("forwarding tcp {listen} -> {node}:{target}"); geth_node::run_tcp_forward(&paths, listen, node, target, bearer_secret) .await .context("run TCP forward through geth daemon")?; } Command::Pipe { command: PipeCommand::ForwardUnix { listen, node, target, bearer_secret, }, } if !cli.json && !cli.jsonl => { println!( "forwarding unix {} -> {node}:{}", listen.display(), target.display() ); geth_node::run_unix_forward(&paths, listen, node, target, bearer_secret) .await .context("run Unix socket forward through geth daemon")?; } command => { let request = request_for_command(command)?; let response = geth_node::send_control(&paths, request) .await .with_context(|| { format!( "connect to daemon at {}\nnext: start it with `geth daemon install` (background) or `geth daemon run` (foreground)", paths.socket_path().display() ) })?; print_response(response, cli.json || cli.jsonl)?; } } Ok(()) } fn run_config_command(paths: &GethPaths, command: ConfigCommand, json: bool) -> Result<()> { let path = paths.config_file(); match command { ConfigCommand::Path => { if json { println!( "{}", serde_json::to_string_pretty(&serde_json::json!({ "type": "config-path", "path": path, "exists": path.exists(), }))? ); } else { println!("{}", path.display()); } } ConfigCommand::Show => { let exists = path.exists(); let text = if exists { std::fs::read_to_string(&path) .with_context(|| format!("read config at {}", path.display()))? } else { GethConfig::default_toml().to_owned() }; let config = GethConfig::parse(&text) .with_context(|| format!("validate config at {}", path.display()))?; if json { println!( "{}", serde_json::to_string_pretty(&serde_json::json!({ "type": "config", "path": path, "source": if exists { "file" } else { "built-in-defaults" }, "raw": text, "effective": config_json(&config), }))? ); } else { println!("# path: {}", path.display()); if !exists { println!("# source: built-in defaults (file does not exist yet)"); } print!("{text}"); if !text.ends_with('\n') { println!(); } } } ConfigCommand::Validate => { let exists = path.exists(); let config = GethConfig::load(&path) .with_context(|| format!("validate config at {}", path.display()))?; if json { println!( "{}", serde_json::to_string_pretty(&serde_json::json!({ "type": "config-validation", "path": path, "valid": true, "source": if exists { "file" } else { "built-in-defaults" }, "effective": config_json(&config), }))? ); } else if exists { println!("valid config: {}", path.display()); } else { println!( "valid built-in defaults; config file does not exist yet: {}", path.display() ); } } ConfigCommand::Set { key, value } => { let key = ConfigKey::from(key); let config = GethConfig::set(&path, key, &value) .with_context(|| format!("set {} in {}", key.as_str(), path.display()))?; if json { println!( "{}", serde_json::to_string_pretty(&serde_json::json!({ "type": "config-updated", "path": path, "key": key.as_str(), "value": value, "restart_required": true, "effective": config_json(&config), }))? ); } else { println!("updated {} in {}", key.as_str(), path.display()); println!("restart the daemon for the change to take effect"); } } } Ok(()) } fn config_json(config: &GethConfig) -> serde_json::Value { let relay_maps = config .iroh .relay_maps .iter() .map(|(name, map)| (name.clone(), map.urls.clone())) .collect::>(); serde_json::json!({ "iroh": { "relay_mode": config.iroh.relay_mode.label(), "relay_maps": relay_maps, "local_discovery": config.iroh.local_discovery, }, "sync": { "live_sync_enabled": config.sync.live_sync_enabled, "live_sync_interval_ms": config.sync.live_sync_interval_ms, }, }) } async fn run_ephemeral_daemon(json: bool) -> Result<()> { let (home, paths, node) = create_ephemeral_node()?; if json { println!( "{}", serde_json::to_string(&serde_json::json!({ "type": "ephemeral-daemon-starting", "home": paths.home(), "node_id": node.node_id, "control_command": format!("geth --home {} status", paths.home().display()), "cleanup": "state is removed after normal daemon shutdown", }))? ); } else { println!("starting ephemeral geth daemon"); println!("home: {}", paths.home().display()); println!("node: {}", node.node_id); println!("control: geth --home {} status", paths.home().display()); println!("cleanup: state is removed after normal shutdown (Ctrl-C)"); } stdout() .flush() .context("flush ephemeral startup details")?; geth_node::run_daemon(paths) .await .context("run ephemeral geth daemon")?; drop(home); Ok(()) } fn create_ephemeral_node() -> Result<(tempfile::TempDir, GethPaths, geth_node::LocalNode)> { let home = tempfile::Builder::new() .prefix("geth-ephemeral-") .tempdir() .context("create ephemeral geth home")?; let paths = GethPaths::from_home(home.path()); let node = geth_node::init_node(&paths).context("initialize ephemeral geth node")?; Ok((home, paths, node)) } fn print_json_error(error: &anyhow::Error) -> Result<()> { let message = error.to_string(); let detail = format!("{error:#}"); let hint = json_error_hint(&detail); println!( "{}", serde_json::to_string_pretty(&serde_json::json!({ "type": "error", "code": json_error_code(&detail), "message": message, "detail": detail, "hint": hint, }))? ); Ok(()) } fn json_error_code(detail: &str) -> &'static str { let lower = detail.to_ascii_lowercase(); if lower.contains("connect to daemon") || lower.contains("connection refused") { "daemon_unavailable" } else if lower.contains("daemon is already running") { "daemon_already_running" } else if lower.contains("unauthorized") || lower.contains("missing grant") { "unauthorized" } else if lower.contains("peer candidate not found") { "peer_not_found" } else if lower.contains("resource not found") { "resource_not_found" } else if lower.contains("invalid") { "invalid_input" } else { "command_failed" } } fn json_error_hint(detail: &str) -> Option { detail .lines() .find_map(|line| line.strip_prefix("next: ")) .map(ToOwned::to_owned) } fn print_guide(topic: Option, json: bool) -> Result<()> { let (name, body) = match topic { None => ("index", GUIDE_INDEX), Some(GuideTopic::Quickstart) => ("quickstart", GUIDE_QUICKSTART), Some(GuideTopic::Init) => ("init", GUIDE_INIT), Some(GuideTopic::OwnerSetup) => ("owner-setup", GUIDE_OWNER_SETUP), Some(GuideTopic::Enrollment) => ("enrollment", GUIDE_ENROLLMENT), Some(GuideTopic::Keys) => ("keys", GUIDE_KEYS), Some(GuideTopic::Overlay) => ("overlay", GUIDE_OVERLAY), Some(GuideTopic::Service) => ("service", GUIDE_SERVICE), Some(GuideTopic::Completions) => ("completions", GUIDE_COMPLETIONS), Some(GuideTopic::SmokeTest) => ("smoke-test", GUIDE_SMOKE_TEST), }; if json { println!( "{}", serde_json::json!({ "topic": name, "body": body, }) ); } else { print!("{body}"); } Ok(()) } fn print_completions(shell: Shell) { let mut command = documented_cli_command(); generate(shell, &mut command, "geth", &mut stdout()); } fn request_for_command(command: Command) -> Result { Ok(match command { Command::Completions { .. } => bail!("completion generation does not use the daemon"), Command::Status => ControlRequest::Status, Command::Sync { command: SyncCommand::Status, } => ControlRequest::SyncStatus, Command::Sync { command: SyncCommand::Now { node }, } => ControlRequest::SyncNow { node }, Command::Node { command: NodeCommand::Id, } => ControlRequest::NodeId, Command::Node { command: NodeCommand::Status, } => ControlRequest::Status, Command::Node { command: NodeCommand::List, } => ControlRequest::NodeList, Command::Node { command: NodeCommand::Enroll { command }, } => match command { NodeEnrollCommand::Request { node_name, capabilities, reason, out, } => ControlRequest::NodeEnrollRequest { node_name, capabilities, reason, out, }, NodeEnrollCommand::Submit { owner_node, request_id, path, } => ControlRequest::NodeEnrollSubmit { owner_node, request_id, path, }, NodeEnrollCommand::Import { path } => ControlRequest::NodeEnrollImport { path }, NodeEnrollCommand::List { status } => ControlRequest::NodeEnrollList { status }, NodeEnrollCommand::Approve { request_id, signing_key, admin_key, node_name, capabilities, } => ControlRequest::NodeEnrollApprove { request_id, signing_key_path: signing_key, admin_key_path: admin_key, node_name, capabilities, }, NodeEnrollCommand::Sync { owner_node } => ControlRequest::NodeEnrollSync { owner_node }, }, Command::Node { command: NodeCommand::Rename { node, name, signing_key, }, } => ControlRequest::NodeRename { node, name, signing_key_path: signing_key, }, Command::Node { command: NodeCommand::Revoke { node, signing_key }, } => ControlRequest::NodeRevoke { node, signing_key_path: signing_key, }, Command::Node { command: NodeCommand::Grant { node, resource, capability, grant_id, signing_key, admin_key, }, } => ControlRequest::NodeGrant { node, resource, capability, grant_id, signing_key_path: Some(signing_key), admin_key_path: admin_key, }, Command::Node { command: NodeCommand::RevokeGrant { resource, grant_id, signing_key, admin_key, }, } => ControlRequest::NodeRevokeGrant { resource, grant_id, signing_key_path: Some(signing_key), admin_key_path: admin_key, }, Command::Node { command: NodeCommand::EndpointAdd { node, endpoint, signing_key, }, } => ControlRequest::NodeEndpointAdd { node, endpoint, signing_key_path: Some(signing_key), }, Command::Node { command: NodeCommand::EndpointRevoke { node, endpoint, signing_key, }, } => ControlRequest::NodeEndpointRevoke { node, endpoint, signing_key_path: Some(signing_key), }, Command::Peer { command } => match command { PeerCommand::Export { out } => ControlRequest::PeerCardExport { out }, PeerCommand::Import { path } => ControlRequest::PeerCardImport { path }, PeerCommand::List => ControlRequest::PeerCardList, PeerCommand::Ping { node } => ControlRequest::PeerPing { node }, PeerCommand::AuthCheck { node, resource, capability, } => ControlRequest::PeerAuthCheck { node, resource, capability, }, }, Command::Overlay { command } => match command { OverlayCommand::Status => ControlRequest::OverlayStatus, OverlayCommand::Plan { name, cidr } => ControlRequest::OverlayPlan { name, cidr }, OverlayCommand::Join { name, secret, cidr } => { ControlRequest::OverlayJoin { name, secret, cidr } } OverlayCommand::Leave { name } => ControlRequest::OverlayLeave { name }, OverlayCommand::InterfacePlan { name, platform } => { ControlRequest::OverlayInterfacePlan { name, platform } } OverlayCommand::Up { name, bearer_secret, mtu, } => ControlRequest::OverlayUp { name, bearer_secret, mtu, }, OverlayCommand::Down { name } => ControlRequest::OverlayDown { name }, OverlayCommand::Peers { name } => ControlRequest::OverlayPeers { name }, OverlayCommand::Send { name, node, packet_base64, bearer_secret, } => ControlRequest::OverlaySend { name, node, packet_base64, bearer_secret, }, OverlayCommand::Recv { name, peek } => ControlRequest::OverlayRecv { name, peek }, }, 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, signing_key, }, } => ControlRequest::KeychainInit { admin_key_path: admin_key, signing_key_path: signing_key, }, Command::Keychain { command: KeychainCommand::Status, } => ControlRequest::KeychainStatus, Command::Keychain { command: KeychainCommand::AdminAdd { admin_key, signing_key, principal, valid_after_ms, valid_before_ms, }, } => ControlRequest::KeychainAdminAdd { admin_key_path: admin_key, signing_key_path: signing_key, principal, valid_after_ms, valid_before_ms, }, Command::Keychain { command: KeychainCommand::AdminRevoke { key, signing_key, admin_key, }, } => ControlRequest::KeychainAdminRevoke { key, signing_key_path: signing_key, admin_key_path: admin_key, }, Command::Keychain { command: KeychainCommand::AllowedSigners { out }, } => ControlRequest::KeychainAllowedSigners { out }, Command::Keychain { command: KeychainCommand::SignFile { input, out, namespace, signing_key, admin_key, }, } => ControlRequest::KeychainSignFile { input, out, namespace, signing_key_path: Some(signing_key), admin_key_path: admin_key, }, Command::Keychain { command: KeychainCommand::VerifyFile { input, signature, namespace, allowed_signers, principal, }, } => ControlRequest::KeychainVerifyFile { input, signature, namespace, allowed_signers_path: allowed_signers, principal, }, Command::Keychain { command: KeychainCommand::Sigchain { out }, } => ControlRequest::KeychainSigchainExport { out }, Command::Keychain { command: KeychainCommand::PublishBundle { out, base_url, signing_key, admin_key, snapshots, }, } => ControlRequest::KeychainPublishBundle { out, base_url: Some(base_url), signing_key_path: signing_key, admin_key_path: admin_key, snapshots, }, Command::Keychain { command: KeychainCommand::VerifySigchain { input }, } => ControlRequest::KeychainVerifySigchain { input }, Command::Keychain { command: KeychainCommand::ImportSigchain { input }, } => ControlRequest::KeychainImportSigchain { input }, Command::Keychain { command: KeychainCommand::VerifyCheckpoint { checkpoint, signature, sigchain, allowed_signers, base_url, principal, }, } => ControlRequest::KeychainVerifyCheckpoint { checkpoint, signature, sigchain, allowed_signers, base_url, principal, }, Command::Keychain { command: KeychainCommand::Fetch { url, out, import }, } => ControlRequest::KeychainFetch { url, out, import }, Command::Keychain { command: KeychainCommand::Explain { op_id }, } => ControlRequest::KeychainExplain { op_id }, Command::Keychain { command: KeychainCommand::ExplainSigner { key }, } => ControlRequest::KeychainExplainSigner { key }, Command::Keychain { command: KeychainCommand::Verify, } => ControlRequest::KeychainVerify, Command::Keychain { command: KeychainCommand::Sync { node }, } => ControlRequest::KeychainSync { node }, Command::Auth { command: AuthCommand::Sync { node }, } => ControlRequest::AuthSync { node }, Command::Auth { command: AuthCommand::Explain { subject, resource, capability, }, } => ControlRequest::AuthExplain { subject, resource, capability, }, Command::Auth { command: AuthCommand::Grant { subject, resource, capability, grant_id, signing_key, admin_key, }, } => ControlRequest::AuthGrant { subject, resource, capability, grant_id, signing_key_path: Some(signing_key), admin_key_path: admin_key, }, Command::Auth { command: AuthCommand::Revoke { resource, grant_id, signing_key, admin_key, }, } => ControlRequest::AuthRevoke { resource, grant_id, signing_key_path: Some(signing_key), admin_key_path: admin_key, }, Command::Secret { command } => match command { SecretCommand::Status => ControlRequest::SecretStatus, SecretCommand::Create { resource } => ControlRequest::SecretCreate { resource }, SecretCommand::Rotate { resource } => ControlRequest::SecretRotate { resource }, SecretCommand::Bearer { command } => match command { SecretBearerCommand::Create { resource, capabilities, expires_at_ms, } => ControlRequest::SecretBearerCreate { resource, capabilities, expires_at_ms, }, SecretBearerCommand::List => ControlRequest::SecretBearerList, SecretBearerCommand::Challenge { resource, capabilities, } => ControlRequest::SecretBearerChallenge { resource, capabilities, }, SecretBearerCommand::Prove { token, resource, capabilities, nonce, } => ControlRequest::SecretBearerProve { secret: token, resource, capabilities, nonce, }, SecretBearerCommand::Verify { token, resource, capabilities, nonce, response, } => ControlRequest::SecretBearerVerify { secret: token, resource, capabilities, nonce, response, }, SecretBearerCommand::Revoke { resource, bearer_id, } => ControlRequest::SecretBearerRevoke { resource, secret: bearer_id, }, }, }, Command::Cas { command } => match command { CasCommand::Add { path } => ControlRequest::CasAdd { path }, CasCommand::AddPrivate { resource, path } => { ControlRequest::CasAddPrivate { resource, path } } CasCommand::Get { hash, out } => ControlRequest::CasGet { hash: hash.into(), out, }, CasCommand::GetPrivate { resource, hash, out, } => ControlRequest::CasGetPrivate { resource, hash: hash.into(), out, }, CasCommand::Fetch { node, hash, bearer_secret, } => ControlRequest::CasFetch { node, hash: hash.into(), bearer_secret, }, CasCommand::Hash { path } => ControlRequest::CasHash { path }, CasCommand::Has { hash } => ControlRequest::CasHas { hash: hash.into() }, CasCommand::Pin { hash } => ControlRequest::CasPin { hash: hash.into() }, CasCommand::Unpin { hash } => ControlRequest::CasUnpin { hash: hash.into() }, CasCommand::Cleanup { dry_run } => ControlRequest::CasCleanup { dry_run }, CasCommand::Providers { hash } => ControlRequest::CasProviders { hash: hash.into() }, CasCommand::List => ControlRequest::CasList, CasCommand::Root { command } => match command { CasRootCommand::Add { name, path } => ControlRequest::CasRootAdd { name, path }, CasRootCommand::List => ControlRequest::CasRootList, CasRootCommand::Scan { name } => ControlRequest::CasRootScan { name }, CasRootCommand::Sync { node, name, bearer_secret, } => ControlRequest::CasRootSync { node, name, bearer_secret, }, CasRootCommand::Apply { source, to, dry_run, } => ControlRequest::CasRootApply { source, target: to, dry_run, }, }, CasCommand::Conflict { command } => match command { CasConflictCommand::Record { root, path, kind, detail, base_tree, local_tree, remote_tree, } => ControlRequest::CasConflictRecord { root, path, kind, detail, base_tree: base_tree.map(Into::into), local_tree: local_tree.map(Into::into), remote_tree: remote_tree.map(Into::into), }, CasConflictCommand::List { root } => ControlRequest::CasConflictList { root }, CasConflictCommand::Resolve { conflict_id, resolution, note, } => ControlRequest::CasConflictResolve { conflict_id, resolution, note, }, }, }, Command::Kv { command } => match command { KvCommand::Create { name } => ControlRequest::KvCreate { name }, KvCommand::Set { name, key, value, subject, } => ControlRequest::KvSet { name, key, value, subject, }, KvCommand::Get { name, key } => ControlRequest::KvGet { name, key }, KvCommand::Sync { node, name, bearer_secret, } => ControlRequest::KvSync { node, name, bearer_secret, }, }, Command::Pubsub { command } => match command { PubsubCommand::Pub { topic, message, node, bearer_secret, } => ControlRequest::PubsubPub { topic, message, node, bearer_secret, }, PubsubCommand::Sub { topic, node, bearer_secret, } => ControlRequest::PubsubSub { topic, node, bearer_secret, }, }, Command::Pipe { command } => match command { PipeCommand::Listen { name, node, bearer_secret, } => ControlRequest::PipeListen { name, node, bearer_secret, }, PipeCommand::Connect { target, node, bearer_secret, } => ControlRequest::PipeConnect { target, node, bearer_secret, }, PipeCommand::ForwardTcp { listen, node, target, bearer_secret, } => ControlRequest::PipeTcpForward { listen_addr: listen, node, target_addr: target, bearer_secret, }, PipeCommand::ForwardUnix { listen, node, target, bearer_secret, } => ControlRequest::PipeUnixForward { listen_path: listen, node, target_path: target, bearer_secret, }, PipeCommand::Send { target, message, input, node, bearer_secret, } => ControlRequest::PipeSend { target, data_base64: pipe_send_payload_base64(message, input)?, node, bearer_secret, }, PipeCommand::Recv { name, peek } => ControlRequest::PipeRecv { name, peek }, }, Command::Db { command } => match command { DbCommand::Add { name, path } => ControlRequest::DbAdd { name, path }, DbCommand::Status { name } => ControlRequest::DbStatus { name }, DbCommand::Changes { name, after_db_version, limit, } => ControlRequest::DbChanges { name, after_db_version, limit, }, DbCommand::Sync { node, name, limit, bearer_secret, } => ControlRequest::DbSync { node, name, limit, bearer_secret, }, }, Command::Document { command } => match command { DocumentCommand::Create { name } => ControlRequest::DocumentCreate { name }, DocumentCommand::Status { name } => ControlRequest::DocumentStatus { name }, DocumentCommand::Set { name, state_json } => { ControlRequest::DocumentSet { name, state_json } } DocumentCommand::Get { name } => ControlRequest::DocumentGet { name }, DocumentCommand::Sync { node, name, bearer_secret, } => ControlRequest::DocumentSync { node, name, bearer_secret, }, }, Command::Ssh { command } => match command { SshCommand::Proxy { node, bearer_secret, } => ControlRequest::SshProxyConnect { node, bearer_secret, }, SshCommand::AdminShell { node, command, bearer_secret, } => ControlRequest::SshAdminShell { node, command, bearer_secret, }, SshCommand::Cert { command } => match command { SshCertCommand::Request { public_key, kind, principals, valid_for, renewal_of, reason, subject, } => ControlRequest::SshCertRequest { public_key_path: public_key, cert_kind: kind, principals, requested_validity: valid_for, renewal_of, reason, subject, }, SshCertCommand::Requests { subject } => ControlRequest::SshCertRequests { subject }, SshCertCommand::Approve { request_id, ca_key, valid_for, serial, out, sign, subject, } => ControlRequest::SshCertApprove { request_id, ca_key_path: ca_key, valid_for, serial, out, sign, subject, }, SshCertCommand::Import { request_id, cert, subject, } => ControlRequest::SshCertImport { request_id, cert_path: cert, subject, }, SshCertCommand::List { subject } => ControlRequest::SshCertList { subject }, SshCertCommand::Sync { node, bearer_secret, } => ControlRequest::SshCertSync { node, bearer_secret, }, }, SshCommand::Revocation { command } => match command { SshRevocationCommand::Add { kind, target, reason, subject, } => ControlRequest::SshRevocationAdd { kind, target, reason, subject, }, SshRevocationCommand::List { subject } => { ControlRequest::SshRevocationList { subject } } SshRevocationCommand::Export { out, format, ca_public, subject, } => ControlRequest::SshRevocationExport { out, format, ca_public, subject, }, SshRevocationCommand::Import { path, format, subject, } => ControlRequest::SshRevocationImport { path, format, subject, }, SshRevocationCommand::Sync { node, bearer_secret, } => ControlRequest::SshRevocationSync { node, bearer_secret, }, }, }, Command::Guide { .. } | Command::Config { .. } | Command::Init { .. } | Command::Daemon { .. } | Command::Backup { .. } | Command::Doctor | Command::Wait { .. } => { bail!("command is handled directly") } }) } #[derive(Debug)] struct WaitReport { target: String, ready: bool, elapsed_ms: u128, attempts: u64, reason: String, } fn run_backup_command(paths: &GethPaths, command: BackupCommand, json: bool) -> Result<()> { match command { BackupCommand::Create { out } => { let report = geth_node::backup::create_backup(paths, &out)?; if json { println!( "{}", serde_json::to_string_pretty(&serde_json::json!({ "type": "backup-created", "backup_dir": report.backup_dir, "files_copied": report.files_copied, "bytes_copied": report.bytes_copied, "manifest": report.manifest, }))? ); } else { println!("backup: {}", report.backup_dir.display()); println!("files_copied: {}", report.files_copied); println!("bytes_copied: {}", report.bytes_copied); println!( "manifest: {}", report.backup_dir.join("manifest.json").display() ); println!( "note: private geth identity keys and private SSH admin keys are not copied" ); } } BackupCommand::Restore { backup_dir, target_home, } => { let report = geth_node::backup::restore_backup(&backup_dir, &target_home)?; if json { println!( "{}", serde_json::to_string_pretty(&serde_json::json!({ "type": "backup-restored", "backup_dir": report.backup_dir, "target_home": report.target_home, "files_restored": report.files_restored, "bytes_restored": report.bytes_restored, "manifest": report.manifest, }))? ); } else { println!("restored: {}", report.target_home.display()); println!("backup: {}", report.backup_dir.display()); println!("files_restored: {}", report.files_restored); println!("bytes_restored: {}", report.bytes_restored); println!( "note: validate with GETH_HOME={} geth status after starting a daemon for the restored home", report.target_home.display() ); } } } Ok(()) } fn print_doctor_report(report: &geth_node::doctor::DoctorReport, json: bool) -> Result<()> { if json { println!( "{}", serde_json::to_string_pretty(&serde_json::json!({ "type": "doctor", "ok": report.ok, "checks": report.checks, }))? ); return Ok(()); } println!("doctor: {}", if report.ok { "ok" } else { "failed" }); for check in &report.checks { println!("{:?}\t{}\t{}", check.status, check.code, check.message); if let Some(hint) = &check.hint { println!("hint\t{}\t{}", check.code, hint); } } Ok(()) } async fn run_wait_command(paths: &GethPaths, command: WaitCommand) -> Result { match command { WaitCommand::Daemon { timeout_ms, interval_ms, } => wait_for_daemon(paths, timeout_ms, interval_ms).await, WaitCommand::Peer { node, timeout_ms, interval_ms, } => wait_for_peer(paths, node, timeout_ms, interval_ms).await, WaitCommand::Sync { node, timeout_ms, interval_ms, } => wait_for_sync(paths, node, timeout_ms, interval_ms).await, } } async fn wait_for_daemon( paths: &GethPaths, timeout_ms: u64, interval_ms: u64, ) -> Result { wait_loop("daemon".to_owned(), timeout_ms, interval_ms, || async { match geth_node::send_control(paths, ControlRequest::Status).await { Ok(ControlResponse::Status(_)) => Ok(Some("daemon control is ready".to_owned())), Ok(other) => Ok(Some(format!("unexpected response: {:?}", other))), Err(error) => Err(anyhow::anyhow!(error.to_string())), } }) .await } async fn wait_for_peer( paths: &GethPaths, node: String, timeout_ms: u64, interval_ms: u64, ) -> Result { wait_loop(format!("peer:{node}"), timeout_ms, interval_ms, || { let node = node.clone(); async move { match geth_node::send_control(paths, ControlRequest::PeerPing { node }).await { Ok(ControlResponse::PeerPinged { note, .. }) => Ok(Some(note)), Ok(ControlResponse::Error { message }) => Err(anyhow::anyhow!(message)), Ok(_) => Ok(None), Err(error) => Err(anyhow::anyhow!(error.to_string())), } } }) .await } async fn wait_for_sync( paths: &GethPaths, node: String, timeout_ms: u64, interval_ms: u64, ) -> Result { wait_loop(format!("sync:{node}"), timeout_ms, interval_ms, || { let node = node.clone(); async move { match geth_node::send_control(paths, ControlRequest::SyncStatus).await { Ok(ControlResponse::SyncStatus { peers, .. }) => { let Some(peer) = peers.into_iter().find(|peer| peer.peer_node_id == node) else { return Ok(None); }; if peer.streams.is_empty() { return Ok(None); } if peer.streams.iter().all(sync_stream_ready) { Ok(Some("sync streams are healthy or stale".to_owned())) } else { Ok(None) } } Ok(ControlResponse::Error { message }) => Err(anyhow::anyhow!(message)), Ok(_) => Ok(None), Err(error) => Err(anyhow::anyhow!(error.to_string())), } } }) .await } fn sync_stream_ready(stream: &SyncStreamStatus) -> bool { stream.state == "ok" || stream.stale } async fn wait_loop( target: String, timeout_ms: u64, interval_ms: u64, mut check: F, ) -> Result where F: FnMut() -> Fut, Fut: std::future::Future>>, { let started = Instant::now(); let timeout = Duration::from_millis(timeout_ms); let interval = Duration::from_millis(interval_ms.max(1)); let mut attempts = 0; let mut last_error: Option = None; loop { attempts += 1; match check().await { Ok(Some(reason)) => { return Ok(WaitReport { target, ready: true, elapsed_ms: started.elapsed().as_millis(), attempts, reason, }); } Ok(None) => {} Err(error) => { last_error = Some(error.to_string()); } } if started.elapsed() >= timeout { return Ok(WaitReport { target, ready: false, elapsed_ms: started.elapsed().as_millis(), attempts, reason: last_error.unwrap_or_else(|| "timeout waiting for readiness".to_owned()), }); } tokio::time::sleep(interval).await; } } fn print_wait_report(report: &WaitReport, json: bool) -> Result<()> { if json { println!( "{}", serde_json::to_string_pretty(&serde_json::json!({ "type": "wait", "target": report.target, "ready": report.ready, "elapsed_ms": report.elapsed_ms, "attempts": report.attempts, "reason": report.reason, }))? ); return Ok(()); } println!("target: {}", report.target); println!("ready: {}", report.ready); println!("elapsed_ms: {}", report.elapsed_ms); println!("attempts: {}", report.attempts); println!("reason: {}", report.reason); Ok(()) } 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_keychain_sigchain_report(report: &geth_keychain::KeychainSigchainReport) { println!("ops: {}", report.ops); println!("signatures: {}", report.signatures); println!("accepted_ops: {}", report.accepted_ops); println!("rejected_ops: {}", report.rejected_ops); println!("active_admin_keys: {}", report.active_admin_keys); println!( "accepted_head: {}", report .accepted_head .as_ref() .map(|head| head.as_str()) .unwrap_or("none") ); println!("note: {}", report.note); } 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!("uptime seconds: {}", status.daemon_uptime_seconds); println!( "store schema: {}/{}", status.store_schema_version, status.store_current_schema_version ); println!("store journal: {}", status.store_journal_mode); println!("store synchronous: {}", status.store_synchronous); println!("store status: {}", status.store_status); println!("store note: {}", status.store_note); 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); for backend in status.native_backends { println!( "native backend {}: {} target {} {} ({})", backend.module, backend.current_backend, backend.target_crate, backend.target_version, backend.status ); if !backend.blocker.is_empty() { println!( "native backend {} note: {}", backend.module, backend.blocker ); } } } 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::PeerCardExported { card, out, note } => { println!("peer card: {}", card.node_id); println!("agent: {}", card.agent_id); println!("endpoints: {}", card.endpoints.len()); if let Some(path) = out { println!("wrote: {}", path.display()); } else { println!("{}", serde_json::to_string_pretty(&card)?); } println!("note: {note}"); } ControlResponse::PeerCardImported { peer, note } => { println!("imported peer: {}", peer.card.node_id); println!("agent: {}", peer.card.agent_id); println!("trust: candidate-only"); println!("note: {note}"); } ControlResponse::PeerCardList { peers, note } => { if peers.is_empty() { println!("no peer candidates"); } else { for peer in peers { println!( "{}\t{}\t{} endpoints\tcandidate-only", peer.card.node_id, peer.card.agent_id, peer.card.endpoints.len() ); } } println!("note: {note}"); } ControlResponse::PeerPinged { peer_node_id, peer_agent_id, endpoint_id, alpn, note, } => { println!("peer pong: {peer_node_id}"); println!("agent: {peer_agent_id}"); println!("endpoint: {endpoint_id}"); println!("alpn: {alpn}"); println!("note: {note}"); } ControlResponse::PeerAuthChecked { peer_node_id, peer_agent_id, endpoint_id, resource, capability, allowed, reason, evaluated_ops, note, } => { println!("peer auth: {peer_node_id}"); println!("agent: {peer_agent_id}"); println!("endpoint: {endpoint_id}"); println!("resource: {resource}"); println!("capability: {capability}"); println!("allowed: {allowed}"); println!("reason: {reason}"); println!("evaluated_ops: {evaluated_ops}"); println!("note: {note}"); } 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::OverlayStatus { networks, note } => { if networks.is_empty() { println!("no overlay networks active"); } else { for network in networks { println!( "{}\t{}\t{}\t{:?}\t{} peers", network.name, network.resource, network.cidr, network.state, network.peers.len() ); } } println!("note: {note}"); } ControlResponse::OverlayPlanned { plan } => { println!("overlay: {}", plan.name); println!("resource: {}", plan.resource); println!("cidr: {}", plan.cidr); println!("alpn: {}", plan.alpn); println!("capabilities: {}", plan.capabilities.join(",")); println!("discovery: {}", plan.discovery); println!("runtime: {}", plan.runtime); for note in plan.security { println!("security: {note}"); } for note in plan.implementation_notes { println!("implementation: {note}"); } } ControlResponse::OverlayJoined { join } => { println!("overlay: {}", join.plan.name); println!("resource: {}", join.plan.resource); println!("cidr: {}", join.network.cidr); println!( "virtual_ip: {}", join.network.virtual_ip.as_deref().unwrap_or("unassigned") ); println!("state: {:?}", join.network.state); println!("enabled: {}", join.enabled); println!("note: {}", join.note); } ControlResponse::OverlayLeft { name, stopped, note, } => { println!("overlay: {name}"); println!("stopped: {stopped}"); println!("note: {note}"); } ControlResponse::OverlayInterfacePlanned { plan } => { println!("overlay: {}", plan.name); println!("platform: {}", plan.platform); println!("interface: {}", plan.interface_name); println!("cidr: {}", plan.cidr); println!( "virtual_ip: {}", plan.virtual_ip.as_deref().unwrap_or("unassigned") ); println!("requires_privileges: {}", plan.requires_privileges); for command in plan.commands { println!("command: {command}"); } for note in plan.notes { println!("note: {note}"); } } ControlResponse::OverlayRuntimeStarted { status } => { println!("overlay: {}", status.name); println!("interface: {}", status.interface_name); println!("virtual_ip: {}", status.virtual_ip); println!("cidr: {}", status.cidr); println!("mtu: {}", status.mtu); println!("packets_from_tun: {}", status.packets_from_tun); println!("packets_to_tun: {}", status.packets_to_tun); println!("packets_to_peers: {}", status.packets_to_peers); if let Some(error) = status.last_error { println!("last_error: {error}"); } println!("note: {}", status.note); } ControlResponse::OverlayRuntimeStopped { name, stopped, note, } => { println!("overlay: {name}"); println!("stopped: {stopped}"); println!("note: {note}"); } ControlResponse::OverlayPeers { name, peers, note } => { println!("overlay: {name}"); if peers.is_empty() { println!("no overlay peer candidates"); } else { for peer in peers { println!( "{}\t{}\t{}\t{}", peer.node_id, peer.endpoint_id.as_deref().unwrap_or("no-endpoint"), peer.virtual_ip.as_deref().unwrap_or("no-virtual-ip"), peer.state ); } } println!("note: {note}"); } ControlResponse::OverlayPacketSent { peer_node_id, peer_agent_id, endpoint_id, packet, allowed, reason, note, } => { println!("peer: {peer_node_id}"); println!("agent: {peer_agent_id}"); println!("endpoint: {endpoint_id}"); println!("allowed: {allowed}"); println!("reason: {reason}"); if let Some(packet) = packet { println!("packet: {}", packet.id); println!("size_bytes: {}", packet.size_bytes); } println!("note: {note}"); } ControlResponse::OverlayPackets { name, packets, drained, note, } => { println!("overlay: {name}"); println!("drained: {drained}"); for packet in packets { println!( "{}\t{}\t{}\t{} bytes", packet.id, packet.source_node, packet.destination_node, packet.size_bytes ); } println!("note: {note}"); } ControlResponse::CasAdded { hash, size_bytes } => { println!("{hash} {size_bytes} bytes"); } ControlResponse::CasPrivateAdded { resource, epoch, plaintext_hash, encrypted_hash, size_bytes, note, } => { println!("encrypted_hash: {encrypted_hash}"); println!("plaintext_hash: {plaintext_hash}"); println!("resource: {resource}"); println!("epoch: {epoch}"); println!("size_bytes: {size_bytes}"); println!("note: {note}"); } ControlResponse::CasGot { hash, out, size_bytes, } => { println!("wrote {hash} to {} ({size_bytes} bytes)", out.display()); } ControlResponse::CasPrivateGot { resource, hash, plaintext_hash, out, size_bytes, note, } => { println!( "decrypted {hash} for {resource} to {} ({size_bytes} bytes)", out.display() ); println!("plaintext_hash: {plaintext_hash}"); println!("note: {note}"); } ControlResponse::CasFetched { peer_node_id, peer_agent_id, endpoint_id, hash, size_bytes, allowed, reason, note, } => { if allowed { println!("fetched {hash} from {peer_node_id} ({size_bytes} bytes)"); } else { println!("fetch denied for {hash} from {peer_node_id}"); } println!("agent: {peer_agent_id}"); println!("endpoint: {endpoint_id}"); println!("allowed: {allowed}"); println!("reason: {reason}"); println!("note: {note}"); } ControlResponse::CasHash { hash } => println!("{hash}"), ControlResponse::CasHas { hash, present } => println!("{hash}: {present}"), ControlResponse::CasPinned { hash, pinned } => { println!("{hash}: pinned={pinned}"); } ControlResponse::CasCleanup { removed, retained_pinned, dry_run, } => { let action = if dry_run { "would remove" } else { "removed" }; println!("{action}: {}", removed.len()); for hash in removed { println!(" {hash}"); } println!("retained_pinned: {}", retained_pinned.len()); for hash in retained_pinned { println!(" {hash}"); } } ControlResponse::CasList { blobs } => { for blob in blobs { let pin = if blob.pinned { "pinned" } else { "unpinned" }; println!("{}\t{} bytes\t{}", blob.hash, blob.size_bytes, pin); } } ControlResponse::CasProviders { hash, providers } => { println!("hash: {hash}"); println!("providers: {}", providers.len()); for provider in providers { println!( "{}\t{}\t{}", provider.peer_node_id, provider.endpoint_id, provider.last_seen_ms ); } } ControlResponse::CasRootAdded { root } => { println!("added file root: {}", root.name); println!("id: {}", root.id); println!("resource: {}", root.resource); println!("path: {}", root.path); } ControlResponse::CasRootList { roots } => { if roots.is_empty() { println!("no file roots"); } else { for root in roots { println!( "{}\t{}\t{}", root.name, root.path, root.latest_tree .map(|hash| hash.to_string()) .unwrap_or_else(|| "unscanned".to_owned()) ); } } } ControlResponse::CasRootScanned { scan } => { println!("file root: {}", scan.root.name); println!("tree: {}", scan.tree.hash); println!("tree_bytes: {}", scan.tree.size_bytes); println!("changes: {}", scan.changes.len()); for change in scan.changes { println!("{change:?}"); } println!("note: {}", scan.note); } ControlResponse::CasRootSynced { peer_node_id, peer_agent_id, endpoint_id, name, root, tree_bytes_imported, sync_conflicts, allowed, reason, note, } => { if let Some(root) = root { println!("synced file root: {name}"); println!("peer: {peer_node_id}"); println!("path: {}", root.path); println!( "tree: {}", root.latest_tree .map(|hash| hash.to_string()) .unwrap_or_else(|| "unscanned".to_owned()) ); println!("tree_bytes_imported: {tree_bytes_imported}"); println!("sync_conflicts: {}", sync_conflicts.len()); for conflict in sync_conflicts { println!( "{}\t{}\t{}\t{}", conflict.id, conflict.path, conflict.kind.as_str(), conflict.status.as_str() ); } } else { println!("file root sync denied by {peer_node_id}"); } println!("agent: {peer_agent_id}"); println!("endpoint: {endpoint_id}"); println!("allowed: {allowed}"); println!("reason: {reason}"); println!("note: {note}"); } ControlResponse::CasRootApplied { source, target, files_written, dirs_created, conflicts, dry_run, note, } => { println!("applied file root: {source}"); println!("target: {}", target.display()); println!("dry_run: {dry_run}"); println!("files_written: {files_written}"); println!("dirs_created: {dirs_created}"); println!("conflicts: {}", conflicts.len()); for conflict in conflicts { println!( "{}\t{}\t{}\t{}", conflict.id, conflict.path, conflict.kind.as_str(), conflict.status.as_str() ); } println!("note: {note}"); } ControlResponse::CasConflictRecorded { conflict } => { println!("recorded conflict: {}", conflict.id); print_file_conflict(&conflict); } ControlResponse::CasConflictList { conflicts } => { if conflicts.is_empty() { println!("no file conflicts"); } else { for conflict in conflicts { println!( "{}\t{}\t{}\t{}\t{}", conflict.id, conflict.root, conflict.path, conflict.kind.as_str(), conflict.status.as_str() ); } } } ControlResponse::CasConflictResolved { conflict } => { println!("resolved conflict: {}", conflict.id); print_file_conflict(&conflict); } ControlResponse::KeychainStatus(status) => { println!("initialized: {}", status.initialized); println!("admin_keys: {}", status.admin_keys); println!("signatures: {}", status.signatures); println!("verified_signatures: {}", status.verified_signatures); println!("failed_signatures: {}", status.failed_signatures); println!("users: {}", status.users); println!("devices: {}", status.devices); println!("nodes: {}", status.nodes); } ControlResponse::KeychainInitialized { ops, signatures } => { println!("initialized keychain"); for op in ops { println!("recorded keychain op: {}", op.id); } for signature in signatures { println!( "signed keychain op: {} by {} ({})", signature.op_id, signature.signer, signature.namespace ); } } ControlResponse::KeychainAdminUpdated { op, signatures, note, } => { println!("recorded keychain op: {}", op.id); for signature in signatures { println!( "signed keychain op: {} by {} ({})", signature.op_id, signature.signer, signature.namespace ); } println!("note: {note}"); } ControlResponse::KeychainAllowedSigners { allowed_signers, out, note, .. } => { if let Some(out) = out { println!("wrote allowed_signers: {}", out.display()); if allowed_signers.is_empty() { println!("warning: generated file has no active admin public keys"); } } else { print!("{allowed_signers}"); if allowed_signers.is_empty() { println!("no active admin public keys available"); } } eprintln!("note: {note}"); } ControlResponse::KeychainFileSigned { input, out, namespace, signer, note, } => { println!("signed file: {}", input.display()); println!( "signature: {}", out.map(|path| path.display().to_string()) .unwrap_or_else(|| "none".to_owned()) ); println!("namespace: {namespace}"); println!("signer: {signer}"); eprintln!("note: {note}"); } ControlResponse::KeychainFileVerified { input, signature, namespace, verified, principal, note, } => { println!("file: {}", input.display()); println!("signature: {}", signature.display()); println!("namespace: {namespace}"); println!("principal: {}", principal.as_deref().unwrap_or("none")); println!("verified: {verified}"); eprintln!("note: {note}"); } ControlResponse::KeychainSigchainExported { jsonl, out, note, .. } => { if let Some(out) = out { println!("wrote keychain sigchain: {}", out.display()); } else { print!("{jsonl}"); } eprintln!("note: {note}"); } ControlResponse::KeychainBundlePublished { out, base_url, allowed_signers_path, sigchain_path, checkpoint_path, checkpoint_signature_path, snapshots, note, .. } => { println!("bundle: {}", out.display()); println!("base_url: {base_url}"); println!("allowed_signers: {}", allowed_signers_path.display()); println!("sigchain: {}", sigchain_path.display()); println!("checkpoint: {}", checkpoint_path.display()); println!( "checkpoint_signature: {}", checkpoint_signature_path.display() ); for snapshot in snapshots { println!( "snapshot: {} {} {}", snapshot.name, snapshot.path.display(), snapshot.signature_path.display() ); } eprintln!("note: {note}"); } ControlResponse::KeychainSigchainFileVerified { input, report, note, } => { println!("sigchain: {}", input.display()); print_keychain_sigchain_report(&report); eprintln!("note: {note}"); } ControlResponse::KeychainSigchainImported { input, ops_imported, signatures_imported, invalid_ops_rejected, note, } => { println!("sigchain: {}", input.display()); println!("ops_imported: {ops_imported}"); println!("signatures_imported: {signatures_imported}"); println!("invalid_ops_rejected: {invalid_ops_rejected}"); eprintln!("note: {note}"); } ControlResponse::KeychainCheckpointVerified { checkpoint, verified, principal, note, } => { println!( "checkpoint_head: {}", checkpoint .head .as_ref() .map(|h| h.as_str()) .unwrap_or("none") ); println!("base_url: {}", checkpoint.base_url); println!("verified: {verified}"); println!("principal: {}", principal.as_deref().unwrap_or("none")); eprintln!("note: {note}"); } ControlResponse::KeychainFetched { url, out, checkpoint, imported, note, } => { println!("url: {url}"); println!("out: {}", out.display()); println!( "checkpoint_head: {}", checkpoint .head .as_ref() .map(|h| h.as_str()) .unwrap_or("none") ); if let Some(imported) = imported { println!("ops_imported: {}", imported.ops_imported); println!("signatures_imported: {}", imported.signatures_imported); println!("invalid_ops_rejected: {}", imported.invalid_ops_rejected); } eprintln!("note: {note}"); } ControlResponse::KeychainExplained { subject, lines } => { println!("subject: {subject}"); for line in lines { println!("{line}"); } } ControlResponse::KeychainVerified { report } => { print_keychain_sigchain_report(&report); } ControlResponse::KeychainSynced { peer_node_id, peer_agent_id, endpoint_id, ops_imported, signatures_imported, invalid_ops_rejected, high_water_ms, note, } => { println!("synced keychain from: {peer_node_id}"); println!("agent: {peer_agent_id}"); println!("endpoint: {endpoint_id}"); println!("ops_imported: {ops_imported}"); println!("signatures_imported: {signatures_imported}"); println!("invalid_ops_rejected: {invalid_ops_rejected}"); println!("high_water_ms: {high_water_ms}"); println!("note: {note}"); } ControlResponse::AuthSynced { peer_node_id, peer_agent_id, endpoint_id, ops_imported, signatures_imported, invalid_ops_rejected, high_water_ms, note, } => { println!("synced auth from: {peer_node_id}"); println!("agent: {peer_agent_id}"); println!("endpoint: {endpoint_id}"); println!("ops_imported: {ops_imported}"); println!("signatures_imported: {signatures_imported}"); println!("invalid_ops_rejected: {invalid_ops_rejected}"); println!("high_water_ms: {high_water_ms}"); println!("note: {note}"); } ControlResponse::SyncStatus { peers, note } => { if peers.is_empty() { println!("no sync peers"); } else { for peer in peers { println!("peer: {}", peer.peer_node_id); if peer.streams.is_empty() { println!(" no sync attempts recorded"); } for stream in peer.streams { println!( " {}\tstate={}\tstale={}\tcursor={}\tlast_attempt={}\tlast_success={}\timported={}\trejected={}\tfailures={}\tretry_in_ms={}\terror={}\tnext={}", stream.stream, stream.state, stream.stale, stream.cursor_ms, stream .last_attempt_ms .map(|value| value.to_string()) .unwrap_or_else(|| "never".to_owned()), stream .last_success_ms .map(|value| value.to_string()) .unwrap_or_else(|| "never".to_owned()), stream.last_imported, stream.last_rejected, stream.consecutive_failures, stream .retry_in_ms .map(|value| value.to_string()) .unwrap_or_else(|| "-".to_owned()), stream.last_error.unwrap_or_else(|| "-".to_owned()), stream.next_action ); } } } println!("note: {note}"); } ControlResponse::SyncRan { peers, note } => { if peers.is_empty() { println!("no sync peers"); } else { for peer in peers { println!("peer: {}", peer.peer_node_id); for stream in peer.streams { let state = if !stream.attempted { "skipped" } else if stream.success { "ok" } else { "failed" }; println!( " {}\t{}\tcursor={}\timported={}\trejected={}\terror={}", stream.stream, state, stream.cursor_ms, stream.imported, stream.rejected, stream.error.unwrap_or_else(|| "-".to_owned()) ); } } } println!("note: {note}"); } ControlResponse::SecretStatus { secrets } => { if secrets.is_empty() { println!("no resource secrets"); } else { for secret in secrets { println!("{}\t{}\tepoch {}", secret.id, secret.resource, secret.epoch); } } } ControlResponse::SecretCreated { secret } => { println!("resource secret: {}", secret.id); println!("resource: {}", secret.resource); println!("epoch: {}", secret.epoch); } ControlResponse::SecretBearerCreated { access } => { println!("bearer id: {}", access.secret); if let Some(token) = access.token { println!("bearer token: {token}"); } println!("resource: {}", access.resource); println!( "capabilities: {}", access .capabilities .iter() .map(ToString::to_string) .collect::>() .join(",") ); if let Some(expires_at) = access.expires_at { println!("expires_at_ms: {}", expires_at.0); } println!("may_delegate: {}", access.may_delegate); } ControlResponse::SecretBearerList { access } => { if access.is_empty() { println!("no bearer access"); } else { for item in access { println!( "{}\t{}\t{}\tmay_delegate={}", item.secret, item.resource, item.capabilities .iter() .map(ToString::to_string) .collect::>() .join(","), item.may_delegate ); } } } ControlResponse::SecretBearerChallenge { challenge } => { println!("bearer challenge"); println!("resource: {}", challenge.resource); println!("nonce: {}", challenge.nonce); println!("issued_at_ms: {}", challenge.issued_at.0); println!( "capabilities: {}", challenge .capabilities .iter() .map(ToString::to_string) .collect::>() .join(",") ); } ControlResponse::SecretBearerProof { proof } => { println!("bearer proof"); println!("secret: {}", proof.secret); println!("resource: {}", proof.resource); println!("nonce: {}", proof.nonce); println!("response: {}", proof.response); println!( "capabilities: {}", proof .capabilities .iter() .map(ToString::to_string) .collect::>() .join(",") ); } ControlResponse::SecretBearerVerified { secret, resource, capabilities, verified, reason, } => { println!("bearer verified: {verified}"); println!("secret: {secret}"); println!("resource: {resource}"); println!("capabilities: {}", capabilities.join(",")); println!("reason: {reason}"); } ControlResponse::SecretBearerRevoked { resource, secret } => { println!("revoked bearer secret: {secret}"); println!("resource: {resource}"); } 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); if !explain.diagnostics.is_empty() { println!("diagnostics: {}", explain.diagnostics.join(",")); } } ControlResponse::AuthOpRecorded { op, signatures } => { println!("recorded auth op: {}", op.id); println!("resource: {}", op.resource); for signature in signatures { println!( "signed auth op: {} by {} ({})", signature.op_id, signature.signer, signature.namespace ); } } ControlResponse::NodeList { nodes, note } => { if nodes.is_empty() { println!("no enrolled nodes"); } else { for node in nodes { println!( "{}\t{}\tdevice {}\t{} endpoints", node.name, node.id, node.device, node.endpoints.len() ); } } println!("note: {note}"); } ControlResponse::NodeKeychainUpdated { ops, signatures, note, } => { for op in ops { println!("recorded keychain op: {}", op.id); } for signature in signatures { println!( "signed keychain op: {} by {} ({})", signature.op_id, signature.signer, signature.namespace ); } println!("note: {note}"); } ControlResponse::NodeGrantUpdated { op, signatures, note, } => { println!("recorded auth op: {}", op.id); println!("resource: {}", op.resource); for signature in signatures { println!( "signed auth op: {} by {} ({})", signature.op_id, signature.signer, signature.namespace ); } println!("note: {note}"); } ControlResponse::NodeEnrollmentRequested { request, out, note } => { println!("node enrollment request: {}", request.id); println!("node: {}", request.requester_node); println!("requested_name: {}", request.requested_node_name); println!("status: {}", request.status); if let Some(out) = out { println!("written: {}", out.display()); } println!("note: {note}"); } ControlResponse::NodeEnrollmentSubmitted { request_id, owner_node_id, accepted, note, } => { println!("submitted enrollment request: {request_id}"); println!("owner_node: {owner_node_id}"); println!("accepted: {accepted}"); println!("note: {note}"); } ControlResponse::NodeEnrollmentImported { request, note } => { println!("imported enrollment request: {}", request.id); println!("node: {}", request.requester_node); println!("requested_name: {}", request.requested_node_name); println!("status: {}", request.status); println!("note: {note}"); } ControlResponse::NodeEnrollmentList { requests, note } => { if requests.is_empty() { println!("no node enrollment requests"); } else { for request in requests { println!( "{}\t{}\t{}\t{} capabilities", request.id, request.status, request.requested_node_name, request.requested_capabilities.len() ); } } println!("note: {note}"); } ControlResponse::NodeEnrollmentApproved { request, keychain_ops, keychain_signatures, auth_ops, auth_signatures, note, } => { println!("approved enrollment request: {}", request.id); println!("node: {}", request.requester_node); println!("keychain_ops: {}", keychain_ops.len()); println!("keychain_signatures: {}", keychain_signatures.len()); println!("auth_ops: {}", auth_ops.len()); println!("auth_signatures: {}", auth_signatures.len()); println!("note: {note}"); } ControlResponse::NodeEnrollmentSynced { owner_node, keychain_ops_imported, keychain_signatures_imported, auth_ops_imported, auth_signatures_imported, invalid_ops_rejected, note, } => { println!("synced enrollment from: {owner_node}"); println!("keychain_ops_imported: {keychain_ops_imported}"); println!("keychain_signatures_imported: {keychain_signatures_imported}"); println!("auth_ops_imported: {auth_ops_imported}"); println!("auth_signatures_imported: {auth_signatures_imported}"); println!("invalid_ops_rejected: {invalid_ops_rejected}"); println!("note: {note}"); } 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!("signed: {}", approval.signed); if let Some(certificate_id) = approval.certificate_id { println!("certificate_id: {certificate_id}"); } 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::SshCertSynced { peer_node_id, peer_agent_id, endpoint_id, requests_imported, certificates_imported, allowed, reason, note, } => { if allowed { println!( "synced ssh cert metadata from {peer_node_id}: {requests_imported} requests, {certificates_imported} certificates" ); } else { println!("ssh cert metadata sync denied by {peer_node_id}"); } println!("agent: {peer_agent_id}"); println!("endpoint: {endpoint_id}"); println!("allowed: {allowed}"); println!("reason: {reason}"); println!("note: {note}"); } 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, format, count, note, } => { println!("exported {count} ssh revocations to {}", out.display()); println!("format: {format}"); println!("note: {note}"); } ControlResponse::SshRevocationImported { revocations, format, count, note, } => { println!("imported {count} ssh revocations"); println!("format: {format}"); for revocation in revocations { println!( "{}\t{}\t{}", revocation.id, revocation.kind, revocation.target ); } println!("note: {note}"); } ControlResponse::SshRevocationSynced { peer_node_id, peer_agent_id, endpoint_id, revocations_imported, allowed, reason, note, } => { if allowed { println!("synced {revocations_imported} ssh revocations from {peer_node_id}"); } else { println!("ssh revocation sync denied by {peer_node_id}"); } println!("agent: {peer_agent_id}"); println!("endpoint: {endpoint_id}"); println!("allowed: {allowed}"); println!("reason: {reason}"); println!("note: {note}"); } ControlResponse::DbAdded { db } => { println!("registered db: {}", db.name); println!("id: {}", db.id); println!("resource: {}", db.resource); println!("path: {}", db.path); println!("sync_status: {}", db.sync_status); } ControlResponse::DbStatus { db } => { println!("db: {}", db.name); println!("id: {}", db.id); println!("resource: {}", db.resource); println!("path: {}", db.path); println!("path_exists: {}", db.path_exists); println!( "size_bytes: {}", db.size_bytes .map(|size| size.to_string()) .unwrap_or_else(|| "unknown".to_owned()) ); println!("schema_metadata: {}", db.schema_metadata); println!( "crsqlite_changes_available: {}", db.crsqlite_changes.available ); if let Some(count) = db.crsqlite_changes.change_count { println!("crsqlite_change_count: {count}"); } if let Some(version) = db.crsqlite_changes.max_db_version { println!("crsqlite_max_db_version: {version}"); } if let Some(error) = db.crsqlite_changes.error { println!("crsqlite_changes_error: {error}"); } println!("sync_status: {}", db.sync_status); } ControlResponse::DbChanges { db, batch } => { println!("db: {}", db.name); println!("changes: {}", batch.changes.len()); println!( "max_db_version: {}", batch .max_db_version .map(|version| version.to_string()) .unwrap_or_else(|| "none".to_owned()) ); println!("schema_metadata: {}", batch.schema_metadata); for change in batch.changes { println!( "{}\t{}\t{}", change.db_version, change.table_name, change.column_id ); } } ControlResponse::DbSynced { peer_node_id, peer_agent_id, endpoint_id, name, changes_received, changes_applied, max_db_version, schema_match, allowed, reason, note, } => { if allowed { println!("synced db changes for {name} from {peer_node_id}"); } else { println!("db sync denied by {peer_node_id}"); } println!("agent: {peer_agent_id}"); println!("endpoint: {endpoint_id}"); println!("changes_received: {changes_received}"); println!("changes_applied: {changes_applied}"); println!( "max_db_version: {}", max_db_version .map(|version| version.to_string()) .unwrap_or_else(|| "none".to_owned()) ); println!("schema_match: {schema_match}"); println!("allowed: {allowed}"); println!("reason: {reason}"); println!("note: {note}"); } ControlResponse::KvCreated { kv } => { println!("created kv: {}", kv.name); println!("id: {}", kv.id); println!("resource: {}", kv.resource); println!("sync_status: {}", kv.sync_status); } ControlResponse::KvSet { entry } => { println!("set {} {}", entry.store, entry.key); } ControlResponse::KvGet { entry } => { if let Some(entry) = entry { println!("{}", entry.value); } else { println!("not found"); } } ControlResponse::KvSynced { peer_node_id, peer_agent_id, endpoint_id, name, entries_imported, allowed, reason, note, } => { if allowed { println!("synced kv {name} from {peer_node_id}: {entries_imported} entries"); } else { println!("kv sync denied for {name} by {peer_node_id}"); } println!("agent: {peer_agent_id}"); println!("endpoint: {endpoint_id}"); println!("allowed: {allowed}"); println!("reason: {reason}"); println!("note: {note}"); } ControlResponse::DocumentCreated { document } => { println!("created document: {}", document.name); println!("id: {}", document.id); println!("resource: {}", document.resource); println!("sync_status: {}", document.sync_status); println!("state_bytes: {}", document.state_bytes); } ControlResponse::DocumentStatus { document } => { println!("document: {}", document.name); println!("id: {}", document.id); println!("resource: {}", document.resource); println!("sync_status: {}", document.sync_status); println!("state_bytes: {}", document.state_bytes); } ControlResponse::DocumentSet { state } => { println!("updated document: {}", state.document.name); println!("state_bytes: {}", state.document.state_bytes); println!("updated_at_ms: {}", state.updated_at.0); } ControlResponse::DocumentGet { state } => { println!("{}", state.state_json); } ControlResponse::DocumentSynced { peer_node_id, peer_agent_id, endpoint_id, name, updated, allowed, reason, note, } => { if allowed { println!("synced document {name} from {peer_node_id}: updated={updated}"); } else { println!("document sync denied for {name} by {peer_node_id}"); } println!("agent: {peer_agent_id}"); println!("endpoint: {endpoint_id}"); println!("allowed: {allowed}"); println!("reason: {reason}"); println!("note: {note}"); } ControlResponse::PubsubPublished { message } => { println!("published: {}", message.topic); println!("published_at_ms: {}", message.published_at.0); } ControlResponse::PubsubRemotePublished { peer_node_id, peer_agent_id, endpoint_id, message, allowed, reason, note, } => { if allowed { println!("published: {}", message.topic); println!("peer: {peer_node_id}"); println!("published_at_ms: {}", message.published_at.0); } else { println!("pubsub publish denied by {peer_node_id}"); } println!("agent: {peer_agent_id}"); println!("endpoint: {endpoint_id}"); println!("allowed: {allowed}"); println!("reason: {reason}"); println!("note: {note}"); } ControlResponse::PubsubMessages { topic, messages, note, } => { println!("topic: {topic}"); println!("messages: {}", messages.len()); for message in messages { println!("{}\t{}", message.published_at.0, message.message); } println!("note: {note}"); } ControlResponse::PubsubRemoteMessages { peer_node_id, peer_agent_id, endpoint_id, topic, messages, allowed, reason, note, } => { if allowed { println!("topic: {topic}"); println!("peer: {peer_node_id}"); println!("messages: {}", messages.len()); for message in messages { println!("{}\t{}", message.published_at.0, message.message); } } else { println!("pubsub subscribe denied by {peer_node_id}"); } println!("agent: {peer_agent_id}"); println!("endpoint: {endpoint_id}"); println!("allowed: {allowed}"); println!("reason: {reason}"); println!("note: {note}"); } ControlResponse::PipeListening { listener } => { println!("listening pipe: {}", listener.name); println!("id: {}", listener.id); println!("listened_at_ms: {}", listener.listened_at.0); println!("note: {}", listener.note); } ControlResponse::PipeConnected { connection } => { println!("pipe target: {}", connection.target); println!("local_listener_found: {}", connection.local_listener_found); println!("connected_at_ms: {}", connection.connected_at.0); println!("note: {}", connection.note); } ControlResponse::PipeRemoteListening { peer_node_id, peer_agent_id, endpoint_id, listener, allowed, reason, note, } => { if let Some(listener) = listener { println!("listening pipe: {}", listener.name); println!("peer: {peer_node_id}"); println!("id: {}", listener.id); println!("listened_at_ms: {}", listener.listened_at.0); } else { println!("pipe listen denied by {peer_node_id}"); } println!("agent: {peer_agent_id}"); println!("endpoint: {endpoint_id}"); println!("allowed: {allowed}"); println!("reason: {reason}"); println!("note: {note}"); } ControlResponse::PipeRemoteConnected { peer_node_id, peer_agent_id, endpoint_id, connection, allowed, reason, note, } => { if allowed { println!("pipe target: {}", connection.target); println!("peer: {peer_node_id}"); println!("remote_listener_found: {}", connection.local_listener_found); println!("connected_at_ms: {}", connection.connected_at.0); } else { println!("pipe connect denied by {peer_node_id}"); } println!("agent: {peer_agent_id}"); println!("endpoint: {endpoint_id}"); println!("allowed: {allowed}"); println!("reason: {reason}"); println!("note: {note}"); } ControlResponse::PipeSent { message, listener_found, note, } => { println!("listener_found: {listener_found}"); if let Some(message) = message { println!("pipe: {}", message.pipe); println!("received_at_ms: {}", message.received_at.0); print_pipe_message_data(&message)?; } println!("note: {note}"); } ControlResponse::PipeRemoteSent { peer_node_id, peer_agent_id, endpoint_id, message, listener_found, allowed, reason, note, } => { println!("peer: {peer_node_id}"); println!("agent: {peer_agent_id}"); println!("endpoint: {endpoint_id}"); println!("allowed: {allowed}"); println!("listener_found: {listener_found}"); if let Some(message) = message { println!("pipe: {}", message.pipe); println!("received_at_ms: {}", message.received_at.0); } println!("reason: {reason}"); println!("note: {note}"); } ControlResponse::PipeMessages { name, messages, drained, note, } => { println!("pipe: {name}"); println!("messages: {}", messages.len()); println!("drained: {drained}"); for message in messages { print_pipe_message_data(&message)?; } println!("note: {note}"); } ControlResponse::SshProxyConnected { peer_node_id, peer_agent_id, endpoint_id, connection, allowed, reason, note, } => { if allowed { println!("ssh proxy target: {peer_node_id}"); if let Some(connection) = connection { println!("connected_at_ms: {}", connection.connected_at.0); if let Some(local_sshd_target) = connection.local_sshd_target { println!("remote_sshd_target: {local_sshd_target}"); } println!( "admin_shell_available: {}", connection.admin_shell_available ); println!("connection_note: {}", connection.note); } } else { println!("ssh proxy denied by {peer_node_id}"); } println!("agent: {peer_agent_id}"); println!("endpoint: {endpoint_id}"); println!("allowed: {allowed}"); println!("reason: {reason}"); println!("note: {note}"); } ControlResponse::SshAdminShellOutput { peer_node_id, peer_agent_id, endpoint_id, command, output, allowed, reason, note, } => { if allowed { println!("{output}"); } else { println!("ssh admin shell denied by {peer_node_id}"); } println!("command: {command}"); println!("peer: {peer_node_id}"); println!("agent: {peer_agent_id}"); println!("endpoint: {endpoint_id}"); println!("allowed: {allowed}"); println!("reason: {reason}"); println!("note: {note}"); } 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, "state": report.state, "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(state) = &report.state { println!("state: {state}"); } 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 print_file_conflict(conflict: &geth_cas::FileConflict) { println!("root: {}", conflict.root); println!("resource: {}", conflict.resource); println!("path: {}", conflict.path); println!("kind: {}", conflict.kind.as_str()); println!("status: {}", conflict.status.as_str()); if let Some(hash) = &conflict.base_tree { println!("base_tree: {hash}"); } if let Some(hash) = &conflict.local_tree { println!("local_tree: {hash}"); } if let Some(hash) = &conflict.remote_tree { println!("remote_tree: {hash}"); } println!("detail: {}", conflict.detail); if let Some(resolution) = &conflict.resolution { println!("resolution: {}", resolution.as_str()); } if let Some(note) = &conflict.resolution_note { println!("resolution_note: {note}"); } } fn print_pipe_message_data(message: &geth_pipe::PipeMessage) -> Result<()> { let bytes = base64::engine::general_purpose::STANDARD .decode(&message.data_base64) .context("decode pipe message")?; match String::from_utf8(bytes) { Ok(text) => println!("{text}"), Err(error) => println!( "base64:{}", base64::engine::general_purpose::STANDARD.encode(error.into_bytes()) ), } Ok(()) } fn pipe_send_payload_base64(message: Option, input: Option) -> Result { match (message, input) { (Some(message), None) => Ok(base64::engine::general_purpose::STANDARD.encode(message)), (None, Some(path)) if path.as_os_str() == "-" => { let mut bytes = Vec::new(); std::io::stdin() .read_to_end(&mut bytes) .context("read pipe payload from stdin")?; Ok(base64::engine::general_purpose::STANDARD.encode(bytes)) } (None, Some(path)) => { let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; Ok(base64::engine::general_purpose::STANDARD.encode(bytes)) } (Some(_), Some(_)) => bail!("pipe send accepts either MESSAGE or --in, not both"), (None, None) => bail!("pipe send requires MESSAGE or --in ; use --in - for stdin"), } } 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(" ") } #[cfg(test)] mod tests { use super::*; #[test] fn help_describes_common_workflows_and_command_families() { let help = documented_cli_command().render_long_help().to_string(); assert!(help.contains("geth daemon install")); assert!(help.contains("geth daemon run --ephemeral")); assert!(help.contains("Show daemon, storage, Iroh, and backend health")); assert!(help.contains("Run, install, and manage the daemon")); assert!(help.contains("--home ")); } #[test] fn every_cli_argument_has_operator_facing_help() { fn check(command: clap::Command, path: String, missing: &mut Vec) { for argument in command.get_arguments() { let id = argument.get_id().as_str(); if matches!(id, "help" | "version") { continue; } if argument.get_help().is_none() { missing.push(format!("{path}: {id}")); } } for subcommand in command.get_subcommands() { check( subcommand.clone(), format!("{path} {}", subcommand.get_name()), missing, ); } } let mut missing = Vec::new(); check(documented_cli_command(), "geth".to_owned(), &mut missing); assert!( missing.is_empty(), "missing argument help:\n{}", missing.join("\n") ); } #[test] fn common_daemon_lifecycle_commands_parse_directly() { for command in ["install", "start", "stop", "status", "uninstall"] { let parsed = Cli::try_parse_from(["geth", "daemon", command]); assert!(parsed.is_ok(), "daemon {command} should parse: {parsed:?}"); } let parsed = Cli::try_parse_from(["geth", "daemon", "run", "--ephemeral"]) .expect("parse ephemeral daemon"); assert!(matches!( parsed.command, Command::Daemon { command: DaemonCommand::Run { ephemeral: true } } )); } #[test] fn configuration_commands_parse_and_update_an_isolated_home() { for command in ["path", "show", "validate"] { let parsed = Cli::try_parse_from(["geth", "config", command]); assert!(parsed.is_ok(), "config {command} should parse: {parsed:?}"); } let parsed = Cli::try_parse_from(["geth", "config", "set", "sync.live_sync_interval_ms", "500"]); assert!(parsed.is_ok(), "config set should parse: {parsed:?}"); let home = tempfile::tempdir().expect("temporary geth home"); let paths = GethPaths::from_home(home.path()); run_config_command( &paths, ConfigCommand::Set { key: ConfigKeyArg::SyncLiveSyncIntervalMs, value: "500".to_owned(), }, false, ) .expect("set config"); let config = GethConfig::load(&paths.config_file()).expect("load config"); assert_eq!(config.sync.live_sync_interval_ms, 500); } #[tokio::test] async fn ephemeral_daemon_rejects_an_explicit_persistent_home() { let parsed = Cli::try_parse_from([ "geth", "--home", "/tmp/persistent-geth", "daemon", "run", "--ephemeral", ]) .expect("parse command before semantic validation"); let error = run_inner(parsed) .await .expect_err("--home and --ephemeral must conflict"); let message = error.to_string(); assert!(message.contains("--home")); assert!(message.contains("--ephemeral")); } #[test] fn ephemeral_node_initializes_and_cleans_up_its_temporary_home() { let (home, paths, node) = create_ephemeral_node().expect("create ephemeral node"); let path = paths.home().to_path_buf(); assert!(paths.metadata_db().exists()); assert!(paths.config_file().exists()); assert!(node.node_id.starts_with("node:")); drop(home); assert!(!path.exists()); } #[test] fn json_error_classification_is_stable_for_common_failures() { assert_eq!( json_error_code("connect to daemon at /tmp/geth.sock: No such file or directory"), "daemon_unavailable" ); assert_eq!( json_error_code("unauthorized: missing grant"), "unauthorized" ); assert_eq!( json_error_code("peer candidate not found: node:missing"), "peer_not_found" ); assert_eq!( json_error_hint( "peer candidate not found: node:missing\nnext: import a peer card with `geth peer import `", ), Some("import a peer card with `geth peer import `".to_owned()) ); } }