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

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