Add overlay planning commands
This commit is contained in:
parent
7d4a288729
commit
c2dee50dae
18 changed files with 928 additions and 16 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
/target/
|
||||
*.swp
|
||||
/references
|
||||
|
|
|
|||
11
Cargo.lock
generated
11
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
11
README.md
11
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 <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>]`
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
11
crates/geth-overlay/Cargo.toml
Normal file
11
crates/geth-overlay/Cargo.toml
Normal 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" }
|
||||
182
crates/geth-overlay/src/lib.rs
Normal file
182
crates/geth-overlay/src/lib.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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())),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
66
docs/adr/0017-optional-iroh-overlay-network.md
Normal file
66
docs/adr/0017-optional-iroh-overlay-network.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue