Bootstrap geth Rust workspace
This commit is contained in:
commit
26f81ff1ef
73 changed files with 4835 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target/
|
||||
1722
Cargo.lock
generated
Normal file
1722
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
60
Cargo.toml
Normal file
60
Cargo.toml
Normal 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
117
README.md
Normal 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
1
clippy.toml
Normal file
|
|
@ -0,0 +1 @@
|
|||
avoid-breaking-exported-api = false
|
||||
14
crates/geth-auth/Cargo.toml
Normal file
14
crates/geth-auth/Cargo.toml
Normal 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
|
||||
96
crates/geth-auth/src/lib.rs
Normal file
96
crates/geth-auth/src/lib.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
15
crates/geth-cas/Cargo.toml
Normal file
15
crates/geth-cas/Cargo.toml
Normal 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
157
crates/geth-cas/src/lib.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
15
crates/geth-cli/Cargo.toml
Normal file
15
crates/geth-cli/Cargo.toml
Normal 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
365
crates/geth-cli/src/lib.rs
Normal 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(())
|
||||
}
|
||||
14
crates/geth-codec/Cargo.toml
Normal file
14
crates/geth-codec/Cargo.toml
Normal 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" }
|
||||
62
crates/geth-codec/src/lib.rs
Normal file
62
crates/geth-codec/src/lib.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
11
crates/geth-config/Cargo.toml
Normal file
11
crates/geth-config/Cargo.toml
Normal 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
|
||||
81
crates/geth-config/src/lib.rs
Normal file
81
crates/geth-config/src/lib.rs
Normal 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),
|
||||
}
|
||||
14
crates/geth-control/Cargo.toml
Normal file
14
crates/geth-control/Cargo.toml
Normal 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" }
|
||||
164
crates/geth-control/src/lib.rs
Normal file
164
crates/geth-control/src/lib.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
18
crates/geth-crypto/Cargo.toml
Normal file
18
crates/geth-crypto/Cargo.toml
Normal 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
|
||||
118
crates/geth-crypto/src/lib.rs
Normal file
118
crates/geth-crypto/src/lib.rs
Normal 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
10
crates/geth-db/Cargo.toml
Normal 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
15
crates/geth-db/src/lib.rs
Normal 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"
|
||||
}
|
||||
10
crates/geth-discovery/Cargo.toml
Normal file
10
crates/geth-discovery/Cargo.toml
Normal 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" }
|
||||
18
crates/geth-discovery/src/lib.rs
Normal file
18
crates/geth-discovery/src/lib.rs
Normal 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"
|
||||
}
|
||||
10
crates/geth-document/Cargo.toml
Normal file
10
crates/geth-document/Cargo.toml
Normal 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" }
|
||||
14
crates/geth-document/src/lib.rs
Normal file
14
crates/geth-document/src/lib.rs
Normal 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"
|
||||
}
|
||||
9
crates/geth-iroh/Cargo.toml
Normal file
9
crates/geth-iroh/Cargo.toml
Normal 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
|
||||
28
crates/geth-iroh/src/lib.rs
Normal file
28
crates/geth-iroh/src/lib.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
14
crates/geth-keychain/Cargo.toml
Normal file
14
crates/geth-keychain/Cargo.toml
Normal 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
|
||||
137
crates/geth-keychain/src/lib.rs
Normal file
137
crates/geth-keychain/src/lib.rs
Normal 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
10
crates/geth-kv/Cargo.toml
Normal 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
14
crates/geth-kv/src/lib.rs
Normal 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"
|
||||
}
|
||||
20
crates/geth-node/Cargo.toml
Normal file
20
crates/geth-node/Cargo.toml
Normal 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
244
crates/geth-node/src/lib.rs
Normal 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}")
|
||||
}
|
||||
10
crates/geth-pipe/Cargo.toml
Normal file
10
crates/geth-pipe/Cargo.toml
Normal 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" }
|
||||
14
crates/geth-pipe/src/lib.rs
Normal file
14
crates/geth-pipe/src/lib.rs
Normal 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"
|
||||
}
|
||||
10
crates/geth-pubsub/Cargo.toml
Normal file
10
crates/geth-pubsub/Cargo.toml
Normal 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" }
|
||||
14
crates/geth-pubsub/src/lib.rs
Normal file
14
crates/geth-pubsub/src/lib.rs
Normal 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"
|
||||
}
|
||||
14
crates/geth-resource/Cargo.toml
Normal file
14
crates/geth-resource/Cargo.toml
Normal 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
|
||||
88
crates/geth-resource/src/lib.rs
Normal file
88
crates/geth-resource/src/lib.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
11
crates/geth-secrets/Cargo.toml
Normal file
11
crates/geth-secrets/Cargo.toml
Normal 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" }
|
||||
47
crates/geth-secrets/src/lib.rs
Normal file
47
crates/geth-secrets/src/lib.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
9
crates/geth-ssh-identity/Cargo.toml
Normal file
9
crates/geth-ssh-identity/Cargo.toml
Normal 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
|
||||
40
crates/geth-ssh-identity/src/lib.rs
Normal file
40
crates/geth-ssh-identity/src/lib.rs
Normal 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
|
||||
}
|
||||
10
crates/geth-ssh-proxy/Cargo.toml
Normal file
10
crates/geth-ssh-proxy/Cargo.toml
Normal 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" }
|
||||
13
crates/geth-ssh-proxy/src/lib.rs
Normal file
13
crates/geth-ssh-proxy/src/lib.rs
Normal 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"
|
||||
}
|
||||
13
crates/geth-store/Cargo.toml
Normal file
13
crates/geth-store/Cargo.toml
Normal 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" }
|
||||
240
crates/geth-store/src/lib.rs
Normal file
240
crates/geth-store/src/lib.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
11
crates/geth-testkit/Cargo.toml
Normal file
11
crates/geth-testkit/Cargo.toml
Normal 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" }
|
||||
14
crates/geth-testkit/src/lib.rs
Normal file
14
crates/geth-testkit/src/lib.rs
Normal 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 })
|
||||
}
|
||||
}
|
||||
10
crates/geth-types/Cargo.toml
Normal file
10
crates/geth-types/Cargo.toml
Normal 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
|
||||
132
crates/geth-types/src/lib.rs
Normal file
132
crates/geth-types/src/lib.rs
Normal 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
22
crates/geth/Cargo.toml
Normal 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
8
crates/geth/src/main.rs
Normal 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
|
||||
}
|
||||
105
crates/geth/tests/bootstrap.rs
Normal file
105
crates/geth/tests/bootstrap.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
18
docs/adr/0000-project-goals.md
Normal file
18
docs/adr/0000-project-goals.md
Normal 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.
|
||||
18
docs/adr/0001-single-binary-cli-and-daemon.md
Normal file
18
docs/adr/0001-single-binary-cli-and-daemon.md
Normal 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.
|
||||
17
docs/adr/0002-iroh-only-remote-transport.md
Normal file
17
docs/adr/0002-iroh-only-remote-transport.md
Normal 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.
|
||||
18
docs/adr/0003-resource-model.md
Normal file
18
docs/adr/0003-resource-model.md
Normal 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.
|
||||
18
docs/adr/0004-keychain-and-auth-model.md
Normal file
18
docs/adr/0004-keychain-and-auth-model.md
Normal 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.
|
||||
18
docs/adr/0005-bearer-resource-secrets.md
Normal file
18
docs/adr/0005-bearer-resource-secrets.md
Normal 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.
|
||||
16
docs/adr/0006-keyhive-beekem-roadmap.md
Normal file
16
docs/adr/0006-keyhive-beekem-roadmap.md
Normal 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.
|
||||
15
docs/adr/0007-synchronized-data-structures.md
Normal file
15
docs/adr/0007-synchronized-data-structures.md
Normal 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.
|
||||
16
docs/adr/0008-cas-and-blob-distribution.md
Normal file
16
docs/adr/0008-cas-and-blob-distribution.md
Normal 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.
|
||||
16
docs/adr/0009-ssh-trust-and-ssh-proxy.md
Normal file
16
docs/adr/0009-ssh-trust-and-ssh-proxy.md
Normal 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.
|
||||
16
docs/adr/0010-crsqlite-db-sync.md
Normal file
16
docs/adr/0010-crsqlite-db-sync.md
Normal 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.
|
||||
15
docs/adr/0011-kv-iroh-documents.md
Normal file
15
docs/adr/0011-kv-iroh-documents.md
Normal 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.
|
||||
16
docs/adr/0012-automerge-documents.md
Normal file
16
docs/adr/0012-automerge-documents.md
Normal 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.
|
||||
19
docs/adr/0013-pipe-and-pubsub.md
Normal file
19
docs/adr/0013-pipe-and-pubsub.md
Normal 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
88
docs/architecture.md
Normal 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
57
docs/roadmap.md
Normal 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
17
justfile
Normal 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
2
rust-toolchain.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[toolchain]
|
||||
channel = "stable"
|
||||
2
rustfmt.toml
Normal file
2
rustfmt.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
edition = "2024"
|
||||
max_width = 100
|
||||
Loading…
Reference in a new issue