Bootstrap geth Rust workspace

This commit is contained in:
Eric Wendland 2026-05-15 15:08:20 +02:00
commit 26f81ff1ef
73 changed files with 4835 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target/

1722
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

60
Cargo.toml Normal file
View file

@ -0,0 +1,60 @@
[workspace]
members = [
"crates/geth",
"crates/geth-cli",
"crates/geth-node",
"crates/geth-control",
"crates/geth-types",
"crates/geth-codec",
"crates/geth-config",
"crates/geth-store",
"crates/geth-crypto",
"crates/geth-ssh-identity",
"crates/geth-keychain",
"crates/geth-auth",
"crates/geth-secrets",
"crates/geth-resource",
"crates/geth-iroh",
"crates/geth-discovery",
"crates/geth-db",
"crates/geth-kv",
"crates/geth-pipe",
"crates/geth-document",
"crates/geth-pubsub",
"crates/geth-cas",
"crates/geth-ssh-proxy",
"crates/geth-testkit",
]
resolver = "3"
[workspace.package]
edition = "2024"
license = "MIT OR Apache-2.0"
repository = "https://example.invalid/local/geth"
rust-version = "1.85"
[workspace.dependencies]
anyhow = "1"
async-trait = "0.1"
base64 = "0.22"
blake3 = "1"
bytes = "1"
clap = { version = "4", features = ["derive", "env"] }
directories = "5"
ed25519-dalek = { version = "2", features = ["rand_core"] }
futures = "0.3"
hex = "0.4"
postcard = { version = "1", features = ["alloc"] }
rand_core = { version = "0.6", features = ["getrandom"] }
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tempfile = "3"
thiserror = "2"
time = { version = "0.3", features = ["formatting", "serde"] }
tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "signal", "time"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
[workspace.lints.rust]
unsafe_code = "forbid"

117
README.md Normal file
View file

@ -0,0 +1,117 @@
# geth
`geth` is a personal, local-first mesh runtime for scripts, devices, databases,
documents, blobs, pipes, and future multi-user collaboration.
This project is not the Ethereum `geth` client. The project and executable are
still named `geth`.
## One Binary
There is one executable: `geth`.
It has daemon mode and control mode:
```sh
geth init
geth daemon run
geth status
geth node id
geth resource list
geth cas add ./file
```
The daemon owns local identity, the future Iroh endpoint, trust state, resource
registry, module router, local metadata store, and synchronized data structures.
Most non-daemon commands talk to the daemon through a local Unix socket at
`$GETH_HOME/run/geth.sock`.
## Transport And SSH
All remote node-to-node geth communication is designed to happen over Iroh only.
SSH is not a geth transport backend, and there is no SSH fallback transport.
SSH keys are used as admin trust anchors and ecosystem integration points.
OpenSSH, FIDO, and YubiKey-backed keys can sign geth trust objects through
explicit namespaces such as `geth.keychain.v1@geth.local`. Future SSH proxying
may carry SSH protocol bytes over authorized Iroh streams, but the geth transport
remains Iroh.
## MVP Features
The bootstrap implementation provides:
- `geth init`
- `geth daemon run`
- `geth status`
- `geth node id`
- `geth resource list`
- `geth resource create <kind> <name>`
- `geth keychain status`
- `geth auth explain <subject> <resource> <capability>`
- local filesystem CAS commands: `add`, `get`, `hash`, `has`, `list`
Other command groups exist as explicit stubs: `db`, `kv`, `pipe`, `document`,
`pubsub`, `secret`, and `ssh`.
## Resource Modules
Everything meaningful is modeled as a resource. Planned resource kinds are:
- `db`: SQLite/cr-sqlite synchronization
- `kv`: Iroh Documents backed key-value stores
- `pipe`: dumbpipe-like byte streams over Iroh
- `document`: Automerge documents over Iroh streams
- `pubsub`: lossy notifications, not authoritative storage
- `cas`: content-addressed blob storage and distribution
- `ssh-proxy`: authorized SSH proxy/admin access over Iroh
Authorization is resource-scoped and capability-based. Bearer secrets may grant
specific resource capabilities but do not create trusted node identity.
## Local State
If `GETH_HOME` is set, geth uses it. Otherwise it uses an OS-specific data
directory. The bootstrap layout is:
```text
$GETH_HOME/
geth.sqlite
config.toml
identity/agent.ed25519
cas/blobs/
run/geth.sock
```
## Quick Start
In one shell:
```sh
export GETH_HOME="$(mktemp -d)"
cargo run -p geth -- init
cargo run -p geth -- daemon run
```
In another shell:
```sh
export GETH_HOME="<same dir>"
cargo run -p geth -- status
cargo run -p geth -- node id
echo "hello geth" > /tmp/hello-geth.txt
cargo run -p geth -- cas add /tmp/hello-geth.txt
cargo run -p geth -- cas list
```
## Authorization Direction
The MVP defines the split between:
- keychain: SSH-rooted users, devices, nodes, agents, and endpoint bindings
- auth: resource-local signed authorization operations and capability grants
- secrets: resource master secrets, epochs, envelopes, and bearer access
The current code does not implement Keyhive, BeeKEM, strong forward secrecy, or
post-compromise security. It leaves room for future local-first, replicated auth
logs and BeeKEM/CGKA-style group key evolution.

1
clippy.toml Normal file
View file

@ -0,0 +1 @@
avoid-breaking-exported-api = false

View file

@ -0,0 +1,14 @@
[package]
name = "geth-auth"
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" }
[dev-dependencies]
serde_json.workspace = true

View file

