Add overlay planning commands

This commit is contained in:
Eric Wendland 2026-05-23 01:17:30 +02:00
commit c2dee50dae
18 changed files with 928 additions and 16 deletions

View file

@ -1,18 +1,239 @@
use anyhow::{Context, Result, bail};
use base64::Engine;
use clap::{Args, Parser, Subcommand};
use clap::{Args, Parser, Subcommand, ValueEnum};
use geth_config::GethPaths;
use geth_control::{ControlRequest, ControlResponse};
use geth_node::service::{ServiceInstallOptions, ServiceManager, ServiceReport};
use std::io::Read;
use std::path::PathBuf;
const TOP_LEVEL_AFTER_HELP: &str = r#"Common starts:
geth guide init
geth guide owner-setup
geth guide overlay
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 command-specific examples."#;
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:
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.
smoke-test Minimal commands to verify a node and daemon."#;
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.
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
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 service install --start
geth daemon service status
geth daemon service stop
geth daemon service start
geth daemon service uninstall
Service installation targets user service managers, not system services:
Linux: systemd --user
macOS: launchd user agent
Windows: current-user scheduled task
Preview definitions without installing:
geth daemon service print
"#;
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>
geth overlay leave home
Current limits:
- join/leave are planning stubs; no TUN/Wintun interface is created yet
- host network changes must remain explicit opt-in in future versions
- 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
"#;
const GUIDE_SMOKE_TEST: &str = r#"Minimal smoke test:
Terminal 1:
export GETH_HOME="$(mktemp -d)"
geth init
geth daemon run
Terminal 2:
export GETH_HOME="<same dir>"
geth status
geth node id
echo "hello geth" > /tmp/hello-geth.txt
geth cas add /tmp/hello-geth.txt
geth cas list
geth keychain status
For two-node owner/enrollment testing, use:
geth guide owner-setup
geth guide enrollment
"#;
#[derive(Debug, Parser)]
#[command(name = "geth", about = "Personal local-first Iroh mesh runtime")]
#[command(
name = "geth",
about = "Personal local-first Iroh mesh runtime",
after_long_help = TOP_LEVEL_AFTER_HELP
)]
pub struct Cli {
#[arg(long, global = true)]
#[arg(long, global = true, help = "Print machine-readable JSON output")]
pub json: bool,
#[arg(long, global = true)]
#[arg(
long,
global = true,
help = "Print newline-delimited JSON output for streaming commands"
)]
pub jsonl: bool,
#[command(subcommand)]
pub command: Command,
@ -20,16 +241,43 @@ pub struct Cli {
#[derive(Debug, Subcommand)]
pub enum Command {
Guide {
#[arg(value_enum)]
topic: Option<GuideTopic>,
},
#[command(long_about = INIT_LONG_ABOUT, after_long_help = INIT_AFTER_HELP)]
Init {
#[arg(long)]
#[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."
)]
admin_key: Option<PathBuf>,
#[arg(long)]
#[arg(
long,
value_name = "OPENSSH_PRIVATE_KEY",
help = "Matching private key or YubiKey/FIDO stub used to sign init statements",
long_help = "Path to the matching private OpenSSH key, or security-key/YubiKey stub such as ~/.ssh/id_ed25519_sk. geth uses ssh-keygen -Y sign with explicit geth namespaces; it does not copy the private key into geth state."
)]
signing_key: Option<PathBuf>,
#[arg(long, default_value = "owner")]
#[arg(
long,
default_value = "owner",
help = "Owner/user display name recorded during owner init"
)]
owner: String,
#[arg(long, default_value = "local")]
#[arg(
long,
default_value = "local",
help = "Friendly node name recorded during owner init"
)]
node_name: String,
#[arg(long = "capability")]
#[arg(
long = "capability",
value_name = "RESOURCE=CAPABILITY",
help = "Initial capability grant for this node; repeatable"
)]
capabilities: Vec<String>,
},
Daemon {
@ -49,6 +297,10 @@ pub enum Command {
#[command(subcommand)]
command: PeerCommand,
},
Overlay {
#[command(subcommand)]
command: OverlayCommand,
},
Resource {
#[command(subcommand)]
command: ResourceCommand,
@ -95,6 +347,17 @@ pub enum Command {
},
}
#[derive(Clone, Debug, ValueEnum)]
pub enum GuideTopic {
Init,
OwnerSetup,
Enrollment,
Keys,
Overlay,
Service,
SmokeTest,
}
#[derive(Debug, Subcommand)]
pub enum DaemonCommand {
Run,
@ -259,6 +522,26 @@ pub enum PeerCommand {
},
}
#[derive(Debug, Subcommand)]
pub enum OverlayCommand {
Status,
Plan {
name: String,
#[arg(long)]
cidr: Option<String>,
},
Join {
name: String,
#[arg(long)]
secret: String,
#[arg(long)]
cidr: Option<String>,
},
Leave {
name: String,
},
}
#[derive(Debug, Subcommand)]
pub enum ResourceCommand {
List,
@ -740,6 +1023,9 @@ pub async fn run() -> Result<()> {
let cli = Cli::parse();
let paths = GethPaths::resolve().context("resolve geth paths")?;
match cli.command {
Command::Guide { topic } => {
print_guide(topic, cli.json || cli.jsonl)?;
}
Command::Init {
admin_key,
signing_key,
@ -832,6 +1118,31 @@ pub async fn run() -> Result<()> {
Ok(())
}
fn print_guide(topic: Option<GuideTopic>, json: bool) -> Result<()> {
let (name, body) = match topic {
None => ("index", GUIDE_INDEX),
Some(GuideTopic::Init) => ("init", GUIDE_INIT),
Some(GuideTopic::OwnerSetup) => ("owner-setup", GUIDE_OWNER_SETUP),
Some(GuideTopic::Enrollment) => ("enrollment", GUIDE_ENROLLMENT),
Some(GuideTopic::Keys) => ("keys", GUIDE_KEYS),
Some(GuideTopic::Overlay) => ("overlay", GUIDE_OVERLAY),
Some(GuideTopic::Service) => ("service", GUIDE_SERVICE),
Some(GuideTopic::SmokeTest) => ("smoke-test", GUIDE_SMOKE_TEST),
};
if json {
println!(
"{}",
serde_json::json!({
"topic": name,
"body": body,
})
);
} else {
print!("{body}");
}
Ok(())
}
fn request_for_command(command: Command) -> Result<ControlRequest> {
Ok(match command {
Command::Status => ControlRequest::Status,
@ -979,6 +1290,14 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
capability,
},
},
Command::Overlay { command } => match command {
OverlayCommand::Status => ControlRequest::OverlayStatus,
OverlayCommand::Plan { name, cidr } => ControlRequest::OverlayPlan { name, cidr },
OverlayCommand::Join { name, secret, cidr } => {
ControlRequest::OverlayJoin { name, secret, cidr }
}
OverlayCommand::Leave { name } => ControlRequest::OverlayLeave { name },
},
Command::Resource {
command: ResourceCommand::List,
} => ControlRequest::ResourceList,
@ -1447,7 +1766,9 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
},
},
},
Command::Init { .. } | Command::Daemon { .. } => bail!("command is handled directly"),
Command::Guide { .. } | Command::Init { .. } | Command::Daemon { .. } => {
bail!("command is handled directly")
}
})
}
@ -1633,6 +1954,53 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
resource.kind, resource.name, resource.id
);
}
ControlResponse::OverlayStatus { networks, note } => {
if networks.is_empty() {
println!("no overlay networks active");
} else {
for network in networks {
println!(
"{}\t{}\t{}\t{:?}\t{} peers",
network.name,
network.resource,
network.cidr,
network.state,
network.peers.len()
);
}
}
println!("note: {note}");
}
ControlResponse::OverlayPlanned { plan } => {
println!("overlay: {}", plan.name);
println!("resource: {}", plan.resource);
println!("cidr: {}", plan.cidr);
println!("alpn: {}", plan.alpn);
println!("capabilities: {}", plan.capabilities.join(","));
println!("discovery: {}", plan.discovery);
println!("runtime: {}", plan.runtime);
for note in plan.security {
println!("security: {note}");
}
for note in plan.implementation_notes {
println!("implementation: {note}");
}
}
ControlResponse::OverlayJoined { join } => {
println!("overlay: {}", join.plan.name);
println!("resource: {}", join.plan.resource);
println!("enabled: {}", join.enabled);
println!("note: {}", join.note);
}
ControlResponse::OverlayLeft {
name,
stopped,
note,
} => {
println!("overlay: {name}");
println!("stopped: {stopped}");
println!("note: {note}");
}
ControlResponse::CasAdded { hash, size_bytes } => {
println!("{hash} {size_bytes} bytes");
}