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:
Generated
+197
-3
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
+116
-6
@@ -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,8 +196,55 @@ 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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
// 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)?;
|
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?;
|
||||||
|
|||||||
Reference in New Issue
Block a user