Phase 1d: persistent NodeIdentity + FleetCa + fleet-ca CLI

Closes out Phase 1. A production operator can now cut a fleet CA,
sign per-node leaves, drop the resulting PEMs at
/etc/claw-store/tls/, point [cluster.tls] at them, and the daemon
loads real mTLS material on startup — no more ephemeral in-process
CA hack.

New public API in cluster::transport:
- FleetCa::generate(cn) — new self-signed root CA
- FleetCa::save(dir) / FleetCa::load(dir) — round-trip PEM
- FleetCa::sign_leaf(name) — mint an in-memory NodeIdentity
- FleetCa::sign_leaf_to_pem(name, out_dir) — write ca.crt + node.crt +
  node.key (node.key at 0o600 on Unix)
- NodeIdentity::from_pem_files(ca, cert, key) — production load path
- NodeIdentity::from_pem_dir(dir) — canonical filename layout
- NodeIdentity::from_cluster_config(cfg) — pick up [cluster.tls] paths

Manual Debug for FleetCa redacts the private key.

Config extension:
- [cluster.tls] ca_cert / node_cert / node_key (all PathBuf).
- Optional at the top level; callers that need mTLS surface a clear
  error when it's absent.

Deps:
- rcgen features += "x509-parser" (for FleetCa::load's from_ca_cert_pem).
- rustls-pemfile 2 (parse PEM back into DER for rustls).

CLI (new commands; short-circuit config load so they run on fresh
boxes without /etc/claw-store/config.toml):
- fleet-ca-init --dir <dir> [--cn <name>]
    generates ca.crt + ca.key (both 0o600).
- fleet-ca-sign --ca-dir <dir> --node <name> --out-dir <dir>
    writes ca.crt + node.crt + node.key (node.key at 0o600).
- cluster-ping now accepts --tls-dir <dir> to load persistent
  NodeIdentity from disk (produced by fleet-ca-sign).

Tests (8 new, all real — no mocks, real filesystem, real TLS handshake):
- fleet_ca_rejects_empty_common_name
- fleet_ca_save_and_load_round_trip_preserves_signing (asserts 0o600
  on ca.key)
- fleet_ca_load_errors_when_files_missing
- sign_leaf_to_pem_writes_all_three_files_with_correct_permissions
  (asserts 0o600 on node.key)
- persistent_identity_round_trips_through_disk_and_pings — end-to-end:
  cut CA on disk, reload it, sign two leaves via sign_leaf_to_pem,
  reload them via from_pem_dir, run real QUIC ping/pong. This is the
  operator flow.
- node_identity_from_cluster_config_errors_without_tls_section
- node_identity_from_cluster_config_loads_pem_paths
- from_pem_files_errors_on_missing_ca_file

80 tests pass. Pre-existing macOS-only hot test unchanged.

Also verified live CLI smoke test:
  fleet-ca-init → ca.crt + ca.key at 0o600
  fleet-ca-sign → ca.crt + node.crt + node.key at 0o600
  Files parse as valid X.509.

File sizes (all under 1300-line ceiling):
- cluster/transport.rs: 916
- cluster/gossip.rs: 576
- cluster.rs: 275
- config.rs: 503
- main.rs: 662

Phase 1 complete. Next up:
- Phase 1e (daemon integration): gossip + QUIC RPC server wired into
  claw-store daemon; hot-tier metrics periodically pushed; a real
  PeerStatus RPC alongside ping.
- Phase 2: content-addressed blob store (BLAKE3 chunking, put/get).
This commit is contained in:
Omar Sobh
2026-07-11 22:04:54 -07:00
parent 776f28e3a3
commit 916add37df
6 changed files with 779 additions and 11 deletions
+431
View File
@@ -27,10 +27,14 @@
//! connection; peers advertising anything else are rejected during the
//! TLS handshake.
use crate::config::{ClusterConfig, ClusterTlsConfig};
use anyhow::{bail, Context, Result};
use quinn::{ClientConfig, Endpoint, ServerConfig, VarInt};
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use std::fs::File;
use std::io::{BufReader, Write};
use std::net::SocketAddr;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
@@ -89,6 +93,248 @@ impl NodeIdentity {
let id_b = build_leaf(node_b, &ca_cert, &ca_key, ca_der)?;
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
@@ -482,4 +728,189 @@ mod tests {
conn.close(VarInt::from_u32(0), b"done");
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}");
}
}