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
+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
/// single-node deployments (pre-v2) keep loading. Once present, describes the
/// local node's zone + bind addresses, and enumerates known peers.
@@ -116,6 +133,10 @@ pub struct ClusterConfig {
/// via gossip; the config list bootstraps discovery.
#[serde(default)]
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.
@@ -373,6 +394,7 @@ tailscale_addr = "100.64.1.5:7701"
peers: vec![],
bind_rpc_lan: None,
bind_rpc_tailscale: None,
tls: None,
};
let err = cluster.validate().unwrap_err().to_string();
assert!(
@@ -403,6 +425,7 @@ tailscale_addr = "100.64.1.5:7701"
],
bind_rpc_lan: None,
bind_rpc_tailscale: None,
tls: None,
};
let err = cluster.validate().unwrap_err().to_string();
assert!(