Add SSH cert flows and user service installer
This commit is contained in:
parent
26f81ff1ef
commit
f302342b1c
21 changed files with 2158 additions and 14 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
|||
/target/
|
||||
*.swp
|
||||
|
|
|
|||
7
Cargo.lock
generated
7
Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
31
README.md
31
README.md
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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(" ")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
622
crates/geth-node/src/service.rs
Normal file
622
crates/geth-node/src/service.rs
Normal 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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
39
docs/adr/0014-ssh-certificate-flows.md
Normal file
39
docs/adr/0014-ssh-certificate-flows.md
Normal 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.
|
||||
29
docs/adr/0015-user-service-installation.md
Normal file
29
docs/adr/0015-user-service-installation.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue