Add SSH cert flows and user service installer

This commit is contained in:
Eric Wendland 2026-05-16 00:17:08 +02:00
commit f302342b1c
21 changed files with 2158 additions and 14 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
/target/
*.swp

7
Cargo.lock generated
View file

@ -449,6 +449,7 @@ dependencies = [
"geth-cas",
"geth-cli",
"geth-config",
"geth-control",
"geth-node",
"tempfile",
"tokio",
@ -516,6 +517,7 @@ version = "0.1.0"
dependencies = [
"geth-auth",
"geth-resource",
"geth-ssh-identity",
"geth-types",
"serde",
"serde_json",
@ -595,6 +597,7 @@ dependencies = [
"geth-control",
"geth-crypto",
"geth-resource",
"geth-ssh-identity",
"geth-store",
"geth-types",
"serde_json",
@ -642,6 +645,10 @@ dependencies = [
name = "geth-ssh-identity"
version = "0.1.0"
dependencies = [
"blake3",
"geth-types",
"serde",
"serde_json",
"thiserror 2.0.18",
]

View file

@ -15,6 +15,7 @@ It has daemon mode and control mode:
```sh
geth init
geth daemon run
geth daemon service install
geth status
geth node id
geth resource list
@ -26,6 +27,18 @@ 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`.
The daemon can also install itself as a user service:
```sh
geth daemon service install
geth daemon service status
geth daemon service uninstall
```
The bootstrap service managers are systemd user units on Linux, launchd user
agents on macOS, and per-user scheduled tasks on Windows. These are user-level
services, not system services.
## Transport And SSH
All remote node-to-node geth communication is designed to happen over Iroh only.
@ -37,12 +50,21 @@ 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.
SSH certificate request and renewal flows are managed as geth metadata. A node
can create a certificate request, another machine can approve it and receive an
explicit `ssh-keygen -s ...` command suitable for a CA key or YubiKey-backed CA,
and the resulting `-cert.pub` can be imported for distribution. Certificate and
key revocation entries are tracked locally and can be exported as JSONL for
distribution. Future Iroh replication will distribute these records between
authorized nodes.
## MVP Features
The bootstrap implementation provides:
- `geth init`
- `geth daemon run`
- `geth daemon service install|uninstall|start|stop|status|print`
- `geth status`
- `geth node id`
- `geth resource list`
@ -50,6 +72,15 @@ The bootstrap implementation provides:
- `geth keychain status`
- `geth auth explain <subject> <resource> <capability>`
- local filesystem CAS commands: `add`, `get`, `hash`, `has`, `list`
- SSH certificate flow metadata:
- `geth ssh cert request --public-key <path> --principal <name>`
- `geth ssh cert requests`
- `geth ssh cert approve <request-id> --ca-key <path>`
- `geth ssh cert import <request-id> --cert <path>`
- `geth ssh cert list`
- `geth ssh revocation add <kind> <target>`
- `geth ssh revocation list`
- `geth ssh revocation export --out <path>`
Other command groups exist as explicit stubs: `db`, `kv`, `pipe`, `document`,
`pubsub`, `secret`, and `ssh`.

View file

@ -2,6 +2,7 @@ use anyhow::{Context, Result, bail};
use clap::{Args, Parser, Subcommand};
use geth_config::GethPaths;
use geth_control::{ControlRequest, ControlResponse};
use geth_node::service::{ServiceInstallOptions, ServiceManager, ServiceReport};
use std::path::PathBuf;
#[derive(Debug, Parser)]
@ -76,6 +77,44 @@ pub enum Command {
#[derive(Debug, Subcommand)]
pub enum DaemonCommand {
Run,
Service {
#[command(subcommand)]
command: ServiceCommand,
},
}
#[derive(Debug, Subcommand)]
pub enum ServiceCommand {
Install {
#[arg(long, default_value = "auto")]
manager: String,
#[arg(long)]
bin: Option<PathBuf>,
#[arg(long)]
start: bool,
},
Uninstall {
#[arg(long, default_value = "auto")]
manager: String,
},
Start {
#[arg(long, default_value = "auto")]
manager: String,
},
Stop {
#[arg(long, default_value = "auto")]
manager: String,
},
Status {
#[arg(long, default_value = "auto")]
manager: String,
},
Print {
#[arg(long, default_value = "auto")]
manager: String,
#[arg(long)]
bin: Option<PathBuf>,
},
}
#[derive(Debug, Subcommand)]
@ -171,7 +210,68 @@ pub enum DocumentCommand {
#[derive(Debug, Subcommand)]
pub enum SshCommand {
Proxy { node: String },
Proxy {
node: String,
},
Cert {
#[command(subcommand)]
command: SshCertCommand,
},
Revocation {
#[command(subcommand)]
command: SshRevocationCommand,
},
}
#[derive(Debug, Subcommand)]
pub enum SshCertCommand {
Request {
#[arg(long)]
public_key: PathBuf,
#[arg(long, default_value = "user")]
kind: String,
#[arg(long = "principal", required = true)]
principals: Vec<String>,
#[arg(long)]
valid_for: Option<String>,
#[arg(long)]
renewal_of: Option<String>,
#[arg(long)]
reason: Option<String>,
},
Requests,
Approve {
request_id: String,
#[arg(long)]
ca_key: PathBuf,
#[arg(long)]
valid_for: Option<String>,
#[arg(long)]
serial: Option<u64>,
#[arg(long)]
out: Option<PathBuf>,
},
Import {
request_id: String,
#[arg(long)]
cert: PathBuf,
},
List,
}
#[derive(Debug, Subcommand)]
pub enum SshRevocationCommand {
Add {
kind: String,
target: String,
#[arg(long)]
reason: Option<String>,
},
List,
Export {
#[arg(long)]
out: PathBuf,
},
}
#[derive(Debug, Args)]
@ -194,6 +294,13 @@ pub async fn run() -> Result<()> {
.await
.context("run geth daemon")?;
}
Command::Daemon {
command: DaemonCommand::Service { command },
} => {
let report =
run_service_command(&paths, command).context("manage geth user service")?;
print_service_report(report, cli.json || cli.jsonl)?;
}
command => {
let request = request_for_command(command)?;
let response = geth_node::send_control(&paths, request)
@ -277,14 +384,113 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
module: "document".to_owned(),
command: format!("{command:?}"),
},
Command::Ssh { command } => ControlRequest::ModuleStub {
module: "ssh-proxy".to_owned(),
command: format!("{command:?}"),
Command::Ssh { command } => match command {
SshCommand::Proxy { node } => ControlRequest::ModuleStub {
module: "ssh-proxy".to_owned(),
command: format!("proxy {node}"),
},
SshCommand::Cert { command } => match command {
SshCertCommand::Request {
public_key,
kind,
principals,
valid_for,
renewal_of,
reason,
} => ControlRequest::SshCertRequest {
public_key_path: public_key,
cert_kind: kind,
principals,
requested_validity: valid_for,
renewal_of,
reason,
},
SshCertCommand::Requests => ControlRequest::SshCertRequests,
SshCertCommand::Approve {
request_id,
ca_key,
valid_for,
serial,
out,
} => ControlRequest::SshCertApprove {
request_id,
ca_key_path: ca_key,
valid_for,
serial,
out,
},
SshCertCommand::Import { request_id, cert } => ControlRequest::SshCertImport {
request_id,
cert_path: cert,
},
SshCertCommand::List => ControlRequest::SshCertList,
},
SshCommand::Revocation { command } => match command {
SshRevocationCommand::Add {
kind,
target,
reason,
} => ControlRequest::SshRevocationAdd {
kind,
target,
reason,
},
SshRevocationCommand::List => ControlRequest::SshRevocationList,
SshRevocationCommand::Export { out } => ControlRequest::SshRevocationExport { out },
},
},
Command::Init | Command::Daemon { .. } => bail!("command is handled directly"),
})
}
fn run_service_command(paths: &GethPaths, command: ServiceCommand) -> Result<ServiceReport> {
Ok(match command {
ServiceCommand::Install {
manager,
bin,
start,
} => {
geth_node::init_node(paths).context("initialize geth home before service install")?;
let manager = manager.parse::<ServiceManager>()?;
let executable = service_executable(bin)?;
geth_node::service::install_user_service(
paths,
ServiceInstallOptions {
manager,
executable,
start,
},
)?
}
ServiceCommand::Uninstall { manager } => {
geth_node::service::uninstall_user_service(manager.parse::<ServiceManager>()?)?
}
ServiceCommand::Start { manager } => {
geth_node::service::start_user_service(manager.parse::<ServiceManager>()?)?
}
ServiceCommand::Stop { manager } => {
geth_node::service::stop_user_service(manager.parse::<ServiceManager>()?)?
}
ServiceCommand::Status { manager } => {
geth_node::service::status_user_service(manager.parse::<ServiceManager>()?)?
}
ServiceCommand::Print { manager, bin } => {
let executable = service_executable(bin)?;
geth_node::service::print_user_service(
paths,
manager.parse::<ServiceManager>()?,
&executable,
)?
}
})
}
fn service_executable(bin: Option<PathBuf>) -> Result<PathBuf> {
bin.map(Ok)
.unwrap_or_else(std::env::current_exe)
.context("resolve current geth executable")
}
fn print_response(response: ControlResponse, json: bool) -> Result<()> {
if json {
println!("{}", serde_json::to_string_pretty(&response)?);
@ -356,6 +562,103 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
println!("reason: {}", explain.reason);
println!("evaluated_ops: {}", explain.evaluated_ops);
}
ControlResponse::SshCertRequested { request } => {
println!("ssh cert request: {}", request.id);
println!("status: {}", request.status);
println!("kind: {}", request.cert_kind);
println!("principals: {}", request.principals.join(","));
println!("public_key_fingerprint: {}", request.public_key_fingerprint);
}
ControlResponse::SshCertRequests { requests } => {
if requests.is_empty() {
println!("no ssh certificate requests");
} else {
for request in requests {
println!(
"{}\t{}\t{}\t{}\t{}",
request.id,
request.status,
request.cert_kind,
request.principals.join(","),
request.public_key_fingerprint
);
}
}
}
ControlResponse::SshCertApproved { approval } => {
println!("approved ssh cert request: {}", approval.request_id);
println!("valid_for: {}", approval.valid_for);
if let Some(serial) = approval.serial {
println!("serial: {serial}");
}
if let Some(output_path) = approval.output_path {
println!("expected_certificate: {output_path}");
}
println!("signing_command:");
println!("{}", shell_quote_command(&approval.signing_command));
println!("note: {}", approval.note);
}
ControlResponse::SshCertImported { certificate } => {
println!("imported ssh certificate: {}", certificate.id);
println!("request: {}", certificate.request_id);
println!("fingerprint: {}", certificate.certificate_fingerprint);
}
ControlResponse::SshCertList {
requests,
certificates,
} => {
println!("requests:");
if requests.is_empty() {
println!(" none");
} else {
for request in requests {
println!(
" {}\t{}\t{}\t{}",
request.id,
request.status,
request.cert_kind,
request.principals.join(",")
);
}
}
println!("certificates:");
if certificates.is_empty() {
println!(" none");
} else {
for certificate in certificates {
println!(
" {}\t{}\t{}",
certificate.id, certificate.request_id, certificate.certificate_fingerprint
);
}
}
}
ControlResponse::SshRevocationAdded { revocation } => {
println!("added ssh revocation: {}", revocation.id);
println!("kind: {}", revocation.kind);
println!("target: {}", revocation.target);
if let Some(reason) = revocation.reason {
println!("reason: {reason}");
}
}
ControlResponse::SshRevocationList { revocations } => {
if revocations.is_empty() {
println!("no ssh revocations");
} else {
for revocation in revocations {
println!(
"{}\t{}\t{}\t{}",
revocation.id,
revocation.kind,
revocation.target,
revocation.reason.unwrap_or_default()
);
}
}
}
ControlResponse::SshRevocationExported { out, count } => {
println!("exported {count} ssh revocations to {}", out.display());
}
ControlResponse::NotImplemented { module, command } => {
println!("{module} {command}: not implemented yet");
}
@ -363,3 +666,57 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
}
Ok(())
}
fn print_service_report(report: ServiceReport, json: bool) -> Result<()> {
if json {
println!(
"{}",
serde_json::json!({
"manager": report.manager.to_string(),
"action": format!("{:?}", report.action),
"service_name": report.service_name,
"definition_path": report.definition_path,
"definition": report.definition,
"commands": report.commands,
"note": report.note,
})
);
return Ok(());
}
println!("service: {}", report.service_name);
println!("manager: {}", report.manager);
println!("action: {:?}", report.action);
if let Some(path) = report.definition_path {
println!("definition: {}", path.display());
}
if !report.commands.is_empty() {
println!("commands:");
for command in report.commands {
println!(" {}", shell_quote_command(&command));
}
}
if let Some(definition) = report.definition {
println!("definition_body:");
print!("{definition}");
}
println!("note: {}", report.note);
Ok(())
}
fn shell_quote_command(command: &[String]) -> String {
command
.iter()
.map(|arg| {
if arg
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"-_./:=+@,".contains(&byte))
{
arg.clone()
} else {
format!("'{}'", arg.replace('\'', "'\\''"))
}
})
.collect::<Vec<_>>()
.join(" ")
}

View file

@ -11,4 +11,5 @@ serde_json.workspace = true
thiserror.workspace = true
geth-auth = { path = "../geth-auth" }
geth-resource = { path = "../geth-resource" }
geth-ssh-identity = { path = "../geth-ssh-identity" }
geth-types = { path = "../geth-types" }

View file

@ -1,5 +1,8 @@
use geth_auth::AuthExplanation;
use geth_resource::ResourceDescriptor;
use geth_ssh_identity::{
SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry,
};
use geth_types::BlobHash;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
@ -34,6 +37,36 @@ pub enum ControlRequest {
resource: String,
capability: String,
},
SshCertRequest {
public_key_path: PathBuf,
cert_kind: String,
principals: Vec<String>,
requested_validity: Option<String>,
renewal_of: Option<String>,
reason: Option<String>,
},
SshCertRequests,
SshCertApprove {
request_id: String,
ca_key_path: PathBuf,
valid_for: Option<String>,
serial: Option<u64>,
out: Option<PathBuf>,
},
SshCertImport {
request_id: String,
cert_path: PathBuf,
},
SshCertList,
SshRevocationAdd {
kind: String,
target: String,
reason: Option<String>,
},
SshRevocationList,
SshRevocationExport {
out: PathBuf,
},
ModuleStub {
module: String,
command: String,
@ -72,6 +105,32 @@ pub enum ControlResponse {
},
KeychainStatus(KeychainStatusResponse),
AuthExplain(AuthExplanation),
SshCertRequested {
request: SshCertRequest,
},
SshCertRequests {
requests: Vec<SshCertRequest>,
},
SshCertApproved {
approval: SshCertApproval,
},
SshCertImported {
certificate: SshCertificateRecord,
},
SshCertList {
requests: Vec<SshCertRequest>,
certificates: Vec<SshCertificateRecord>,
},
SshRevocationAdded {
revocation: SshRevocationEntry,
},
SshRevocationList {
revocations: Vec<SshRevocationEntry>,
},
SshRevocationExported {
out: PathBuf,
count: usize,
},
NotImplemented {
module: String,
command: String,

View file

@ -16,5 +16,6 @@ geth-config = { path = "../geth-config" }
geth-control = { path = "../geth-control" }
geth-crypto = { path = "../geth-crypto" }
geth-resource = { path = "../geth-resource" }
geth-ssh-identity = { path = "../geth-ssh-identity" }
geth-store = { path = "../geth-store" }
geth-types = { path = "../geth-types" }

View file

@ -1,3 +1,5 @@
pub mod service;
use geth_auth::AuthExplanation;
use geth_cas::{LocalCas, hash_path};
use geth_config::GethPaths;
@ -7,8 +9,17 @@ use geth_control::{
};
use geth_crypto::AgentKey;
use geth_resource::ResourceDescriptor;
use geth_store::{Store, StoredResource};
use geth_types::{ResourceId, ResourceKind, ResourceName};
use geth_ssh_identity::{
SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord,
SshRevocationEntry, SshRevocationKind, build_ssh_cert_sign_command, cert_request_id,
certificate_id, revocation_id, ssh_public_key_fingerprint,
};
use geth_store::{
Store, StoredResource, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation,
};
use geth_types::{
NodeId, ResourceId, ResourceKind, ResourceName, SshCertId, SshCertRequestId, UnixMillis,
};
use std::path::Path;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
@ -25,10 +36,24 @@ pub enum NodeError {
Cas(#[from] geth_cas::CasError),
#[error("control error: {0}")]
Control(#[from] geth_control::ControlError),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("invalid resource kind: {0}")]
InvalidResourceKind(String),
#[error("invalid ssh certificate kind: {0}")]
InvalidSshCertKind(String),
#[error("invalid ssh certificate request status: {0}")]
InvalidSshCertStatus(String),
#[error("invalid ssh revocation kind: {0}")]
InvalidSshRevocationKind(String),
#[error("ssh certificate request not found: {0}")]
SshCertRequestNotFound(String),
#[error("ssh certificate request must include at least one principal")]
MissingSshCertPrincipal,
#[error("ssh certificate flow error: {0}")]
SshCertFlow(#[from] geth_ssh_identity::SshCertFlowError),
}
#[derive(Clone, Debug)]
@ -221,6 +246,176 @@ pub fn handle_request(
} => Ok(ControlResponse::AuthExplain(AuthExplanation::stub(
subject, resource, capability,
))),
ControlRequest::SshCertRequest {
public_key_path,
cert_kind,
principals,
requested_validity,
renewal_of,
reason,
} => {
if principals.is_empty() {
return Err(NodeError::MissingSshCertPrincipal);
}
let cert_kind = cert_kind
.parse::<SshCertKind>()
.map_err(|_| NodeError::InvalidSshCertKind(cert_kind.clone()))?;
let public_key = std::fs::read_to_string(&public_key_path)?;
let created_at = UnixMillis(geth_store::now_ms());
let request = SshCertRequest {
id: cert_request_id(
&NodeId::new(node.node_id.clone()),
&public_key,
&principals,
created_at,
),
requester_node: NodeId::new(node.node_id.clone()),
public_key_fingerprint: ssh_public_key_fingerprint(&public_key),
public_key,
cert_kind,
principals,
requested_validity,
renewal_of: renewal_of.map(SshCertId::new),
reason,
status: SshCertRequestStatus::Pending,
created_at,
};
store.insert_ssh_cert_request(&stored_from_ssh_cert_request(&request))?;
Ok(ControlResponse::SshCertRequested { request })
}
ControlRequest::SshCertRequests => Ok(ControlResponse::SshCertRequests {
requests: store
.list_ssh_cert_requests()?
.into_iter()
.map(ssh_cert_request_from_stored)
.collect::<Result<Vec<_>, _>>()?,
}),
ControlRequest::SshCertApprove {
request_id,
ca_key_path,
valid_for,
serial,
out,
} => {
let stored = store
.get_ssh_cert_request(&request_id)?
.ok_or_else(|| NodeError::SshCertRequestNotFound(request_id.clone()))?;
let mut request = ssh_cert_request_from_stored(stored)?;
request.status = SshCertRequestStatus::Approved;
store.update_ssh_cert_request_status(request.id.as_str(), request.status.as_str())?;
let public_key_path = out.clone().unwrap_or_else(|| {
node.paths
.home()
.join("ssh-cert-requests")
.join(format!("{}.pub", request.id))
});
if let Some(parent) = public_key_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&public_key_path, &request.public_key)?;
let valid_for = valid_for
.or_else(|| request.requested_validity.clone())
.unwrap_or_else(|| "+52w".to_owned());
let signing_command = build_ssh_cert_sign_command(
&request,
&ca_key_path,
&public_key_path,
&valid_for,
serial,
)?;
let approval = SshCertApproval {
request_id: request.id,
approved_by_node: NodeId::new(node.node_id.clone()),
ca_key_path: ca_key_path.display().to_string(),
key_id: request_id,
valid_for,
serial,
output_path: Some(expected_openssh_cert_path(&public_key_path)),
signing_command,
note: "request approved; run the signing command on the CA/YubiKey machine, then import the resulting -cert.pub file".to_owned(),
};
Ok(ControlResponse::SshCertApproved { approval })
}
ControlRequest::SshCertImport {
request_id,
cert_path,
} => {
let certificate = std::fs::read_to_string(&cert_path)?;
let record = SshCertificateRecord {
id: certificate_id(&certificate),
request_id: SshCertRequestId::new(request_id.clone()),
certificate_fingerprint: ssh_public_key_fingerprint(&certificate),
certificate,
imported_at: UnixMillis(geth_store::now_ms()),
};
store.insert_ssh_certificate(&stored_from_ssh_certificate(&record))?;
store.update_ssh_cert_request_status(
&request_id,
SshCertRequestStatus::Signed.as_str(),
)?;
Ok(ControlResponse::SshCertImported {
certificate: record,
})
}
ControlRequest::SshCertList => Ok(ControlResponse::SshCertList {
requests: store
.list_ssh_cert_requests()?
.into_iter()
.map(ssh_cert_request_from_stored)
.collect::<Result<Vec<_>, _>>()?,
certificates: store
.list_ssh_certificates()?
.into_iter()
.map(ssh_certificate_from_stored)
.collect(),
}),
ControlRequest::SshRevocationAdd {
kind,
target,
reason,
} => {
let kind = kind
.parse::<SshRevocationKind>()
.map_err(|_| NodeError::InvalidSshRevocationKind(kind.clone()))?;
let created_at = UnixMillis(geth_store::now_ms());
let revocation = SshRevocationEntry {
id: revocation_id(&kind, &target, created_at),
kind,
target,
reason,
created_at,
published: true,
};
store.insert_ssh_revocation(&stored_from_ssh_revocation(&revocation))?;
Ok(ControlResponse::SshRevocationAdded { revocation })
}
ControlRequest::SshRevocationList => Ok(ControlResponse::SshRevocationList {
revocations: store
.list_ssh_revocations()?
.into_iter()
.map(ssh_revocation_from_stored)
.collect::<Result<Vec<_>, _>>()?,
}),
ControlRequest::SshRevocationExport { out } => {
let revocations = store
.list_ssh_revocations()?
.into_iter()
.map(ssh_revocation_from_stored)
.collect::<Result<Vec<_>, _>>()?;
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)?;
}
let mut body = String::new();
for revocation in &revocations {
body.push_str(&serde_json::to_string(revocation)?);
body.push('\n');
}
std::fs::write(&out, body)?;
Ok(ControlResponse::SshRevocationExported {
out,
count: revocations.len(),
})
}
ControlRequest::ModuleStub { module, command } => {
Ok(ControlResponse::NotImplemented { module, command })
}
@ -242,3 +437,100 @@ fn stored_resource_to_descriptor(stored: StoredResource) -> Result<ResourceDescr
fn stable_node_id(agent_id: &str) -> String {
format!("node:{agent_id}")
}
fn expected_openssh_cert_path(public_key_path: &Path) -> String {
let text = public_key_path.display().to_string();
if let Some(prefix) = text.strip_suffix(".pub") {
format!("{prefix}-cert.pub")
} else {
format!("{text}-cert.pub")
}
}
fn stored_from_ssh_cert_request(request: &SshCertRequest) -> StoredSshCertRequest {
StoredSshCertRequest {
request_id: request.id.to_string(),
requester_node: request.requester_node.to_string(),
public_key: request.public_key.clone(),
public_key_fingerprint: request.public_key_fingerprint.clone(),
cert_kind: request.cert_kind.to_string(),
principals: request.principals.clone(),
requested_validity: request.requested_validity.clone(),
renewal_of: request.renewal_of.as_ref().map(ToString::to_string),
reason: request.reason.clone(),
status: request.status.to_string(),
created_at_ms: request.created_at.0,
}
}
fn ssh_cert_request_from_stored(stored: StoredSshCertRequest) -> Result<SshCertRequest, NodeError> {
let cert_kind = stored
.cert_kind
.parse::<SshCertKind>()
.map_err(|_| NodeError::InvalidSshCertKind(stored.cert_kind.clone()))?;
let status = stored
.status
.parse::<SshCertRequestStatus>()
.map_err(|_| NodeError::InvalidSshCertStatus(stored.status.clone()))?;
Ok(SshCertRequest {
id: SshCertRequestId::new(stored.request_id),
requester_node: NodeId::new(stored.requester_node),
public_key: stored.public_key,
public_key_fingerprint: stored.public_key_fingerprint,
cert_kind,
principals: stored.principals,
requested_validity: stored.requested_validity,
renewal_of: stored.renewal_of.map(SshCertId::new),
reason: stored.reason,
status,
created_at: UnixMillis(stored.created_at_ms),
})
}
fn stored_from_ssh_certificate(certificate: &SshCertificateRecord) -> StoredSshCertificate {
StoredSshCertificate {
cert_id: certificate.id.to_string(),
request_id: certificate.request_id.to_string(),
certificate: certificate.certificate.clone(),
certificate_fingerprint: certificate.certificate_fingerprint.clone(),
imported_at_ms: certificate.imported_at.0,
}
}
fn ssh_certificate_from_stored(stored: StoredSshCertificate) -> SshCertificateRecord {
SshCertificateRecord {
id: SshCertId::new(stored.cert_id),
request_id: SshCertRequestId::new(stored.request_id),
certificate: stored.certificate,
certificate_fingerprint: stored.certificate_fingerprint,
imported_at: UnixMillis(stored.imported_at_ms),
}
}
fn stored_from_ssh_revocation(revocation: &SshRevocationEntry) -> StoredSshRevocation {
StoredSshRevocation {
revocation_id: revocation.id.to_string(),
kind: revocation.kind.to_string(),
target: revocation.target.clone(),
reason: revocation.reason.clone(),
created_at_ms: revocation.created_at.0,
published: revocation.published,
}
}
fn ssh_revocation_from_stored(
stored: StoredSshRevocation,
) -> Result<SshRevocationEntry, NodeError> {
let kind = stored
.kind
.parse::<SshRevocationKind>()
.map_err(|_| NodeError::InvalidSshRevocationKind(stored.kind.clone()))?;
Ok(SshRevocationEntry {
id: geth_types::SshRevocationId::new(stored.revocation_id),
kind,
target: stored.target,
reason: stored.reason,
created_at: UnixMillis(stored.created_at_ms),
published: stored.published,
})
}

View file

@ -0,0 +1,622 @@
use geth_config::GethPaths;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ServiceManager {
Auto,
SystemdUser,
LaunchdUser,
WindowsTask,
}
impl ServiceManager {
pub fn detect() -> Result<Self, ServiceError> {
match std::env::consts::OS {
"linux" => Ok(Self::SystemdUser),
"macos" => Ok(Self::LaunchdUser),
"windows" => Ok(Self::WindowsTask),
other => Err(ServiceError::UnsupportedPlatform(other.to_owned())),
}
}
pub fn resolve(self) -> Result<Self, ServiceError> {
match self {
Self::Auto => Self::detect(),
manager => Ok(manager),
}
}
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SystemdUser => "systemd-user",
Self::LaunchdUser => "launchd-user",
Self::WindowsTask => "windows-task",
}
}
}
impl std::fmt::Display for ServiceManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for ServiceManager {
type Err = ServiceError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"auto" => Ok(Self::Auto),
"systemd" | "systemd-user" => Ok(Self::SystemdUser),
"launchd" | "launchd-user" => Ok(Self::LaunchdUser),
"windows" | "windows-task" | "scheduled-task" => Ok(Self::WindowsTask),
_ => Err(ServiceError::InvalidManager(value.to_owned())),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ServiceAction {
Installed,
Uninstalled,
Started,
Stopped,
Status,
Printed,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ServiceReport {
pub manager: ServiceManager,
pub action: ServiceAction,
pub service_name: String,
pub definition_path: Option<PathBuf>,
pub definition: Option<String>,
pub commands: Vec<Vec<String>>,
pub note: String,
}
#[derive(Clone, Debug)]
pub struct ServiceInstallOptions {
pub manager: ServiceManager,
pub executable: PathBuf,
pub start: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum ServiceError {
#[error("unsupported service platform: {0}")]
UnsupportedPlatform(String),
#[error("invalid service manager: {0}")]
InvalidManager(String),
#[error("could not determine home directory")]
MissingHome,
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("service command failed: {program} {args:?}: {stderr}")]
CommandFailed {
program: String,
args: Vec<String>,
stderr: String,
},
}
pub fn install_user_service(
paths: &GethPaths,
options: ServiceInstallOptions,
) -> Result<ServiceReport, ServiceError> {
let manager = options.manager.resolve()?;
match manager {
ServiceManager::SystemdUser => {
install_systemd_user(paths, &options.executable, options.start)
}
ServiceManager::LaunchdUser => {
install_launchd_user(paths, &options.executable, options.start)
}
ServiceManager::WindowsTask => {
install_windows_task(paths, &options.executable, options.start)
}
ServiceManager::Auto => unreachable!("auto is resolved above"),
}
}
pub fn uninstall_user_service(manager: ServiceManager) -> Result<ServiceReport, ServiceError> {
let manager = manager.resolve()?;
match manager {
ServiceManager::SystemdUser => uninstall_systemd_user(),
ServiceManager::LaunchdUser => uninstall_launchd_user(),
ServiceManager::WindowsTask => uninstall_windows_task(),
ServiceManager::Auto => unreachable!("auto is resolved above"),
}
}
pub fn start_user_service(manager: ServiceManager) -> Result<ServiceReport, ServiceError> {
let manager = manager.resolve()?;
match manager {
ServiceManager::SystemdUser => {
let command = run_command("systemctl", &["--user", "start", SYSTEMD_UNIT])?;
Ok(report(
manager,
ServiceAction::Started,
Some(systemd_unit_path()?),
None,
vec![command],
"started systemd user service",
))
}
ServiceManager::LaunchdUser => {
let command = run_command(
"launchctl",
&["load", "-w", &launchd_plist_path()?.display().to_string()],
)?;
Ok(report(
manager,
ServiceAction::Started,
Some(launchd_plist_path()?),
None,
vec![command],
"loaded launchd user agent",
))
}
ServiceManager::WindowsTask => {
let command = run_command("schtasks", &["/Run", "/TN", WINDOWS_TASK_NAME])?;
Ok(report(
manager,
ServiceAction::Started,
None,
None,
vec![command],
"started Windows per-user scheduled task",
))
}
ServiceManager::Auto => unreachable!("auto is resolved above"),
}
}
pub fn stop_user_service(manager: ServiceManager) -> Result<ServiceReport, ServiceError> {
let manager = manager.resolve()?;
match manager {
ServiceManager::SystemdUser => {
let command = run_command("systemctl", &["--user", "stop", SYSTEMD_UNIT])?;
Ok(report(
manager,
ServiceAction::Stopped,
Some(systemd_unit_path()?),
None,
vec![command],
"stopped systemd user service",
))
}
ServiceManager::LaunchdUser => {
let command = run_command(
"launchctl",
&["unload", &launchd_plist_path()?.display().to_string()],
)?;
Ok(report(
manager,
ServiceAction::Stopped,
Some(launchd_plist_path()?),
None,
vec![command],
"unloaded launchd user agent",
))
}
ServiceManager::WindowsTask => {
let command = run_command("schtasks", &["/End", "/TN", WINDOWS_TASK_NAME])?;
Ok(report(
manager,
ServiceAction::Stopped,
None,
None,
vec![command],
"stopped Windows per-user scheduled task",
))
}
ServiceManager::Auto => unreachable!("auto is resolved above"),
}
}
pub fn status_user_service(manager: ServiceManager) -> Result<ServiceReport, ServiceError> {
let manager = manager.resolve()?;
match manager {
ServiceManager::SystemdUser => {
let command = run_command(
"systemctl",
&["--user", "status", SYSTEMD_UNIT, "--no-pager"],
)?;
Ok(report(
manager,
ServiceAction::Status,
Some(systemd_unit_path()?),
None,
vec![command],
"queried systemd user service",
))
}
ServiceManager::LaunchdUser => {
let command = run_command("launchctl", &["list", LAUNCHD_LABEL])?;
Ok(report(
manager,
ServiceAction::Status,
Some(launchd_plist_path()?),
None,
vec![command],
"queried launchd user agent",
))
}
ServiceManager::WindowsTask => {
let command = run_command("schtasks", &["/Query", "/TN", WINDOWS_TASK_NAME])?;
Ok(report(
manager,
ServiceAction::Status,
None,
None,
vec![command],
"queried Windows per-user scheduled task",
))
}
ServiceManager::Auto => unreachable!("auto is resolved above"),
}
}
pub fn print_user_service(
paths: &GethPaths,
manager: ServiceManager,
executable: &Path,
) -> Result<ServiceReport, ServiceError> {
let manager = manager.resolve()?;
let (path, definition) = match manager {
ServiceManager::SystemdUser => {
(Some(systemd_unit_path()?), systemd_unit(paths, executable))
}
ServiceManager::LaunchdUser => (
Some(launchd_plist_path()?),
launchd_plist(paths, executable),
),
ServiceManager::WindowsTask => (None, windows_task_command(paths, executable)),
ServiceManager::Auto => unreachable!("auto is resolved above"),
};
Ok(report(
manager,
ServiceAction::Printed,
path,
Some(definition),
Vec::new(),
"printed user service definition",
))
}
const SYSTEMD_UNIT: &str = "geth.service";
const LAUNCHD_LABEL: &str = "local.geth.daemon";
const WINDOWS_TASK_NAME: &str = "geth-daemon";
fn install_systemd_user(
paths: &GethPaths,
executable: &Path,
start: bool,
) -> Result<ServiceReport, ServiceError> {
let unit_path = systemd_unit_path()?;
if let Some(parent) = unit_path.parent() {
std::fs::create_dir_all(parent)?;
}
let definition = systemd_unit(paths, executable);
std::fs::write(&unit_path, &definition)?;
let mut commands = vec![
run_command("systemctl", &["--user", "daemon-reload"])?,
run_command("systemctl", &["--user", "enable", SYSTEMD_UNIT])?,
];
if start {
commands.push(run_command(
"systemctl",
&["--user", "start", SYSTEMD_UNIT],
)?);
}
Ok(report(
ServiceManager::SystemdUser,
ServiceAction::Installed,
Some(unit_path),
Some(definition),
commands,
"installed systemd user service; lingering may be needed for startup before login",
))
}
fn uninstall_systemd_user() -> Result<ServiceReport, ServiceError> {
let unit_path = systemd_unit_path()?;
let mut commands = Vec::new();
commands.push(run_command(
"systemctl",
&["--user", "disable", "--now", SYSTEMD_UNIT],
)?);
if unit_path.exists() {
std::fs::remove_file(&unit_path)?;
}
commands.push(run_command("systemctl", &["--user", "daemon-reload"])?);
Ok(report(
ServiceManager::SystemdUser,
ServiceAction::Uninstalled,
Some(unit_path),
None,
commands,
"uninstalled systemd user service",
))
}
fn install_launchd_user(
paths: &GethPaths,
executable: &Path,
start: bool,
) -> Result<ServiceReport, ServiceError> {
let plist_path = launchd_plist_path()?;
if let Some(parent) = plist_path.parent() {
std::fs::create_dir_all(parent)?;
}
let definition = launchd_plist(paths, executable);
std::fs::write(&plist_path, &definition)?;
let mut commands = vec![run_command(
"launchctl",
&["load", "-w", &plist_path.display().to_string()],
)?];
if start {
commands.push(run_command("launchctl", &["start", LAUNCHD_LABEL])?);
}
Ok(report(
ServiceManager::LaunchdUser,
ServiceAction::Installed,
Some(plist_path),
Some(definition),
commands,
"installed launchd user agent",
))
}
fn uninstall_launchd_user() -> Result<ServiceReport, ServiceError> {
let plist_path = launchd_plist_path()?;
let mut commands = Vec::new();
if plist_path.exists() {
commands.push(run_command(
"launchctl",
&["unload", &plist_path.display().to_string()],
)?);
std::fs::remove_file(&plist_path)?;
}
Ok(report(
ServiceManager::LaunchdUser,
ServiceAction::Uninstalled,
Some(plist_path),
None,
commands,
"uninstalled launchd user agent",
))
}
fn install_windows_task(
paths: &GethPaths,
executable: &Path,
start: bool,
) -> Result<ServiceReport, ServiceError> {
let task_command = windows_task_run_command(paths, executable);
let mut commands = vec![run_command(
"schtasks",
&[
"/Create",
"/TN",
WINDOWS_TASK_NAME,
"/SC",
"ONLOGON",
"/TR",
&task_command,
"/F",
],
)?];
if start {
commands.push(run_command(
"schtasks",
&["/Run", "/TN", WINDOWS_TASK_NAME],
)?);
}
Ok(report(
ServiceManager::WindowsTask,
ServiceAction::Installed,
None,
Some(task_command),
commands,
"installed Windows per-user scheduled task",
))
}
fn uninstall_windows_task() -> Result<ServiceReport, ServiceError> {
let command = run_command("schtasks", &["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"])?;
Ok(report(
ServiceManager::WindowsTask,
ServiceAction::Uninstalled,
None,
None,
vec![command],
"uninstalled Windows per-user scheduled task",
))
}
fn systemd_unit(paths: &GethPaths, executable: &Path) -> String {
format!(
r#"[Unit]
Description=geth personal mesh daemon
Documentation=https://example.invalid/local/geth
[Service]
Type=simple
Environment=GETH_HOME={}
ExecStart={} daemon run
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=default.target
"#,
systemd_escape(paths.home()),
systemd_escape(executable)
)
}
fn launchd_plist(paths: &GethPaths, executable: &Path) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{}</string>
<key>ProgramArguments</key>
<array>
<string>{}</string>
<string>daemon</string>
<string>run</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>GETH_HOME</key>
<string>{}</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>{}/daemon.out.log</string>
<key>StandardErrorPath</key>
<string>{}/daemon.err.log</string>
</dict>
</plist>
"#,
LAUNCHD_LABEL,
xml_escape(&executable.display().to_string()),
xml_escape(&paths.home().display().to_string()),
xml_escape(&paths.run_dir().display().to_string()),
xml_escape(&paths.run_dir().display().to_string())
)
}
fn windows_task_command(paths: &GethPaths, executable: &Path) -> String {
windows_task_run_command(paths, executable)
}
fn windows_task_run_command(paths: &GethPaths, executable: &Path) -> String {
format!(
r#"cmd.exe /C "set GETH_HOME={}&& "{}" daemon run""#,
paths.home().display(),
executable.display()
)
}
fn systemd_unit_path() -> Result<PathBuf, ServiceError> {
Ok(home_dir()?.join(".config/systemd/user").join(SYSTEMD_UNIT))
}
fn launchd_plist_path() -> Result<PathBuf, ServiceError> {
Ok(home_dir()?
.join("Library/LaunchAgents")
.join(format!("{LAUNCHD_LABEL}.plist")))
}
fn home_dir() -> Result<PathBuf, ServiceError> {
std::env::var_os("HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
.ok_or(ServiceError::MissingHome)
}
fn run_command(program: &str, args: &[&str]) -> Result<Vec<String>, ServiceError> {
let output = Command::new(program).args(args).output()?;
let command = std::iter::once(program.to_owned())
.chain(args.iter().map(|arg| (*arg).to_owned()))
.collect::<Vec<_>>();
if output.status.success() {
Ok(command)
} else {
Err(ServiceError::CommandFailed {
program: program.to_owned(),
args: args.iter().map(|arg| (*arg).to_owned()).collect(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
})
}
}
fn report(
manager: ServiceManager,
action: ServiceAction,
definition_path: Option<PathBuf>,
definition: Option<String>,
commands: Vec<Vec<String>>,
note: &str,
) -> ServiceReport {
ServiceReport {
service_name: match manager {
ServiceManager::SystemdUser => SYSTEMD_UNIT.to_owned(),
ServiceManager::LaunchdUser => LAUNCHD_LABEL.to_owned(),
ServiceManager::WindowsTask => WINDOWS_TASK_NAME.to_owned(),
ServiceManager::Auto => "geth".to_owned(),
},
manager,
action,
definition_path,
definition,
commands,
note: note.to_owned(),
}
}
fn systemd_escape(path: &Path) -> String {
let value = path.display().to_string();
if value.bytes().any(|byte| byte.is_ascii_whitespace()) {
format!("\"{}\"", value.replace('"', "\\\""))
} else {
value
}
}
fn xml_escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn systemd_unit_is_user_service() {
let paths = GethPaths::from_home("/tmp/geth home");
let unit = systemd_unit(&paths, Path::new("/usr/local/bin/geth"));
assert!(unit.contains("WantedBy=default.target"));
assert!(unit.contains("GETH_HOME="));
assert!(unit.contains("ExecStart=/usr/local/bin/geth daemon run"));
assert!(!unit.contains("WantedBy=multi-user.target"));
}
#[test]
fn launchd_plist_is_user_agent() {
let paths = GethPaths::from_home("/Users/eric/Library/Application Support/geth");
let plist = launchd_plist(&paths, Path::new("/usr/local/bin/geth"));
assert!(plist.contains("<string>local.geth.daemon</string>"));
assert!(plist.contains("<string>daemon</string>"));
assert!(plist.contains("<key>RunAtLoad</key>"));
}
#[test]
fn windows_task_uses_current_user_logon_trigger() {
let paths = GethPaths::from_home(r"C:\Users\Eric\AppData\Local\geth");
let command = windows_task_run_command(&paths, Path::new(r"C:\bin\geth.exe"));
assert!(command.contains("cmd.exe /C"));
assert!(command.contains("GETH_HOME="));
assert!(command.contains("daemon run"));
}
}

View file

@ -6,4 +6,10 @@ rust-version.workspace = true
license.workspace = true
[dependencies]
blake3.workspace = true
serde.workspace = true
thiserror.workspace = true
geth-types = { path = "../geth-types" }
[dev-dependencies]
serde_json.workspace = true

View file

@ -1,19 +1,17 @@
use std::path::Path;
use std::process::Command;
use geth_types::{NodeId, SshCertId, SshCertRequestId, SshRevocationId, UnixMillis};
use serde::{Deserialize, Serialize};
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 const SSH_CERT_REQUEST_NAMESPACE: &str = "geth.ssh-cert-request.v1@geth.local";
pub const SSH_CERT_ISSUANCE_NAMESPACE: &str = "geth.ssh-cert-issuance.v1@geth.local";
pub const SSH_REVOCATION_LIST_NAMESPACE: &str = "geth.ssh-revocation-list.v1@geth.local";
pub fn ensure_ssh_keygen_available() -> Result<(), SshIdentityError> {
let status = Command::new("ssh-keygen").arg("-?").status();
@ -38,3 +36,316 @@ pub fn sign_command(key_path: &Path, namespace: &str, input_path: &Path) -> Comm
.arg(input_path);
command
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SshCertKind {
User,
Host,
}
impl SshCertKind {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::User => "user",
Self::Host => "host",
}
}
}
impl std::fmt::Display for SshCertKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for SshCertKind {
type Err = SshIdentityError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"user" => Ok(Self::User),
"host" => Ok(Self::Host),
_ => Err(SshIdentityError::InvalidCertKind(value.to_owned())),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SshCertRequestStatus {
Pending,
Approved,
Signed,
Rejected,
Revoked,
}
impl SshCertRequestStatus {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Approved => "approved",
Self::Signed => "signed",
Self::Rejected => "rejected",
Self::Revoked => "revoked",
}
}
}
impl std::fmt::Display for SshCertRequestStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for SshCertRequestStatus {
type Err = SshIdentityError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"pending" => Ok(Self::Pending),
"approved" => Ok(Self::Approved),
"signed" => Ok(Self::Signed),
"rejected" => Ok(Self::Rejected),
"revoked" => Ok(Self::Revoked),
_ => Err(SshIdentityError::InvalidRequestStatus(value.to_owned())),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SshCertRequest {
pub id: SshCertRequestId,
pub requester_node: NodeId,
pub public_key: String,
pub public_key_fingerprint: String,
pub cert_kind: SshCertKind,
pub principals: Vec<String>,
pub requested_validity: Option<String>,
pub renewal_of: Option<SshCertId>,
pub reason: Option<String>,
pub status: SshCertRequestStatus,
pub created_at: UnixMillis,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SshCertApproval {
pub request_id: SshCertRequestId,
pub approved_by_node: NodeId,
pub ca_key_path: String,
pub key_id: String,
pub valid_for: String,
pub serial: Option<u64>,
pub output_path: Option<String>,
pub signing_command: Vec<String>,
pub note: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SshCertificateRecord {
pub id: SshCertId,
pub request_id: SshCertRequestId,
pub certificate: String,
pub certificate_fingerprint: String,
pub imported_at: UnixMillis,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SshRevocationKind {
PublicKey,
Certificate,
Serial,
KeyId,
}
impl SshRevocationKind {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::PublicKey => "public-key",
Self::Certificate => "certificate",
Self::Serial => "serial",
Self::KeyId => "key-id",
}
}
}
impl std::fmt::Display for SshRevocationKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for SshRevocationKind {
type Err = SshIdentityError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"public-key" | "key" => Ok(Self::PublicKey),
"certificate" | "cert" => Ok(Self::Certificate),
"serial" => Ok(Self::Serial),
"key-id" | "keyid" => Ok(Self::KeyId),
_ => Err(SshIdentityError::InvalidRevocationKind(value.to_owned())),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SshRevocationEntry {
pub id: SshRevocationId,
pub kind: SshRevocationKind,
pub target: String,
pub reason: Option<String>,
pub created_at: UnixMillis,
pub published: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum SshCertFlowError {
#[error("certificate request has no principals")]
MissingPrincipals,
}
#[must_use]
pub fn ssh_public_key_fingerprint(public_key: &str) -> String {
format!("ssh:blake3:{}", blake3::hash(public_key.trim().as_bytes()))
}
pub fn cert_request_id(
requester_node: &NodeId,
public_key: &str,
principals: &[String],
created_at: UnixMillis,
) -> SshCertRequestId {
let mut input = String::new();
input.push_str(requester_node.as_str());
input.push('\n');
input.push_str(public_key.trim());
input.push('\n');
input.push_str(&principals.join(","));
input.push('\n');
input.push_str(&created_at.0.to_string());
SshCertRequestId::new(format!(
"ssh-cert-request:{}",
blake3::hash(input.as_bytes())
))
}
pub fn certificate_id(certificate: &str) -> SshCertId {
SshCertId::new(format!(
"ssh-cert:{}",
blake3::hash(certificate.trim().as_bytes())
))
}
pub fn revocation_id(
kind: &SshRevocationKind,
target: &str,
created_at: UnixMillis,
) -> SshRevocationId {
let input = format!("{}\n{}\n{}", kind, target.trim(), created_at.0);
SshRevocationId::new(format!("ssh-revocation:{}", blake3::hash(input.as_bytes())))
}
pub fn build_ssh_cert_sign_command(
request: &SshCertRequest,
ca_key_path: &Path,
public_key_path: &Path,
valid_for: &str,
serial: Option<u64>,
) -> Result<Vec<String>, SshCertFlowError> {
if request.principals.is_empty() {
return Err(SshCertFlowError::MissingPrincipals);
}
let mut command = vec![
"ssh-keygen".to_owned(),
"-s".to_owned(),
ca_key_path.display().to_string(),
"-I".to_owned(),
request.id.to_string(),
"-n".to_owned(),
request.principals.join(","),
"-V".to_owned(),
valid_for.to_owned(),
];
if let Some(serial) = serial {
command.push("-z".to_owned());
command.push(serial.to_string());
}
if request.cert_kind == SshCertKind::Host {
command.push("-h".to_owned());
}
command.push(public_key_path.display().to_string());
Ok(command)
}
#[derive(Debug, thiserror::Error)]
pub enum SshIdentityError {
#[error("ssh-keygen failed or is unavailable")]
SshKeygenUnavailable,
#[error("invalid ssh certificate kind: {0}")]
InvalidCertKind(String),
#[error("invalid ssh certificate request status: {0}")]
InvalidRequestStatus(String),
#[error("invalid ssh revocation kind: {0}")]
InvalidRevocationKind(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ssh_cert_request_roundtrips() {
let request = SshCertRequest {
id: "ssh-cert-request:1".into(),
requester_node: "node:laptop".into(),
public_key: "ssh-ed25519 AAAA test".to_owned(),
public_key_fingerprint: ssh_public_key_fingerprint("ssh-ed25519 AAAA test"),
cert_kind: SshCertKind::User,
principals: vec!["eric".to_owned()],
requested_validity: Some("+52w".to_owned()),
renewal_of: None,
reason: Some("bootstrap".to_owned()),
status: SshCertRequestStatus::Pending,
created_at: UnixMillis(1),
};
let json = serde_json::to_string(&request).expect("json");
let decoded: SshCertRequest = serde_json::from_str(&json).expect("decode");
assert_eq!(decoded, request);
}
#[test]
fn sign_command_includes_host_flag_for_host_certs() {
let request = SshCertRequest {
id: "ssh-cert-request:1".into(),
requester_node: "node:server".into(),
public_key: "ssh-ed25519 AAAA host".to_owned(),
public_key_fingerprint: ssh_public_key_fingerprint("ssh-ed25519 AAAA host"),
cert_kind: SshCertKind::Host,
principals: vec!["server.local".to_owned()],
requested_validity: None,
renewal_of: None,
reason: None,
status: SshCertRequestStatus::Pending,
created_at: UnixMillis(1),
};
let command = build_ssh_cert_sign_command(
&request,
Path::new("/keys/ca_sk"),
Path::new("/tmp/host.pub"),
"+4w",
Some(7),
)
.expect("command");
assert!(command.contains(&"-h".to_owned()));
assert!(command.contains(&"server.local".to_owned()));
}
}

View file

@ -118,6 +118,34 @@ impl Store {
state_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS ssh_cert_requests (
request_id TEXT PRIMARY KEY,
requester_node TEXT NOT NULL,
public_key TEXT NOT NULL,
public_key_fingerprint TEXT NOT NULL,
cert_kind TEXT NOT NULL,
principals_json TEXT NOT NULL,
requested_validity TEXT,
renewal_of TEXT,
reason TEXT,
status TEXT NOT NULL,
created_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS ssh_certificates (
cert_id TEXT PRIMARY KEY,
request_id TEXT NOT NULL,
certificate TEXT NOT NULL,
certificate_fingerprint TEXT NOT NULL,
imported_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS ssh_revocations (
revocation_id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
target TEXT NOT NULL,
reason TEXT,
created_at_ms INTEGER NOT NULL,
published INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO meta(key, value) VALUES ('schema_version', '1');
"#,
)?;
@ -200,6 +228,170 @@ impl Store {
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn insert_ssh_cert_request(
&self,
request: &StoredSshCertRequest,
) -> Result<(), StoreError> {
self.conn.execute(
r#"INSERT OR REPLACE INTO ssh_cert_requests(
request_id, requester_node, public_key, public_key_fingerprint, cert_kind,
principals_json, requested_validity, renewal_of, reason, status, created_at_ms
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"#,
params![
request.request_id,
request.requester_node,
request.public_key,
request.public_key_fingerprint,
request.cert_kind,
serde_json::to_string(&request.principals)?,
request.requested_validity,
request.renewal_of,
request.reason,
request.status,
request.created_at_ms
],
)?;
Ok(())
}
pub fn get_ssh_cert_request(
&self,
request_id: &str,
) -> Result<Option<StoredSshCertRequest>, StoreError> {
let mut stmt = self.conn.prepare(
r#"SELECT request_id, requester_node, public_key, public_key_fingerprint, cert_kind,
principals_json, requested_validity, renewal_of, reason, status, created_at_ms
FROM ssh_cert_requests WHERE request_id = ?1"#,
)?;
let mut rows = stmt.query(params![request_id])?;
if let Some(row) = rows.next()? {
Ok(Some(stored_ssh_cert_request_from_row(row)?))
} else {
Ok(None)
}
}
pub fn update_ssh_cert_request_status(
&self,
request_id: &str,
status: &str,
) -> Result<(), StoreError> {
self.conn.execute(
"UPDATE ssh_cert_requests SET status = ?2 WHERE request_id = ?1",
params![request_id, status],
)?;
Ok(())
}
pub fn list_ssh_cert_requests(&self) -> Result<Vec<StoredSshCertRequest>, StoreError> {
let mut stmt = self.conn.prepare(
r#"SELECT request_id, requester_node, public_key, public_key_fingerprint, cert_kind,
principals_json, requested_validity, renewal_of, reason, status, created_at_ms
FROM ssh_cert_requests ORDER BY created_at_ms, request_id"#,
)?;
let rows = stmt.query_map([], stored_ssh_cert_request_from_row)?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn insert_ssh_certificate(
&self,
certificate: &StoredSshCertificate,
) -> Result<(), StoreError> {
self.conn.execute(
r#"INSERT OR REPLACE INTO ssh_certificates(
cert_id, request_id, certificate, certificate_fingerprint, imported_at_ms
) VALUES (?1, ?2, ?3, ?4, ?5)"#,
params![
certificate.cert_id,
certificate.request_id,
certificate.certificate,
certificate.certificate_fingerprint,
certificate.imported_at_ms
],
)?;
Ok(())
}
pub fn list_ssh_certificates(&self) -> Result<Vec<StoredSshCertificate>, StoreError> {
let mut stmt = self.conn.prepare(
r#"SELECT cert_id, request_id, certificate, certificate_fingerprint, imported_at_ms
FROM ssh_certificates ORDER BY imported_at_ms, cert_id"#,
)?;
let rows = stmt.query_map([], |row| {
Ok(StoredSshCertificate {
cert_id: row.get(0)?,
request_id: row.get(1)?,
certificate: row.get(2)?,
certificate_fingerprint: row.get(3)?,
imported_at_ms: row.get(4)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn insert_ssh_revocation(
&self,
revocation: &StoredSshRevocation,
) -> Result<(), StoreError> {
self.conn.execute(
r#"INSERT OR REPLACE INTO ssh_revocations(
revocation_id, kind, target, reason, created_at_ms, published
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#,
params![
revocation.revocation_id,
revocation.kind,
revocation.target,
revocation.reason,
revocation.created_at_ms,
if revocation.published { 1_i64 } else { 0_i64 }
],
)?;
Ok(())
}
pub fn list_ssh_revocations(&self) -> Result<Vec<StoredSshRevocation>, StoreError> {
let mut stmt = self.conn.prepare(
r#"SELECT revocation_id, kind, target, reason, created_at_ms, published
FROM ssh_revocations ORDER BY created_at_ms, revocation_id"#,
)?;
let rows = stmt.query_map([], |row| {
Ok(StoredSshRevocation {
revocation_id: row.get(0)?,
kind: row.get(1)?,
target: row.get(2)?,
reason: row.get(3)?,
created_at_ms: row.get(4)?,
published: row.get::<_, i64>(5)? != 0,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
}
fn stored_ssh_cert_request_from_row(
row: &rusqlite::Row<'_>,
) -> Result<StoredSshCertRequest, rusqlite::Error> {
let principals_json: String = row.get(5)?;
let principals = serde_json::from_str(&principals_json).map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, Box::new(error))
})?;
Ok(StoredSshCertRequest {
request_id: row.get(0)?,
requester_node: row.get(1)?,
public_key: row.get(2)?,
public_key_fingerprint: row.get(3)?,
cert_kind: row.get(4)?,
principals,
requested_validity: row.get(6)?,
renewal_of: row.get(7)?,
reason: row.get(8)?,
status: row.get(9)?,
created_at_ms: row.get(10)?,
})
}
#[derive(Clone, Debug, PartialEq, Eq)]
@ -217,6 +409,40 @@ pub struct CasObject {
pub path: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredSshCertRequest {
pub request_id: String,
pub requester_node: String,
pub public_key: String,
pub public_key_fingerprint: String,
pub cert_kind: String,
pub principals: Vec<String>,
pub requested_validity: Option<String>,
pub renewal_of: Option<String>,
pub reason: Option<String>,
pub status: String,
pub created_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredSshCertificate {
pub cert_id: String,
pub request_id: String,
pub certificate: String,
pub certificate_fingerprint: String,
pub imported_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredSshRevocation {
pub revocation_id: String,
pub kind: String,
pub target: String,
pub reason: Option<String>,
pub created_at_ms: i64,
pub published: bool,
}
#[must_use]
pub fn now_ms() -> i64 {
let now = std::time::SystemTime::now()
@ -237,4 +463,47 @@ mod tests {
let resources = store.list_resources().expect("resources");
assert!(resources.is_empty());
}
#[test]
fn ssh_cert_request_and_revocation_roundtrip() {
let store = Store::open_memory().expect("open");
let request = StoredSshCertRequest {
request_id: "ssh-cert-request:1".to_owned(),
requester_node: "node:laptop".to_owned(),
public_key: "ssh-ed25519 AAAA test".to_owned(),
public_key_fingerprint: "ssh:blake3:test".to_owned(),
cert_kind: "user".to_owned(),
principals: vec!["eric".to_owned()],
requested_validity: Some("+52w".to_owned()),
renewal_of: None,
reason: Some("renewal".to_owned()),
status: "pending".to_owned(),
created_at_ms: 1,
};
store
.insert_ssh_cert_request(&request)
.expect("insert request");
assert_eq!(
store
.get_ssh_cert_request("ssh-cert-request:1")
.expect("get request"),
Some(request.clone())
);
let revocation = StoredSshRevocation {
revocation_id: "ssh-revocation:1".to_owned(),
kind: "public-key".to_owned(),
target: "ssh:blake3:test".to_owned(),
reason: Some("lost key".to_owned()),
created_at_ms: 2,
published: true,
};
store
.insert_ssh_revocation(&revocation)
.expect("insert revocation");
assert_eq!(
store.list_ssh_revocations().expect("list revocations"),
vec![revocation]
);
}
}

View file

@ -57,6 +57,9 @@ string_id!(TopicId);
string_id!(AuthOpId);
string_id!(KeyId);
string_id!(SecretId);
string_id!(SshCertRequestId);
string_id!(SshCertId);
string_id!(SshRevocationId);
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct UnixMillis(pub i64);

View file

@ -18,5 +18,6 @@ geth-cli = { path = "../geth-cli" }
[dev-dependencies]
geth-cas = { path = "../geth-cas" }
geth-config = { path = "../geth-config" }
geth-control = { path = "../geth-control" }
geth-node = { path = "../geth-node" }
tempfile.workspace = true

View file

@ -103,3 +103,72 @@ fn initialized_node_can_roundtrip_cas_blob() {
b"hello geth integration"
);
}
#[test]
fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
let home = tempfile::tempdir().expect("tempdir");
let paths = geth_config::GethPaths::from_home(home.path());
let node = geth_node::init_node(&paths).expect("init node");
let public_key_path = home.path().join("id_ed25519.pub");
std::fs::write(&public_key_path, "ssh-ed25519 AAAATEST eric@geth\n").expect("write pubkey");
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::SshCertRequest {
public_key_path: public_key_path.clone(),
cert_kind: "user".to_owned(),
principals: vec!["eric".to_owned()],
requested_validity: Some("+52w".to_owned()),
renewal_of: None,
reason: Some("renewal".to_owned()),
},
)
.expect("request cert");
let request_id = match response {
geth_control::ControlResponse::SshCertRequested { request } => request.id.to_string(),
other => panic!("unexpected response: {other:?}"),
};
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::SshCertApprove {
request_id: request_id.clone(),
ca_key_path: home.path().join("ca_sk"),
valid_for: Some("+4w".to_owned()),
serial: Some(42),
out: None,
},
)
.expect("approve cert");
match response {
geth_control::ControlResponse::SshCertApproved { approval } => {
assert_eq!(approval.request_id.to_string(), request_id);
assert!(approval.signing_command.contains(&"ssh-keygen".to_owned()));
assert!(approval.signing_command.contains(&"42".to_owned()));
}
other => panic!("unexpected response: {other:?}"),
}
let export_path = home.path().join("revocations.jsonl");
geth_node::handle_request(
&node,
geth_control::ControlRequest::SshRevocationAdd {
kind: "public-key".to_owned(),
target: "ssh:blake3:test".to_owned(),
reason: Some("lost key".to_owned()),
},
)
.expect("add revocation");
geth_node::handle_request(
&node,
geth_control::ControlRequest::SshRevocationExport {
out: export_path.clone(),
},
)
.expect("export revocations");
assert!(
std::fs::read_to_string(export_path)
.expect("read revocations")
.contains("lost key")
);
}

View file

@ -12,7 +12,13 @@ The project ships one executable: `geth`. It has daemon mode with
Control commands use a local Unix socket. There are no separate `gethd` or
`gethctl` binaries.
The same binary also owns daemon service installation through `geth daemon
service ...`. Service installation targets user service managers: systemd user
units on Linux, launchd user agents on macOS, and per-user scheduled tasks on
Windows.
## Consequences
Packaging and user mental model stay simple. The daemon remains the owner of
identity, Iroh endpoint state, resources, and synchronized data structures.
The bootstrap avoids privileged system service installation.

View file

@ -10,7 +10,16 @@ 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.
Geth also manages OpenSSH certificate request, renewal, approval, import, and
revocation-list metadata. Signing is explicit: an approved request yields a
concrete `ssh-keygen -s ...` command that can be run on the machine holding the
CA key or attached YubiKey.
## Consequences
Knowing an Iroh EndpointID is insufficient to reach sshd. Geth authorization must
allow `ssh_proxy.connect` before any SSH/admin endpoint is opened.
Certificate and key revocations are durable geth metadata and should be
distributed over Iroh between authorized nodes. Discovery does not grant trust in
certificate requests, issued certificates, or revocation lists.

View file

@ -0,0 +1,39 @@
# ADR 0014: SSH Certificate Flows And Revocation Lists
## Status
Accepted.
## Decision
Geth manages OpenSSH certificate request, renewal, approval, import, and
revocation-list metadata.
A node may request a user or host certificate by submitting its public key,
principals, requested validity, and optional renewal target. An authorized
machine can approve that request. Approval does not silently sign inside the
daemon; it returns an explicit `ssh-keygen -s ...` command that can be run on the
machine with the CA private key or YubiKey-backed CA key. The resulting
`-cert.pub` file is imported back into geth for distribution.
Certificate and key revocations are durable records. The bootstrap stores and
exports them as JSONL. Future work may derive OpenSSH KRL files and replicate
signed revocation lists over Iroh.
## Consequences
SSH certificate lifecycle management becomes part of the SSH trust integration
layer without making SSH a geth transport. The daemon can coordinate requests and
distribution while the CA/YubiKey machine remains the explicit signing point.
Future authorization should protect capabilities such as:
- `ssh_cert.request`
- `ssh_cert.approve`
- `ssh_cert.import`
- `ssh_revocation.publish`
- `ssh_revocation.read`
The current bootstrap does not claim that revocation reaches offline nodes
immediately. Revocation distribution is eventual until stronger synchronization
and policy enforcement are implemented.

View file

@ -0,0 +1,29 @@
# ADR 0015: User Service Installation
## Status
Accepted.
## Decision
Geth provides `geth daemon service ...` commands to install, uninstall, start,
stop, inspect, and print daemon service definitions.
The service is always installed as a user service:
- Linux: systemd user unit, `~/.config/systemd/user/geth.service`
- macOS: launchd user agent, `~/Library/LaunchAgents/local.geth.daemon.plist`
- Windows: per-user scheduled task named `geth-daemon`
The service runs the same single binary as `geth daemon run` and preserves
`GETH_HOME` in the service environment.
## Consequences
There are still no separate `gethd` or `gethctl` binaries. Installation does not
require privileged system service access. Linux users who need the daemon before
interactive login may still need platform-specific user-service setup such as
systemd lingering.
Future service managers can be added behind the same service-manager enum and
CLI shape.

View file

@ -6,6 +6,12 @@ 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.
Service management is also exposed through the single binary. `geth daemon
service ...` installs and controls a user-level service definition for the local
daemon. The initial backends are systemd user units on Linux, launchd user agents
on macOS, and per-user scheduled tasks on Windows. Geth does not install itself
as a privileged system service.
## Iroh-Only Remote Communication
Remote geth node-to-node communication is Iroh-only. The daemon will own one
@ -17,6 +23,13 @@ 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.
SSH certificate flows use the same split. Nodes can request new OpenSSH
certificates or renewals through geth metadata. A machine with the CA key or
YubiKey can approve the request and run an explicit `ssh-keygen -s ...` command,
then import the resulting certificate for distribution. Certificate and key
revocations are stored as signed-list-ready records and will be replicated over
Iroh in later phases.
## Resource Model
Everything meaningful is modeled as a resource. Resources have a kind, name,
@ -42,6 +55,11 @@ 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.
`geth-ssh-identity` defines SSH trust namespaces plus certificate request,
approval, certificate import, and revocation-list data models. The bootstrap
persists these flows locally and exports revocations as JSONL. It does not yet
generate OpenSSH KRL binaries or replicate the lists over Iroh.
## Keychain, Auth, And Secrets
The identity plane is `geth-keychain`: admin keys, users, devices, nodes, agents,
@ -85,4 +103,10 @@ strong forward secrecy or post-compromise security.
- Bearer secrets are resource-scoped capabilities.
- Bearer access does not imply trust graph mutation rights.
- Authorization is capability-based and resource-scoped.
- SSH certificate issuance must be explicitly approved by an authorized
principal before signing.
- SSH certificate and key revocations are durable metadata that should be
distributed over Iroh, not fetched through unauthenticated discovery.
- Network and control decoders treat input as untrusted.
- Service installation targets user service managers, not system service
managers.

View file

@ -7,6 +7,7 @@
- local store
- local identity
- local CAS
- user service installer for systemd, launchd, and Windows scheduled tasks
- docs and ADRs
## Phase 1: Iroh Foundation
@ -16,10 +17,14 @@
- peer discovery
- peer exchange
- basic authenticated peer connection
- additional user service manager backends where useful
## Phase 2: Trust And Authorization
- SSH-admin-rooted keychain
- SSH certificate request and renewal queues
- CA/YubiKey approval workflow
- certificate and key revocation-list records
- signed keychain ops
- resource auth ops
- grants and revocations
@ -39,6 +44,8 @@
- dumbpipe-style streams
- TCP and Unix socket forwarding
- SSH proxy over Iroh
- SSH certificate and revocation distribution over Iroh
- OpenSSH KRL export/import support
- restricted geth admin shell
## Phase 5: DB And Documents