Bootstrap geth Rust workspace

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

View file

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

View file

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

View file

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

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

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

View file

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