@ -0,0 +1,96 @@
use geth_types::{AuthOpId, Capability, GroupId, PrincipalId, ResourceId, SecretId, UnixMillis};
use serde::{Deserialize, Serialize};
pub const AUTH_SIGNATURE_NAMESPACE: &str = "geth.auth-op.v1@geth.local";
pub const RESOURCE_GRANT_SIGNATURE_NAMESPACE: &str = "geth.resource-grant.v1@geth.local";
pub const REVOCATION_SIGNATURE_NAMESPACE: &str = "geth.revocation.v1@geth.local";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthOp {
pub id: AuthOpId,
pub resource: ResourceId,
pub created_at: UnixMillis,
pub kind: AuthOpKind,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum AuthOpKind {
ResourceCreate,
ResourceAuthoritySet {
authority: ResourceId,
},
GrantCreate {
grant_id: String,
principal: PrincipalId,
capabilities: Vec<Capability>,
},
GrantRevoke {
grant_id: String,
},
BearerAccessCreate {
secret: SecretId,
capabilities: Vec<Capability>,
expires_at: Option<UnixMillis>,
},
BearerAccessRevoke {
secret: SecretId,
},
GroupCreate {
group: GroupId,
},
GroupAddMember {
group: GroupId,
principal: PrincipalId,
},
GroupRemoveMember {
group: GroupId,
principal: PrincipalId,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthExplanation {
pub subject: String,
pub resource: String,
pub capability: String,
pub allowed: bool,
pub reason: String,
pub evaluated_ops: usize,
}
impl AuthExplanation {
#[must_use]
pub fn stub(subject: String, resource: String, capability: String) -> Self {
Self {
subject,
resource,
capability,
allowed: false,
reason: "authorization logs are scaffolded; no grant reducer is active yet".to_owned(),
evaluated_ops: 0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auth_structs_roundtrip() {
let op = AuthOp {
id: "op:auth:1".into(),
resource: "resource:notes".into(),
created_at: UnixMillis(10),
kind: AuthOpKind::GrantCreate {
grant_id: "grant:1".to_owned(),
principal: "node:laptop".into(),
capabilities: vec!["kv.read".into(), "kv.write_prefix:apps/foo/".into()],
},
};
let json = serde_json::to_string(&op).expect("json");
let decoded: AuthOp = serde_json::from_str(&json).expect("decode");
assert_eq!(decoded, op);
}
}

View file

@ -0,0 +1,15 @@
[package]
name = "geth-cas"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
blake3.workspace = true
hex.workspace = true
thiserror.workspace = true
geth-types = { path = "../geth-types" }
[dev-dependencies]
tempfile.workspace = true

157
crates/geth-cas/src/lib.rs Normal file
View file

@ -0,0 +1,157 @@
use geth_types::BlobHash;
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
pub enum CasError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("invalid blob hash: {0}")]
InvalidHash(String),
#[error("blob not found: {0}")]
NotFound(String),
}
#[derive(Clone, Debug)]
pub struct LocalCas {
root: PathBuf,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BlobInfo {
pub hash: BlobHash,
pub size_bytes: u64,
pub path: PathBuf,
}
impl LocalCas {
#[must_use]
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn add_path(&self, path: &Path) -> Result<BlobInfo, CasError> {
let bytes = std::fs::read(path)?;
self.add_bytes(&bytes)
}
pub fn add_bytes(&self, bytes: &[u8]) -> Result<BlobInfo, CasError> {
let hash = hash_bytes(bytes);
let path = self.blob_path(&hash)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
if !path.exists() {
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes)?;
std::fs::rename(tmp, &path)?;
}
Ok(BlobInfo {
hash,
size_bytes: bytes.len() as u64,
path,
})
}
pub fn get_to_path(&self, hash: &BlobHash, out: &Path) -> Result<u64, CasError> {
let path = self.blob_path(hash)?;
if !path.exists() {
return Err(CasError::NotFound(hash.to_string()));
}
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(path, out).map_err(CasError::from)
}
pub fn has(&self, hash: &BlobHash) -> Result<bool, CasError> {
Ok(self.blob_path(hash)?.exists())
}
pub fn list(&self) -> Result<Vec<BlobInfo>, CasError> {
let blobs = self.root.join("blobs");
if !blobs.exists() {
return Ok(Vec::new());
}
let mut infos = Vec::new();
for first in std::fs::read_dir(blobs)? {
let first = first?;
if !first.file_type()?.is_dir() {
continue;
}
for second in std::fs::read_dir(first.path())? {
let second = second?;
if !second.file_type()?.is_dir() {
continue;
}
for entry in std::fs::read_dir(second.path())? {
let entry = entry?;
if !entry.file_type()?.is_file() {
continue;
}
let hash = entry.file_name().to_string_lossy().to_string();
if is_valid_hash(&hash) {
let meta = entry.metadata()?;
infos.push(BlobInfo {
hash: BlobHash::new(hash),
size_bytes: meta.len(),
path: entry.path(),
});
}
}
}
}
infos.sort_by(|a, b| a.hash.as_str().cmp(b.hash.as_str()));
Ok(infos)
}
pub fn blob_path(&self, hash: &BlobHash) -> Result<PathBuf, CasError> {
validate_hash(hash)?;
let hash = hash.as_str();
Ok(self
.root
.join("blobs")
.join(&hash[0..2])
.join(&hash[2..4])
.join(hash))
}
}
#[must_use]
pub fn hash_bytes(bytes: &[u8]) -> BlobHash {
BlobHash::new(blake3::hash(bytes).to_hex().to_string())
}
pub fn hash_path(path: &Path) -> Result<BlobHash, CasError> {
let bytes = std::fs::read(path)?;
Ok(hash_bytes(&bytes))
}
pub fn validate_hash(hash: &BlobHash) -> Result<(), CasError> {
if is_valid_hash(hash.as_str()) {
Ok(())
} else {
Err(CasError::InvalidHash(hash.to_string()))
}
}
#[must_use]
pub fn is_valid_hash(hash: &str) -> bool {
hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cas_add_get_has_list() {
let dir = tempfile::tempdir().expect("tempdir");
let cas = LocalCas::new(dir.path());
let info = cas.add_bytes(b"hello geth").expect("add");
assert!(cas.has(&info.hash).expect("has"));
assert_eq!(cas.list().expect("list").len(), 1);
let out = dir.path().join("out.txt");
cas.get_to_path(&info.hash, &out).expect("get");
assert_eq!(std::fs::read(out).expect("read"), b"hello geth");
}
}

View file

@ -0,0 +1,15 @@
[package]
name = "geth-cli"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
clap.workspace = true
serde_json.workspace = true
tokio.workspace = true
geth-config = { path = "../geth-config" }
geth-control = { path = "../geth-control" }
geth-node = { path = "../geth-node" }

365
crates/geth-cli/src/lib.rs Normal file
View file

@ -0,0 +1,365 @@
use anyhow::{Context, Result, bail};
use clap::{Args, Parser, Subcommand};
use geth_config::GethPaths;
use geth_control::{ControlRequest, ControlResponse};
use std::path::PathBuf;
#[derive(Debug, Parser)]
#[command(name = "geth", about = "Personal local-first Iroh mesh runtime")]
pub struct Cli {
#[arg(long, global = true)]
pub json: bool,
#[arg(long, global = true)]
pub jsonl: bool,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Init,
Daemon {
#[command(subcommand)]
command: DaemonCommand,
},
Status,
Node {
#[command(subcommand)]
command: NodeCommand,
},
Resource {
#[command(subcommand)]
command: ResourceCommand,
},
Keychain {
#[command(subcommand)]
command: KeychainCommand,
},
Auth {
#[command(subcommand)]
command: AuthCommand,
},
Secret {
#[command(subcommand)]
command: SecretCommand,
},
Cas {
#[command(subcommand)]
command: CasCommand,
},
Kv {
#[command(subcommand)]
command: KvCommand,
},
Pubsub {
#[command(subcommand)]
command: PubsubCommand,
},
Pipe {
#[command(subcommand)]
command: PipeCommand,
},
Db {
#[command(subcommand)]
command: DbCommand,
},
Document {
#[command(subcommand)]
command: DocumentCommand,
},
Ssh {
#[command(subcommand)]
command: SshCommand,
},
}
#[derive(Debug, Subcommand)]
pub enum DaemonCommand {
Run,
}
#[derive(Debug, Subcommand)]
pub enum NodeCommand {
Id,
Status,
}
#[derive(Debug, Subcommand)]
pub enum ResourceCommand {
List,
Create { kind: String, name: String },
}
#[derive(Debug, Subcommand)]
pub enum KeychainCommand {
Init,
Status,
}
#[derive(Debug, Subcommand)]
pub enum AuthCommand {
Explain {
subject: String,
resource: String,
capability: String,
},
}
#[derive(Debug, Subcommand)]
pub enum SecretCommand {
Status,
}
#[derive(Debug, Subcommand)]
pub enum CasCommand {
Add {
path: PathBuf,
},
Get {
hash: String,
#[arg(long)]
out: PathBuf,
},
Hash {
path: PathBuf,
},
Has {
hash: String,
},
List,
}
#[derive(Debug, Subcommand)]
pub enum KvCommand {
Create {
name: String,
},
Set {
name: String,
key: String,
value: String,
},
Get {
name: String,
key: String,
},
}
#[derive(Debug, Subcommand)]
pub enum PubsubCommand {
Pub { topic: String, message: String },
Sub { topic: String },
}
#[derive(Debug, Subcommand)]
pub enum PipeCommand {
Listen { name: String },
Connect { target: String },
}
#[derive(Debug, Subcommand)]
pub enum DbCommand {
Add { name: String, path: PathBuf },
Status { name: String },
}
#[derive(Debug, Subcommand)]
pub enum DocumentCommand {
Create { name: String },
Status { name: String },
}
#[derive(Debug, Subcommand)]
pub enum SshCommand {
Proxy { node: String },
}
#[derive(Debug, Args)]
pub struct EmptyArgs {}
pub async fn run() -> Result<()> {
let cli = Cli::parse();
let paths = GethPaths::resolve().context("resolve geth paths")?;
match cli.command {
Command::Init => {
let node = geth_node::init_node(&paths).context("initialize geth node")?;
println!("initialized geth home: {}", node.paths.home().display());
println!("agent: {}", node.agent_id);
println!("node: {}", node.node_id);
}
Command::Daemon {
command: DaemonCommand::Run,
} => {
geth_node::run_daemon(paths)
.await
.context("run geth daemon")?;
}
command => {
let request = request_for_command(command)?;
let response = geth_node::send_control(&paths, request)
.await
.with_context(|| {
format!("connect to daemon at {}", paths.socket_path().display())
})?;
print_response(response, cli.json || cli.jsonl)?;
}
}
Ok(())
}
fn request_for_command(command: Command) -> Result<ControlRequest> {
Ok(match command {
Command::Status => ControlRequest::Status,
Command::Node {
command: NodeCommand::Id,
} => ControlRequest::NodeId,
Command::Node {
command: NodeCommand::Status,
} => ControlRequest::Status,
Command::Resource {
command: ResourceCommand::List,
} => ControlRequest::ResourceList,
Command::Resource {
command: ResourceCommand::Create { kind, name },
} => ControlRequest::ResourceCreate { kind, name },
Command::Keychain {
command: KeychainCommand::Init,
} => ControlRequest::ModuleStub {
module: "keychain".to_owned(),
command: "init".to_owned(),
},
Command::Keychain {
command: KeychainCommand::Status,
} => ControlRequest::KeychainStatus,
Command::Auth {
command:
AuthCommand::Explain {
subject,
resource,
capability,
},
} => ControlRequest::AuthExplain {
subject,
resource,
capability,
},
Command::Secret { command } => ControlRequest::ModuleStub {
module: "secret".to_owned(),
command: format!("{command:?}"),
},
Command::Cas { command } => match command {
CasCommand::Add { path } => ControlRequest::CasAdd { path },
CasCommand::Get { hash, out } => ControlRequest::CasGet {
hash: hash.into(),
out,
},
CasCommand::Hash { path } => ControlRequest::CasHash { path },
CasCommand::Has { hash } => ControlRequest::CasHas { hash: hash.into() },
CasCommand::List => ControlRequest::CasList,
},
Command::Kv { command } => ControlRequest::ModuleStub {
module: "kv".to_owned(),
command: format!("{command:?}"),
},
Command::Pubsub { command } => ControlRequest::ModuleStub {
module: "pubsub".to_owned(),
command: format!("{command:?}"),
},
Command::Pipe { command } => ControlRequest::ModuleStub {
module: "pipe".to_owned(),
command: format!("{command:?}"),
},
Command::Db { command } => ControlRequest::ModuleStub {
module: "db".to_owned(),
command: format!("{command:?}"),
},
Command::Document { command } => ControlRequest::ModuleStub {
module: "document".to_owned(),
command: format!("{command:?}"),
},
Command::Ssh { command } => ControlRequest::ModuleStub {
module: "ssh-proxy".to_owned(),
command: format!("{command:?}"),
},
Command::Init | Command::Daemon { .. } => bail!("command is handled directly"),
})
}
fn print_response(response: ControlResponse, json: bool) -> Result<()> {
if json {
println!("{}", serde_json::to_string_pretty(&response)?);
return Ok(());
}
match response {
ControlResponse::Status(status) => {
println!("geth daemon: running");
println!("home: {}", status.home.display());
println!("socket: {}", status.socket.display());
println!("agent: {}", status.agent_id);
println!("node: {}", status.node_id);
println!("iroh: {}", status.iroh);
}
ControlResponse::NodeId(node) => {
println!("agent: {}", node.agent_id);
println!("node: {}", node.node_id);
println!(
"endpoint: {}",
node.endpoint_id
.as_deref()
.unwrap_or("not started in bootstrap")
);
}
ControlResponse::ResourceList { resources } => {
if resources.is_empty() {
println!("no resources");
} else {
for resource in resources {
println!("{}\t{}\t{}", resource.kind, resource.name, resource.id);
}
}
}
ControlResponse::ResourceCreated { resource } => {
println!(
"created resource: {} {} ({})",
resource.kind, resource.name, resource.id
);
}
ControlResponse::CasAdded { hash, size_bytes } => {
println!("{hash} {size_bytes} bytes");
}
ControlResponse::CasGot {
hash,
out,
size_bytes,
} => {
println!("wrote {hash} to {} ({size_bytes} bytes)", out.display());
}
ControlResponse::CasHash { hash } => println!("{hash}"),
ControlResponse::CasHas { hash, present } => println!("{hash}: {present}"),
ControlResponse::CasList { blobs } => {
for blob in blobs {
println!("{}\t{} bytes", blob.hash, blob.size_bytes);
}
}
ControlResponse::KeychainStatus(status) => {
println!("initialized: {}", status.initialized);
println!("admin_keys: {}", status.admin_keys);
println!("users: {}", status.users);
println!("devices: {}", status.devices);
println!("nodes: {}", status.nodes);
}
ControlResponse::AuthExplain(explain) => {
println!("allowed: {}", explain.allowed);
println!("subject: {}", explain.subject);
println!("resource: {}", explain.resource);
println!("capability: {}", explain.capability);
println!("reason: {}", explain.reason);
println!("evaluated_ops: {}", explain.evaluated_ops);
}
ControlResponse::NotImplemented { module, command } => {
println!("{module} {command}: not implemented yet");
}
ControlResponse::Error { message } => bail!(message),
}
Ok(())
}

View file

@ -0,0 +1,14 @@
[package]
name = "geth-codec"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
blake3.workspace = true
hex.workspace = true
postcard.workspace = true
serde.workspace = true
thiserror.workspace = true
geth-types = { path = "../geth-types" }

View file

@ -0,0 +1,62 @@
use serde::{Serialize, de::DeserializeOwned};
#[derive(Debug, thiserror::Error)]
pub enum CodecError {
#[error("canonical encoding failed: {0}")]
Encode(#[from] postcard::Error),
}
pub fn encode_canonical<T: Serialize + ?Sized>(value: &T) -> Result<Vec<u8>, CodecError> {
postcard::to_allocvec(value).map_err(CodecError::from)
}
pub fn decode_canonical<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, CodecError> {
postcard::from_bytes(bytes).map_err(CodecError::from)
}
pub fn hash_canonical<T: Serialize + ?Sized>(
value: &T,
) -> Result<geth_types::BlobHash, CodecError> {
let bytes = encode_canonical(value)?;
Ok(blake3_hash_bytes(&bytes))
}
#[must_use]
pub fn blake3_hash_bytes(bytes: &[u8]) -> geth_types::BlobHash {
geth_types::BlobHash::new(blake3::hash(bytes).to_hex().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
struct Sample {
version: u8,
name: String,
values: Vec<u16>,
}
#[test]
fn canonical_encoding_is_deterministic() {
let sample = Sample {
version: 1,
name: "geth".to_owned(),
values: vec![1, 2, 3],
};
assert_eq!(
encode_canonical(&sample).expect("encode"),
encode_canonical(&sample).expect("encode again")
);
assert_eq!(
hash_canonical(&sample).expect("hash"),
hash_canonical(&sample).expect("hash again")
);
assert_eq!(
decode_canonical::<Sample>(&encode_canonical(&sample).expect("encode"))
.expect("decode"),
sample
);
}
}

View file

@ -0,0 +1,11 @@
[package]
name = "geth-config"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
directories.workspace = true
serde.workspace = true
thiserror.workspace = true

View file

@ -0,0 +1,81 @@
use std::path::{Path, PathBuf};
#[derive(Clone, Debug)]
pub struct GethPaths {
home: PathBuf,
}
impl GethPaths {
pub fn resolve() -> Result<Self, ConfigError> {
if let Some(home) = std::env::var_os("GETH_HOME") {
return Ok(Self {
home: PathBuf::from(home),
});
}
let project_dirs = directories::ProjectDirs::from("local", "geth", "geth")
.ok_or(ConfigError::NoDataDirectory)?;
Ok(Self {
home: project_dirs.data_dir().to_path_buf(),
})
}
#[must_use]
pub fn from_home(home: impl Into<PathBuf>) -> Self {
Self { home: home.into() }
}
#[must_use]
pub fn home(&self) -> &Path {
&self.home
}
#[must_use]
pub fn config_file(&self) -> PathBuf {
self.home.join("config.toml")
}
#[must_use]
pub fn metadata_db(&self) -> PathBuf {
self.home.join("geth.sqlite")
}
#[must_use]
pub fn identity_dir(&self) -> PathBuf {
self.home.join("identity")
}
#[must_use]
pub fn agent_key(&self) -> PathBuf {
self.identity_dir().join("agent.ed25519")
}
#[must_use]
pub fn cas_dir(&self) -> PathBuf {
self.home.join("cas")
}
#[must_use]
pub fn run_dir(&self) -> PathBuf {
self.home.join("run")
}
#[must_use]
pub fn socket_path(&self) -> PathBuf {
self.run_dir().join("geth.sock")
}
pub fn ensure_base_dirs(&self) -> Result<(), ConfigError> {
std::fs::create_dir_all(self.identity_dir())?;
std::fs::create_dir_all(self.cas_dir().join("blobs"))?;
std::fs::create_dir_all(self.run_dir())?;
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("could not determine OS data directory and GETH_HOME is unset")]
NoDataDirectory,
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}

View file

@ -0,0 +1,14 @@
[package]
name = "geth-control"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
geth-auth = { path = "../geth-auth" }
geth-resource = { path = "../geth-resource" }
geth-types = { path = "../geth-types" }

View file

@ -0,0 +1,164 @@
use geth_auth::AuthExplanation;
use geth_resource::ResourceDescriptor;
use geth_types::BlobHash;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ControlRequest {
Status,
NodeId,
ResourceList,
ResourceCreate {
kind: String,
name: String,
},
CasAdd {
path: PathBuf,
},
CasGet {
hash: BlobHash,
out: PathBuf,
},
CasHash {
path: PathBuf,
},
CasHas {
hash: BlobHash,
},
CasList,
KeychainStatus,
AuthExplain {
subject: String,
resource: String,
capability: String,
},
ModuleStub {
module: String,
command: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ControlResponse {
Status(StatusResponse),
NodeId(NodeIdResponse),
ResourceList {
resources: Vec<ResourceDescriptor>,
},
ResourceCreated {
resource: ResourceDescriptor,
},
CasAdded {
hash: BlobHash,
size_bytes: u64,
},
CasGot {
hash: BlobHash,
out: PathBuf,
size_bytes: u64,
},
CasHash {
hash: BlobHash,
},
CasHas {
hash: BlobHash,
present: bool,
},
CasList {
blobs: Vec<CasBlob>,
},
KeychainStatus(KeychainStatusResponse),
AuthExplain(AuthExplanation),
NotImplemented {
module: String,
command: String,
},
Error {
message: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatusResponse {
pub home: PathBuf,
pub socket: PathBuf,
pub agent_id: String,
pub node_id: String,
pub iroh: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeIdResponse {
pub agent_id: String,
pub node_id: String,
pub endpoint_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainStatusResponse {
pub initialized: bool,
pub admin_keys: usize,
pub users: usize,
pub devices: usize,
pub nodes: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CasBlob {
pub hash: BlobHash,
pub size_bytes: u64,
}
#[derive(Debug, thiserror::Error)]
pub enum ControlError {
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
}
pub fn encode_request(request: &ControlRequest) -> Result<String, ControlError> {
let mut line = serde_json::to_string(request)?;
line.push('\n');
Ok(line)
}
pub fn decode_request(line: &str) -> Result<ControlRequest, ControlError> {
serde_json::from_str(line).map_err(ControlError::from)
}
pub fn encode_response(response: &ControlResponse) -> Result<String, ControlError> {
let mut line = serde_json::to_string(response)?;
line.push('\n');
Ok(line)
}
pub fn decode_response(line: &str) -> Result<ControlResponse, ControlError> {
serde_json::from_str(line).map_err(ControlError::from)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn control_request_response_serialization_roundtrip() {
let request = ControlRequest::CasHas {
hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
};
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request
);
let response = ControlResponse::CasHas {
hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
present: true,
};
assert_eq!(
decode_response(&encode_response(&response).expect("encode")).expect("decode"),
response
);
}
}

View file

@ -0,0 +1,18 @@
[package]
name = "geth-crypto"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
blake3.workspace = true
ed25519-dalek.workspace = true
hex.workspace = true
rand_core.workspace = true
serde.workspace = true
thiserror.workspace = true
geth-types = { path = "../geth-types" }
[dev-dependencies]
tempfile.workspace = true

View file

@ -0,0 +1,118 @@
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rand_core::OsRng;
use std::path::Path;
#[derive(Debug, thiserror::Error)]
pub enum CryptoError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("invalid hex key: {0}")]
Hex(#[from] hex::FromHexError),
#[error("invalid ed25519 key material")]
InvalidKey,
#[error("signature verification failed")]
Verify,
}
pub struct AgentKey {
signing_key: SigningKey,
}
impl AgentKey {
#[must_use]
pub fn generate() -> Self {
Self {
signing_key: SigningKey::generate(&mut OsRng),
}
}
pub fn load_or_create(path: &Path) -> Result<Self, CryptoError> {
if path.exists() {
return Self::load(path);
}
let key = Self::generate();
key.save(path)?;
Ok(key)
}
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)?;
Ok(Self {
signing_key: SigningKey::from_bytes(&key_bytes),
})
}
pub fn save(&self, path: &Path) -> Result<(), CryptoError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, hex::encode(self.signing_key.to_bytes()))?;
std::fs::rename(tmp, path)?;
Ok(())
}
#[must_use]
pub fn verifying_key(&self) -> VerifyingKey {
self.signing_key.verifying_key()
}
#[must_use]
pub fn public_key_hex(&self) -> String {
hex::encode(self.verifying_key().to_bytes())
}
#[must_use]
pub fn agent_id(&self) -> geth_types::AgentId {
geth_types::AgentId::new(key_fingerprint(&self.verifying_key().to_bytes()))
}
#[must_use]
pub fn sign(&self, bytes: &[u8]) -> Vec<u8> {
self.signing_key.sign(bytes).to_bytes().to_vec()
}
}
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 =
VerifyingKey::from_bytes(&key_bytes).map_err(|_| CryptoError::InvalidKey)?;
let sig = Signature::from_slice(signature).map_err(|_| CryptoError::InvalidKey)?;
verifying_key
.verify(message, &sig)
.map_err(|_| CryptoError::Verify)
}
#[must_use]
pub fn blake3_hex(bytes: &[u8]) -> String {
blake3::hash(bytes).to_hex().to_string()
}
#[must_use]
pub fn key_fingerprint(public_key: &[u8]) -> String {
format!("ed25519:{}", blake3_hex(public_key))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_identity_persists() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("agent.ed25519");
let first = AgentKey::load_or_create(&path).expect("create");
let second = AgentKey::load_or_create(&path).expect("load");
assert_eq!(first.agent_id(), second.agent_id());
}
#[test]
fn blake3_helper_matches_known_hash() {
assert_eq!(
blake3_hex(b"hello geth"),
"3a4aa805ade0d4694a1bb69ad5b9a2f1dffcd4de2136df9a465023722d26e325"
);
}
}

10
crates/geth-db/Cargo.toml Normal file
View file

@ -0,0 +1,10 @@
[package]
name = "geth-db"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
geth-types = { path = "../geth-types" }

15
crates/geth-db/src/lib.rs Normal file
View file

@ -0,0 +1,15 @@
use geth_types::{DbId, ResourceId};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DbResource {
pub id: DbId,
pub resource: ResourceId,
pub path: String,
pub sync_status: String,
}
#[must_use]
pub fn crsqlite_sync_roadmap() -> &'static str {
"future db sync reads crsql_changes, exchanges changes over Iroh, and applies through crsql_changes"
}

View file

@ -0,0 +1,10 @@
[package]
name = "geth-discovery"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
geth-types = { path = "../geth-types" }

View file

@ -0,0 +1,18 @@
use geth_types::NodeId;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerCard {
pub node: NodeId,
pub endpoints: Vec<String>,
pub signed_by: String,
}
pub trait DiscoveryBackend {
fn candidates(&self) -> Vec<PeerCard>;
}
#[must_use]
pub fn discovery_is_untrusted_note() -> &'static str {
"discovery returns candidate peers only and never grants trust or authorization"
}

View file

@ -0,0 +1,10 @@
[package]
name = "geth-document"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
geth-types = { path = "../geth-types" }

View file

@ -0,0 +1,14 @@
use geth_types::{DocumentId, ResourceId};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DocumentResource {
pub id: DocumentId,
pub resource: ResourceId,
pub name: String,
}
#[must_use]
pub fn automerge_roadmap() -> &'static str {
"future documents use Automerge sync over Iroh with resource-local authorization"
}

View file

@ -0,0 +1,9 @@
[package]
name = "geth-iroh"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true

View file

@ -0,0 +1,28 @@
use serde::{Deserialize, Serialize};
pub const ALPN_CONTROL: &[u8] = b"/geth/control/1";
pub const ALPN_KV: &[u8] = b"/geth/kv/1";
pub const ALPN_CAS: &[u8] = b"/geth/cas/1";
pub const ALPN_PUBSUB: &[u8] = b"/geth/pubsub/1";
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";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EndpointStatus {
pub enabled: bool,
pub endpoint_id: Option<String>,
pub note: String,
}
impl EndpointStatus {
#[must_use]
pub fn scaffolded() -> Self {
Self {
enabled: false,
endpoint_id: None,
note: "Iroh endpoint integration is scaffolded for a later pinned API pass".to_owned(),
}
}
}

