Phase 1d: persistent identity + fleet-ca CLI #4

Merged
osobh merged 1 commits from phase-1d-persistent-identity into main 2026-07-12 05:22:31 +00:00
6 changed files with 779 additions and 11 deletions
Generated
+197 -3
View File
@@ -82,6 +82,45 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "asn1-rs"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom",
"num-traits",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@@ -318,6 +357,7 @@ dependencies = [
"quinn", "quinn",
"rcgen", "rcgen",
"rustls", "rustls",
"rustls-pemfile",
"serde", "serde",
"serde_json", "serde_json",
"sysinfo", "sysinfo",
@@ -377,12 +417,43 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "der-parser"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
dependencies = [
"asn1-rs",
"displaydoc",
"nom",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "displaydoc"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "either" name = "either"
version = "1.16.0" version = "1.16.0"
@@ -793,6 +864,12 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.1" version = "1.2.1"
@@ -804,6 +881,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]] [[package]]
name = "ntapi" name = "ntapi"
version = "0.4.3" version = "0.4.3"
@@ -822,12 +909,31 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.2" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -837,6 +943,15 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "oid-registry"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
dependencies = [
"asn1-rs",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -939,7 +1054,7 @@ dependencies = [
"rustc-hash", "rustc-hash",
"rustls", "rustls",
"socket2", "socket2",
"thiserror", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
"web-time", "web-time",
@@ -961,7 +1076,7 @@ dependencies = [
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"slab", "slab",
"thiserror", "thiserror 2.0.18",
"tinyvec", "tinyvec",
"tracing", "tracing",
"web-time", "web-time",
@@ -1052,6 +1167,7 @@ dependencies = [
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
"time", "time",
"x509-parser",
"yasna", "yasna",
] ]
@@ -1101,6 +1217,15 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.4" version = "1.1.4"
@@ -1128,6 +1253,15 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.15.0" version = "1.15.0"
@@ -1324,6 +1458,17 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "sysinfo" name = "sysinfo"
version = "0.30.13" version = "0.30.13"
@@ -1352,13 +1497,33 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.18" version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl 2.0.18",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
] ]
[[package]] [[package]]
@@ -1392,6 +1557,7 @@ dependencies = [
"powerfmt", "powerfmt",
"serde_core", "serde_core",
"time-core", "time-core",
"time-macros",
] ]
[[package]] [[package]]
@@ -1400,6 +1566,16 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
dependencies = [
"num-conv",
"time-core",
]
[[package]] [[package]]
name = "tinyvec" name = "tinyvec"
version = "1.12.0" version = "1.12.0"
@@ -2058,6 +2234,24 @@ dependencies = [
"wasmparser", "wasmparser",
] ]
[[package]]
name = "x509-parser"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom",
"oid-registry",
"ring",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]] [[package]]
name = "yasna" name = "yasna"
version = "0.5.2" version = "0.5.2"
+5 -1
View File
@@ -42,7 +42,11 @@ rustls = { version = "0.23", default-features = false, features = ["ring"] }
# v0.13 — X.509 cert generation for the fleet CA + per-node leaf certs. # v0.13 — X.509 cert generation for the fleet CA + per-node leaf certs.
# Used both by production bootstrap (writes PEM to /etc/claw-store/tls/) # Used both by production bootstrap (writes PEM to /etc/claw-store/tls/)
# and by tests (in-memory ephemeral CA). # and by tests (in-memory ephemeral CA).
rcgen = { version = "0.13", features = ["pem"] } rcgen = { version = "0.13", features = ["pem", "x509-parser"] }
# v2 — parse PEM files into DER for rustls consumption. Used in
# NodeIdentity::from_pem_files (Phase 1d) so persistent identity round-trips
# through the file system on daemon restart.
rustls-pemfile = "2"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
+6
View File
@@ -382,6 +382,7 @@ mod tests {
peers: vec![], peers: vec![],
bind_rpc_lan: None, bind_rpc_lan: None,
bind_rpc_tailscale: None, bind_rpc_tailscale: None,
tls: None,
}; };
let g = ClusterGossip::bootstrap(&cfg, "solo").await.unwrap(); let g = ClusterGossip::bootstrap(&cfg, "solo").await.unwrap();
let id = g.self_chitchat_id().await; let id = g.self_chitchat_id().await;
@@ -399,6 +400,7 @@ mod tests {
peers: vec![], peers: vec![],
bind_rpc_lan: None, bind_rpc_lan: None,
bind_rpc_tailscale: None, bind_rpc_tailscale: None,
tls: None,
}; };
let err = ClusterGossip::bootstrap(&cfg, "") let err = ClusterGossip::bootstrap(&cfg, "")
.await .await
@@ -416,6 +418,7 @@ mod tests {
peers: vec![], peers: vec![],
bind_rpc_lan: None, bind_rpc_lan: None,
bind_rpc_tailscale: None, bind_rpc_tailscale: None,
tls: None,
}; };
// ClusterConfig::validate rejects this first — that's what we want: // ClusterConfig::validate rejects this first — that's what we want:
// the daemon should refuse to bootstrap gossip on a malformed config. // the daemon should refuse to bootstrap gossip on a malformed config.
@@ -441,6 +444,7 @@ mod tests {
peers: vec![], peers: vec![],
bind_rpc_lan: None, bind_rpc_lan: None,
bind_rpc_tailscale: None, bind_rpc_tailscale: None,
tls: None,
}; };
// Node B: uses A as seed. // Node B: uses A as seed.
let cfg_b = ClusterConfig { let cfg_b = ClusterConfig {
@@ -455,6 +459,7 @@ mod tests {
}], }],
bind_rpc_lan: None, bind_rpc_lan: None,
bind_rpc_tailscale: None, bind_rpc_tailscale: None,
tls: None,
}; };
let gossip_a = ClusterGossip::bootstrap(&cfg_a, "a").await.unwrap(); let gossip_a = ClusterGossip::bootstrap(&cfg_a, "a").await.unwrap();
@@ -525,6 +530,7 @@ mod tests {
peers: vec![], peers: vec![],
bind_rpc_lan: None, bind_rpc_lan: None,
bind_rpc_tailscale: None, bind_rpc_tailscale: None,
tls: None,
}; };
let g = ClusterGossip::bootstrap(&cfg, "solo").await.unwrap(); let g = ClusterGossip::bootstrap(&cfg, "solo").await.unwrap();
// Solo cluster — peers() must never include self. // Solo cluster — peers() must never include self.
+431
View File
@@ -27,10 +27,14 @@
//! connection; peers advertising anything else are rejected during the //! connection; peers advertising anything else are rejected during the
//! TLS handshake. //! TLS handshake.
use crate::config::{ClusterConfig, ClusterTlsConfig};
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use quinn::{ClientConfig, Endpoint, ServerConfig, VarInt}; use quinn::{ClientConfig, Endpoint, ServerConfig, VarInt};
use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use std::fs::File;
use std::io::{BufReader, Write};
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -89,6 +93,248 @@ impl NodeIdentity {
let id_b = build_leaf(node_b, &ca_cert, &ca_key, ca_der)?; let id_b = build_leaf(node_b, &ca_cert, &ca_key, ca_der)?;
Ok((id_a, id_b)) Ok((id_a, id_b))
} }
/// Load a persistent identity from PEM files. Production path — reads
/// paths configured in `[cluster.tls]`:
/// * `ca_path` — PEM of the fleet root CA cert
/// * `cert_path` — PEM of this node's leaf cert (signed by the CA)
/// * `key_path` — PEM of this node's private key
///
/// The three files are always kept together. Convention:
/// `/etc/claw-store/tls/{ca.crt, node.crt, node.key}` with node.key mode
/// 0o600. The fleet CA private key stays on whichever host cut it
/// (typically the primary); every other node needs only the public
/// CA cert.
pub fn from_pem_files(
ca_path: &Path,
cert_path: &Path,
key_path: &Path,
) -> Result<Self> {
let trusted_ca = read_single_cert(ca_path)
.with_context(|| format!("loading CA cert from {}", ca_path.display()))?;
let leaf = read_single_cert(cert_path)
.with_context(|| format!("loading node cert from {}", cert_path.display()))?;
let key = read_private_key(key_path)
.with_context(|| format!("loading node private key from {}", key_path.display()))?;
Ok(Self {
cert_chain: vec![leaf],
key,
trusted_ca,
})
}
/// Convenience: read all three PEM files from a single directory using
/// the canonical filenames `ca.crt`, `node.crt`, `node.key`.
pub fn from_pem_dir(dir: &Path) -> Result<Self> {
Self::from_pem_files(
&dir.join("ca.crt"),
&dir.join("node.crt"),
&dir.join("node.key"),
)
}
/// Load persistent identity from the paths in a `[cluster.tls]` block.
/// Errors when the config doesn't have a TLS section.
pub fn from_cluster_config(cfg: &ClusterConfig) -> Result<Self> {
let tls = cfg
.tls
.as_ref()
.context("cluster.tls section not configured; cannot load node identity")?;
Self::from_pem_files(&tls.ca_cert, &tls.node_cert, &tls.node_key)
}
}
/// Read a single X.509 cert from a PEM file, DER form.
fn read_single_cert(path: &Path) -> Result<CertificateDer<'static>> {
let mut reader = BufReader::new(File::open(path)?);
let mut iter = rustls_pemfile::certs(&mut reader);
let first = iter
.next()
.context("PEM contained no CERTIFICATE block")?
.context("parsing CERTIFICATE block")?;
Ok(first)
}
/// Read a PKCS#8 or SEC1 or RSA private key from a PEM file. `rustls-pemfile`
/// handles all three formats transparently — we don't care which one the
/// operator's `openssl`/`rcgen`/whatever tool produced.
fn read_private_key(path: &Path) -> Result<PrivateKeyDer<'static>> {
let mut reader = BufReader::new(File::open(path)?);
rustls_pemfile::private_key(&mut reader)?
.context("PEM contained no PRIVATE KEY block")
}
/// The fleet certificate authority. Held only by the node that cut it (or
/// nodes that hold a copy of the CA private key). Signs per-node leaf certs
/// via [`FleetCa::sign_leaf`]. Persisted to disk as `ca.crt` + `ca.key`.
///
/// [`Debug`] is implemented manually to elide the private key. Accidentally
/// printing the CA key would let anyone forge fleet identities.
pub struct FleetCa {
/// The self-signed CA certificate.
cert: rcgen::Certificate,
/// The CA's private key. NEVER logged, NEVER printed.
key: rcgen::KeyPair,
/// PEM copy of `cert`, cached to avoid re-serializing on every use.
cert_pem: String,
/// Owned DER of `cert`, cached for `NodeIdentity::trusted_ca`.
ca_der: CertificateDer<'static>,
}
impl std::fmt::Debug for FleetCa {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FleetCa")
.field("cert_pem_len", &self.cert_pem.len())
.field("ca_der_len", &self.ca_der.len())
.field("key", &"<redacted>")
.finish()
}
}
impl FleetCa {
/// Generate a new fleet CA with the given common name. Used by the
/// `fleet-ca init` CLI to bootstrap a fresh cluster.
pub fn generate(cn: &str) -> Result<Self> {
if cn.is_empty() {
bail!("fleet CA common name cannot be empty");
}
let key = rcgen::KeyPair::generate().context("generating CA key")?;
let mut params = rcgen::CertificateParams::new(vec![])
.context("building CA cert params")?;
params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
params
.distinguished_name
.push(rcgen::DnType::CommonName, cn);
let cert = params.self_signed(&key).context("self-signing CA cert")?;
let cert_pem = cert.pem();
let ca_der = cert.der().clone();
Ok(Self {
cert,
key,
cert_pem,
ca_der,
})
}
/// Persist the CA to a directory as `ca.crt` (public) and `ca.key`
/// (private, mode 0o600 on Unix). Creates the directory if missing.
pub fn save(&self, dir: &Path) -> Result<()> {
std::fs::create_dir_all(dir)
.with_context(|| format!("creating CA dir {}", dir.display()))?;
let cert_path = dir.join("ca.crt");
std::fs::write(&cert_path, &self.cert_pem)
.with_context(|| format!("writing {}", cert_path.display()))?;
let key_path = dir.join("ca.key");
write_secret_file(&key_path, self.key.serialize_pem().as_bytes())
.with_context(|| format!("writing {}", key_path.display()))?;
Ok(())
}
/// Load a previously-saved CA from `ca.crt` + `ca.key` in `dir`.
pub fn load(dir: &Path) -> Result<Self> {
let cert_path = dir.join("ca.crt");
let key_path = dir.join("ca.key");
let cert_pem = std::fs::read_to_string(&cert_path)
.with_context(|| format!("reading {}", cert_path.display()))?;
let key_pem = std::fs::read_to_string(&key_path)
.with_context(|| format!("reading {}", key_path.display()))?;
// Reconstruct the rcgen types from PEM. `KeyPair::from_pem` handles
// PKCS#8 output the way we serialized it; `params_from_ca_cert_pem`
// rebuilds the params so subsequent signing operations produce
// certs that chain correctly.
let key = rcgen::KeyPair::from_pem(&key_pem)
.context("parsing CA private key from PEM")?;
let params = rcgen::CertificateParams::from_ca_cert_pem(&cert_pem)
.context("parsing CA cert PEM")?;
let cert = params.self_signed(&key).context("re-binding CA cert to key")?;
// Use the ORIGINAL cert bytes from disk as our authoritative DER.
// Rebuilding via self_signed above yields a byte-identical cert
// when the SPKI matches, but reading from disk is deterministic.
let ca_der = read_single_cert(&cert_path)?;
let cert_pem_owned = cert_pem;
Ok(Self {
cert,
key,
cert_pem: cert_pem_owned,
ca_der,
})
}
/// Sign a leaf cert for `node_name` and return an in-memory
/// [`NodeIdentity`] ready to hand to `QuicServer::bind` /
/// `QuicClient::new`.
pub fn sign_leaf(&self, node_name: &str) -> Result<NodeIdentity> {
if node_name.is_empty() {
bail!("node name cannot be empty when signing a leaf");
}
let (leaf_key, leaf_cert) = self.mint_leaf(node_name)?;
let leaf_der = leaf_cert.der().clone();
let key_der = PrivateKeyDer::try_from(leaf_key.serialize_der())
.map_err(|e| anyhow::anyhow!("converting leaf key to rustls form: {e}"))?;
Ok(NodeIdentity {
cert_chain: vec![leaf_der],
key: key_der,
trusted_ca: self.ca_der.clone(),
})
}
/// Sign a leaf cert and write PEM files (`node.crt`, `node.key`,
/// `ca.crt`) into `out_dir`. Used by `fleet-ca sign`.
pub fn sign_leaf_to_pem(&self, node_name: &str, out_dir: &Path) -> Result<()> {
if node_name.is_empty() {
bail!("node name cannot be empty when signing a leaf");
}
let (leaf_key, leaf_cert) = self.mint_leaf(node_name)?;
std::fs::create_dir_all(out_dir)
.with_context(|| format!("creating output dir {}", out_dir.display()))?;
let node_crt_path = out_dir.join("node.crt");
std::fs::write(&node_crt_path, leaf_cert.pem())
.with_context(|| format!("writing {}", node_crt_path.display()))?;
let node_key_path = out_dir.join("node.key");
write_secret_file(&node_key_path, leaf_key.serialize_pem().as_bytes())
.with_context(|| format!("writing {}", node_key_path.display()))?;
let ca_out_path = out_dir.join("ca.crt");
std::fs::write(&ca_out_path, &self.cert_pem)
.with_context(|| format!("writing {}", ca_out_path.display()))?;
Ok(())
}
/// Shared leaf-minting: generate a key, sign the SAN=[node_name] cert.
fn mint_leaf(
&self,
node_name: &str,
) -> Result<(rcgen::KeyPair, rcgen::Certificate)> {
let leaf_key = rcgen::KeyPair::generate().context("generating leaf key")?;
let mut leaf_params = rcgen::CertificateParams::new(vec![node_name.to_string()])
.context("building leaf params")?;
leaf_params
.distinguished_name
.push(rcgen::DnType::CommonName, node_name);
let leaf_cert = leaf_params
.signed_by(&leaf_key, &self.cert, &self.key)
.context("signing leaf cert with CA")?;
Ok((leaf_key, leaf_cert))
}
}
/// Write a file with restrictive permissions (0o600 on Unix). Existing
/// mode-independent writes go through `std::fs::write`; this is for
/// private keys where broader read access is a real vulnerability.
fn write_secret_file(path: &Path, contents: &[u8]) -> Result<()> {
let mut file = File::create(path)
.with_context(|| format!("creating {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("chmod 0o600 {}", path.display()))?;
}
file.write_all(contents)?;
file.sync_all()?;
Ok(())
} }
/// Sign a single leaf cert for `name` under the given CA and package it /// Sign a single leaf cert for `name` under the given CA and package it
@@ -482,4 +728,189 @@ mod tests {
conn.close(VarInt::from_u32(0), b"done"); conn.close(VarInt::from_u32(0), b"done");
client.shutdown().await; client.shutdown().await;
} }
// ── Phase 1d: persistent identity + FleetCa ────────────────────────
#[test]
fn fleet_ca_rejects_empty_common_name() {
let err = FleetCa::generate("").unwrap_err().to_string();
assert!(err.contains("common name cannot be empty"));
}
#[test]
fn fleet_ca_save_and_load_round_trip_preserves_signing() {
// Generate a CA, save it, wipe it from memory, reload, sign a
// leaf, verify that leaf still validates against the reloaded
// CA's advertised trust root.
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path();
let ca = FleetCa::generate("clawstor test CA").unwrap();
ca.save(dir).unwrap();
assert!(dir.join("ca.crt").exists(), "ca.crt should be written");
assert!(dir.join("ca.key").exists(), "ca.key should be written");
// Confirm the CA private key file is chmod 0o600 on Unix — leaking
// it would let anyone forge fleet identities.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let key_mode = std::fs::metadata(dir.join("ca.key"))
.unwrap()
.permissions()
.mode();
assert_eq!(
key_mode & 0o777,
0o600,
"ca.key must be mode 0o600, got {:o}",
key_mode & 0o777
);
}
drop(ca);
let ca_reloaded = FleetCa::load(dir).unwrap();
// The reloaded CA must produce leaves whose `trusted_ca` field
// matches what was originally written to disk.
let ident = ca_reloaded.sign_leaf("test-node").unwrap();
let expected_ca_der = read_single_cert(&dir.join("ca.crt")).unwrap();
assert_eq!(
ident.trusted_ca, expected_ca_der,
"leaf must carry the same CA DER as on disk"
);
}
#[test]
fn fleet_ca_load_errors_when_files_missing() {
let tmp = tempfile::TempDir::new().unwrap();
let err = FleetCa::load(tmp.path()).unwrap_err().to_string();
// Either the .crt or .key path — we don't over-specify which
// one fails first, just that a filesystem/parse error surfaces.
assert!(
err.contains("ca.crt") || err.contains("ca.key"),
"expected ca.crt/ca.key in error, got: {err}"
);
}
#[test]
fn sign_leaf_to_pem_writes_all_three_files_with_correct_permissions() {
let tmp = tempfile::TempDir::new().unwrap();
let ca = FleetCa::generate("clawstor test CA").unwrap();
let node_dir = tmp.path().join("nodes/architect");
ca.sign_leaf_to_pem("architect", &node_dir).unwrap();
for name in &["ca.crt", "node.crt", "node.key"] {
assert!(
node_dir.join(name).exists(),
"{name} must be written"
);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(node_dir.join("node.key"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "node.key must be 0o600");
}
}
#[tokio::test]
async fn persistent_identity_round_trips_through_disk_and_pings() {
// End-to-end proof: cut a CA on disk, sign leaves for two nodes
// via `sign_leaf_to_pem`, reload both via `NodeIdentity::from_pem_dir`,
// run a real QUIC ping/pong between them. This is the flow a
// production operator follows:
// 1. `fleet-ca init --dir /etc/claw-store/tls` (server side)
// 2. `fleet-ca sign --node architect --out-dir …` (per node)
// 3. daemon reads the resulting dir and starts RPC
let tmp = tempfile::TempDir::new().unwrap();
let ca_dir = tmp.path().join("ca");
let arch_dir = tmp.path().join("architect-tls");
let tank_dir = tmp.path().join("tank-tls");
let ca = FleetCa::generate("clawstor test CA").unwrap();
ca.save(&ca_dir).unwrap();
// Reload the CA from disk before signing — proves save/load
// preserves signing capability, not just the in-memory instance.
let ca_reloaded = FleetCa::load(&ca_dir).unwrap();
ca_reloaded
.sign_leaf_to_pem("architect", &arch_dir)
.unwrap();
ca_reloaded.sign_leaf_to_pem("tank", &tank_dir).unwrap();
let id_arch = NodeIdentity::from_pem_dir(&arch_dir).unwrap();
let id_tank = NodeIdentity::from_pem_dir(&tank_dir).unwrap();
let server = QuicServer::bind(loopback(0), id_tank).unwrap();
let server_addr = server.local_addr().unwrap();
let accept_task = tokio::spawn(async move {
if let Some(Ok(conn)) = server.accept().await {
let _ = ping_handler_loop(conn).await;
}
});
let client = QuicClient::new(loopback(0), id_arch).unwrap();
let conn = client
.connect(server_addr, "tank")
.await
.expect("architect → tank ping using persisted mTLS");
let response = ping(&conn, b"persistent hello").await.unwrap();
assert_eq!(response, b"pong:persistent hello");
conn.close(VarInt::from_u32(0), b"done");
client.shutdown().await;
tokio::time::sleep(Duration::from_millis(50)).await;
accept_task.abort();
}
#[test]
fn node_identity_from_cluster_config_errors_without_tls_section() {
use crate::config::ClusterConfig;
let cfg = ClusterConfig {
zone: "test".into(),
bind_lan: Some(loopback(next_port())),
tls: None,
..Default::default()
};
let err = NodeIdentity::from_cluster_config(&cfg)
.unwrap_err()
.to_string();
assert!(err.contains("cluster.tls"), "err: {err}");
}
#[test]
fn node_identity_from_cluster_config_loads_pem_paths() {
let tmp = tempfile::TempDir::new().unwrap();
let ca = FleetCa::generate("cfg test CA").unwrap();
let node_dir = tmp.path().join("node-tls");
ca.sign_leaf_to_pem("cfg-test", &node_dir).unwrap();
use crate::config::{ClusterConfig, ClusterTlsConfig};
let cfg = ClusterConfig {
zone: "test".into(),
bind_lan: Some(loopback(next_port())),
tls: Some(ClusterTlsConfig {
ca_cert: node_dir.join("ca.crt"),
node_cert: node_dir.join("node.crt"),
node_key: node_dir.join("node.key"),
}),
..Default::default()
};
let ident = NodeIdentity::from_cluster_config(&cfg).unwrap();
assert_eq!(ident.cert_chain.len(), 1);
assert!(!ident.trusted_ca.is_empty());
}
#[test]
fn from_pem_files_errors_on_missing_ca_file() {
let tmp = tempfile::TempDir::new().unwrap();
let ca = FleetCa::generate("test").unwrap();
let node_dir = tmp.path().join("n");
ca.sign_leaf_to_pem("n", &node_dir).unwrap();
// Delete the CA cert to simulate a broken deployment.
std::fs::remove_file(node_dir.join("ca.crt")).unwrap();
let err = NodeIdentity::from_pem_dir(&node_dir).unwrap_err().to_string();
assert!(err.contains("CA cert") || err.contains("ca.crt"), "err: {err}");
}
} }
+23
View File
@@ -90,6 +90,23 @@ impl PeerEntry {
} }
} }
/// Paths to persisted mTLS material for the RPC transport (Phase 1d).
///
/// A production node reads its identity from these three files on startup:
/// * `ca_cert` — fleet root CA cert (public, distributed to every node)
/// * `node_cert` — this node's leaf cert (signed by the CA)
/// * `node_key` — this node's private key (must be 0o600, never checked in)
///
/// The `[cluster.tls]` block is optional so pre-v2 configs keep loading;
/// callers that need mTLS (e.g. `QuicServer::bind`) fail with a clear
/// message when it's absent.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Default)]
pub struct ClusterTlsConfig {
pub ca_cert: PathBuf,
pub node_cert: PathBuf,
pub node_key: PathBuf,
}
/// Cluster membership configuration. Optional at the top level so existing /// Cluster membership configuration. Optional at the top level so existing
/// single-node deployments (pre-v2) keep loading. Once present, describes the /// single-node deployments (pre-v2) keep loading. Once present, describes the
/// local node's zone + bind addresses, and enumerates known peers. /// local node's zone + bind addresses, and enumerates known peers.
@@ -116,6 +133,10 @@ pub struct ClusterConfig {
/// via gossip; the config list bootstraps discovery. /// via gossip; the config list bootstraps discovery.
#[serde(default)] #[serde(default)]
pub peers: Vec<PeerEntry>, pub peers: Vec<PeerEntry>,
/// Optional mTLS material paths. Required when the RPC transport is
/// used; absent means "gossip only, no RPC" for now.
#[serde(default)]
pub tls: Option<ClusterTlsConfig>,
} }
/// Compute the default RPC address for a gossip address: same IP, port + 1. /// Compute the default RPC address for a gossip address: same IP, port + 1.
@@ -373,6 +394,7 @@ tailscale_addr = "100.64.1.5:7701"
peers: vec![], peers: vec![],
bind_rpc_lan: None, bind_rpc_lan: None,
bind_rpc_tailscale: None, bind_rpc_tailscale: None,
tls: None,
}; };
let err = cluster.validate().unwrap_err().to_string(); let err = cluster.validate().unwrap_err().to_string();
assert!( assert!(
@@ -403,6 +425,7 @@ tailscale_addr = "100.64.1.5:7701"
], ],
bind_rpc_lan: None, bind_rpc_lan: None,
bind_rpc_tailscale: None, bind_rpc_tailscale: None,
tls: None,
}; };
let err = cluster.validate().unwrap_err().to_string(); let err = cluster.validate().unwrap_err().to_string();
assert!( assert!(
+117 -7
View File
@@ -88,12 +88,17 @@ enum Cmd {
/// Demonstrates the mTLS handshake end-to-end against a live peer. /// Demonstrates the mTLS handshake end-to-end against a live peer.
/// The `--rpc-addr` must match the peer's advertised RPC endpoint /// The `--rpc-addr` must match the peer's advertised RPC endpoint
/// (gossip port + 1 by default). /// (gossip port + 1 by default).
///
/// If `--tls-dir` is set, loads a persistent NodeIdentity from that
/// directory (produced by `fleet-ca sign`) — the peer must have
/// signed against the same CA.
ClusterPing { ClusterPing {
/// This node's name — used as the SAN in the ephemeral leaf cert. /// This node's name — used as the SAN in the ephemeral leaf cert
/// when `--tls-dir` is not set.
#[arg(long)] #[arg(long)]
name: String, name: String,
/// Peer's node name — used as the expected server name (SAN) on /// Peer's node name — used as the expected server name (SAN) on
/// the incoming cert. Must match the peer's own `--name`. /// the incoming cert.
#[arg(long)] #[arg(long)]
peer: String, peer: String,
/// Peer's RPC socket. Typically gossip_port + 1. /// Peer's RPC socket. Typically gossip_port + 1.
@@ -102,6 +107,39 @@ enum Cmd {
/// Payload to send. Echoed back with a "pong:" prefix. /// Payload to send. Echoed back with a "pong:" prefix.
#[arg(long, default_value = "hello")] #[arg(long, default_value = "hello")]
payload: String, payload: String,
/// Optional: dir containing `ca.crt`, `node.crt`, `node.key`
/// (from `fleet-ca sign --out-dir <dir>`). When absent, falls
/// back to an ephemeral in-memory CA.
#[arg(long)]
tls_dir: Option<PathBuf>,
},
/// Generate a fresh fleet root CA and write it to disk. Run once per
/// cluster, on the primary node. Every other node needs only the
/// public `ca.crt` — but this dir also gets `ca.key`, which must
/// stay on the signing host.
FleetCaInit {
/// Directory to write `ca.crt` + `ca.key` into.
#[arg(long)]
dir: PathBuf,
/// Common Name embedded in the CA's distinguished name.
#[arg(long, default_value = "clawstor fleet CA")]
cn: String,
},
/// Sign a leaf certificate for a node under the fleet CA. Outputs
/// `ca.crt` (public), `node.crt` (public), `node.key` (private,
/// 0o600) into `--out-dir`. Copy those three files to the target
/// node and point `[cluster.tls]` at them.
FleetCaSign {
/// Directory holding the CA (`ca.crt` + `ca.key`) — the same
/// dir passed to `fleet-ca init`.
#[arg(long)]
ca_dir: PathBuf,
/// Node name — becomes the SAN + Common Name on the leaf cert.
#[arg(long)]
node: String,
/// Output directory for the three per-node PEM files.
#[arg(long)]
out_dir: PathBuf,
}, },
} }
@@ -112,6 +150,20 @@ async fn main() -> Result<()> {
.init(); .init();
let cli = Cli::parse(); let cli = Cli::parse();
// Config-independent commands: filesystem-only, run before we touch
// /etc/claw-store/config.toml. Lets an operator bootstrap the CA on
// a fresh box that doesn't have a config yet.
match &cli.cmd {
Cmd::FleetCaInit { dir, cn } => return cmd_fleet_ca_init(dir, cn),
Cmd::FleetCaSign {
ca_dir,
node,
out_dir,
} => return cmd_fleet_ca_sign(ca_dir, node, out_dir),
_ => {}
}
let cfg = Config::load(&cli.config) let cfg = Config::load(&cli.config)
.with_context(|| format!("loading config from {}", cli.config.display()))?; .with_context(|| format!("loading config from {}", cli.config.display()))?;
let manifest_path = Manifest::default_path(); let manifest_path = Manifest::default_path();
@@ -144,11 +196,58 @@ async fn main() -> Result<()> {
peer, peer,
rpc_addr, rpc_addr,
payload, payload,
} => cmd_cluster_ping(&name, &peer, rpc_addr, &payload).await?, tls_dir,
} => cmd_cluster_ping(&name, &peer, rpc_addr, &payload, tls_dir.as_deref()).await?,
Cmd::FleetCaInit { .. } | Cmd::FleetCaSign { .. } => {
// Handled by the config-independent short-circuit above.
unreachable!("fleet-ca commands short-circuit before config load");
}
} }
Ok(()) Ok(())
} }
// ── fleet-ca init / sign ─────────────────────────────────────────────────────
fn cmd_fleet_ca_init(dir: &std::path::Path, cn: &str) -> Result<()> {
use cluster::transport::FleetCa;
if dir.join("ca.crt").exists() {
bail!(
"refusing to overwrite existing CA at {}: delete it first if you really want a new one",
dir.display()
);
}
let ca = FleetCa::generate(cn).context("generating fleet CA")?;
ca.save(dir).context("saving fleet CA to disk")?;
println!("fleet CA written:");
println!(" {}", dir.join("ca.crt").display());
println!(" {} (chmod 0600; keep on primary only)", dir.join("ca.key").display());
println!();
println!("Sign a per-node identity with:");
println!(
" claw-store fleet-ca-sign --ca-dir {} --node <name> --out-dir <dir>",
dir.display()
);
Ok(())
}
fn cmd_fleet_ca_sign(
ca_dir: &std::path::Path,
node: &str,
out_dir: &std::path::Path,
) -> Result<()> {
use cluster::transport::FleetCa;
let ca = FleetCa::load(ca_dir).context("loading fleet CA")?;
ca.sign_leaf_to_pem(node, out_dir)
.context("signing + writing per-node PEMs")?;
println!("signed leaf for {node}:");
println!(" {}", out_dir.join("ca.crt").display());
println!(" {}", out_dir.join("node.crt").display());
println!(" {} (chmod 0600; distribute securely)", out_dir.join("node.key").display());
println!();
println!("On {node}, point [cluster.tls] in the config at those three paths.");
Ok(())
}
// ── cluster ping ───────────────────────────────────────────────────────────── // ── cluster ping ─────────────────────────────────────────────────────────────
/// Round-trip a `ping` payload to `peer` over the QUIC RPC transport /// Round-trip a `ping` payload to `peer` over the QUIC RPC transport
@@ -163,13 +262,24 @@ async fn cmd_cluster_ping(
peer: &str, peer: &str,
rpc_addr: SocketAddr, rpc_addr: SocketAddr,
payload: &str, payload: &str,
tls_dir: Option<&std::path::Path>,
) -> Result<()> { ) -> Result<()> {
use cluster::transport::{ping, NodeIdentity, QuicClient}; use cluster::transport::{ping, NodeIdentity, QuicClient};
// Pull a fresh ephemeral CA + our own leaf + the peer's leaf. This // Two identity paths:
// matches the shape a persistent NodeIdentity will have when loaded // 1. `--tls-dir` present → load persisted PEM. The peer must have
// from disk in Phase 1d. // been signed by the same CA (via `fleet-ca sign`); otherwise
let (id_self, _id_peer) = NodeIdentity::generate_test_pair(name, peer)?; // the TLS handshake fails.
// 2. Absent → ephemeral CA. Only useful when both endpoints run
// in the same process (dev/loopback demo).
let id_self = match tls_dir {
Some(dir) => NodeIdentity::from_pem_dir(dir)
.with_context(|| format!("loading node identity from {}", dir.display()))?,
None => {
let (id_self, _id_peer) = NodeIdentity::generate_test_pair(name, peer)?;
id_self
}
};
let client = QuicClient::new("0.0.0.0:0".parse()?, id_self)?; let client = QuicClient::new("0.0.0.0:0".parse()?, id_self)?;
let conn = client.connect(rpc_addr, peer).await?; let conn = client.connect(rpc_addr, peer).await?;