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

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
/target/
*.swp
/references

11
Cargo.lock generated
View file

@ -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"

View file

@ -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",

View file

@ -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 <public-key> --signing-key <private-key> --node-name <name>`
- `geth daemon run`
@ -110,6 +113,14 @@ The bootstrap implementation provides:
- `geth peer list`
- `geth peer ping <node-id>`
- `geth peer auth-check <node-id> <resource> <capability>`
- optional overlay-network planning:
`geth overlay status`,
`geth overlay plan <name> [--cidr 172.22.0.0/24]`,
`geth overlay join <name> --secret <resource-secret> [--cidr 172.22.0.0/24]`,
and `geth overlay leave <name>`. 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 <kind> <name>`
- `geth keychain init [--admin-key <path>] [--signing-key <path>]`

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");
}

View file

@ -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" }

View file

@ -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<String>,
},
OverlayJoin {
name: String,
secret: String,
cidr: Option<String>,
},
OverlayLeave {
name: String,
},
CasAdd {
path: PathBuf,
},
@ -472,6 +486,21 @@ pub enum ControlResponse {
ResourceCreated {
resource: ResourceDescriptor,
},
OverlayStatus {
networks: Vec<OverlayNetworkStatus>,
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"),

View file

@ -39,9 +39,18 @@ impl AgentKey {
}
pub fn load(path: &Path) -> Result<Self, CryptoError> {
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<u8>) -> 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!(

View file

@ -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> {
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]

View file

@ -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" }

View file

@ -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)?;

View file

@ -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" }

View file

@ -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<String>,
pub discovery: String,
pub runtime: String,
pub security: Vec<String>,
pub implementation_notes: Vec<String>,
}
#[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<String>,
pub virtual_ip: Option<String>,
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<String>,
pub peers: Vec<OverlayPeer>,
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::<Ipv4Addr>()
.map_err(|_| OverlayError::InvalidCidr(cidr.to_owned()))?;
let prefix = prefix
.parse::<u8>()
.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<OverlayPlan, OverlayError> {
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 <name>` 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"));
}
}

View file

@ -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())),
}
}

View file

@ -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 <OPENSSH_PUBLIC_KEY>"));
assert!(stdout.contains("--signing-key <OPENSSH_PRIVATE_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");

View file

@ -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 <name> [--cidr <cidr>]`
- `geth overlay join <name> --secret <resource-secret> [--cidr <cidr>]`
- `geth overlay leave <name>`
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.

View file

@ -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:<name>`)
with `overlay.join`, `overlay.route`, and `overlay.admin` capabilities. The
prototype exposes `geth overlay status`, `geth overlay plan <name>`,
`geth overlay join <name> --secret <resource-secret>`, and `geth overlay leave
<name>` 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 <name> --node <node-id>` sends an
authorized remote connect request over the protected Iroh control ALPN. The

View file

@ -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 <topic>` 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