From c2dee50dae09dc6b07478faf814ad8f0c83e709c Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Sat, 23 May 2026 01:17:30 +0200 Subject: [PATCH] Add overlay planning commands --- .gitignore | 1 + Cargo.lock | 11 + Cargo.toml | 1 + README.md | 11 + crates/geth-cli/src/lib.rs | 388 +++++++++++++++++- crates/geth-control/Cargo.toml | 1 + crates/geth-control/src/lib.rs | 47 +++ crates/geth-crypto/src/lib.rs | 52 ++- crates/geth-iroh/src/lib.rs | 13 +- crates/geth-node/Cargo.toml | 1 + crates/geth-node/src/lib.rs | 37 ++ crates/geth-overlay/Cargo.toml | 11 + crates/geth-overlay/src/lib.rs | 182 ++++++++ crates/geth-types/src/lib.rs | 4 + crates/geth/tests/bootstrap.rs | 74 ++++ .../adr/0017-optional-iroh-overlay-network.md | 66 +++ docs/architecture.md | 18 +- docs/roadmap.md | 26 ++ 18 files changed, 928 insertions(+), 16 deletions(-) create mode 100644 crates/geth-overlay/Cargo.toml create mode 100644 crates/geth-overlay/src/lib.rs create mode 100644 docs/adr/0017-optional-iroh-overlay-network.md diff --git a/.gitignore b/.gitignore index 4fcb722..7753b41 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target/ *.swp +/references diff --git a/Cargo.lock b/Cargo.lock index 0af2c01..24ca1ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1491,6 +1491,7 @@ dependencies = [ "geth-document", "geth-keychain", "geth-kv", + "geth-overlay", "geth-pipe", "geth-pubsub", "geth-resource", @@ -1614,6 +1615,7 @@ dependencies = [ "geth-iroh", "geth-keychain", "geth-kv", + "geth-overlay", "geth-pipe", "geth-pubsub", "geth-resource", @@ -1637,6 +1639,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "geth-overlay" +version = "0.1.0" +dependencies = [ + "geth-types", + "serde", + "thiserror 2.0.18", +] + [[package]] name = "geth-pipe" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 04b544a..a3098ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "crates/geth-pipe", "crates/geth-document", "crates/geth-pubsub", + "crates/geth-overlay", "crates/geth-cas", "crates/geth-ssh-proxy", "crates/geth-testkit", diff --git a/README.md b/README.md index 054f995..478fb17 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,9 @@ metadata from an authorized peer over Iroh. The bootstrap implementation provides: +- `geth guide [init|owner-setup|enrollment|keys|overlay|service|smoke-test]` for + embedded workflow help, including `--admin-key` / `--signing-key` setup + examples - `geth init` - `geth init --admin-key --signing-key --node-name ` - `geth daemon run` @@ -110,6 +113,14 @@ The bootstrap implementation provides: - `geth peer list` - `geth peer ping ` - `geth peer auth-check ` +- optional overlay-network planning: + `geth overlay status`, + `geth overlay plan [--cidr 172.22.0.0/24]`, + `geth overlay join --secret [--cidr 172.22.0.0/24]`, + and `geth overlay leave `. These commands expose the resource, + capability, and ALPN shape for a future Iroh-carried packet overlay inspired + by iroh-lan. The prototype does not create TUN/Wintun interfaces or route + packets yet. - `geth resource list` - `geth resource create ` - `geth keychain init [--admin-key ] [--signing-key ]` diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index ae4ccdc..4b5a31f 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -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 --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 + +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 --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 + 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="" + 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, + }, + #[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/.pub. This key is public and replicated in the geth keychain." + )] admin_key: Option, - #[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, - #[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, }, 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, + }, + Join { + name: String, + #[arg(long)] + secret: String, + #[arg(long)] + cidr: Option, + }, + 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, 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 { Ok(match command { Command::Status => ControlRequest::Status, @@ -979,6 +1290,14 @@ fn request_for_command(command: Command) -> Result { 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 { }, }, }, - 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"); } diff --git a/crates/geth-control/Cargo.toml b/crates/geth-control/Cargo.toml index bb75e01..2ea463c 100644 --- a/crates/geth-control/Cargo.toml +++ b/crates/geth-control/Cargo.toml @@ -16,6 +16,7 @@ geth-discovery = { path = "../geth-discovery" } geth-document = { path = "../geth-document" } geth-keychain = { path = "../geth-keychain" } geth-kv = { path = "../geth-kv" } +geth-overlay = { path = "../geth-overlay" } geth-pipe = { path = "../geth-pipe" } geth-pubsub = { path = "../geth-pubsub" } geth-resource = { path = "../geth-resource" } diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 2ff8394..9eadfd6 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -5,6 +5,7 @@ use geth_discovery::{DiscoveredPeer, PeerCard}; use geth_document::{DocumentResource, DocumentState}; use geth_keychain::{KeychainOp, KeychainOpSignature, NodeEnrollmentRequest, NodeRecord}; use geth_kv::{KvEntry, KvResource, KvSyncEntry}; +use geth_overlay::{OverlayJoinPlan, OverlayNetworkStatus, OverlayPlan}; use geth_pipe::{PipeConnection, PipeListener, PipeMessage}; use geth_pubsub::PubsubMessage; use geth_resource::ResourceDescriptor; @@ -43,6 +44,19 @@ pub enum ControlRequest { kind: String, name: String, }, + OverlayStatus, + OverlayPlan { + name: String, + cidr: Option, + }, + OverlayJoin { + name: String, + secret: String, + cidr: Option, + }, + OverlayLeave { + name: String, + }, CasAdd { path: PathBuf, }, @@ -472,6 +486,21 @@ pub enum ControlResponse { ResourceCreated { resource: ResourceDescriptor, }, + OverlayStatus { + networks: Vec, + note: String, + }, + OverlayPlanned { + plan: OverlayPlan, + }, + OverlayJoined { + join: OverlayJoinPlan, + }, + OverlayLeft { + name: String, + stopped: bool, + note: String, + }, CasAdded { hash: BlobHash, size_bytes: u64, @@ -1549,6 +1578,15 @@ mod tests { request ); + let request = ControlRequest::OverlayPlan { + name: "home-lan".to_owned(), + cidr: Some("172.22.0.0/24".to_owned()), + }; + assert_eq!( + decode_request(&encode_request(&request).expect("encode")).expect("decode"), + request + ); + let response = ControlResponse::CasHas { hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(), present: true, @@ -1558,6 +1596,15 @@ mod tests { response ); + let response = ControlResponse::OverlayPlanned { + plan: geth_overlay::plan_overlay("home-lan", None, geth_overlay::OVERLAY_ALPN) + .expect("overlay plan"), + }; + assert_eq!( + decode_response(&encode_response(&response).expect("encode")).expect("decode"), + response + ); + let response = ControlResponse::NodeList { nodes: vec![NodeRecord { id: geth_types::NodeId::new("node:local"), diff --git a/crates/geth-crypto/src/lib.rs b/crates/geth-crypto/src/lib.rs index 4d2b80b..d8ed94e 100644 --- a/crates/geth-crypto/src/lib.rs +++ b/crates/geth-crypto/src/lib.rs @@ -39,9 +39,18 @@ impl AgentKey { } pub fn load(path: &Path) -> Result { - let hex_key = std::fs::read_to_string(path)?; - let bytes = hex::decode(hex_key.trim())?; - let key_bytes: [u8; 32] = bytes.try_into().map_err(|_| CryptoError::InvalidKey)?; + let key_file = std::fs::read(path)?; + let bytes = if let Ok(hex_key) = std::str::from_utf8(&key_file) { + let trimmed = hex_key.trim(); + if trimmed.len() == 64 && trimmed.bytes().all(|byte| byte.is_ascii_hexdigit()) { + hex::decode(trimmed)? + } else { + key_file + } + } else { + key_file + }; + let key_bytes = signing_key_bytes_from_file(bytes)?; Ok(Self { signing_key: SigningKey::from_bytes(&key_bytes), }) @@ -86,6 +95,14 @@ impl AgentKey { } } +fn signing_key_bytes_from_file(bytes: Vec) -> Result<[u8; 32], CryptoError> { + match bytes.len() { + 32 => bytes.try_into().map_err(|_| CryptoError::InvalidKey), + 64 => bytes[..32].try_into().map_err(|_| CryptoError::InvalidKey), + _ => Err(CryptoError::InvalidKey), + } +} + pub fn verify(public_key: &[u8], message: &[u8], signature: &[u8]) -> Result<(), CryptoError> { let key_bytes: [u8; 32] = public_key.try_into().map_err(|_| CryptoError::InvalidKey)?; let verifying_key = @@ -132,6 +149,35 @@ mod tests { assert_eq!(first.agent_id(), second.agent_id()); } + #[test] + fn agent_identity_loads_raw_32_byte_key_material() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("agent.ed25519"); + std::fs::write(&path, [0_u8; 32]).expect("write raw key"); + + let loaded = AgentKey::load(&path).expect("load raw key"); + + assert_eq!( + loaded.signing_key.to_bytes(), + [0_u8; 32], + "raw binary keys can contain bytes that are valid UTF-8 but not hex text" + ); + } + + #[test] + fn agent_identity_loads_raw_64_byte_keypair_material() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("agent.ed25519"); + let key = AgentKey::generate(); + let mut keypair = key.signing_key.to_bytes().to_vec(); + keypair.extend_from_slice(&key.verifying_key().to_bytes()); + std::fs::write(&path, keypair).expect("write raw keypair"); + + let loaded = AgentKey::load(&path).expect("load raw keypair"); + + assert_eq!(key.agent_id(), loaded.agent_id()); + } + #[test] fn blake3_helper_matches_known_hash() { assert_eq!( diff --git a/crates/geth-iroh/src/lib.rs b/crates/geth-iroh/src/lib.rs index 9b4f953..1df8bf2 100644 --- a/crates/geth-iroh/src/lib.rs +++ b/crates/geth-iroh/src/lib.rs @@ -11,6 +11,7 @@ pub const ALPN_PIPE: &[u8] = b"/geth/pipe/1"; pub const ALPN_DB: &[u8] = b"/geth/db/1"; pub const ALPN_DOCUMENT: &[u8] = b"/geth/document/1"; pub const ALPN_SSH_PROXY: &[u8] = b"/geth/ssh-proxy/1"; +pub const ALPN_OVERLAY: &[u8] = b"/geth/overlay/1"; pub const IROH_VERSION: &str = "0.95.1"; pub const IROH_BLOBS_VERSION: &str = "0.97.0"; @@ -60,6 +61,7 @@ pub enum ProtocolKind { Db, Document, SshProxy, + Overlay, } impl ProtocolKind { @@ -74,6 +76,7 @@ impl ProtocolKind { Self::Db => "db", Self::Document => "document", Self::SshProxy => "ssh-proxy", + Self::Overlay => "overlay", } } } @@ -158,6 +161,7 @@ pub fn default_protocol_descriptors() -> Vec { ProtocolDescriptor::new(ProtocolKind::Db, ALPN_DB), ProtocolDescriptor::new(ProtocolKind::Document, ALPN_DOCUMENT), ProtocolDescriptor::new(ProtocolKind::SshProxy, ALPN_SSH_PROXY), + ProtocolDescriptor::new(ProtocolKind::Overlay, ALPN_OVERLAY), ] } @@ -431,7 +435,8 @@ mod tests { assert!(alpns.contains(&ALPN_CONTROL.to_vec())); assert!(alpns.contains(&ALPN_CAS.to_vec())); assert!(alpns.contains(&ALPN_SSH_PROXY.to_vec())); - assert_eq!(alpns.len(), 8); + assert!(alpns.contains(&ALPN_OVERLAY.to_vec())); + assert_eq!(alpns.len(), 9); } #[test] @@ -478,7 +483,11 @@ mod tests { router.require(ALPN_SSH_PROXY).expect("ssh proxy").kind, ProtocolKind::SshProxy ); - assert_eq!(router.descriptors().len(), 8); + assert_eq!( + router.require(ALPN_OVERLAY).expect("overlay").kind, + ProtocolKind::Overlay + ); + assert_eq!(router.descriptors().len(), 9); } #[test] diff --git a/crates/geth-node/Cargo.toml b/crates/geth-node/Cargo.toml index ad8009f..8a939be 100644 --- a/crates/geth-node/Cargo.toml +++ b/crates/geth-node/Cargo.toml @@ -26,6 +26,7 @@ geth-document = { path = "../geth-document" } geth-iroh = { path = "../geth-iroh" } geth-keychain = { path = "../geth-keychain" } geth-kv = { path = "../geth-kv" } +geth-overlay = { path = "../geth-overlay" } geth-pipe = { path = "../geth-pipe" } geth-pubsub = { path = "../geth-pubsub" } geth-resource = { path = "../geth-resource" } diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 9eb5841..df27b5b 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -73,6 +73,8 @@ pub enum NodeError { Cas(#[from] geth_cas::CasError), #[error("db error: {0}")] Db(#[from] geth_db::DbError), + #[error("overlay error: {0}")] + Overlay(#[from] geth_overlay::OverlayError), #[error("control error: {0}")] Control(#[from] geth_control::ControlError), #[error("codec error: {0}")] @@ -6591,6 +6593,41 @@ pub fn handle_request( resource: stored_resource_to_descriptor(stored)?, }) } + ControlRequest::OverlayStatus => Ok(ControlResponse::OverlayStatus { + networks: Vec::new(), + note: geth_overlay::overlay_status_note().to_owned(), + }), + ControlRequest::OverlayPlan { name, cidr } => { + let plan = geth_overlay::plan_overlay( + &name, + cidr.as_deref(), + &String::from_utf8_lossy(geth_iroh::ALPN_OVERLAY), + )?; + Ok(ControlResponse::OverlayPlanned { plan }) + } + ControlRequest::OverlayJoin { name, secret, cidr } => { + geth_overlay::validate_overlay_secret(&secret)?; + let plan = geth_overlay::plan_overlay( + &name, + cidr.as_deref(), + &String::from_utf8_lossy(geth_iroh::ALPN_OVERLAY), + )?; + Ok(ControlResponse::OverlayJoined { + join: geth_overlay::OverlayJoinPlan { + plan, + enabled: false, + note: "overlay join is recorded as an implementation plan only; this prototype does not create a TUN/Wintun interface or route packets".to_owned(), + }, + }) + } + ControlRequest::OverlayLeave { name } => { + geth_overlay::validate_overlay_name(&name)?; + Ok(ControlResponse::OverlayLeft { + name, + stopped: false, + note: "no overlay packet runtime is active in this prototype".to_owned(), + }) + } ControlRequest::CasAdd { path } => { let cas = LocalCas::new(node.paths.cas_dir()); let info = cas.add_path(&path)?; diff --git a/crates/geth-overlay/Cargo.toml b/crates/geth-overlay/Cargo.toml new file mode 100644 index 0000000..2570a13 --- /dev/null +++ b/crates/geth-overlay/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "geth-overlay" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +thiserror.workspace = true +geth-types = { path = "../geth-types" } diff --git a/crates/geth-overlay/src/lib.rs b/crates/geth-overlay/src/lib.rs new file mode 100644 index 0000000..a0c6128 --- /dev/null +++ b/crates/geth-overlay/src/lib.rs @@ -0,0 +1,182 @@ +use geth_types::ResourceId; +use serde::{Deserialize, Serialize}; +use std::net::Ipv4Addr; + +pub const DEFAULT_OVERLAY_CIDR: &str = "172.22.0.0/24"; +pub const OVERLAY_ALPN: &str = "/geth/overlay/1"; + +pub const CAPABILITY_JOIN: &str = "overlay.join"; +pub const CAPABILITY_ROUTE: &str = "overlay.route"; +pub const CAPABILITY_ADMIN: &str = "overlay.admin"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum OverlayState { + Planned, + Joined, + Running, + Stopped, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct OverlayPlan { + pub name: String, + pub resource: ResourceId, + pub cidr: String, + pub alpn: String, + pub capabilities: Vec, + pub discovery: String, + pub runtime: String, + pub security: Vec, + pub implementation_notes: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct OverlayJoinPlan { + pub plan: OverlayPlan, + pub enabled: bool, + pub note: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct OverlayPeer { + pub node_id: String, + pub endpoint_id: Option, + pub virtual_ip: Option, + pub state: OverlayState, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct OverlayNetworkStatus { + pub name: String, + pub resource: ResourceId, + pub cidr: String, + pub state: OverlayState, + pub virtual_ip: Option, + pub peers: Vec, + pub note: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum OverlayError { + #[error("invalid overlay name `{0}`")] + InvalidName(String), + #[error("invalid overlay IPv4 CIDR `{0}`")] + InvalidCidr(String), + #[error("overlay join requires a non-empty resource secret")] + EmptySecret, +} + +pub fn validate_overlay_name(name: &str) -> Result<(), OverlayError> { + if name.is_empty() + || name.len() > 63 + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + || name.starts_with('.') + || name.starts_with('-') + { + return Err(OverlayError::InvalidName(name.to_owned())); + } + Ok(()) +} + +pub fn validate_overlay_cidr(cidr: &str) -> Result<(), OverlayError> { + let (addr, prefix) = cidr + .split_once('/') + .ok_or_else(|| OverlayError::InvalidCidr(cidr.to_owned()))?; + addr.parse::() + .map_err(|_| OverlayError::InvalidCidr(cidr.to_owned()))?; + let prefix = prefix + .parse::() + .map_err(|_| OverlayError::InvalidCidr(cidr.to_owned()))?; + if prefix > 32 { + return Err(OverlayError::InvalidCidr(cidr.to_owned())); + } + Ok(()) +} + +pub fn validate_overlay_secret(secret: &str) -> Result<(), OverlayError> { + if secret.trim().is_empty() { + Err(OverlayError::EmptySecret) + } else { + Ok(()) + } +} + +#[must_use] +pub fn overlay_resource_id(name: &str) -> ResourceId { + ResourceId::new(format!("resource:overlay:{name}")) +} + +pub fn plan_overlay( + name: &str, + cidr: Option<&str>, + alpn: &str, +) -> Result { + validate_overlay_name(name)?; + let cidr = cidr.unwrap_or(DEFAULT_OVERLAY_CIDR); + validate_overlay_cidr(cidr)?; + Ok(OverlayPlan { + name: name.to_owned(), + resource: overlay_resource_id(name), + cidr: cidr.to_owned(), + alpn: alpn.to_owned(), + capabilities: vec![ + CAPABILITY_JOIN.to_owned(), + CAPABILITY_ROUTE.to_owned(), + CAPABILITY_ADMIN.to_owned(), + ], + discovery: "future overlay discovery may use mDNS, peer exchange, and resource metadata; discovery remains untrusted".to_owned(), + runtime: "planned only in this prototype; no TUN/Wintun interface is created".to_owned(), + security: vec![ + "all overlay packets must be carried over daemon-owned Iroh connections".to_owned(), + "knowing an EndpointID or overlay name must not grant overlay access".to_owned(), + "overlay membership must be resource-authorized with overlay.join/overlay.route capabilities".to_owned(), + "shared overlay secrets are resource-scoped and must not mutate node identity".to_owned(), + ], + implementation_notes: vec![ + "inspired by iroh-lan's Iroh-carried packet overlay".to_owned(), + "future packet runtime should register /geth/overlay/1 on the shared geth Iroh router".to_owned(), + "future host integration may need TUN/Wintun privileges and must remain explicitly opt-in".to_owned(), + ], + }) +} + +#[must_use] +pub fn overlay_status_note() -> &'static str { + "overlay runtime is scaffolded but inactive; use `geth overlay plan ` to inspect the intended resource and capabilities" +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_overlay_names() { + validate_overlay_name("home-lan").expect("valid name"); + validate_overlay_name("dev.mesh_1").expect("valid name"); + assert!(validate_overlay_name("").is_err()); + assert!(validate_overlay_name("../lan").is_err()); + assert!(validate_overlay_name("-lan").is_err()); + } + + #[test] + fn validates_overlay_cidrs() { + validate_overlay_cidr(DEFAULT_OVERLAY_CIDR).expect("valid cidr"); + validate_overlay_cidr("10.44.0.0/16").expect("valid cidr"); + assert!(validate_overlay_cidr("10.44.0.0").is_err()); + assert!(validate_overlay_cidr("10.44.0.0/33").is_err()); + assert!(validate_overlay_cidr("not-a-cidr").is_err()); + } + + #[test] + fn overlay_plan_is_iroh_only_and_resource_scoped() { + let plan = plan_overlay("home", None, OVERLAY_ALPN).expect("plan"); + assert_eq!(plan.resource, ResourceId::new("resource:overlay:home")); + assert_eq!(plan.cidr, DEFAULT_OVERLAY_CIDR); + assert!(plan.capabilities.contains(&CAPABILITY_JOIN.to_owned())); + assert!(plan.security.iter().any(|note| note.contains("Iroh"))); + assert!(plan.runtime.contains("planned only")); + } +} diff --git a/crates/geth-types/src/lib.rs b/crates/geth-types/src/lib.rs index 9e7be9a..4daf58e 100644 --- a/crates/geth-types/src/lib.rs +++ b/crates/geth-types/src/lib.rs @@ -53,6 +53,7 @@ string_id!(DocumentId); string_id!(KvId); string_id!(DbId); string_id!(PipeId); +string_id!(OverlayId); string_id!(TopicId); string_id!(AuthOpId); string_id!(KeyId); @@ -74,6 +75,7 @@ pub enum ResourceKind { Pubsub, Cas, SshProxy, + Overlay, } impl ResourceKind { @@ -87,6 +89,7 @@ impl ResourceKind { Self::Pubsub => "pubsub", Self::Cas => "cas", Self::SshProxy => "ssh-proxy", + Self::Overlay => "overlay", } } } @@ -109,6 +112,7 @@ impl std::str::FromStr for ResourceKind { "pubsub" => Ok(Self::Pubsub), "cas" => Ok(Self::Cas), "ssh-proxy" | "ssh" => Ok(Self::SshProxy), + "overlay" => Ok(Self::Overlay), _ => Err(TypeParseError::UnknownResourceKind(value.to_owned())), } } diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index c63c0fe..1043b7f 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -111,6 +111,55 @@ fn geth_init_in_temp_home() { ); } +#[test] +fn cli_help_documents_owner_init_keys() { + let home = tempfile::tempdir().expect("tempdir"); + let output = run_geth(home.path(), &["init", "--help"]); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("--admin-key ")); + assert!(stdout.contains("--signing-key ")); + assert!(stdout.contains("OpenSSH public key")); + assert!(stdout.contains("YubiKey")); + assert!(stdout.contains("both `--admin-key` and `--signing-key` are")); + assert!(stdout.contains("geth guide owner-setup")); +} + +#[test] +fn guide_command_explains_key_roles() { + let home = tempfile::tempdir().expect("tempdir"); + let output = run_geth(home.path(), &["guide", "keys"]); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("--admin-key")); + assert!(stdout.contains("trust anchor")); + assert!(stdout.contains("--signing-key")); + assert!(stdout.contains("ssh-keygen -Y sign")); +} + +#[test] +fn guide_command_explains_overlay_boundaries() { + let home = tempfile::tempdir().expect("tempdir"); + let output = run_geth(home.path(), &["guide", "overlay"]); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("geth overlay plan home")); + assert!(stdout.contains("TUN/Wintun")); + assert!(stdout.contains("all overlay packets must be carried over Iroh")); +} + #[test] fn owner_init_requires_admin_key_and_signing_key() { let home = tempfile::tempdir().expect("tempdir"); @@ -1378,6 +1427,31 @@ fn initialized_node_can_roundtrip_cas_blob() { ); } +#[test] +fn overlay_plan_is_available_without_starting_packet_runtime() { + let home = tempfile::tempdir().expect("tempdir"); + let paths = geth_config::GethPaths::from_home(home.path()); + let node = geth_node::init_node(&paths).expect("init node"); + + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::OverlayPlan { + name: "home-lan".to_owned(), + cidr: None, + }, + ) + .expect("overlay plan"); + match response { + geth_control::ControlResponse::OverlayPlanned { plan } => { + assert_eq!(plan.resource.to_string(), "resource:overlay:home-lan"); + assert_eq!(plan.alpn, "/geth/overlay/1"); + assert!(plan.capabilities.contains(&"overlay.join".to_owned())); + assert!(plan.runtime.contains("no TUN/Wintun")); + } + other => panic!("unexpected response: {other:?}"), + } +} + #[test] fn private_cas_blob_uses_resource_secret_epoch() { let home = tempfile::tempdir().expect("tempdir"); diff --git a/docs/adr/0017-optional-iroh-overlay-network.md b/docs/adr/0017-optional-iroh-overlay-network.md new file mode 100644 index 0000000..25d85e6 --- /dev/null +++ b/docs/adr/0017-optional-iroh-overlay-network.md @@ -0,0 +1,66 @@ +# ADR 0017: Optional Iroh Overlay Network + +## Status + +Accepted for prototype scaffolding. + +## Context + +`iroh-lan` demonstrates a useful pattern for a private packet overlay carried by +Iroh connections. That model is attractive for geth because it could make local +services, scripts, and devices reachable through stable mesh resources without +exposing host networking directly to untrusted discovery. + +Geth must keep its core invariants: + +- all remote geth communication is over Iroh +- the daemon owns the Iroh endpoint +- discovery is untrusted +- resource authorization gates access +- SSH is not a geth transport + +Packet overlays also carry host-network risk. TUN/Wintun setup is +platform-specific, may require privileges, and can route arbitrary traffic if +misconfigured. + +## Decision + +Add an optional `overlay` resource kind and reserve `/geth/overlay/1` for a +future Iroh-carried packet overlay. The initial implementation is a typed model +and CLI/control planning surface: + +- `geth overlay status` +- `geth overlay plan [--cidr ]` +- `geth overlay join --secret [--cidr ]` +- `geth overlay leave ` + +The prototype does not create TUN/Wintun interfaces, assign virtual IPs, or +route packets. Join/leave are explicit stubs that describe the intended +resource, capabilities, ALPN, and security boundaries. + +Overlay resources use capabilities: + +- `overlay.join` +- `overlay.route` +- `overlay.admin` + +Future overlay implementation must use the shared daemon-owned Iroh endpoint and +must not create a second endpoint or non-Iroh transport. + +## Consequences + +The CLI can now document and test the intended overlay shape without changing +host networking. This keeps the prototype safe to run on development machines +while preserving a clear path toward an iroh-lan-inspired overlay. + +Future work must add: + +- persisted overlay membership/resource metadata +- resource-secret-backed join authorization +- platform-specific opt-in TUN/Wintun management +- packet routing over `/geth/overlay/1` +- peer/IP coordination through trusted resource metadata and untrusted discovery + candidates + +No current code claims VPN-grade isolation, forward secrecy, or automatic host +network security. diff --git a/docs/architecture.md b/docs/architecture.md index b211bfd..0cdc526 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,7 +16,8 @@ as a privileged system service. Remote geth node-to-node communication is Iroh-only. The daemon will own one shared Iroh endpoint and register module protocols on ALPNs such as -`/geth/cas/1`, `/geth/kv/1`, `/geth/pipe/1`, and `/geth/ssh-proxy/1`. +`/geth/cas/1`, `/geth/kv/1`, `/geth/pipe/1`, `/geth/ssh-proxy/1`, and +`/geth/overlay/1`. The pinned Iroh integration uses `iroh = 0.95.1`. `geth-iroh` wraps `iroh::Endpoint::builder()`, configures geth ALPNs with `Builder::alpns`, uses @@ -150,6 +151,7 @@ Resource kinds: - `pubsub`: lossy notifications and presence - `cas`: content-addressed blobs - `ssh-proxy`: SSH/admin proxying over Iroh +- `overlay`: optional Iroh-carried packet overlay planning ## Module Overview @@ -244,6 +246,20 @@ message and broadcasting it through a deterministic native `iroh-gossip` topic. the gossip topic when the caller has `pubsub.subscribe`, and returns the peer's current daemon-lifetime snapshot. Private topics remain future work. +`geth-overlay` defines an optional packet-overlay plan inspired by `iroh-lan`. +The target runtime is a private L3-style overlay where packets from an explicit +TUN/Wintun interface are carried over the daemon-owned Iroh endpoint on +`/geth/overlay/1`. The overlay is a geth resource (`resource:overlay:`) +with `overlay.join`, `overlay.route`, and `overlay.admin` capabilities. The +prototype exposes `geth overlay status`, `geth overlay plan `, +`geth overlay join --secret `, and `geth overlay leave +` as planning/control stubs only. They validate names, CIDRs, resource +IDs, capabilities, and security notes, but they do not create host network +interfaces, assign virtual IPs, or route packets yet. Future implementation must +remain explicitly opt-in because TUN/Wintun setup may need platform-specific +privileges. Overlay discovery can use mDNS, peer exchange, and resource +metadata, but discovery remains untrusted and cannot grant overlay access. + `geth-pipe` currently supports `pipe listen/connect/send/recv` against a daemon-lifetime runtime. `geth pipe connect --node ` sends an authorized remote connect request over the protected Iroh control ALPN. The diff --git a/docs/roadmap.md b/docs/roadmap.md index 27495a5..c114741 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -135,6 +135,9 @@ Implementation order: - `[x]` CLI errors for stale peer cards, missing endpoint bindings, missing grants, unavailable relays, unavailable service managers, and unsupported platform features tell the operator what command to run next. + - `[x]` `geth init --help` and `geth guide ` explain owner setup, + enrollment, key roles, service installation, and smoke-test workflows from + the binary itself. - `[x]` `geth sync status --json` is sufficient for scripts to detect stale peers and failed streams. @@ -572,6 +575,29 @@ Goal: add authorized stream-oriented management workflows over Iroh. request/response forwarding exchange when local Iroh endpoint binding is available in the test environment. +- `[~]` Optional Iroh overlay network. + Acceptance criteria: + - `[x]` Add a focused `geth-overlay` crate for overlay names, CIDRs, + resource IDs, capabilities, status, and plan models. + - `[x]` Reserve `/geth/overlay/1` in the daemon-owned Iroh protocol router. + - `[x]` Add `overlay` as a resource kind and document capabilities: + `overlay.join`, `overlay.route`, and `overlay.admin`. + - `[x]` Add CLI/control commands for `geth overlay status`, `plan`, `join`, + and `leave`. + - `[x]` Prototype commands make clear that no TUN/Wintun interface is created + and no packets are routed yet. + - `[x]` Tests cover overlay validation and control serialization. + - `[ ]` Persist overlay network configuration and membership state as + resource metadata. + - `[ ]` Implement resource-authorized overlay join using resource secrets + without granting node identity. + - `[ ]` Add platform-specific, opt-in TUN/Wintun interface management with + generated-definition tests and no privileged test requirements. + - `[ ]` Route IPv4 packets over `/geth/overlay/1` using the shared daemon + Iroh endpoint. + - `[ ]` Add live peer/IP coordination over trusted resource metadata and + untrusted discovery candidates. + - `[~]` Unix socket forwarding where supported. Acceptance criteria: - `[x]` Unix socket forwarding is available on Unix platforms through