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

4907 lines
167 KiB
Rust
Raw Normal View History

2026-05-15 15:08:20 +02:00
use anyhow::{Context, Result, bail};
2026-05-20 13:57:14 +02:00
use base64::Engine;
2026-07-18 16:08:19 +02:00
use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
2026-05-23 02:40:03 +02:00
use clap_complete::{Shell, generate};
2026-05-15 15:08:20 +02:00
use geth_config::GethPaths;
2026-07-05 22:24:08 +02:00
use geth_control::{ControlRequest, ControlResponse, SyncStreamStatus};
use geth_node::service::{ServiceInstallOptions, ServiceManager, ServiceReport};
use std::io::{Read, Write, stdout};
2026-05-15 15:08:20 +02:00
use std::path::PathBuf;
2026-07-05 22:24:08 +02:00
use std::time::{Duration, Instant};
2026-05-15 15:08:20 +02:00
2026-05-23 01:17:30 +02:00
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
2026-05-23 01:17:30 +02:00
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 <command> --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";
2026-05-23 01:17:30 +02:00
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 <topic>
Topics:
quickstart Choose disposable, foreground, or background startup.
2026-05-23 01:17:30 +02:00
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.
2026-05-23 02:40:03 +02:00
completions Shell completion installation examples.
2026-05-23 01:17:30 +02:00
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 <printed-path> status
geth --home <printed-path> 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 <dir>` on any command to select an isolated home without exporting
GETH_HOME. See `geth guide owner-setup` before adding other machines.
"#;
2026-05-23 01:17:30 +02:00
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 <request-id> --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 <provider>`, then pass
the public key path with --signing-key.
2026-05-23 01:17:30 +02:00
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
2026-05-23 01:17:30 +02:00
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
2026-05-23 01:17:30 +02:00
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`.
2026-05-23 01:17:30 +02:00
"#;
2026-05-23 02:40:03 +02:00
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
"#;
2026-05-23 01:17:30 +02:00
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 <resource-secret>
2026-05-23 02:08:51 +02:00
geth overlay interface-plan home --platform linux
geth overlay up home
geth overlay down home
geth overlay send home <node> --packet-base64 <ipv4-packet>
geth overlay recv home
2026-05-23 01:17:30 +02:00
geth overlay leave home
Current limits:
2026-05-23 02:08:51 +02:00
- 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
2026-05-23 01:17:30 +02:00
- 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
2026-05-23 02:08:51 +02:00
Bearer invite flow:
geth resource create overlay home
geth secret bearer create resource:overlay:home --capability overlay.join
geth overlay join home --secret <bearer-token>
2026-05-23 01:17:30 +02:00
"#;
const GUIDE_SMOKE_TEST: &str = r#"Minimal smoke test:
Terminal 1 (prints a temporary home):
geth daemon run --ephemeral
2026-05-23 01:17:30 +02:00
Terminal 2:
geth --home <printed-path> status
geth --home <printed-path> node id
2026-05-23 01:17:30 +02:00
echo "hello geth" > /tmp/hello-geth.txt
geth --home <printed-path> cas add /tmp/hello-geth.txt
geth --home <printed-path> cas list
geth --home <printed-path> keychain status
2026-05-23 01:17:30 +02:00
For two-node owner/enrollment testing, use:
geth guide owner-setup
geth guide enrollment
"#;
2026-05-15 15:08:20 +02:00
#[derive(Debug, Parser)]
2026-05-23 01:17:30 +02:00
#[command(
name = "geth",
version,
2026-05-23 01:17:30 +02:00
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
2026-05-23 01:17:30 +02:00
)]
2026-05-15 15:08:20 +02:00
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<PathBuf>,
2026-05-23 01:17:30 +02:00
#[arg(long, global = true, help = "Print machine-readable JSON output")]
2026-05-15 15:08:20 +02:00
pub json: bool,
2026-05-23 01:17:30 +02:00
#[arg(
long,
global = true,
help = "Print newline-delimited JSON output for streaming commands"
)]
2026-05-15 15:08:20 +02:00
pub jsonl: bool,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
/// Show task-oriented setup and workflow guides
2026-05-23 01:17:30 +02:00
Guide {
#[arg(value_enum)]
topic: Option<GuideTopic>,
},
/// Generate shell completion scripts
2026-05-23 02:40:03 +02:00
Completions {
#[arg(value_enum)]
shell: Shell,
},
#[command(
about = "Initialize local state and identity",
long_about = INIT_LONG_ABOUT,
after_long_help = INIT_AFTER_HELP
)]
2026-05-21 11:29:29 +02:00
Init {
2026-05-23 01:17:30 +02:00
#[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/<key>.pub. This key is public and replicated in the geth keychain."
)]
2026-05-21 11:29:29 +02:00
admin_key: Option<PathBuf>,
2026-05-23 01:17:30 +02:00
#[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."
)]
2026-05-21 11:29:29 +02:00
signing_key: Option<PathBuf>,
2026-05-23 01:17:30 +02:00
#[arg(
long,
default_value = "owner",
help = "Owner/user display name recorded during owner init"
)]
2026-05-21 11:29:29 +02:00
owner: String,
2026-05-23 01:17:30 +02:00
#[arg(
long,
default_value = "local",
help = "Friendly node name recorded during owner init"
)]
2026-05-21 11:29:29 +02:00
node_name: String,
2026-05-23 01:17:30 +02:00
#[arg(
long = "capability",
value_name = "RESOURCE=CAPABILITY",
help = "Initial capability grant for this node; repeatable"
)]
2026-05-21 11:29:29 +02:00
capabilities: Vec<String>,
},
/// Run, install, and manage the daemon
2026-05-15 15:08:20 +02:00
Daemon {
#[command(subcommand)]
command: DaemonCommand,
},
/// Show daemon, storage, Iroh, and backend health
2026-05-15 15:08:20 +02:00
Status,
/// Inspect or trigger peer synchronization
Sync {
#[command(subcommand)]
command: SyncCommand,
},
/// Wait for daemon, peer, or sync readiness
2026-07-05 22:24:08 +02:00
Wait {
#[command(subcommand)]
command: WaitCommand,
},
/// Create or restore an offline local-state backup
2026-07-05 22:35:18 +02:00
Backup {
#[command(subcommand)]
command: BackupCommand,
},
/// Diagnose local configuration, daemon, trust, and dependency problems
2026-07-05 22:39:15 +02:00
Doctor,
/// Inspect and manage trusted nodes and enrollment
2026-05-15 15:08:20 +02:00
Node {
#[command(subcommand)]
command: NodeCommand,
},
/// Exchange peer cards and test peer connectivity
2026-05-18 04:03:52 +02:00
Peer {
#[command(subcommand)]
command: PeerCommand,
},
/// Plan and operate the experimental Iroh overlay network
2026-05-23 01:17:30 +02:00
Overlay {
#[command(subcommand)]
command: OverlayCommand,
},
/// List and create resource registrations
2026-05-15 15:08:20 +02:00
Resource {
#[command(subcommand)]
command: ResourceCommand,
},
/// Manage the SSH-admin-rooted identity keychain
2026-05-15 15:08:20 +02:00
Keychain {
#[command(subcommand)]
command: KeychainCommand,
},
/// Explain and manage resource-scoped authorization
2026-05-15 15:08:20 +02:00
Auth {
#[command(subcommand)]
command: AuthCommand,
},
/// Manage resource secret epochs and bearer access
2026-05-15 15:08:20 +02:00
Secret {
#[command(subcommand)]
command: SecretCommand,
},
/// Store, fetch, pin, and synchronize content-addressed data
2026-05-15 15:08:20 +02:00
Cas {
#[command(subcommand)]
command: CasCommand,
},
/// Operate synchronized key-value stores
2026-05-15 15:08:20 +02:00
Kv {
#[command(subcommand)]
command: KvCommand,
},
/// Publish or read daemon-lifetime topic messages
2026-05-15 15:08:20 +02:00
Pubsub {
#[command(subcommand)]
command: PubsubCommand,
},
/// Open authorized message and byte-stream pipes
2026-05-15 15:08:20 +02:00
Pipe {
#[command(subcommand)]
command: PipeCommand,
},
/// Register and synchronize SQLite/cr-sqlite databases
2026-05-15 15:08:20 +02:00
Db {
#[command(subcommand)]
command: DbCommand,
},
/// Create and synchronize document resources
2026-05-15 15:08:20 +02:00
Document {
#[command(subcommand)]
command: DocumentCommand,
},
/// Use geth-managed SSH proxy, certificate, and revocation workflows
2026-05-15 15:08:20 +02:00
Ssh {
#[command(subcommand)]
command: SshCommand,
},
}
2026-05-23 01:17:30 +02:00
#[derive(Clone, Debug, ValueEnum)]
pub enum GuideTopic {
Quickstart,
2026-05-23 01:17:30 +02:00
Init,
OwnerSetup,
Enrollment,
Keys,
Overlay,
Service,
2026-05-23 02:40:03 +02:00
Completions,
2026-05-23 01:17:30 +02:00
SmokeTest,
}
2026-05-15 15:08:20 +02:00
#[derive(Debug, Subcommand)]
#[command(after_long_help = DAEMON_AFTER_HELP)]
2026-05-15 15:08:20 +02:00
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<PathBuf>,
},
/// 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<PathBuf>,
#[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<PathBuf>,
},
2026-05-15 15:08:20 +02:00
}
#[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<String> },
}
2026-07-05 22:24:08 +02:00
#[derive(Debug, Subcommand)]
pub enum WaitCommand {
/// Wait until the local control socket answers
2026-07-05 22:24:08 +02:00
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
2026-07-05 22:24:08 +02:00
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
2026-07-05 22:24:08 +02:00
Sync {
node: String,
#[arg(long, default_value_t = 30_000)]
timeout_ms: u64,
#[arg(long, default_value_t = 500)]
interval_ms: u64,
},
}
2026-07-05 22:35:18 +02:00
#[derive(Debug, Subcommand)]
pub enum BackupCommand {
/// Create an offline backup directory without private identity keys
2026-07-05 22:35:18 +02:00
Create {
#[arg(long, value_name = "DIR")]
out: PathBuf,
},
/// Restore a backup into a new, empty geth home
2026-07-05 22:35:18 +02:00
Restore {
backup_dir: PathBuf,
#[arg(long, value_name = "DIR")]
target_home: PathBuf,
},
}
2026-05-15 15:08:20 +02:00
#[derive(Debug, Subcommand)]
pub enum NodeCommand {
/// Print the local stable node, agent, and Iroh endpoint identifiers
2026-05-15 15:08:20 +02:00
Id,
/// Show the same local runtime health as `geth status`
2026-05-15 15:08:20 +02:00
Status,
/// List the active trusted-node view
2026-05-21 11:29:29 +02:00
List,
/// Request, review, approve, or synchronize node enrollment
2026-05-21 18:01:38 +02:00
Enroll {
#[command(subcommand)]
command: NodeEnrollCommand,
},
/// Rename a trusted node with an admin-signed keychain operation
2026-05-21 11:29:29 +02:00
Rename {
node: String,
name: String,
#[arg(long)]
signing_key: Option<PathBuf>,
},
/// Revoke a trusted node with an admin-signed keychain operation
2026-05-21 11:29:29 +02:00
Revoke {
node: String,
#[arg(long)]
signing_key: Option<PathBuf>,
},
/// Grant a trusted node one resource capability
2026-05-21 11:29:29 +02:00
Grant {
node: String,
resource: String,
capability: String,
#[arg(long)]
grant_id: Option<String>,
2026-05-21 18:15:10 +02:00
#[arg(long)]
signing_key: PathBuf,
#[arg(long)]
admin_key: Option<PathBuf>,
2026-05-21 11:29:29 +02:00
},
/// Revoke a previously issued node grant
2026-05-21 11:29:29 +02:00
RevokeGrant {
resource: String,
grant_id: String,
2026-05-21 18:15:10 +02:00
#[arg(long)]
signing_key: PathBuf,
#[arg(long)]
admin_key: Option<PathBuf>,
},
/// Bind an Iroh endpoint to a trusted node
2026-05-21 18:15:10 +02:00
EndpointAdd {
node: String,
endpoint: String,
#[arg(long)]
signing_key: PathBuf,
},
/// Revoke an Iroh endpoint binding
2026-05-21 18:15:10 +02:00
EndpointRevoke {
node: String,
endpoint: String,
#[arg(long)]
signing_key: PathBuf,
2026-05-21 11:29:29 +02:00
},
2026-05-15 15:08:20 +02:00
}
2026-05-21 18:01:38 +02:00
#[derive(Debug, Subcommand)]
pub enum NodeEnrollCommand {
/// Create a signed enrollment request on the new node
2026-07-18 16:08:19 +02:00
#[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"
)]
2026-05-21 18:01:38 +02:00
Request {
#[arg(long)]
node_name: String,
#[arg(long = "capability")]
capabilities: Vec<String>,
#[arg(long)]
reason: Option<String>,
#[arg(long)]
out: Option<PathBuf>,
},
/// Send an enrollment request to an imported owner peer
2026-05-21 18:01:38 +02:00
Submit {
owner_node: String,
#[arg(long)]
request_id: Option<String>,
#[arg(long)]
path: Option<PathBuf>,
},
/// Import an enrollment request from a file
Import { path: PathBuf },
/// List received enrollment requests
2026-05-21 18:01:38 +02:00
List {
#[arg(long)]
status: Option<String>,
},
/// Approve a request with the owner's SSH admin key
2026-05-21 18:01:38 +02:00
Approve {
request_id: String,
#[arg(long)]
signing_key: PathBuf,
#[arg(long)]
admin_key: Option<PathBuf>,
#[arg(long)]
node_name: Option<String>,
#[arg(long = "capability")]
capabilities: Vec<String>,
},
/// Pull approved enrollment state from the owner node
Sync { owner_node: String },
2026-05-21 18:01:38 +02:00
}
2026-05-18 04:03:52 +02:00
#[derive(Debug, Subcommand)]
pub enum PeerCommand {
/// Export this daemon's signed peer card
2026-05-18 04:03:52 +02:00
Export {
#[arg(long)]
out: Option<PathBuf>,
},
/// Import and verify a signed peer card
Import { path: PathBuf },
/// List imported and discovered peer candidates
2026-05-18 04:03:52 +02:00
List,
/// Test protected Iroh connectivity to a peer
Ping { node: String },
/// Ask a peer whether this node has a capability
2026-05-18 17:01:50 +02:00
AuthCheck {
node: String,
resource: String,
capability: String,
},
2026-05-18 04:03:52 +02:00
}
2026-05-23 01:17:30 +02:00
#[derive(Debug, Subcommand)]
pub enum OverlayCommand {
/// Show local overlay memberships and active interfaces
2026-05-23 01:17:30 +02:00
Status,
/// Preview deterministic overlay addressing without changing state
2026-05-23 01:17:30 +02:00
Plan {
name: String,
#[arg(long)]
cidr: Option<String>,
},
/// Join an overlay using a resource or bearer secret
2026-05-23 01:17:30 +02:00
Join {
name: String,
#[arg(long)]
secret: String,
#[arg(long)]
cidr: Option<String>,
},
/// Remove local overlay membership
Leave { name: String },
/// Preview platform-specific interface setup
2026-05-23 02:08:51 +02:00
InterfacePlan {
name: String,
#[arg(long)]
platform: Option<String>,
},
/// Create the local TUN/Wintun-style overlay interface
2026-05-23 02:08:51 +02:00
Up {
name: String,
#[arg(long)]
bearer_secret: Option<String>,
#[arg(long)]
mtu: Option<u16>,
},
/// Stop the local overlay interface
Down { name: String },
/// List peers visible to an overlay
Peers { name: String },
/// Send one validated IPv4 packet over Iroh
2026-05-23 02:08:51 +02:00
Send {
name: String,
node: String,
#[arg(long)]
packet_base64: String,
#[arg(long)]
bearer_secret: Option<String>,
},
/// Read locally received overlay packets
2026-05-23 02:08:51 +02:00
Recv {
name: String,
#[arg(long)]
peek: bool,
},
2026-05-23 01:17:30 +02:00
}
2026-05-15 15:08:20 +02:00
#[derive(Debug, Subcommand)]
pub enum ResourceCommand {
/// List registered resources
2026-05-15 15:08:20 +02:00
List,
/// Register a named resource
2026-05-15 15:08:20 +02:00
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<PathBuf>,
2026-05-19 16:04:20 +02:00
#[arg(long)]
signing_key: Option<PathBuf>,
},
/// Show the reduced identity view and signature verification state
2026-05-15 15:08:20 +02:00
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<String>,
#[arg(long)]
valid_after_ms: Option<i64>,
#[arg(long)]
valid_before_ms: Option<i64>,
},
/// Revoke an admin trust anchor
AdminRevoke {
key: String,
#[arg(long)]
signing_key: PathBuf,
#[arg(long)]
admin_key: Option<PathBuf>,
},
/// Write the active OpenSSH allowed_signers projection
AllowedSigners {
#[arg(long)]
out: Option<PathBuf>,
},
/// Sign an arbitrary file through an active SSH admin key
SignFile {
#[arg(long = "in")]
input: PathBuf,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
namespace: Option<String>,
#[arg(long)]
signing_key: PathBuf,
#[arg(long)]
admin_key: Option<PathBuf>,
},
/// Verify a file signature against current keychain trust
VerifyFile {
#[arg(long = "in")]
input: PathBuf,
#[arg(long)]
signature: PathBuf,
#[arg(long)]
namespace: Option<String>,
#[arg(long)]
allowed_signers: Option<PathBuf>,
#[arg(long)]
principal: Option<String>,
},
/// Export the canonical SSH signature chain
Sigchain {
#[arg(long)]
out: Option<PathBuf>,
},
/// 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<PathBuf>,
#[arg(long = "snapshot")]
snapshots: Vec<String>,
},
/// 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<String>,
#[arg(long)]
principal: Option<String>,
},
/// 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<PathBuf>,
#[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 },
2026-05-15 15:08:20 +02:00
}
#[derive(Debug, Subcommand)]
pub enum AuthCommand {
/// Explain an allow or deny decision for one subject and capability
2026-05-15 15:08:20 +02:00
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
2026-07-18 16:08:19 +02:00
#[command(
after_help = "Example:\n geth auth grant <node-id> resource:kv:preferences kv.read --signing-key ~/.ssh/id_ed25519"
)]
2026-05-16 16:32:03 +02:00
Grant {
subject: String,
resource: String,
capability: String,
#[arg(long)]
grant_id: Option<String>,
2026-05-21 18:15:10 +02:00
#[arg(long)]
signing_key: PathBuf,
#[arg(long)]
admin_key: Option<PathBuf>,
2026-05-16 16:32:03 +02:00
},
/// Revoke an admin-signed grant by id
2026-05-16 16:32:03 +02:00
Revoke {
resource: String,
grant_id: String,
2026-05-21 18:15:10 +02:00
#[arg(long)]
signing_key: PathBuf,
#[arg(long)]
admin_key: Option<PathBuf>,
2026-05-16 16:32:03 +02:00
},
2026-05-15 15:08:20 +02:00
}
#[derive(Debug, Subcommand)]
pub enum SecretCommand {
/// List resource secret epochs
2026-05-15 15:08:20 +02:00
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<String>,
#[arg(long)]
expires_at_ms: Option<i64>,
},
/// List public bearer metadata without private tokens
List,
/// Issue a possession challenge for requested capabilities
2026-05-19 19:08:08 +02:00
Challenge {
resource: String,
#[arg(long = "capability", required = true)]
capabilities: Vec<String>,
},
/// Produce a challenge response from a bearer token
2026-05-19 19:08:08 +02:00
Prove {
token: String,
2026-05-19 19:08:08 +02:00
resource: String,
#[arg(long)]
nonce: String,
#[arg(long = "capability", required = true)]
capabilities: Vec<String>,
},
/// Verify a bearer challenge response locally
2026-05-19 19:08:08 +02:00
Verify {
token: String,
2026-05-19 19:08:08 +02:00
resource: String,
#[arg(long)]
nonce: String,
#[arg(long)]
response: String,
#[arg(long = "capability", required = true)]
capabilities: Vec<String>,
},
/// Revoke bearer access by its public id
Revoke { resource: String, bearer_id: String },
2026-05-15 15:08:20 +02:00
}
#[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
2026-05-15 15:08:20 +02:00
Get {
hash: String,
#[arg(long)]
out: PathBuf,
},
/// Decrypt a prototype private blob to a path
2026-05-21 01:35:00 +02:00
GetPrivate {
resource: String,
hash: String,
#[arg(long)]
out: PathBuf,
},
/// Fetch an authorized blob from an imported peer over Iroh
2026-05-18 17:18:25 +02:00
Fetch {
node: String,
hash: String,
#[arg(long)]
bearer_secret: Option<String>,
2026-05-18 17:18:25 +02:00
},
/// 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
2026-05-16 21:10:25 +02:00
Cleanup {
#[arg(long)]
dry_run: bool,
},
/// List known local and peer providers for a blob
Providers { hash: String },
/// List local blobs and pin state
2026-05-15 15:08:20 +02:00
List,
/// Register, scan, synchronize, and safely apply file roots
2026-05-18 03:50:09 +02:00
Root {
#[command(subcommand)]
command: CasRootCommand,
},
/// Inspect and resolve durable file-root conflicts
2026-05-18 03:57:26 +02:00
Conflict {
#[command(subcommand)]
command: CasConflictCommand,
},
2026-05-18 03:50:09 +02:00
}
#[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
2026-05-18 03:50:09 +02:00
List,
/// Scan a local root and store its deterministic CAS tree
Scan { name: String },
/// Pull authorized tree metadata and bytes from a peer
2026-05-20 13:22:40 +02:00
Sync {
node: String,
name: String,
#[arg(long)]
bearer_secret: Option<String>,
},
/// Materialize a tree without overwriting local edits
2026-07-18 16:08:19 +02:00
#[command(
after_help = "Examples:\n geth cas root apply photos --to ./restored-photos --dry-run\n geth cas root apply remote-<peer-id>-photos --to ./restored-photos"
)]
2026-05-20 13:30:57 +02:00
Apply {
source: String,
#[arg(long)]
to: PathBuf,
#[arg(long)]
dry_run: bool,
},
2026-05-15 15:08:20 +02:00
}
2026-05-18 03:57:26 +02:00
#[derive(Debug, Subcommand)]
pub enum CasConflictCommand {
/// Record a file-root conflict explicitly
2026-05-18 03:57:26 +02:00
Record {
root: String,
path: String,
kind: String,
#[arg(long)]
detail: String,
#[arg(long)]
base_tree: Option<String>,
#[arg(long)]
local_tree: Option<String>,
#[arg(long)]
remote_tree: Option<String>,
},
/// List unresolved and resolved conflicts
2026-05-18 03:57:26 +02:00
List {
#[arg(long)]
root: Option<String>,
},
/// Record an operator-selected conflict resolution
2026-05-18 03:57:26 +02:00
Resolve {
conflict_id: String,
resolution: String,
#[arg(long)]
note: Option<String>,
},
}
2026-05-15 15:08:20 +02:00
#[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
2026-07-18 16:08:19 +02:00
#[command(
after_help = "Examples:\n geth kv set preferences theme dark\n geth kv set preferences theme dark --subject <node-id>"
)]
2026-05-15 15:08:20 +02:00
Set {
name: String,
key: String,
value: String,
2026-05-18 04:06:41 +02:00
#[arg(long)]
subject: Option<String>,
2026-05-15 15:08:20 +02:00
},
/// Read a local value
Get { name: String, key: String },
/// Pull authorized values from a peer over Iroh
2026-05-18 18:36:03 +02:00
Sync {
node: String,
name: String,
#[arg(long)]
bearer_secret: Option<String>,
2026-05-18 18:36:03 +02:00
},
2026-05-15 15:08:20 +02:00
}
#[derive(Debug, Subcommand)]
pub enum PubsubCommand {
/// Publish a daemon-lifetime message locally or to a peer
2026-05-18 18:41:04 +02:00
Pub {
topic: String,
message: String,
#[arg(long)]
node: Option<String>,
#[arg(long)]
bearer_secret: Option<String>,
2026-05-18 18:41:04 +02:00
},
/// Read the current daemon-lifetime topic snapshot
2026-05-18 18:41:04 +02:00
Sub {
topic: String,
2026-05-19 15:28:48 +02:00
#[arg(long)]
node: Option<String>,
#[arg(long)]
bearer_secret: Option<String>,
2026-05-18 18:41:04 +02:00
},
2026-05-15 15:08:20 +02:00
}
#[derive(Debug, Subcommand)]
pub enum PipeCommand {
/// Register a daemon-lifetime listener locally or on a peer
2026-05-18 18:45:10 +02:00
Listen {
name: String,
2026-05-19 19:21:42 +02:00
#[arg(long)]
node: Option<String>,
#[arg(long)]
bearer_secret: Option<String>,
2026-05-18 18:45:10 +02:00
},
/// Request an authorized pipe connection
2026-05-18 18:45:10 +02:00
Connect {
target: String,
#[arg(long)]
node: Option<String>,
#[arg(long)]
bearer_secret: Option<String>,
2026-05-18 18:45:10 +02:00
},
/// Forward a local loopback TCP listener to a peer's loopback target
2026-05-21 01:12:01 +02:00
ForwardTcp {
#[arg(long)]
listen: String,
#[arg(long)]
node: String,
#[arg(long)]
target: String,
#[arg(long)]
bearer_secret: Option<String>,
},
/// Forward a local Unix socket to an absolute socket path on a peer
2026-05-21 01:15:51 +02:00
ForwardUnix {
#[arg(long)]
listen: PathBuf,
#[arg(long)]
node: String,
#[arg(long)]
target: PathBuf,
#[arg(long)]
bearer_secret: Option<String>,
},
/// Send text, a file, or stdin through a dedicated Iroh pipe
2026-05-20 13:57:14 +02:00
Send {
target: String,
2026-05-20 13:59:41 +02:00
message: Option<String>,
#[arg(long = "in", value_name = "PATH")]
input: Option<PathBuf>,
2026-05-20 13:57:14 +02:00
#[arg(long)]
node: Option<String>,
#[arg(long)]
bearer_secret: Option<String>,
},
/// Drain messages from a local daemon-lifetime listener
2026-05-20 13:57:14 +02:00
Recv {
name: String,
#[arg(long)]
peek: bool,
},
2026-05-15 15:08:20 +02:00
}
#[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
2026-05-17 20:32:36 +02:00
Changes {
name: String,
#[arg(long)]
after_db_version: Option<i64>,
#[arg(long, default_value_t = 100)]
limit: u32,
},
/// Pull and apply an authorized compatible change batch
2026-05-18 22:11:57 +02:00
Sync {
node: String,
name: String,
#[arg(long, default_value_t = 100)]
limit: u32,
#[arg(long)]
bearer_secret: Option<String>,
2026-05-18 22:11:57 +02:00
},
2026-05-15 15:08:20 +02:00
}
#[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<String>,
},
2026-05-15 15:08:20 +02:00
}
#[derive(Debug, Subcommand)]
pub enum SshCommand {
/// Act as an OpenSSH ProxyCommand over an authorized Iroh stream
Proxy {
node: String,
#[arg(long)]
bearer_secret: Option<String>,
},
/// Run a restricted geth admin command on a peer
2026-05-21 01:49:48 +02:00
AdminShell {
node: String,
command: String,
#[arg(long)]
bearer_secret: Option<String>,
},
/// 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<String>,
#[arg(long)]
valid_for: Option<String>,
#[arg(long)]
renewal_of: Option<String>,
#[arg(long)]
reason: Option<String>,
#[arg(long)]
subject: Option<String>,
},
/// List local certificate requests
Requests {
#[arg(long)]
subject: Option<String>,
},
/// Build or execute the OpenSSH certificate signing command
Approve {
request_id: String,
#[arg(long)]
ca_key: PathBuf,
#[arg(long)]
valid_for: Option<String>,
#[arg(long)]
serial: Option<u64>,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
2026-05-19 15:56:47 +02:00
sign: bool,
#[arg(long)]
subject: Option<String>,
},
/// Attach a signed certificate to its request
Import {
request_id: String,
#[arg(long)]
cert: PathBuf,
#[arg(long)]
subject: Option<String>,
},
/// List stored SSH certificates
List {
#[arg(long)]
subject: Option<String>,
},
/// Pull authorized certificate metadata from a peer
2026-05-18 17:24:10 +02:00
Sync {
node: String,
#[arg(long)]
bearer_secret: Option<String>,
2026-05-18 17:24:10 +02:00
},
}
#[derive(Debug, Subcommand)]
pub enum SshRevocationCommand {
/// Record a key, certificate, serial, or key-id revocation
Add {
kind: String,
target: String,
#[arg(long)]
reason: Option<String>,
#[arg(long)]
subject: Option<String>,
},
/// List stored SSH revocations
List {
#[arg(long)]
subject: Option<String>,
},
/// Export JSONL, OpenSSH KRL specification, or binary KRL data
Export {
#[arg(long)]
out: PathBuf,
2026-05-17 18:29:47 +02:00
#[arg(long, default_value = "jsonl")]
format: String,
2026-05-18 11:51:12 +02:00
#[arg(long)]
ca_public: Option<PathBuf>,
#[arg(long)]
subject: Option<String>,
},
/// Import JSONL or an enumerable OpenSSH KRL specification
2026-05-18 11:56:42 +02:00
Import {
path: PathBuf,
#[arg(long, default_value = "jsonl")]
format: String,
#[arg(long)]
subject: Option<String>,
2026-05-18 11:56:42 +02:00
},
/// Pull authorized revocation metadata from a peer
2026-05-18 17:24:10 +02:00
Sync {
node: String,
#[arg(long)]
bearer_secret: Option<String>,
2026-05-18 17:24:10 +02:00
},
2026-05-15 15:08:20 +02:00
}
#[derive(Debug, Args)]
pub struct EmptyArgs {}
pub async fn run() -> Result<()> {
2026-07-18 16:08:19 +02:00
let matches = documented_cli_command().get_matches();
let cli = Cli::from_arg_matches(&matches).context("parse geth command")?;
2026-07-05 18:30:44 +02:00
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),
}
}
2026-07-18 16:08:19 +02:00
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 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,
})
}
2026-07-05 18:30:44 +02:00
async fn run_inner(cli: Cli) -> Result<()> {
2026-05-23 02:40:03 +02:00
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")?;
2026-05-15 15:08:20 +02:00
match cli.command {
2026-05-23 01:17:30 +02:00
Command::Guide { topic } => {
print_guide(topic, cli.json || cli.jsonl)?;
}
2026-05-21 11:29:29 +02:00
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")?;
2026-05-15 15:08:20 +02:00
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 },
2026-05-15 15:08:20 +02:00
} => {
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)?;
}
2026-07-05 22:24:08 +02:00
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);
}
}
2026-07-05 22:35:18 +02:00
Command::Backup { command } => {
run_backup_command(&paths, command, cli.json || cli.jsonl)
.context("run backup command")?;
}
2026-07-05 22:39:15 +02:00
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);
}
}
2026-05-21 01:03:38 +02:00
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")?;
}
2026-05-21 01:12:01 +02:00
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")?;
}
2026-05-21 01:15:51 +02:00
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")?;
}
2026-05-15 15:08:20 +02:00
command => {
let request = request_for_command(command)?;
let response = geth_node::send_control(&paths, request)
.await
.with_context(|| {
format!(
"connect to daemon at {}\nnext: start it with `geth daemon install` (background) or `geth daemon run` (foreground)",
paths.socket_path().display()
)
2026-05-15 15:08:20 +02:00
})?;
print_response(response, cli.json || cli.jsonl)?;
}
}
Ok(())
}
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))
}
2026-07-05 18:30:44 +02:00
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"
2026-07-18 15:56:45 +02:00
} else if lower.contains("daemon is already running") {
"daemon_already_running"
2026-07-05 18:30:44 +02:00
} 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<String> {
detail
.lines()
.find_map(|line| line.strip_prefix("next: "))
.map(ToOwned::to_owned)
}
2026-05-23 01:17:30 +02:00
fn print_guide(topic: Option<GuideTopic>, json: bool) -> Result<()> {
let (name, body) = match topic {
None => ("index", GUIDE_INDEX),
Some(GuideTopic::Quickstart) => ("quickstart", GUIDE_QUICKSTART),
2026-05-23 01:17:30 +02:00
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),
2026-05-23 02:40:03 +02:00
Some(GuideTopic::Completions) => ("completions", GUIDE_COMPLETIONS),
2026-05-23 01:17:30 +02:00
Some(GuideTopic::SmokeTest) => ("smoke-test", GUIDE_SMOKE_TEST),
};
if json {
println!(
"{}",
serde_json::json!({
"topic": name,
"body": body,
})
);
} else {
print!("{body}");
}
Ok(())
}
2026-05-23 02:40:03 +02:00
fn print_completions(shell: Shell) {
2026-07-18 16:08:19 +02:00
let mut command = documented_cli_command();
2026-05-23 02:40:03 +02:00
generate(shell, &mut command, "geth", &mut stdout());
}
2026-05-15 15:08:20 +02:00
fn request_for_command(command: Command) -> Result<ControlRequest> {
Ok(match command {
2026-05-23 02:40:03 +02:00
Command::Completions { .. } => bail!("completion generation does not use the daemon"),
2026-05-15 15:08:20 +02:00
Command::Status => ControlRequest::Status,
Command::Sync {
command: SyncCommand::Status,
} => ControlRequest::SyncStatus,
Command::Sync {
command: SyncCommand::Now { node },
} => ControlRequest::SyncNow { node },
2026-05-15 15:08:20 +02:00
Command::Node {
command: NodeCommand::Id,
} => ControlRequest::NodeId,
Command::Node {
command: NodeCommand::Status,
} => ControlRequest::Status,
2026-05-21 11:29:29 +02:00
Command::Node {
command: NodeCommand::List,
} => ControlRequest::NodeList,
2026-05-21 18:01:38 +02:00
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 },
},
2026-05-21 11:29:29 +02:00
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,
2026-05-21 18:15:10 +02:00
signing_key,
admin_key,
2026-05-21 11:29:29 +02:00
},
} => ControlRequest::NodeGrant {
node,
resource,
capability,
grant_id,
2026-05-21 18:15:10 +02:00
signing_key_path: Some(signing_key),
admin_key_path: admin_key,
2026-05-21 11:29:29 +02:00
},
Command::Node {
2026-05-21 18:15:10 +02:00
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),
},
2026-05-18 04:03:52 +02:00
Command::Peer { command } => match command {
PeerCommand::Export { out } => ControlRequest::PeerCardExport { out },
PeerCommand::Import { path } => ControlRequest::PeerCardImport { path },
PeerCommand::List => ControlRequest::PeerCardList,
2026-05-18 12:09:50 +02:00
PeerCommand::Ping { node } => ControlRequest::PeerPing { node },
2026-05-18 17:01:50 +02:00
PeerCommand::AuthCheck {
node,
resource,
capability,
} => ControlRequest::PeerAuthCheck {
node,
resource,
capability,
},
2026-05-18 04:03:52 +02:00
},
2026-05-23 01:17:30 +02:00
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 },
2026-05-23 02:08:51 +02:00
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 },
2026-05-23 01:17:30 +02:00
},
2026-05-15 15:08:20 +02:00
Command::Resource {
command: ResourceCommand::List,
} => ControlRequest::ResourceList,
Command::Resource {
command: ResourceCommand::Create { kind, name },
} => ControlRequest::ResourceCreate { kind, name },
Command::Keychain {
2026-05-19 16:04:20 +02:00
command:
KeychainCommand::Init {
admin_key,
signing_key,
},
} => ControlRequest::KeychainInit {
admin_key_path: admin_key,
2026-05-19 16:04:20 +02:00
signing_key_path: signing_key,
2026-05-15 15:08:20 +02:00
},
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,
2026-05-21 11:29:29 +02:00
Command::Keychain {
command: KeychainCommand::Sync { node },
} => ControlRequest::KeychainSync { node },
2026-05-21 18:01:38 +02:00
Command::Auth {
command: AuthCommand::Sync { node },
} => ControlRequest::AuthSync { node },
2026-05-15 15:08:20 +02:00
Command::Auth {
command:
AuthCommand::Explain {
subject,
resource,
capability,
},
} => ControlRequest::AuthExplain {
subject,
resource,
capability,
},
2026-05-16 16:32:03 +02:00
Command::Auth {
command:
AuthCommand::Grant {
subject,
resource,
capability,
grant_id,
2026-05-21 18:15:10 +02:00
signing_key,
admin_key,
2026-05-16 16:32:03 +02:00
},
} => ControlRequest::AuthGrant {
subject,
resource,
capability,
grant_id,
2026-05-21 18:15:10 +02:00
signing_key_path: Some(signing_key),
admin_key_path: admin_key,
2026-05-16 16:32:03 +02:00
},
Command::Auth {
2026-05-21 18:15:10 +02:00
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,
},
2026-05-16 22:18:49 +02:00
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,
2026-05-19 19:08:08 +02:00
SecretBearerCommand::Challenge {
resource,
capabilities,
} => ControlRequest::SecretBearerChallenge {
resource,
capabilities,
},
SecretBearerCommand::Prove {
token,
2026-05-19 19:08:08 +02:00
resource,
capabilities,
nonce,
} => ControlRequest::SecretBearerProve {
secret: token,
2026-05-19 19:08:08 +02:00
resource,
capabilities,
nonce,
},
SecretBearerCommand::Verify {
token,
2026-05-19 19:08:08 +02:00
resource,
capabilities,
nonce,
response,
} => ControlRequest::SecretBearerVerify {
secret: token,
2026-05-19 19:08:08 +02:00
resource,
capabilities,
nonce,
response,
},
SecretBearerCommand::Revoke {
resource,
bearer_id,
} => ControlRequest::SecretBearerRevoke {
resource,
secret: bearer_id,
},
},
2026-05-15 15:08:20 +02:00
},
Command::Cas { command } => match command {
CasCommand::Add { path } => ControlRequest::CasAdd { path },
2026-05-21 01:35:00 +02:00
CasCommand::AddPrivate { resource, path } => {
ControlRequest::CasAddPrivate { resource, path }
}
2026-05-15 15:08:20 +02:00
CasCommand::Get { hash, out } => ControlRequest::CasGet {
hash: hash.into(),
out,
},
2026-05-21 01:35:00 +02:00
CasCommand::GetPrivate {
resource,
hash,
out,
} => ControlRequest::CasGetPrivate {
resource,
hash: hash.into(),
out,
},
CasCommand::Fetch {
node,
hash,
bearer_secret,
} => ControlRequest::CasFetch {
2026-05-18 17:18:25 +02:00
node,
hash: hash.into(),
bearer_secret,
2026-05-18 17:18:25 +02:00
},
2026-05-15 15:08:20 +02:00
CasCommand::Hash { path } => ControlRequest::CasHash { path },
CasCommand::Has { hash } => ControlRequest::CasHas { hash: hash.into() },
2026-05-16 16:36:35 +02:00
CasCommand::Pin { hash } => ControlRequest::CasPin { hash: hash.into() },
CasCommand::Unpin { hash } => ControlRequest::CasUnpin { hash: hash.into() },
2026-05-16 21:10:25 +02:00
CasCommand::Cleanup { dry_run } => ControlRequest::CasCleanup { dry_run },
2026-05-19 15:37:02 +02:00
CasCommand::Providers { hash } => ControlRequest::CasProviders { hash: hash.into() },
2026-05-15 15:08:20 +02:00
CasCommand::List => ControlRequest::CasList,
2026-05-18 03:50:09 +02:00
CasCommand::Root { command } => match command {
CasRootCommand::Add { name, path } => ControlRequest::CasRootAdd { name, path },
CasRootCommand::List => ControlRequest::CasRootList,
CasRootCommand::Scan { name } => ControlRequest::CasRootScan { name },
2026-05-20 13:22:40 +02:00
CasRootCommand::Sync {
node,
name,
bearer_secret,
} => ControlRequest::CasRootSync {
node,
name,
bearer_secret,
},
2026-05-20 13:30:57 +02:00
CasRootCommand::Apply {
source,
to,
dry_run,
} => ControlRequest::CasRootApply {
source,
target: to,
dry_run,
},
2026-05-18 03:50:09 +02:00
},
2026-05-18 03:57:26 +02:00
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,
},
},
2026-05-15 15:08:20 +02:00
},
2026-05-16 21:52:35 +02:00
Command::Kv { command } => match command {
KvCommand::Create { name } => ControlRequest::KvCreate { name },
2026-05-18 04:06:41 +02:00
KvCommand::Set {
name,
key,
value,
subject,
} => ControlRequest::KvSet {
name,
key,
value,
subject,
},
2026-05-16 21:52:35 +02:00
KvCommand::Get { name, key } => ControlRequest::KvGet { name, key },
KvCommand::Sync {
node,
name,
bearer_secret,
} => ControlRequest::KvSync {
node,
name,
bearer_secret,
},
2026-05-15 15:08:20 +02:00
},
2026-05-17 18:23:51 +02:00
Command::Pubsub { command } => match command {
2026-05-18 18:41:04 +02:00
PubsubCommand::Pub {
topic,
message,
node,
bearer_secret,
2026-05-18 18:41:04 +02:00
} => ControlRequest::PubsubPub {
topic,
message,
node,
bearer_secret,
},
PubsubCommand::Sub {
topic,
node,
bearer_secret,
} => ControlRequest::PubsubSub {
topic,
node,
bearer_secret,
2026-05-18 18:41:04 +02:00
},
2026-05-15 15:08:20 +02:00
},
2026-05-17 20:17:26 +02:00
Command::Pipe { command } => match command {
2026-05-19 19:21:42 +02:00
PipeCommand::Listen {
name,
node,
bearer_secret,
} => ControlRequest::PipeListen {
name,
node,
bearer_secret,
},
PipeCommand::Connect {
target,
node,
bearer_secret,
} => ControlRequest::PipeConnect {
target,
node,
bearer_secret,
},
2026-05-21 01:12:01 +02:00
PipeCommand::ForwardTcp {
listen,
node,
target,
bearer_secret,
} => ControlRequest::PipeTcpForward {
listen_addr: listen,
node,
target_addr: target,
bearer_secret,
},
2026-05-21 01:15:51 +02:00
PipeCommand::ForwardUnix {
listen,
node,
target,
bearer_secret,
} => ControlRequest::PipeUnixForward {
listen_path: listen,
node,
target_path: target,
bearer_secret,
},
2026-05-20 13:57:14 +02:00
PipeCommand::Send {
target,
message,
2026-05-20 13:59:41 +02:00
input,
2026-05-20 13:57:14 +02:00
node,
bearer_secret,
} => ControlRequest::PipeSend {
target,
2026-05-20 13:59:41 +02:00
data_base64: pipe_send_payload_base64(message, input)?,
2026-05-20 13:57:14 +02:00
node,
bearer_secret,
},
PipeCommand::Recv { name, peek } => ControlRequest::PipeRecv { name, peek },
2026-05-15 15:08:20 +02:00
},
2026-05-16 21:13:33 +02:00
Command::Db { command } => match command {
DbCommand::Add { name, path } => ControlRequest::DbAdd { name, path },
DbCommand::Status { name } => ControlRequest::DbStatus { name },
2026-05-17 20:32:36 +02:00
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,
},
2026-05-15 15:08:20 +02:00
},
2026-05-16 22:15:18 +02:00
Command::Document { command } => match command {
DocumentCommand::Create { name } => ControlRequest::DocumentCreate { name },
DocumentCommand::Status { name } => ControlRequest::DocumentStatus { name },
2026-05-17 19:59:03 +02:00
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,
},
2026-05-15 15:08:20 +02:00
},
Command::Ssh { command } => match command {
SshCommand::Proxy {
node,
bearer_secret,
} => ControlRequest::SshProxyConnect {
node,
bearer_secret,
},
2026-05-21 01:49:48 +02:00
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,
2026-05-19 15:56:47 +02:00
sign,
subject,
} => ControlRequest::SshCertApprove {
request_id,
ca_key_path: ca_key,
valid_for,
serial,
out,
2026-05-19 15:56:47 +02:00
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 }
}
2026-05-18 11:51:12 +02:00
SshRevocationCommand::Export {
out,
format,
ca_public,
subject,
2026-05-18 11:51:12 +02:00
} => ControlRequest::SshRevocationExport {
out,
format,
ca_public,
subject,
},
SshRevocationCommand::Import {
path,
format,
subject,
} => ControlRequest::SshRevocationImport {
path,
format,
subject,
2026-05-18 11:51:12 +02:00
},
SshRevocationCommand::Sync {
node,
bearer_secret,
} => ControlRequest::SshRevocationSync {
node,
bearer_secret,
},
},
2026-05-15 15:08:20 +02:00
},
2026-07-05 22:24:08 +02:00
Command::Guide { .. }
| Command::Init { .. }
| Command::Daemon { .. }
2026-07-05 22:35:18 +02:00
| Command::Backup { .. }
2026-07-05 22:39:15 +02:00
| Command::Doctor
2026-07-05 22:24:08 +02:00
| Command::Wait { .. } => {
2026-05-23 01:17:30 +02:00
bail!("command is handled directly")
}
2026-05-15 15:08:20 +02:00
})
}
2026-07-05 22:24:08 +02:00
#[derive(Debug)]
struct WaitReport {
target: String,
ready: bool,
elapsed_ms: u128,
attempts: u64,
reason: String,
}
2026-07-05 22:35:18 +02:00
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(())
}
2026-07-05 22:39:15 +02:00
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(())
}
2026-07-05 22:24:08 +02:00
async fn run_wait_command(paths: &GethPaths, command: WaitCommand) -> Result<WaitReport> {
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<WaitReport> {
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<WaitReport> {
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<WaitReport> {
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<F, Fut>(
target: String,
timeout_ms: u64,
interval_ms: u64,
mut check: F,
) -> Result<WaitReport>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<Option<String>>>,
{
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<String> = 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<ServiceReport> {
Ok(match command {
ServiceCommand::Install {
manager,
bin,
start,
} => {
geth_node::init_node(paths).context("initialize geth home before service install")?;
let manager = manager.parse::<ServiceManager>()?;
let executable = service_executable(bin)?;
geth_node::service::install_user_service(
paths,
ServiceInstallOptions {
manager,
executable,
start,
},
)?
}
ServiceCommand::Uninstall { manager } => {
geth_node::service::uninstall_user_service(manager.parse::<ServiceManager>()?)?
}
ServiceCommand::Start { manager } => {
geth_node::service::start_user_service(manager.parse::<ServiceManager>()?)?
}
ServiceCommand::Stop { manager } => {
geth_node::service::stop_user_service(manager.parse::<ServiceManager>()?)?
}
ServiceCommand::Status { manager } => {
geth_node::service::status_user_service(manager.parse::<ServiceManager>()?)?
}
ServiceCommand::Print { manager, bin } => {
let executable = service_executable(bin)?;
geth_node::service::print_user_service(
paths,
manager.parse::<ServiceManager>()?,
&executable,
)?
}
})
}
fn service_executable(bin: Option<PathBuf>) -> Result<PathBuf> {
bin.map(Ok)
.unwrap_or_else(std::env::current_exe)
.context("resolve current geth executable")
}
fn print_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);
}
2026-05-15 15:08:20 +02:00
fn print_response(response: ControlResponse, json: bool) -> Result<()> {
if json {
println!("{}", serde_json::to_string_pretty(&response)?);
return Ok(());
}
match response {
ControlResponse::Status(status) => {
println!("geth daemon: running");
println!("home: {}", status.home.display());
println!("socket: {}", status.socket.display());
println!("agent: {}", status.agent_id);
println!("node: {}", status.node_id);
2026-07-05 18:14:52 +02:00
println!("uptime seconds: {}", status.daemon_uptime_seconds);
2026-07-05 18:10:41 +02:00
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);
2026-05-16 01:54:00 +02:00
println!(
"endpoint: {}",
status.endpoint_id.as_deref().unwrap_or("not started")
);
2026-05-16 03:17:45 +02:00
println!("iroh relay: {}", status.iroh_relay_mode);
2026-05-16 14:33:45 +02:00
println!(
"iroh discovery: {}",
if status.iroh_local_discovery {
"local-network enabled"
} else {
"local-network disabled"
}
);
2026-05-15 15:08:20 +02:00
println!("iroh: {}", status.iroh);
2026-05-22 14:33:46 +02:00
for backend in status.native_backends {
println!(
"native backend {}: {} target {} {} ({})",
backend.module,
backend.current_backend,
backend.target_crate,
backend.target_version,
backend.status
);
2026-05-22 15:28:52 +02:00
if !backend.blocker.is_empty() {
println!(
"native backend {} note: {}",
backend.module, backend.blocker
);
}
2026-05-22 14:33:46 +02:00
}
2026-05-15 15:08:20 +02:00
}
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")
);
}
2026-05-18 04:03:52 +02:00
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}");
}
2026-05-18 12:09:50 +02:00
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}");
}
2026-05-18 17:01:50 +02:00
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}");
}
2026-05-15 15:08:20 +02:00
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
);
}
2026-05-23 01:17:30 +02:00
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);
2026-05-23 02:08:51 +02:00
println!("cidr: {}", join.network.cidr);
println!(
"virtual_ip: {}",
join.network.virtual_ip.as_deref().unwrap_or("unassigned")
);
println!("state: {:?}", join.network.state);
2026-05-23 01:17:30 +02:00
println!("enabled: {}", join.enabled);
println!("note: {}", join.note);
}
ControlResponse::OverlayLeft {
name,
stopped,
note,
} => {
println!("overlay: {name}");
println!("stopped: {stopped}");
println!("note: {note}");
}
2026-05-23 02:08:51 +02:00
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}");
}
2026-05-15 15:08:20 +02:00
ControlResponse::CasAdded { hash, size_bytes } => {
println!("{hash} {size_bytes} bytes");
}
2026-05-21 01:35:00 +02:00
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}");
}
2026-05-15 15:08:20 +02:00
ControlResponse::CasGot {
hash,
out,
size_bytes,
} => {
println!("wrote {hash} to {} ({size_bytes} bytes)", out.display());
}
2026-05-21 01:35:00 +02:00
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}");
}
2026-05-18 17:18:25 +02:00
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}");
}
2026-05-15 15:08:20 +02:00
ControlResponse::CasHash { hash } => println!("{hash}"),
ControlResponse::CasHas { hash, present } => println!("{hash}: {present}"),
2026-05-16 16:36:35 +02:00
ControlResponse::CasPinned { hash, pinned } => {
println!("{hash}: pinned={pinned}");
}
2026-05-16 21:10:25 +02:00
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}");
}
}
2026-05-15 15:08:20 +02:00
ControlResponse::CasList { blobs } => {
for blob in blobs {
2026-05-16 16:36:35 +02:00
let pin = if blob.pinned { "pinned" } else { "unpinned" };
println!("{}\t{} bytes\t{}", blob.hash, blob.size_bytes, pin);
2026-05-15 15:08:20 +02:00
}
}
2026-05-19 15:37:02 +02:00
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
);
}
}
2026-05-18 03:50:09 +02:00
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);
}
2026-05-20 13:22:40 +02:00
ControlResponse::CasRootSynced {
peer_node_id,
peer_agent_id,
endpoint_id,
name,
root,
tree_bytes_imported,
2026-05-21 01:40:50 +02:00
sync_conflicts,
2026-05-20 13:22:40 +02:00
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}");
2026-05-21 01:40:50 +02:00
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()
);
}
2026-05-20 13:22:40 +02:00
} 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}");
}
2026-05-20 13:30:57 +02:00
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}");
}
2026-05-18 03:57:26 +02:00
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);
}
2026-05-15 15:08:20 +02:00
ControlResponse::KeychainStatus(status) => {
println!("initialized: {}", status.initialized);
println!("admin_keys: {}", status.admin_keys);
2026-05-19 16:05:57 +02:00
println!("signatures: {}", status.signatures);
2026-05-19 18:58:07 +02:00
println!("verified_signatures: {}", status.verified_signatures);
println!("failed_signatures: {}", status.failed_signatures);
2026-05-15 15:08:20 +02:00
println!("users: {}", status.users);
println!("devices: {}", status.devices);
println!("nodes: {}", status.nodes);
}
2026-05-19 16:04:20 +02:00
ControlResponse::KeychainInitialized { ops, signatures } => {
println!("initialized keychain");
for op in ops {
println!("recorded keychain op: {}", op.id);
}
2026-05-19 16:04:20 +02:00
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);
}
2026-05-21 11:29:29 +02:00
ControlResponse::KeychainSynced {
peer_node_id,
peer_agent_id,
endpoint_id,
ops_imported,
signatures_imported,
invalid_ops_rejected,
high_water_ms,
2026-05-21 11:29:29 +02:00
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}");
2026-05-21 11:29:29 +02:00
println!("note: {note}");
}
2026-05-21 18:01:38 +02:00
ControlResponse::AuthSynced {
peer_node_id,
peer_agent_id,
endpoint_id,
ops_imported,
signatures_imported,
invalid_ops_rejected,
high_water_ms,
2026-05-21 18:01:38 +02:00
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!(
2026-07-05 22:57:52 +02:00
" {}\tstate={}\tstale={}\tcursor={}\tlast_attempt={}\tlast_success={}\timported={}\trejected={}\tfailures={}\tretry_in_ms={}\terror={}\tnext={}",
stream.stream,
2026-05-22 15:00:09 +02:00
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,
2026-07-05 22:57:52 +02:00
stream.consecutive_failures,
stream
.retry_in_ms
.map(|value| value.to_string())
.unwrap_or_else(|| "-".to_owned()),
2026-05-22 15:00:09 +02:00
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())
);
}
}
}
2026-05-21 18:01:38 +02:00
println!("note: {note}");
}
2026-05-16 22:18:49 +02:00
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::<Vec<_>>()
.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::<Vec<_>>()
.join(","),
item.may_delegate
);
}
}
}
2026-05-19 19:08:08 +02:00
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::<Vec<_>>()
.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::<Vec<_>>()
.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}");
}
2026-05-15 15:08:20 +02:00
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);
2026-05-22 14:28:44 +02:00
if !explain.diagnostics.is_empty() {
println!("diagnostics: {}", explain.diagnostics.join(","));
}
2026-05-15 15:08:20 +02:00
}
2026-05-21 18:15:10 +02:00
ControlResponse::AuthOpRecorded { op, signatures } => {
2026-05-16 16:32:03 +02:00
println!("recorded auth op: {}", op.id);
println!("resource: {}", op.resource);
2026-05-21 18:15:10 +02:00
for signature in signatures {
println!(
"signed auth op: {} by {} ({})",
signature.op_id, signature.signer, signature.namespace
);
}
2026-05-16 16:32:03 +02:00
}
2026-05-21 11:29:29 +02:00
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}");
}
2026-05-21 18:01:38 +02:00
ControlResponse::NodeGrantUpdated {
op,
signatures,
note,
} => {
2026-05-21 11:29:29 +02:00
println!("recorded auth op: {}", op.id);
println!("resource: {}", op.resource);
2026-05-21 18:01:38 +02:00
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}");
2026-05-21 11:29:29 +02:00
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));
2026-05-19 15:56:47 +02:00
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
);
}
}
}
2026-05-18 17:24:10 +02:00
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()
);
}
}
}
2026-05-17 18:29:47 +02:00
ControlResponse::SshRevocationExported {
out,
format,
count,
note,
} => {
println!("exported {count} ssh revocations to {}", out.display());
2026-05-17 18:29:47 +02:00
println!("format: {format}");
println!("note: {note}");
}
2026-05-18 11:56:42 +02:00
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}");
}
2026-05-18 17:24:10 +02:00
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}");
}
2026-05-16 21:13:33 +02:00
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);
2026-05-17 20:01:36 +02:00
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}");
}
2026-05-16 21:13:33 +02:00
println!("sync_status: {}", db.sync_status);
}
2026-05-17 20:32:36 +02:00
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
);
}
}
2026-05-18 22:11:57 +02:00
ControlResponse::DbSynced {
peer_node_id,
peer_agent_id,
endpoint_id,
name,
changes_received,
2026-05-19 19:02:39 +02:00
changes_applied,
2026-05-18 22:11:57 +02:00
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}");
2026-05-19 19:02:39 +02:00
println!("changes_applied: {changes_applied}");
2026-05-18 22:11:57 +02:00
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}");
}
2026-05-16 21:52:35 +02:00
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");
}
}
2026-05-18 18:36:03 +02:00
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}");
}
2026-05-16 22:15:18 +02:00
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);
}
2026-05-17 19:59:03 +02:00
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);
}
2026-05-18 18:49:34 +02:00
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}");
}
2026-05-17 18:23:51 +02:00
ControlResponse::PubsubPublished { message } => {
println!("published: {}", message.topic);
println!("published_at_ms: {}", message.published_at.0);
}
2026-05-18 18:41:04 +02:00
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}");
}
2026-05-17 18:23:51 +02:00
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}");
}
2026-05-19 15:28:48 +02:00
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}");
}
2026-05-17 20:17:26 +02:00
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);
}
2026-05-19 19:21:42 +02:00
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}");
}
2026-05-18 18:45:10 +02:00
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}");
}
2026-05-20 13:57:14 +02:00
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}");
}
2026-05-19 15:51:11 +02:00
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}");
2026-05-21 01:49:48 +02:00
}
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}");
2026-05-19 15:51:11 +02:00
}
2026-05-15 15:08:20 +02:00
ControlResponse::NotImplemented { module, command } => {
println!("{module} {command}: not implemented yet");
}
ControlResponse::Error { message } => bail!(message),
}
Ok(())
}
fn print_service_report(report: ServiceReport, json: bool) -> Result<()> {
if json {
println!(
"{}",
serde_json::json!({
"manager": report.manager.to_string(),
"action": format!("{:?}", report.action),
"service_name": report.service_name,
"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(())
}
2026-05-18 03:57:26 +02:00
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}");
}
}
2026-05-20 13:57:14 +02:00
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(())
}
2026-05-20 13:59:41 +02:00
fn pipe_send_payload_base64(message: Option<String>, input: Option<PathBuf>) -> Result<String> {
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 <path>; 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::<Vec<_>>()
.join(" ")
}
2026-07-05 18:30:44 +02:00
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn help_describes_common_workflows_and_command_families() {
2026-07-18 16:08:19 +02:00
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 <DIR>"));
}
2026-07-18 16:08:19 +02:00
#[test]
fn every_cli_argument_has_operator_facing_help() {
fn check(command: clap::Command, path: String, missing: &mut Vec<String>) {
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 }
}
));
}
#[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());
}
2026-07-05 18:30:44 +02:00
#[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 <path>`",
),
Some("import a peer card with `geth peer import <path>`".to_owned())
);
}
}