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:
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user