View file

@ -0,0 +1,14 @@
[package]
name = "geth-keychain"
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" }
[dev-dependencies]
serde_json.workspace = true

View file

@ -0,0 +1,137 @@
use geth_types::{AgentId, DeviceId, KeyId, NodeId, UnixMillis, UserId};
use serde::{Deserialize, Serialize};
pub const KEYCHAIN_SIGNATURE_NAMESPACE: &str = "geth.keychain.v1@geth.local";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignedKeychainOp {
pub op: KeychainOp,
pub signer: KeyId,
pub signature_namespace: String,
pub signature: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainOp {
pub id: geth_types::AuthOpId,
pub created_at: UnixMillis,
pub kind: KeychainOpKind,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum KeychainOpKind {
KeychainInit,
AdminKeyAdd {
key: KeyId,
},
AdminKeyRevoke {
key: KeyId,
},
UserAdd {
user: UserId,
name: String,
},
UserRename {
user: UserId,
name: String,
},
UserRevoke {
user: UserId,
},
DeviceAdd {
device: DeviceId,
user: UserId,
},
DeviceRevoke {
device: DeviceId,
},
DeviceKeyAdd {
device: DeviceId,
key: KeyId,
},
DeviceKeyRevoke {
device: DeviceId,
key: KeyId,
},
NodeAdd {
node: NodeId,
device: DeviceId,
name: String,
},
NodeRename {
node: NodeId,
name: String,
},
NodeRevoke {
node: NodeId,
},
NodeEndpointAdd {
node: NodeId,
endpoint: String,
},
NodeEndpointRevoke {
node: NodeId,
endpoint: String,
},
AgentBind {
agent: AgentId,
node: NodeId,
},
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainView {
pub initialized: bool,
pub admin_keys: Vec<KeyId>,
pub users: Vec<UserId>,
pub devices: Vec<DeviceId>,
pub nodes: Vec<NodeId>,
}
pub fn reduce_keychain_ops(ops: &[KeychainOp]) -> KeychainView {
let mut view = KeychainView::default();
for op in ops {
match &op.kind {
KeychainOpKind::KeychainInit => view.initialized = true,
KeychainOpKind::AdminKeyAdd { key } if !view.admin_keys.contains(key) => {
view.admin_keys.push(key.clone());
}
KeychainOpKind::AdminKeyRevoke { key } => view.admin_keys.retain(|item| item != key),
KeychainOpKind::UserAdd { user, .. } if !view.users.contains(user) => {
view.users.push(user.clone());
}
KeychainOpKind::UserRevoke { user } => view.users.retain(|item| item != user),
KeychainOpKind::DeviceAdd { device, .. } if !view.devices.contains(device) => {
view.devices.push(device.clone());
}
KeychainOpKind::DeviceRevoke { device } => view.devices.retain(|item| item != device),
KeychainOpKind::NodeAdd { node, .. } if !view.nodes.contains(node) => {
view.nodes.push(node.clone());
}
KeychainOpKind::NodeRevoke { node } => view.nodes.retain(|item| item != node),
_ => {}
}
}
view
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keychain_structs_roundtrip() {
let op = KeychainOp {
id: "op:1".into(),
created_at: UnixMillis(1),
kind: KeychainOpKind::UserAdd {
user: "user:eric".into(),
name: "Eric".to_owned(),
},
};
let json = serde_json::to_string(&op).expect("json");
let decoded: KeychainOp = serde_json::from_str(&json).expect("decode");
assert_eq!(decoded, op);
}
}

10
crates/geth-kv/Cargo.toml Normal file
View file

@ -0,0 +1,10 @@
[package]
name = "geth-kv"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
geth-types = { path = "../geth-types" }

14
crates/geth-kv/src/lib.rs Normal file
View file

@ -0,0 +1,14 @@
use geth_types::{KvId, ResourceId};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KvResource {
pub id: KvId,
pub resource: ResourceId,
pub name: String,
}
#[must_use]
pub fn iroh_docs_roadmap() -> &'static str {
"future kv storage uses Iroh Documents namespaces with prefix-scoped authorization"
}

View file

@ -0,0 +1,20 @@
[package]
name = "geth-node"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
tracing.workspace = true
geth-auth = { path = "../geth-auth" }
geth-cas = { path = "../geth-cas" }
geth-config = { path = "../geth-config" }
geth-control = { path = "../geth-control" }
geth-crypto = { path = "../geth-crypto" }
geth-resource = { path = "../geth-resource" }
geth-store = { path = "../geth-store" }
geth-types = { path = "../geth-types" }

244
crates/geth-node/src/lib.rs Normal file
View file

@ -0,0 +1,244 @@
use geth_auth::AuthExplanation;
use geth_cas::{LocalCas, hash_path};
use geth_config::GethPaths;
use geth_control::{
CasBlob, ControlRequest, ControlResponse, KeychainStatusResponse, NodeIdResponse,
StatusResponse,
};
use geth_crypto::AgentKey;
use geth_resource::ResourceDescriptor;
use geth_store::{Store, StoredResource};
use geth_types::{ResourceId, ResourceKind, ResourceName};
use std::path::Path;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
#[derive(Debug, thiserror::Error)]
pub enum NodeError {
#[error("config error: {0}")]
Config(#[from] geth_config::ConfigError),
#[error("crypto error: {0}")]
Crypto(#[from] geth_crypto::CryptoError),
#[error("store error: {0}")]
Store(#[from] geth_store::StoreError),
#[error("cas error: {0}")]
Cas(#[from] geth_cas::CasError),
#[error("control error: {0}")]
Control(#[from] geth_control::ControlError),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("invalid resource kind: {0}")]
InvalidResourceKind(String),
}
#[derive(Clone, Debug)]
pub struct LocalNode {
pub paths: GethPaths,
pub agent_id: String,
pub node_id: String,
}
pub fn init_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
paths.ensure_base_dirs()?;
if !paths.config_file().exists() {
std::fs::write(
paths.config_file(),
"# geth local node config\n# Remote node-to-node communication is Iroh-only.\n",
)?;
}
let key = AgentKey::load_or_create(&paths.agent_key())?;
let agent_id = key.agent_id().to_string();
let node_id = stable_node_id(&agent_id);
let store = Store::open(&paths.metadata_db())?;
store.upsert_agent(&agent_id, &key.public_key_hex())?;
store.upsert_node(&node_id, "local", &agent_id)?;
store.insert_resource(&StoredResource {
resource_id: "resource:cas:local".to_owned(),
kind: ResourceKind::Cas.to_string(),
name: "local-cas".to_owned(),
status: "active".to_owned(),
})?;
Ok(LocalNode {
paths: paths.clone(),
agent_id,
node_id,
})
}
pub fn open_node(paths: &GethPaths) -> Result<LocalNode, NodeError> {
init_node(paths)
}
pub async fn run_daemon(paths: GethPaths) -> Result<(), NodeError> {
let node = init_node(&paths)?;
if Path::new(&paths.socket_path()).exists() {
std::fs::remove_file(paths.socket_path())?;
}
let listener = UnixListener::bind(paths.socket_path())?;
tracing::info!(socket = %paths.socket_path().display(), "geth daemon listening");
loop {
let (stream, _) = listener.accept().await?;
let node = node.clone();
tokio::spawn(async move {
if let Err(error) = handle_stream(node, stream).await {
tracing::warn!(%error, "control request failed");
}
});
}
}
pub async fn send_control(
paths: &GethPaths,
request: ControlRequest,
) -> Result<ControlResponse, NodeError> {
let mut stream = UnixStream::connect(paths.socket_path()).await?;
stream
.write_all(geth_control::encode_request(&request)?.as_bytes())
.await?;
stream.shutdown().await?;
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).await?;
Ok(geth_control::decode_response(&line)?)
}
async fn handle_stream(node: LocalNode, stream: UnixStream) -> Result<(), NodeError> {
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).await?;
let request = geth_control::decode_request(&line)?;
let response = match handle_request(&node, request) {
Ok(response) => response,
Err(error) => ControlResponse::Error {
message: error.to_string(),
},
};
let mut stream = reader.into_inner();
stream
.write_all(geth_control::encode_response(&response)?.as_bytes())
.await?;
Ok(())
}
pub fn handle_request(
node: &LocalNode,
request: ControlRequest,
) -> Result<ControlResponse, NodeError> {
let store = Store::open(&node.paths.metadata_db())?;
match request {
ControlRequest::Status => Ok(ControlResponse::Status(StatusResponse {
home: node.paths.home().to_path_buf(),
socket: node.paths.socket_path(),
agent_id: node.agent_id.clone(),
node_id: node.node_id.clone(),
iroh: "scaffolded; no remote endpoint is started in bootstrap".to_owned(),
})),
ControlRequest::NodeId => Ok(ControlResponse::NodeId(NodeIdResponse {
agent_id: node.agent_id.clone(),
node_id: node.node_id.clone(),
endpoint_id: None,
})),
ControlRequest::ResourceList => Ok(ControlResponse::ResourceList {
resources: store
.list_resources()?
.into_iter()
.map(stored_resource_to_descriptor)
.collect::<Result<Vec<_>, _>>()?,
}),
ControlRequest::ResourceCreate { kind, name } => {
let kind = kind
.parse::<ResourceKind>()
.map_err(|_| NodeError::InvalidResourceKind(kind.clone()))?;
let id = format!("resource:{}:{}", kind, name);
let stored = StoredResource {
resource_id: id,
kind: kind.to_string(),
name,
status: "active".to_owned(),
};
store.insert_resource(&stored)?;
Ok(ControlResponse::ResourceCreated {
resource: stored_resource_to_descriptor(stored)?,
})
}
ControlRequest::CasAdd { path } => {
let cas = LocalCas::new(node.paths.cas_dir());
let info = cas.add_path(&path)?;
store.record_cas_object(
info.hash.as_str(),
info.size_bytes,
&info.path.to_string_lossy(),
)?;
Ok(ControlResponse::CasAdded {
hash: info.hash,
size_bytes: info.size_bytes,
})
}
ControlRequest::CasGet { hash, out } => {
let cas = LocalCas::new(node.paths.cas_dir());
let size_bytes = cas.get_to_path(&hash, &out)?;
Ok(ControlResponse::CasGot {
hash,
out,
size_bytes,
})
}
ControlRequest::CasHash { path } => Ok(ControlResponse::CasHash {
hash: hash_path(&path)?,
}),
ControlRequest::CasHas { hash } => {
let cas = LocalCas::new(node.paths.cas_dir());
let present = cas.has(&hash)?;
Ok(ControlResponse::CasHas { hash, present })
}
ControlRequest::CasList => {
let cas = LocalCas::new(node.paths.cas_dir());
Ok(ControlResponse::CasList {
blobs: cas
.list()?
.into_iter()
.map(|blob| CasBlob {
hash: blob.hash,
size_bytes: blob.size_bytes,
})
.collect(),
})
}
ControlRequest::KeychainStatus => {
Ok(ControlResponse::KeychainStatus(KeychainStatusResponse {
initialized: false,
admin_keys: 0,
users: 0,
devices: 0,
nodes: 1,
}))
}
ControlRequest::AuthExplain {
subject,
resource,
capability,
} => Ok(ControlResponse::AuthExplain(AuthExplanation::stub(
subject, resource, capability,
))),
ControlRequest::ModuleStub { module, command } => {
Ok(ControlResponse::NotImplemented { module, command })
}
}
}
fn stored_resource_to_descriptor(stored: StoredResource) -> Result<ResourceDescriptor, NodeError> {
let kind = stored
.kind
.parse::<ResourceKind>()
.map_err(|_| NodeError::InvalidResourceKind(stored.kind.clone()))?;
Ok(ResourceDescriptor::local(
ResourceId::new(stored.resource_id),
kind,
ResourceName::new(stored.name),
))
}
fn stable_node_id(agent_id: &str) -> String {
format!("node:{agent_id}")
}

View file

@ -0,0 +1,10 @@
[package]
name = "geth-pipe"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
geth-types = { path = "../geth-types" }

View file

@ -0,0 +1,14 @@
use geth_types::{PipeId, ResourceId};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PipeResource {
pub id: PipeId,
pub resource: ResourceId,
pub name: String,
}
#[must_use]
pub fn pipe_roadmap() -> &'static str {
"future pipes are authorized Iroh bidirectional streams for stdin/stdout and forwarding"
}

View file

@ -0,0 +1,10 @@
[package]
name = "geth-pubsub"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
geth-types = { path = "../geth-types" }

View file

@ -0,0 +1,14 @@
use geth_types::{ResourceId, TopicId};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PubsubTopic {
pub id: TopicId,
pub resource: ResourceId,
pub name: String,
}
#[must_use]
pub fn pubsub_storage_warning() -> &'static str {
"pubsub is lossy notification transport, not authoritative storage"
}

View file

@ -0,0 +1,14 @@
[package]
name = "geth-resource"
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" }
[dev-dependencies]
serde_json.workspace = true

View file

@ -0,0 +1,88 @@
use geth_types::{ResourceId, ResourceKind, ResourceName};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResourceDescriptor {
pub id: ResourceId,
pub kind: ResourceKind,
pub name: ResourceName,
pub authority: ResourceAuthorityRef,
pub local_role: LocalRole,
pub replication: ReplicationPolicy,
pub retention: RetentionPolicy,
pub status: ResourceStatus,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "kebab-case")]
pub enum ResourceAuthorityRef {
Local,
Resource(ResourceId),
External(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LocalRole {
Owner,
Replica,
Cache,
Stub,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ReplicationPolicy {
LocalOnly,
Manual,
Mesh,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RetentionPolicy {
Keep,
Cache,
Ephemeral,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ResourceStatus {
Active,
Stub,
Revoked,
}
impl ResourceDescriptor {
#[must_use]
pub fn local(id: ResourceId, kind: ResourceKind, name: ResourceName) -> Self {
Self {
id,
kind,
name,
authority: ResourceAuthorityRef::Local,
local_role: LocalRole::Owner,
replication: ReplicationPolicy::LocalOnly,
retention: RetentionPolicy::Keep,
status: ResourceStatus::Active,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resource_descriptor_serializes() {
let descriptor = ResourceDescriptor::local(
ResourceId::new("resource:notes"),
ResourceKind::Kv,
ResourceName::new("notes"),
);
let json = serde_json::to_string(&descriptor).expect("json");
let decoded: ResourceDescriptor = serde_json::from_str(&json).expect("decode");
assert_eq!(decoded, descriptor);
}
}

View file

@ -0,0 +1,11 @@
[package]
name = "geth-secrets"
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,47 @@
use geth_types::{Capability, PrincipalId, ResourceId, SecretId, UnixMillis};
use serde::{Deserialize, Serialize};
pub const RESOURCE_SECRET_SIGNATURE_NAMESPACE: &str = "geth.resource-secret.v1@geth.local";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResourceMasterSecret {
pub id: SecretId,
pub resource: ResourceId,
pub epoch: u64,
pub created_at: UnixMillis,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResourceKeyEnvelope {
pub secret: SecretId,
pub recipient: PrincipalId,
pub epoch: u64,
pub algorithm: String,
pub ciphertext: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BearerAccess {
pub secret: SecretId,
pub resource: ResourceId,
pub capabilities: Vec<Capability>,
pub expires_at: Option<UnixMillis>,
pub may_delegate: bool,
}
impl BearerAccess {
#[must_use]
pub fn resource_scoped(
secret: SecretId,
resource: ResourceId,
capabilities: Vec<Capability>,
) -> Self {
Self {
secret,
resource,
capabilities,
expires_at: None,
may_delegate: false,
}
}
}

View file

@ -0,0 +1,9 @@
[package]
name = "geth-ssh-identity"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
thiserror.workspace = true

View file

@ -0,0 +1,40 @@
use std::path::Path;
use std::process::Command;
pub const KEYCHAIN_NAMESPACE: &str = "geth.keychain.v1@geth.local";
pub const AUTH_OP_NAMESPACE: &str = "geth.auth-op.v1@geth.local";
pub const RESOURCE_GRANT_NAMESPACE: &str = "geth.resource-grant.v1@geth.local";
pub const RESOURCE_SECRET_NAMESPACE: &str = "geth.resource-secret.v1@geth.local";
pub const REVOCATION_NAMESPACE: &str = "geth.revocation.v1@geth.local";
#[derive(Debug, thiserror::Error)]
pub enum SshIdentityError {
#[error("ssh-keygen failed or is unavailable")]
SshKeygenUnavailable,
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
pub fn ensure_ssh_keygen_available() -> Result<(), SshIdentityError> {
let status = Command::new("ssh-keygen").arg("-?").status();
match status {
Ok(_) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Err(SshIdentityError::SshKeygenUnavailable)
}
Err(error) => Err(SshIdentityError::Io(error)),
}
}
pub fn sign_command(key_path: &Path, namespace: &str, input_path: &Path) -> Command {
let mut command = Command::new("ssh-keygen");
command
.arg("-Y")
.arg("sign")
.arg("-f")
.arg(key_path)
.arg("-n")
.arg(namespace)
.arg(input_path);
command
}

View file

@ -0,0 +1,10 @@
[package]
name = "geth-ssh-proxy"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
geth-types = { path = "../geth-types" }

View file

@ -0,0 +1,13 @@
use geth_types::{NodeId, ResourceId};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SshProxyTarget {
pub resource: ResourceId,
pub node: NodeId,
}
#[must_use]
pub fn ssh_proxy_roadmap() -> &'static str {
"future SSH proxy carries SSH protocol bytes over authorized Iroh streams; SSH is not a geth transport"
}

View file

@ -0,0 +1,13 @@
[package]
name = "geth-store"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
rusqlite.workspace = true
serde_json.workspace = true
thiserror.workspace = true
time.workspace = true
geth-types = { path = "../geth-types" }

View file

@ -0,0 +1,240 @@
use rusqlite::{Connection, params};
use std::path::Path;
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("sqlite error: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
}
pub struct Store {
conn: Connection,
}
impl Store {
pub fn open(path: &Path) -> Result<Self, StoreError> {
let conn = Connection::open(path)?;
let store = Self { conn };
store.migrate()?;
Ok(store)
}
pub fn open_memory() -> Result<Self, StoreError> {
let conn = Connection::open_in_memory()?;
let store = Self { conn };
store.migrate()?;
Ok(store)
}
pub fn migrate(&self) -> Result<(), StoreError> {
self.conn.execute_batch(
r#"
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS agents (
agent_id TEXT PRIMARY KEY,
public_key TEXT NOT NULL,
created_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS nodes (
node_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
agent_id TEXT,
created_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS resources (
resource_id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
name TEXT NOT NULL,
authority_ref TEXT NOT NULL,
local_role TEXT NOT NULL,
replication_policy TEXT NOT NULL,
retention_policy TEXT NOT NULL,
status TEXT NOT NULL,
created_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS keychain_ops (
op_id TEXT PRIMARY KEY,
op_json TEXT NOT NULL,
created_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS auth_ops (
op_id TEXT PRIMARY KEY,
resource_id TEXT NOT NULL,
op_json TEXT NOT NULL,
created_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS grants (
grant_id TEXT PRIMARY KEY,
resource_id TEXT NOT NULL,
principal_id TEXT NOT NULL,
capability TEXT NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS resource_secrets (
secret_id TEXT PRIMARY KEY,
resource_id TEXT NOT NULL,
epoch INTEGER NOT NULL,
status TEXT NOT NULL,
created_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS cas_objects (
hash TEXT PRIMARY KEY,
size_bytes INTEGER NOT NULL,
path TEXT NOT NULL,
created_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS cas_pins (
hash TEXT PRIMARY KEY,
pinned_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS kv_stores (
kv_id TEXT PRIMARY KEY,
resource_id TEXT NOT NULL,
name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS db_resources (
db_id TEXT PRIMARY KEY,
resource_id TEXT NOT NULL,
path TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS document_resources (
document_id TEXT PRIMARY KEY,
resource_id TEXT NOT NULL,
name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS peer_cards (
peer_id TEXT PRIMARY KEY,
card_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS module_state (
module TEXT PRIMARY KEY,
state_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
INSERT OR IGNORE INTO meta(key, value) VALUES ('schema_version', '1');
"#,
)?;
Ok(())
}
pub fn upsert_agent(&self, agent_id: &str, public_key: &str) -> Result<(), StoreError> {
self.conn.execute(
"INSERT OR IGNORE INTO agents(agent_id, public_key, created_at_ms) VALUES (?1, ?2, ?3)",
params![agent_id, public_key, now_ms()],
)?;
Ok(())
}
pub fn upsert_node(&self, node_id: &str, name: &str, agent_id: &str) -> Result<(), StoreError> {
self.conn.execute(
"INSERT OR IGNORE INTO nodes(node_id, name, agent_id, created_at_ms) VALUES (?1, ?2, ?3, ?4)",
params![node_id, name, agent_id, now_ms()],
)?;
Ok(())
}
pub fn list_resources(&self) -> Result<Vec<StoredResource>, StoreError> {
let mut stmt = self.conn.prepare(
"SELECT resource_id, kind, name, status FROM resources ORDER BY kind, name, resource_id",
)?;
let rows = stmt.query_map([], |row| {
Ok(StoredResource {
resource_id: row.get(0)?,
kind: row.get(1)?,
name: row.get(2)?,
status: row.get(3)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn insert_resource(&self, resource: &StoredResource) -> Result<(), StoreError> {
self.conn.execute(
r#"INSERT OR IGNORE INTO resources(
resource_id, kind, name, authority_ref, local_role, replication_policy,
retention_policy, status, created_at_ms
) VALUES (?1, ?2, ?3, 'local', 'owner', 'local-only', 'keep', ?4, ?5)"#,
params![
resource.resource_id,
resource.kind,
resource.name,
resource.status,
now_ms()
],
)?;
Ok(())
}
pub fn record_cas_object(
&self,
hash: &str,
size_bytes: u64,
path: &str,
) -> Result<(), StoreError> {
self.conn.execute(
"INSERT OR REPLACE INTO cas_objects(hash, size_bytes, path, created_at_ms) VALUES (?1, ?2, ?3, ?4)",
params![hash, size_bytes as i64, path, now_ms()],
)?;
Ok(())
}
pub fn list_cas_objects(&self) -> Result<Vec<CasObject>, StoreError> {
let mut stmt = self.conn.prepare(
"SELECT hash, size_bytes, path FROM cas_objects ORDER BY created_at_ms, hash",
)?;
let rows = stmt.query_map([], |row| {
Ok(CasObject {
hash: row.get(0)?,
size_bytes: row.get::<_, i64>(1)? as u64,
path: row.get(2)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredResource {
pub resource_id: String,
pub kind: String,
pub name: String,
pub status: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CasObject {
pub hash: String,
pub size_bytes: u64,
pub path: String,
}
#[must_use]
pub fn now_ms() -> i64 {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
i64::try_from(now.as_millis()).unwrap_or(i64::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn migrations_are_idempotent() {
let store = Store::open_memory().expect("open");
store.migrate().expect("migrate again");
store.migrate().expect("migrate third time");
let resources = store.list_resources().expect("resources");
assert!(resources.is_empty());
}
}

View file

@ -0,0 +1,11 @@
[package]
name = "geth-testkit"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
tempfile.workspace = true
geth-config = { path = "../geth-config" }
geth-node = { path = "../geth-node" }

View file

@ -0,0 +1,14 @@
use tempfile::TempDir;
pub struct TestHome {
_dir: TempDir,
pub paths: geth_config::GethPaths,
}
impl TestHome {
pub fn new() -> std::io::Result<Self> {
let dir = tempfile::tempdir()?;
let paths = geth_config::GethPaths::from_home(dir.path());
Ok(Self { _dir: dir, paths })
}
}

View file

@ -0,0 +1,10 @@
[package]
name = "geth-types"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
thiserror.workspace = true

View file

@ -0,0 +1,132 @@
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
macro_rules! string_id {
($name:ident) => {
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct $name(String);
impl $name {
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Display for $name {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for $name {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<&str> for $name {
fn from(value: &str) -> Self {
Self(value.to_owned())
}
}
};
}
string_id!(AgentId);
string_id!(UserId);
string_id!(DeviceId);
string_id!(NodeId);
string_id!(MemberId);
string_id!(GroupId);
string_id!(ResourceId);
string_id!(ResourceName);
string_id!(PrincipalId);
string_id!(Capability);
string_id!(BlobHash);
string_id!(DocumentId);
string_id!(KvId);
string_id!(DbId);
string_id!(PipeId);
string_id!(TopicId);
string_id!(AuthOpId);
string_id!(KeyId);
string_id!(SecretId);
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct UnixMillis(pub i64);
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ResourceKind {
Db,
Kv,
Pipe,
Document,
Pubsub,
Cas,
SshProxy,
}
impl ResourceKind {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Db => "db",
Self::Kv => "kv",
Self::Pipe => "pipe",
Self::Document => "document",
Self::Pubsub => "pubsub",
Self::Cas => "cas",
Self::SshProxy => "ssh-proxy",
}
}
}
impl Display for ResourceKind {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for ResourceKind {
type Err = TypeParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"db" => Ok(Self::Db),
"kv" => Ok(Self::Kv),
"pipe" => Ok(Self::Pipe),
"document" => Ok(Self::Document),
"pubsub" => Ok(Self::Pubsub),
"cas" => Ok(Self::Cas),
"ssh-proxy" | "ssh" => Ok(Self::SshProxy),
_ => Err(TypeParseError::UnknownResourceKind(value.to_owned())),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum TypeParseError {
#[error("unknown resource kind: {0}")]
UnknownResourceKind(String),
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", content = "id", rename_all = "kebab-case")]
pub enum Principal {
AdminKey(KeyId),
User(UserId),
Device(DeviceId),
Node(NodeId),
Agent(AgentId),
IrohEndpoint(String),
Group(GroupId),
Resource(ResourceId),
BearerSecret(SecretId),
}

22
crates/geth/Cargo.toml Normal file
View file

@ -0,0 +1,22 @@
[package]
name = "geth"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[[bin]]
name = "geth"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
tokio.workspace = true
tracing-subscriber.workspace = true
geth-cli = { path = "../geth-cli" }
[dev-dependencies]
geth-cas = { path = "../geth-cas" }
geth-config = { path = "../geth-config" }
geth-node = { path = "../geth-node" }
tempfile.workspace = true

8
crates/geth/src/main.rs Normal file
View file

@ -0,0 +1,8 @@
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_target(false)
.init();
geth_cli::run().await
}

View file

@ -0,0 +1,105 @@
use std::process::{Child, Command};
use std::time::{Duration, Instant};
fn unix_sockets_available(home: &std::path::Path) -> bool {
let probe = home.join("probe.sock");
match std::os::unix::net::UnixListener::bind(&probe) {
Ok(listener) => {
drop(listener);
let _ = std::fs::remove_file(probe);
true
}
Err(error) => {
eprintln!("skipping daemon socket test; Unix sockets unavailable: {error}");
false
}
}
}
fn geth_bin() -> &'static str {
env!("CARGO_BIN_EXE_geth")
}
fn run_geth(home: &std::path::Path, args: &[&str]) -> std::process::Output {
Command::new(geth_bin())
.env("GETH_HOME", home)
.args(args)
.output()
.expect("run geth")
}
fn spawn_daemon(home: &std::path::Path) -> Child {
Command::new(geth_bin())
.env("GETH_HOME", home)
.args(["daemon", "run"])
.spawn()
.expect("spawn daemon")
}
fn wait_for_socket(path: &std::path::Path) {
let started = Instant::now();
while started.elapsed() < Duration::from_secs(5) {
if path.exists() {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
panic!("socket did not appear: {}", path.display());
}
#[test]
fn geth_init_in_temp_home() {
let home = tempfile::tempdir().expect("tempdir");
let output = run_geth(home.path(), &["init"]);
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(home.path().join("geth.sqlite").exists());
assert!(home.path().join("identity/agent.ed25519").exists());
assert!(home.path().join("config.toml").exists());
}
#[test]
fn geth_status_against_running_daemon() {
let home = tempfile::tempdir().expect("tempdir");
if !unix_sockets_available(home.path()) {
return;
}
assert!(run_geth(home.path(), &["init"]).status.success());
let mut daemon = spawn_daemon(home.path());
wait_for_socket(&home.path().join("run/geth.sock"));
let output = run_geth(home.path(), &["status"]);
let _ = daemon.kill();
let _ = daemon.wait();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("geth daemon: running"));
assert!(stdout.contains("agent:"));
}
#[test]
fn initialized_node_can_roundtrip_cas_blob() {
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");
assert_eq!(node.paths.home(), home.path());
let cas = geth_cas::LocalCas::new(paths.cas_dir());
let output_path = home.path().join("output.txt");
let added = cas.add_bytes(b"hello geth integration").expect("add blob");
assert!(cas.has(&added.hash).expect("has blob"));
cas.get_to_path(&added.hash, &output_path)
.expect("get blob");
assert_eq!(
std::fs::read(output_path).expect("read output"),
b"hello geth integration"
);
}

View file

@ -0,0 +1,18 @@
# ADR 0000: Project Goals
## Status
Accepted.
## Decision
`geth` is a personal, local-first, script-friendly mesh runtime built as a Rust
monorepo around Iroh networking and resource-oriented modules.
It is not a blockchain, not a consensus system, not the Ethereum geth client,
and not a replacement for OpenSSH authentication.
## Consequences
The project favors local ownership, explicit resources, durable metadata,
capability-based authorization, and future multi-user collaboration.

View file

@ -0,0 +1,18 @@
# ADR 0001: Single Binary CLI And Daemon
## Status
Accepted.
## Decision
The project ships one executable: `geth`. It has daemon mode with
`geth daemon run` and control mode through commands such as `geth status`.
Control commands use a local Unix socket. There are no separate `gethd` or
`gethctl` binaries.
## Consequences
Packaging and user mental model stay simple. The daemon remains the owner of
identity, Iroh endpoint state, resources, and synchronized data structures.

View file

@ -0,0 +1,17 @@
# ADR 0002: Iroh-Only Remote Transport
## Status
Accepted.
## Decision
All remote geth node-to-node communication happens over Iroh. The daemon will
own one shared endpoint and register module protocols on explicit ALPNs.
There is no SSH transport fallback and no `ssh host gethd stdio` flow.
## Consequences
Transport behavior is coherent and resource authorization can be enforced before
opening module streams.

View file

@ -0,0 +1,18 @@
# ADR 0003: Resource Model
## Status
Accepted.
## Decision
Everything meaningful is modeled as a resource. Initial kinds are `db`, `kv`,
`pipe`, `document`, `pubsub`, `cas`, and `ssh-proxy`.
Resources have capability-scoped authorization. Roles may exist as bundles, but
capabilities are the underlying permission unit.
## Consequences
Resource APIs, auth logs, secrets, and future replication can share one model
without relying on global roles.

View file

@ -0,0 +1,18 @@
# ADR 0004: Keychain And Auth Model
## Status
Accepted.
## Decision
The keychain is SSH-rooted and models admin keys, users, devices, nodes, agents,
and endpoint bindings. Resource authorization is represented as signed operation
logs reduced into a current permissions view.
Mutable ACL blobs are not the primary source of truth.
## Consequences
The system can answer why an operation is allowed or denied and can evolve toward
local-first multi-user authorization.

View file

@ -0,0 +1,18 @@
# ADR 0005: Bearer Resource Secrets
## Status
Accepted.
## Decision
Bearer secrets may grant resource-specific capabilities. They do not create
trusted node identity and should not normally grant trust graph mutation rights.
Private resource access is modeled with resource master secret epochs. Revocation
is approximated by rotating to a new epoch and distributing it only to remaining
authorized devices.
## Consequences
Invites and temporary access can be practical without weakening node identity.

View file

@ -0,0 +1,16 @@
# ADR 0006: Keyhive/BeeKEM Roadmap
## Status
Accepted.
## Decision
The project will leave room for Keyhive-like local-first authorization and
BeeKEM/CGKA-style group key evolution later. The bootstrap does not implement
BeeKEM and does not claim strong forward secrecy or post-compromise security.
## Consequences
Types are versioned and capability-oriented, while advanced cryptography remains
out of scope for the MVP.

View file

@ -0,0 +1,15 @@
# ADR 0007: Synchronized Data Structures
## Status
Accepted.
## Decision
The planned synchronized structures are `db`, `kv`, `pipe`, `document`,
`pubsub`, `cas`, and `ssh-proxy`.
## Consequences
Each module can progress independently while sharing resource registration,
capability checks, daemon-owned Iroh endpoint access, and local metadata.

View file

@ -0,0 +1,16 @@
# ADR 0008: CAS And Blob Distribution
## Status
Accepted.
## Decision
CAS starts as local filesystem storage with BLAKE3 content hashes. Future work
adds iroh-blobs, provider tracking, pins, encrypted private blobs, collections,
manifests, and file sync tree objects.
## Consequences
The MVP has useful local blob commands while preserving a path to networked blob
distribution.

View file

@ -0,0 +1,16 @@
# ADR 0009: SSH Trust And SSH Proxy
## Status
Accepted.
## Decision
SSH keys are admin signing identities and ecosystem integration points. SSH is
not a geth transport. Future SSH proxy support will carry SSH protocol bytes over
authorized Iroh streams, and OpenSSH will still perform normal login auth.
## Consequences
Knowing an Iroh EndpointID is insufficient to reach sshd. Geth authorization must
allow `ssh_proxy.connect` before any SSH/admin endpoint is opened.

View file

@ -0,0 +1,16 @@
# ADR 0010: cr-sqlite DB Sync
## Status
Accepted.
## Decision
Database sync will use local SQLite databases with cr-sqlite. Changes from
`crsql_changes` will be exchanged over Iroh and applied through cr-sqlite, with
schema hash gating and resource authorization.
## Consequences
The MVP stores DB resource metadata and leaves synchronization as explicit future
work.

View file

@ -0,0 +1,15 @@
# ADR 0011: KV With Iroh Documents
## Status
Accepted.
## Decision
KV resources will use Iroh Documents namespaces for small replicated state,
configuration, metadata, preferences, and script state. Authorization can include
prefix-scoped capabilities.
## Consequences
The MVP exposes CLI shape and types while deferring iroh-docs API pinning.

View file

@ -0,0 +1,16 @@
# ADR 0012: Automerge Documents
## Status
Accepted.
## Decision
Document resources will use Automerge for JSON-like CRDT state synchronized over
Iroh streams. Documents may carry or delegate to their own resource-local auth
state.
## Consequences
This leaves a path to multi-user local-first documents without implementing the
full cryptographic model during bootstrap.

View file

@ -0,0 +1,19 @@
# ADR 0013: Pipe And Pubsub
## Status
Accepted.
## Decision
Pipe resources will use authorized Iroh streams for stdin/stdout-style byte
streams and forwarding. Pubsub will use lossy gossip for wakeups, presence,
status, and ephemeral notifications.
Pubsub is not authoritative storage. Important facts must live in durable
resources such as kv, document, db, CAS manifests, or future auth logs.
## Consequences
The modules can support ad hoc workflow and presence without confusing gossip
with durable state.

88
docs/architecture.md Normal file
View file

@ -0,0 +1,88 @@
# Architecture
`geth` is a single-binary local-first mesh runtime. One executable provides both
daemon mode and control mode. The daemon owns local identity, metadata storage,
the future shared Iroh endpoint, resource registry, module routing, and local
control socket. Control commands connect to the Unix socket and send typed JSONL
requests.
## Iroh-Only Remote Communication
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`.
SSH keys are not transport keys. They are admin trust anchors and signing
identities for keychain and authorization operations. SSH proxying, when added,
will carry SSH bytes over an authorized Iroh stream and will not make SSH a geth
transport backend.
## Resource Model
Everything meaningful is modeled as a resource. Resources have a kind, name,
authority reference, local role, replication policy, retention policy, and
status. Authorization is resource-scoped and capability-based.
Resource kinds:
- `db`: cr-sqlite-backed SQLite synchronization
- `kv`: Iroh Documents backed key-value data
- `pipe`: authorized byte streams and forwarding
- `document`: Automerge CRDT documents
- `pubsub`: lossy notifications and presence
- `cas`: content-addressed blobs
- `ssh-proxy`: SSH/admin proxying over Iroh
## Module Overview
`geth-cas` is implemented locally first using BLAKE3 hashes and filesystem blob
storage. Iroh-blobs, providers, encrypted blobs, manifests, and file sync trees
are future work.
`geth-kv`, `geth-db`, `geth-document`, `geth-pubsub`, `geth-pipe`, and
`geth-ssh-proxy` currently define types, command shape, and roadmap stubs.
## Keychain, Auth, And Secrets
The identity plane is `geth-keychain`: admin keys, users, devices, nodes, agents,
and endpoint bindings. Endpoint rotation must not destroy higher-level node
identity.
The authorization plane is `geth-auth`: resource-local signed operation logs,
grants, revocations, groups, and `auth explain`.
The payload access plane is `geth-secrets`: resource master secrets, epochs,
key envelopes, bearer secrets, and rotation. Revocation for private data is
modeled initially as secret epoch rotation.
## Multi-User Direction
The project is structured for future multi-user local-first authorization:
- authorization is replicated data, not one mutable ACL blob
- resources can carry or delegate to their own auth state
- users, devices, nodes, agents, and endpoints are separate principals
- capabilities are the underlying permission unit
- bearer access is resource-scoped and does not mutate the trust graph
- offline revocation is eventual
- encryption key distribution is part of authorization
## Keyhive/BeeKEM Roadmap
Resource secret epochs are the v0/v1 approximation for private payload access.
Later designs can add Keyhive-like convergent capabilities and BeeKEM/CGKA-style
group key evolution. The bootstrap does not implement BeeKEM and does not claim
strong forward secrecy or post-compromise security.
## Security Invariants
- All remote node-to-node communication is over Iroh.
- SSH is not a geth transport.
- SSH keys are admin trust anchors and signing identities.
- Agent/node keys handle routine local identity.
- Discovery is untrusted.
- Knowing an EndpointID does not grant access.
- Bearer secrets are resource-scoped capabilities.
- Bearer access does not imply trust graph mutation rights.
- Authorization is capability-based and resource-scoped.
- Network and control decoders treat input as untrusted.

57
docs/roadmap.md Normal file
View file

@ -0,0 +1,57 @@
# Roadmap
## Phase 0: Bootstrap
- single CLI
- local daemon
- local store
- local identity
- local CAS
- docs and ADRs
## Phase 1: Iroh Foundation
- Iroh endpoint
- peer cards
- peer discovery
- peer exchange
- basic authenticated peer connection
## Phase 2: Trust And Authorization
- SSH-admin-rooted keychain
- signed keychain ops
- resource auth ops
- grants and revocations
- auth explain
- resource secrets
- bearer invites
## Phase 3: CAS/KV/Pubsub
- iroh-blobs CAS
- iroh-docs KV
- iroh-gossip pubsub
- private topics and encrypted values
## Phase 4: Pipes And SSH Proxy
- dumbpipe-style streams
- TCP and Unix socket forwarding
- SSH proxy over Iroh
- restricted geth admin shell
## Phase 5: DB And Documents
- cr-sqlite sync
- Automerge sync
- resource-attached authorization
- secret-derived encryption
## Phase 6: File Sync And Advanced Local-First Auth
- CAS tree objects
- file roots
- conflict handling
- Keyhive-like convergent capabilities
- BeeKEM/CGKA-inspired group key evolution

17
justfile Normal file
View file

@ -0,0 +1,17 @@
fmt:
cargo fmt --all
check:
cargo check --workspace --all-targets
test:
cargo test --workspace
clippy:
cargo clippy --workspace --all-targets -- -D warnings
ci:
cargo fmt --all -- --check
cargo check --workspace --all-targets
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace

2
rust-toolchain.toml Normal file
View file

@ -0,0 +1,2 @@
[toolchain]
channel = "stable"

2
rustfmt.toml Normal file
View file

@ -0,0 +1,2 @@
edition = "2024"
max_width = 100