swarm-bao: agent certificates issued by a store-generated agent CA
An agent's store identity was signed in swarm-controller's memory by a CA a controller-host unit generated on disk, and the listener never trusted that CA. Agent leaves now come from the store itself: a `pki-agents` PKI mount whose root openbao generates internally, so the agent CA's key never exists outside the store. - swarm-bao-agent-pki (new, store host, as the bao granter): enables and tunes the mount, generates the root once (guarded on an empty issuer list, no replace branch), upserts the `swarm-agent` role (client certificates named `hive-agent-*` only, 90 days), caches the CA at /var/lib/swarm-bao-tls/agent-ca.pem and composes the listener bundle. - The listener's tls_client_ca_file is a new listener-client-ca.pem (client-ca.pem, then the agent CA). Host cert-auth roles still pin client-ca.pem, so an agent leaf satisfies no host role. swarm-bao-certs composes the same bundle before openbao starts. - openbao reads tls_client_ca_file only at start, so when the bundle changed after openbao started, swarm-bao-agent-pki restarts openbao.service in the container; under `seal = "shamir"` it prints the step instead. Once swarm-bao-certs has a cached CA, later boots start openbao with it and do not restart. - The controller policy gains exactly `update` on pki-agents/issue/swarm-agent. mint_and_verify now asks that role for the leaf (the store generates the key), writes the agent's cert-auth role pinning the issuing CA bao returned, and writes the agent's policy as render_agent alone: the hive-shared queue credential stanza is gone. - deploy.bao.agentPkiRoleName (must start `swarm-`, asserted with the other pki role names); swarm-controller gets SWARM_CONTROLLER_AGENT_PKI_MOUNT/_ROLE from the deploy.bao options. Deleted: swarm-controller-agent-ca and its options (agentCaFile, agentCaKeyFile), env, LoadCredential entries and assertion; agent_identity's Authority, rcgen signing and validity window; the rcgen and time dependencies of swarm-controller (rcgen leaves the workspace); policy::render_agent_with_queue and its tests. The CN-prefix assertion policy.rs said was owed is not: agent and host roles pin different CAs. Migration is re-creating each agent after deploy; that overwrites the stale role and policy. Closes #4756
This commit is contained in:
parent
5cd7f866f4
commit
6170e74a31
16 changed files with 894 additions and 925 deletions
203
Cargo.lock
generated
203
Cargo.lock
generated
|
|
@ -162,45 +162,6 @@ version = "1.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9dbc3a507a82b17ba0d98f6ce8fd6954ea0c8152e98009d36a40d8dcc8ce078a"
|
checksum = "9dbc3a507a82b17ba0d98f6ce8fd6954ea0c8152e98009d36a40d8dcc8ce078a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "asn1-rs"
|
|
||||||
version = "0.7.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
|
|
||||||
dependencies = [
|
|
||||||
"asn1-rs-derive",
|
|
||||||
"asn1-rs-impl",
|
|
||||||
"displaydoc",
|
|
||||||
"nom",
|
|
||||||
"num-traits",
|
|
||||||
"rusticata-macros",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"time",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "asn1-rs-derive"
|
|
||||||
version = "0.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.119",
|
|
||||||
"synstructure 0.13.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[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 2.0.119",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "assign"
|
name = "assign"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
|
|
@ -237,7 +198,7 @@ version = "0.50.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d83a251fa1a4c9d0fe6e816b7acd60549e473e08d14f27a1d992c2675abff05f"
|
checksum = "d83a251fa1a4c9d0fe6e816b7acd60549e473e08d14f27a1d992c2675abff05f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"memchr",
|
"memchr",
|
||||||
|
|
@ -347,7 +308,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum-core",
|
"axum-core",
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"form_urlencoded",
|
"form_urlencoded",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
|
@ -412,12 +373,6 @@ version = "0.22.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "base64"
|
|
||||||
version = "0.23.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64ct"
|
name = "base64ct"
|
||||||
version = "1.8.3"
|
version = "1.8.3"
|
||||||
|
|
@ -430,22 +385,13 @@ version = "0.19.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f3c067aa24dd4ed5c79cf222a38f260c8f23d3b82a062fba3f28c6fe563b753"
|
checksum = "7f3c067aa24dd4ed5c79cf222a38f260c8f23d3b82a062fba3f28c6fe563b753"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"blowfish",
|
"blowfish",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bit-vec"
|
|
||||||
version = "0.9.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.1"
|
version = "2.13.1"
|
||||||
|
|
@ -1089,20 +1035,6 @@ dependencies = [
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "der-parser"
|
|
||||||
version = "10.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
|
|
||||||
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"
|
||||||
|
|
@ -1808,7 +1740,7 @@ dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-nats",
|
"async-nats",
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
|
|
@ -2269,7 +2201,7 @@ version = "0.1.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
|
@ -2982,7 +2914,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fef37395fffb7c916f7109ab0d16d8ca599403dd8164d08c0d966176b66ede47"
|
checksum = "fef37395fffb7c916f7109ab0d16d8ca599403dd8164d08c0d966176b66ede47"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"gloo-utils",
|
"gloo-utils",
|
||||||
|
|
@ -3041,7 +2973,7 @@ version = "0.18.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2f48f304e553fb6200b1d7d1f77a88fd182076d4b25624e9dbfa42d6a37de35e"
|
checksum = "2f48f304e553fb6200b1d7d1f77a88fd182076d4b25624e9dbfa42d6a37de35e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"blake3",
|
"blake3",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
|
|
@ -3124,12 +3056,6 @@ 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 = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
|
|
@ -3185,16 +3111,6 @@ dependencies = [
|
||||||
"signatory",
|
"signatory",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[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 = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
|
|
@ -3204,31 +3120,12 @@ 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.47"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
|
||||||
dependencies = [
|
|
||||||
"num-traits",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
|
|
@ -3254,7 +3151,7 @@ version = "5.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
|
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"http",
|
"http",
|
||||||
|
|
@ -3277,15 +3174,6 @@ dependencies = [
|
||||||
"reqwest",
|
"reqwest",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "oid-registry"
|
|
||||||
version = "0.8.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
|
|
||||||
dependencies = [
|
|
||||||
"asn1-rs",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
|
|
@ -3360,7 +3248,7 @@ version = "0.32.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638"
|
checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"const-hex",
|
"const-hex",
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
"opentelemetry_sdk",
|
"opentelemetry_sdk",
|
||||||
|
|
@ -3434,16 +3322,6 @@ dependencies = [
|
||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pem"
|
|
||||||
version = "4.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120"
|
|
||||||
dependencies = [
|
|
||||||
"base64 0.23.1",
|
|
||||||
"serde_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
|
@ -3887,20 +3765,6 @@ dependencies = [
|
||||||
"rand_core 0.9.5",
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rcgen"
|
|
||||||
version = "0.14.10"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8"
|
|
||||||
dependencies = [
|
|
||||||
"pem",
|
|
||||||
"ring",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"time",
|
|
||||||
"x509-parser",
|
|
||||||
"yasna",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "readlock"
|
name = "readlock"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
|
|
@ -3980,7 +3844,7 @@ version = "0.13.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
|
|
@ -4142,7 +4006,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2c3b4f00112791b490acce57df1ce3eb3f88899b045bebcff8a29f75369640cc"
|
checksum = "2c3b4f00112791b490acce57df1ce3eb3f88899b045bebcff8a29f75369640cc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"as_variant",
|
"as_variant",
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"date_header",
|
"date_header",
|
||||||
"form_urlencoded",
|
"form_urlencoded",
|
||||||
|
|
@ -4259,15 +4123,6 @@ dependencies = [
|
||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[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 = "rustify"
|
name = "rustify"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
|
|
@ -4890,7 +4745,7 @@ dependencies = [
|
||||||
"async-nats",
|
"async-nats",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"forgejo-api",
|
"forgejo-api",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
|
@ -4907,7 +4762,6 @@ dependencies = [
|
||||||
"opentelemetry-otlp",
|
"opentelemetry-otlp",
|
||||||
"opentelemetry_sdk",
|
"opentelemetry_sdk",
|
||||||
"problem_details",
|
"problem_details",
|
||||||
"rcgen",
|
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
@ -4917,7 +4771,6 @@ dependencies = [
|
||||||
"swarm-matrix-client",
|
"swarm-matrix-client",
|
||||||
"swarm-queue-client",
|
"swarm-queue-client",
|
||||||
"swarm-secret-client",
|
"swarm-secret-client",
|
||||||
"time",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
|
|
@ -5321,7 +5174,7 @@ version = "0.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d"
|
checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
|
|
@ -5774,7 +5627,7 @@ checksum = "b98bf83c0992966775b8012f194b07b44928996163e5a05b741b43891571ae5b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"base64ct",
|
"base64ct",
|
||||||
"cbc",
|
"cbc",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
|
|
@ -6264,40 +6117,12 @@ dependencies = [
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "x509-parser"
|
|
||||||
version = "0.18.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
|
|
||||||
dependencies = [
|
|
||||||
"asn1-rs",
|
|
||||||
"data-encoding",
|
|
||||||
"der-parser",
|
|
||||||
"lazy_static",
|
|
||||||
"nom",
|
|
||||||
"oid-registry",
|
|
||||||
"ring",
|
|
||||||
"rusticata-macros",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"time",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xxhash-rust"
|
name = "xxhash-rust"
|
||||||
version = "0.8.17"
|
version = "0.8.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "985eec839aaf2a1270af8f4ebcf63cf9401cfd90f0902f97c28d9f104ffbde72"
|
checksum = "985eec839aaf2a1270af8f4ebcf63cf9401cfd90f0902f97c28d9f104ffbde72"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "yasna"
|
|
||||||
version = "0.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
|
|
||||||
dependencies = [
|
|
||||||
"bit-vec",
|
|
||||||
"time",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
|
|
|
||||||
14
Cargo.toml
14
Cargo.toml
|
|
@ -112,20 +112,6 @@ vaultrs = "0.8"
|
||||||
# resolves, since its `exec_with_empty` takes *its* `Endpoint` trait.
|
# resolves, since its `exec_with_empty` takes *its* `Endpoint` trait.
|
||||||
rustify = "0.7"
|
rustify = "0.7"
|
||||||
rustify_derive = "0.5"
|
rustify_derive = "0.5"
|
||||||
# Signs the per-agent client leaf `swarm-controller::agent_identity` mints.
|
|
||||||
# A library rather than an `openssl` shellout, which is what every other CA
|
|
||||||
# in this tree is (`glue-bao-tls.nix`, `hive-tls.nix`, `swarm-ca.nix`): those
|
|
||||||
# run once at deploy time and write to disk, this one runs per agent
|
|
||||||
# creation and must keep the private key it generates in memory long enough
|
|
||||||
# to log in with it and no longer. `ring` over `aws_lc_rs` because `ring` is
|
|
||||||
# already in the lock; the crypto backend is not otherwise load-bearing.
|
|
||||||
#
|
|
||||||
# `x509-parser`: `Issuer::from_ca_cert_pem` is gated behind it, and reading
|
|
||||||
# the authority back out of its own PEM is how a leaf inherits the subject and
|
|
||||||
# key identifier that make it chain. The ungated constructors take a
|
|
||||||
# `CertificateParams` the caller would have to restate by hand — two spellings
|
|
||||||
# of one authority, agreeing until the day they don't.
|
|
||||||
rcgen = { version = "0.14", features = ["x509-parser"] }
|
|
||||||
tower-http = { version = "0.7", features = ["fs"] }
|
tower-http = { version = "0.7", features = ["fs"] }
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
rmcp = { version = "2", default-features = false, features = [
|
rmcp = { version = "2", default-features = false, features = [
|
||||||
|
|
|
||||||
|
|
@ -55,16 +55,16 @@ strategy for every credential, including the mTLS leaf.
|
||||||
<!-- vale write-good.Passive = NO -->
|
<!-- vale write-good.Passive = NO -->
|
||||||
|
|
||||||
| store path | minter | reader — pulls at runtime, holds in memory | renewal |
|
| store path | minter | reader — pulls at runtime, holds in memory | renewal |
|
||||||
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `swarm/agents/<agent>/matrix/main` | `swarm-controller`, with the swarm's appservice token, at agent creation and in a five-minute pass | the agent container itself, under the certificate its hive passed in | the pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's |
|
| `swarm/agents/<agent>/matrix/main` | `swarm-controller`, with the swarm's appservice token, at agent creation and in a five-minute pass | the agent container itself, under the certificate its hive passed in | the pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's |
|
||||||
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated |
|
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated |
|
||||||
| `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl`, inside the `hive-matrix` container, once | `swarm-controller`, under its own certificate | none: the container keeps its copy and republishes it when the store's differs |
|
| `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl`, inside the `hive-matrix` container, once | `swarm-controller`, under its own certificate | none: the container keeps its copy and republishes it when the store's differs |
|
||||||
| `swarm/agents/<agent>/bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated |
|
| `swarm/agents/<agent>/bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), which generates the key, at `swarm-controller`'s request at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated |
|
||||||
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | the agent container itself, under its own certificate — the identity it presents to the swarm queue, naming that one agent rather than its hive | none: the secret is fixed for the life of the agent and is revoked by deleting the path. A rotation mechanism is tracked as separate work, because rotating this credential needs a reconnect path — a queue client holding a revoked secret doesn't find out until it reconnects |
|
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | the agent container itself, under its own certificate — the identity it presents to the swarm queue, naming that one agent rather than its hive | none: the secret is fixed for the life of the agent and is revoked by deleting the path. A rotation mechanism is tracked as separate work, because rotating this credential needs a reconnect path — a queue client holding a revoked secret doesn't find out until it reconnects |
|
||||||
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes); the agent re-fetches on a 10-minute timer |
|
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes); the agent re-fetches on a 10-minute timer |
|
||||||
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated |
|
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated |
|
||||||
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated |
|
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated |
|
||||||
| `swarm/hives/<hive>/queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated |
|
| `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated |
|
||||||
| `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated |
|
| `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated |
|
||||||
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated |
|
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -325,6 +325,14 @@ plus one leaf per principal it runs (the table below names the options). Those a
|
||||||
credentials that can't come out of the store, being what opens it; everything
|
credentials that can't come out of the store, being what opens it; everything
|
||||||
else a hive needs does.
|
else a hive needs does.
|
||||||
|
|
||||||
|
Agents are the one principal whose leaf the store issues. Its `pki-agents`
|
||||||
|
mount (`deploy.bao.agentPkiMountPath`) holds an agent CA generated inside the
|
||||||
|
store, and `swarm-controller`, already logged in under its own host leaf, asks
|
||||||
|
that mount's one role for a `hive-agent-<agent>` client certificate at agent
|
||||||
|
creation. Host roles never pin that CA and agent roles pin only it, so an
|
||||||
|
agent's certificate opens that agent's own `swarm/agents/<agent>/*` and
|
||||||
|
nothing else.
|
||||||
|
|
||||||
### Per-principal identities
|
### Per-principal identities
|
||||||
|
|
||||||
Bao matches a cert-auth role on the certificate's subject, so a certificate is an
|
Bao matches a cert-auth role on the certificate's subject, so a certificate is an
|
||||||
|
|
@ -414,7 +422,9 @@ hive domain behind the gateway, down. `network.exposeHostPorts` opens the port
|
||||||
on the bridge firewall and nowhere else.
|
on the bridge firewall and nowhere else.
|
||||||
|
|
||||||
Reaching the port grants nothing by itself: openbao answers nothing without a
|
Reaching the port grants nothing by itself: openbao answers nothing without a
|
||||||
client certificate signed by `deploy.bao.clientCaFile`. The passthrough carries
|
client certificate signed by `deploy.bao.clientCaFile` or by the store's own
|
||||||
|
agent CA. The listener reads both from `listener-client-ca.pem`, which no
|
||||||
|
cert-auth role pins. The passthrough carries
|
||||||
whichever certificate the reader presents, unchanged.
|
whichever certificate the reader presents, unchanged.
|
||||||
|
|
||||||
## The constraint that decides where the root lives
|
## The constraint that decides where the root lives
|
||||||
|
|
|
||||||
|
|
@ -356,6 +356,9 @@ let
|
||||||
# The swarm appservice token, read-only: the controller creates agents'
|
# The swarm appservice token, read-only: the controller creates agents'
|
||||||
# matrix accounts with it and never writes it. matrix-ctl mints and publishes
|
# matrix accounts with it and never writes it. matrix-ctl mints and publishes
|
||||||
# it (`matrixCtlPolicyText` below).
|
# it (`matrixCtlPolicyText` below).
|
||||||
|
#
|
||||||
|
# The agent PKI grant is its only path on that mount: it can ask the one role
|
||||||
|
# for a certificate, not write that role or reach the issuer.
|
||||||
controllerPolicyText = ''
|
controllerPolicyText = ''
|
||||||
path "auth/cert/certs/hive-*" {
|
path "auth/cert/certs/hive-*" {
|
||||||
capabilities = ["create", "update", "read", "delete"]
|
capabilities = ["create", "update", "read", "delete"]
|
||||||
|
|
@ -380,6 +383,10 @@ let
|
||||||
path "${credentialMountPath}/data/${swarmAppserviceTokenLeaf}" {
|
path "${credentialMountPath}/data/${swarmAppserviceTokenLeaf}" {
|
||||||
capabilities = ["read"]
|
capabilities = ["read"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
path "${agentPkiMountPath}/issue/${agentPkiRoleName}" {
|
||||||
|
capabilities = ["update"]
|
||||||
|
}
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# The identity that copies authelia's minted OIDC client secrets into the
|
# The identity that copies authelia's minted OIDC client secrets into the
|
||||||
|
|
@ -489,7 +496,9 @@ let
|
||||||
#
|
#
|
||||||
# ⚠️ NOT bao's own client-auth PKI. That one is ./glue-bao-tls.nix's
|
# ⚠️ NOT bao's own client-auth PKI. That one is ./glue-bao-tls.nix's
|
||||||
# self-signed CA under `/var/lib/swarm-bao-pki`, and it stays outside the
|
# self-signed CA under `/var/lib/swarm-bao-pki`, and it stays outside the
|
||||||
# store permanently: bao cannot issue the credential that opens bao.
|
# store permanently: bao cannot issue the credential a host opens bao with.
|
||||||
|
# Agent leaves come from `agentPkiMountPath`, requested by a principal that
|
||||||
|
# has already logged in.
|
||||||
#
|
#
|
||||||
# The mount's own root is generated into it by the bootstrap unit below and
|
# The mount's own root is generated into it by the bootstrap unit below and
|
||||||
# its private key never leaves — `root/generate/internal` keeps it inside
|
# its private key never leaves — `root/generate/internal` keeps it inside
|
||||||
|
|
@ -500,13 +509,6 @@ let
|
||||||
# and has to spell it the same way.
|
# and has to spell it the same way.
|
||||||
servicesPkiMountPath = baoDeploy.servicesPkiMountPath;
|
servicesPkiMountPath = baoDeploy.servicesPkiMountPath;
|
||||||
|
|
||||||
# The PKI mount agent client certificates are issued from. Its root is
|
|
||||||
# generated inside the store, so the agent CA's key never exists outside it.
|
|
||||||
# A mount of its own because the services mount holds exactly one issuer; a
|
|
||||||
# root apart from ./glue-bao-tls.nix's CA because that is what keeps an
|
|
||||||
# agent's certificate from satisfying any host role.
|
|
||||||
agentPkiMountPath = baoDeploy.agentPkiMountPath;
|
|
||||||
|
|
||||||
# Subject of the root generated into that mount. A label for a human reading
|
# Subject of the root generated into that mount. A label for a human reading
|
||||||
# a chain, not an identity anything authenticates against — same fall-through
|
# a chain, not an identity anything authenticates against — same fall-through
|
||||||
# ./swarm-ca.nix:29-37 uses, and for the same reason: a hive that has set
|
# ./swarm-ca.nix:29-37 uses, and for the same reason: a hive that has set
|
||||||
|
|
@ -594,6 +596,77 @@ let
|
||||||
}
|
}
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
# The PKI mount agent client certificates are issued from. Its root is
|
||||||
|
# generated inside the store, so the agent CA's key never exists outside it.
|
||||||
|
# A mount of its own because the services mount holds exactly one issuer; a
|
||||||
|
# root apart from ./glue-bao-tls.nix's CA because that is what keeps an
|
||||||
|
# agent's certificate from satisfying any host role.
|
||||||
|
agentPkiMountPath = baoDeploy.agentPkiMountPath;
|
||||||
|
agentPkiRoleName = baoDeploy.agentPkiRoleName;
|
||||||
|
|
||||||
|
# Every common name the agent role issues for: `swarm_secret_client`'s
|
||||||
|
# `policy::AGENT_PREFIX`, which names each agent's cert-auth role and
|
||||||
|
# policy too. Outside it the role refuses, so the controller cannot obtain
|
||||||
|
# a certificate for any other name.
|
||||||
|
agentCnGlob = "hive-agent-*";
|
||||||
|
|
||||||
|
# The anchor every agent's cert-auth role pins by value, so it is never
|
||||||
|
# replaced by a deploy. 262800h like `servicesPkiRootTtl`, and for the same
|
||||||
|
# reason: the mount is tuned to it before generation, or bao clamps it.
|
||||||
|
agentPkiRootTtl = "262800h";
|
||||||
|
|
||||||
|
# The agent leaf's window, pinned on the role. Nothing re-issues a leaf
|
||||||
|
# before it ends; an operator re-runs agent creation.
|
||||||
|
agentPkiLeafTtl = "2160h";
|
||||||
|
|
||||||
|
# The agent CA's certificate, cached on this host by `swarm-bao-agent-pki`,
|
||||||
|
# so `swarm-bao-certs` can compose the listener's bundle before the store
|
||||||
|
# is up. Public material.
|
||||||
|
agentCaCachePath = "${tlsDir}/agent-ca.pem";
|
||||||
|
|
||||||
|
# What the listener verifies client certificates against: `client-ca.pem`
|
||||||
|
# first, then the agent CA. A file of its own because every host cert-auth
|
||||||
|
# role pins `client-ca.pem`: with the agent CA in that file, every leaf the
|
||||||
|
# controller can request would satisfy every host role.
|
||||||
|
listenerClientCaPath = "${tlsDir}/listener-client-ca.pem";
|
||||||
|
|
||||||
|
# Writes `listenerClientCaPath` from `clientCaPath` and, when it parses, the
|
||||||
|
# cached agent CA. The file is replaced only when its bytes change, so its
|
||||||
|
# mtime says when the listener's trust last changed; `swarm-bao-agent-pki`
|
||||||
|
# restarts openbao on exactly that. Refuses, leaving the current file, when
|
||||||
|
# the result would not begin with `client-ca.pem`: host logins depend on it.
|
||||||
|
composeListenerBundle = ''
|
||||||
|
compose_listener_bundle() {
|
||||||
|
if [ ! -s ${clientCaPath} ]; then
|
||||||
|
echo "${clientCaPath} is missing or empty; not composing the listener bundle" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp -p ${tlsDir} .listener-client-ca.XXXXXX)"
|
||||||
|
cat ${clientCaPath} > "$tmp"
|
||||||
|
if [ -s ${agentCaCachePath} ]; then
|
||||||
|
if openssl x509 -noout -in ${agentCaCachePath} >/dev/null 2>&1; then
|
||||||
|
printf '\n' >> "$tmp"
|
||||||
|
cat ${agentCaCachePath} >> "$tmp"
|
||||||
|
else
|
||||||
|
echo "${agentCaCachePath} does not parse; the listener will not trust agent certificates" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if ! cmp -s -n "$(stat -c %s ${clientCaPath})" ${clientCaPath} "$tmp"; then
|
||||||
|
rm -f "$tmp"
|
||||||
|
echo "the composed listener bundle does not begin with ${clientCaPath}; leaving the current one" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if cmp -s "$tmp" ${listenerClientCaPath}; then
|
||||||
|
rm -f "$tmp"
|
||||||
|
else
|
||||||
|
chmod 0644 "$tmp"
|
||||||
|
mv -f "$tmp" ${listenerClientCaPath}
|
||||||
|
echo "wrote ${listenerClientCaPath}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
|
||||||
# ── the four principals that used to share the hive's own leaf ─────────────
|
# ── the four principals that used to share the hive's own leaf ─────────────
|
||||||
#
|
#
|
||||||
# 🩸 Each of the four reads exactly ONE path in the store, and until this
|
# 🩸 Each of the four reads exactly ONE path in the store, and until this
|
||||||
|
|
@ -760,7 +833,7 @@ let
|
||||||
tls_key_file = serverKeyCredentialPath;
|
tls_key_file = serverKeyCredentialPath;
|
||||||
}
|
}
|
||||||
// lib.optionalAttrs (baoDeploy.clientCaFile != null) {
|
// lib.optionalAttrs (baoDeploy.clientCaFile != null) {
|
||||||
tls_client_ca_file = clientCaPath;
|
tls_client_ca_file = listenerClientCaPath;
|
||||||
tls_require_and_verify_client_cert = true;
|
tls_require_and_verify_client_cert = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1251,7 +1324,9 @@ in
|
||||||
|
|
||||||
⚠️ NOT the store's own client-auth PKI. That one is
|
⚠️ NOT the store's own client-auth PKI. That one is
|
||||||
./glue-bao-tls.nix's self-signed CA on disk, and it stays outside the
|
./glue-bao-tls.nix's self-signed CA on disk, and it stays outside the
|
||||||
store permanently — bao cannot issue the credential that opens bao.
|
store permanently — bao cannot issue the credential a host opens bao
|
||||||
|
with. Agent certificates come from
|
||||||
|
{option}`services.hyperhive.deploy.bao.agentPkiMountPath`.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1269,6 +1344,17 @@ in
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
agentPkiRoleName = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "swarm-agent";
|
||||||
|
description = ''
|
||||||
|
Role on {option}`services.hyperhive.deploy.bao.agentPkiMountPath`
|
||||||
|
agent client certificates are issued through. It issues client
|
||||||
|
certificates named `hive-agent-*` and nothing else, and swarm-controller
|
||||||
|
may call its `issue` endpoint and no other path on the mount.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
servicesPkiRoleName = lib.mkOption {
|
servicesPkiRoleName = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
default = "swarm-services";
|
default = "swarm-services";
|
||||||
|
|
@ -1859,13 +1945,18 @@ in
|
||||||
# else, so a role named otherwise is a 403 at deploy time.
|
# else, so a role named otherwise is a 403 at deploy time.
|
||||||
assertion =
|
assertion =
|
||||||
!haveGranter
|
!haveGranter
|
||||||
|| (lib.hasPrefix "swarm-" servicesPkiRoleName && lib.hasPrefix "swarm-" natsPkiRoleName);
|
|| (
|
||||||
|
lib.hasPrefix "swarm-" servicesPkiRoleName
|
||||||
|
&& lib.hasPrefix "swarm-" natsPkiRoleName
|
||||||
|
&& lib.hasPrefix "swarm-" agentPkiRoleName
|
||||||
|
);
|
||||||
message = ''
|
message = ''
|
||||||
services.hyperhive.deploy.bao.servicesPkiRoleName
|
services.hyperhive.deploy.bao.servicesPkiRoleName
|
||||||
(${servicesPkiRoleName}) and
|
(${servicesPkiRoleName}),
|
||||||
services.hyperhive.deploy.bao.natsPkiRoleName (${natsPkiRoleName})
|
services.hyperhive.deploy.bao.natsPkiRoleName (${natsPkiRoleName})
|
||||||
must both start with `swarm-`: the bao granter that writes them may
|
and services.hyperhive.deploy.bao.agentPkiRoleName
|
||||||
write pki roles under that prefix only.
|
(${agentPkiRoleName}) must all start with `swarm-`: the bao granter
|
||||||
|
that writes them may write pki roles under that prefix only.
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
@ -1929,6 +2020,7 @@ in
|
||||||
"swarm-bao-forwarder-oidc-policy"
|
"swarm-bao-forwarder-oidc-policy"
|
||||||
"swarm-bao-services-issuer-policy"
|
"swarm-bao-services-issuer-policy"
|
||||||
"swarm-bao-nats-tls-policy"
|
"swarm-bao-nats-tls-policy"
|
||||||
|
"swarm-bao-agent-pki"
|
||||||
];
|
];
|
||||||
|
|
||||||
# 🚫 No `swarm.otel.scrapeTargets.bao` entry any more, and its absence is
|
# 🚫 No `swarm.otel.scrapeTargets.bao` entry any more, and its absence is
|
||||||
|
|
@ -2119,7 +2211,11 @@ in
|
||||||
description = "deliver the swarm secret store's server certificate";
|
description = "deliver the swarm secret store's server certificate";
|
||||||
before = [ "container@${cfg.machine}.service" ];
|
before = [ "container@${cfg.machine}.service" ];
|
||||||
requiredBy = [ "container@${cfg.machine}.service" ];
|
requiredBy = [ "container@${cfg.machine}.service" ];
|
||||||
path = [ pkgs.coreutils ];
|
path = [
|
||||||
|
pkgs.coreutils
|
||||||
|
pkgs.diffutils
|
||||||
|
pkgs.openssl
|
||||||
|
];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
RemainAfterExit = true;
|
RemainAfterExit = true;
|
||||||
|
|
@ -2155,6 +2251,12 @@ in
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
install -m 0644 ${lib.escapeShellArg baoDeploy.clientCaFile} ${tlsDir}/client-ca.pem
|
install -m 0644 ${lib.escapeShellArg baoDeploy.clientCaFile} ${tlsDir}/client-ca.pem
|
||||||
|
|
||||||
|
# Composed here as well as by `swarm-bao-agent-pki` so that openbao
|
||||||
|
# starts already trusting the cached agent CA, and so the file it
|
||||||
|
# refuses to start without exists before the store's first run.
|
||||||
|
${composeListenerBundle}
|
||||||
|
compose_listener_bundle
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -2734,6 +2836,170 @@ in
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# The agent PKI mount: its root, its one role, the host's copy of the
|
||||||
|
# root's certificate, and the listener's trust in it.
|
||||||
|
#
|
||||||
|
# ⚠️ This unit RESTARTS openbao. openbao reads `tls_client_ca_file` only
|
||||||
|
# when a listener is built, at start: neither SIGHUP nor a reload re-reads
|
||||||
|
# it. So when the listener bundle changed after openbao last started,
|
||||||
|
# openbao is restarted here, which drops every client for the restart and
|
||||||
|
# the unseal. `swarm-bao-certs` composes the same bundle before every
|
||||||
|
# later start, so this happens when the agent CA first appears (or is
|
||||||
|
# replaced by a re-initialised store), not per boot. Under `shamir` a
|
||||||
|
# restart needs a human unseal, so there it prints the step instead.
|
||||||
|
#
|
||||||
|
# `after` the granting units so a restart does not cut their writes off.
|
||||||
|
systemd.services.swarm-bao-agent-pki = lib.mkIf haveGranter {
|
||||||
|
description = "set up the swarm agent PKI mount and make the store's listener trust it";
|
||||||
|
after = [
|
||||||
|
"container@${cfg.machine}.service"
|
||||||
|
"swarm-bao-controller-policy.service"
|
||||||
|
"swarm-bao-secret-publisher-policy.service"
|
||||||
|
"swarm-bao-matrix-ctl-policy.service"
|
||||||
|
"swarm-bao-matrix-token-policy.service"
|
||||||
|
"swarm-bao-queue-agent-policy.service"
|
||||||
|
"swarm-bao-grafana-oidc-policy.service"
|
||||||
|
"swarm-bao-otel-oidc-policy.service"
|
||||||
|
"swarm-bao-forwarder-oidc-policy.service"
|
||||||
|
"swarm-bao-services-issuer-policy.service"
|
||||||
|
"swarm-bao-nats-tls-policy.service"
|
||||||
|
]
|
||||||
|
++ granterAfter;
|
||||||
|
requires = [ "swarm-bao-pki.service" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
path = [
|
||||||
|
baoCli
|
||||||
|
pkgs.coreutils
|
||||||
|
pkgs.diffutils
|
||||||
|
pkgs.openssl
|
||||||
|
];
|
||||||
|
environment = granterEnv;
|
||||||
|
# Same unseal wait as its siblings above.
|
||||||
|
startLimitBurst = 2880;
|
||||||
|
startLimitIntervalSec = 90000;
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 30;
|
||||||
|
};
|
||||||
|
script = ''
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
${granterLogin}
|
||||||
|
|
||||||
|
# Asked rather than attempted: `secrets enable` errors on a path
|
||||||
|
# already in use.
|
||||||
|
mounts="$(bao secrets list -format=json)"
|
||||||
|
case "$mounts" in
|
||||||
|
*'"${agentPkiMountPath}/"'*) ;;
|
||||||
|
*) bao secrets enable -path=${agentPkiMountPath} pki ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Before generation, on every run: an untuned mount silently clamps
|
||||||
|
# the root to 768h (see the services mount above).
|
||||||
|
bao secrets tune -max-lease-ttl=${agentPkiRootTtl} ${agentPkiMountPath}
|
||||||
|
|
||||||
|
# Generated once, ever. Every agent's cert-auth role pins this root
|
||||||
|
# by value, so a second one locks every agent out; there is no
|
||||||
|
# replace branch, and the granter holds no delete on the root. The
|
||||||
|
# mount's own issuer list is the guard, for the reason the services
|
||||||
|
# root gives: `{}` is the only answer that means "none".
|
||||||
|
if issuers="$(bao list -format=json ${lib.escapeShellArg "${agentPkiMountPath}/issuers"})"; then
|
||||||
|
echo "the ${agentPkiMountPath} mount already has an issuer — leaving it alone"
|
||||||
|
elif [ "$issuers" = '{}' ]; then
|
||||||
|
echo "generating the swarm agent CA into the ${agentPkiMountPath} mount"
|
||||||
|
# `max_path_length=0`: this CA signs leaves only.
|
||||||
|
bao write -field=issuing_ca ${lib.escapeShellArg "${agentPkiMountPath}/root/generate/internal"} \
|
||||||
|
common_name=${lib.escapeShellArg "swarm-bao-agent-ca ${servicesPkiRootLabel}"} \
|
||||||
|
issuer_name=swarm-agent-ca \
|
||||||
|
ttl=${agentPkiRootTtl} \
|
||||||
|
max_path_length=0 \
|
||||||
|
key_type=rsa \
|
||||||
|
key_bits=4096 >/dev/null
|
||||||
|
else
|
||||||
|
echo "could not list the ${agentPkiMountPath} issuers — not generating a root over one that may exist" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Upserted every run. The whole narrowing of what the controller can
|
||||||
|
# obtain: client certificates, named `hive-agent-*`, nothing else.
|
||||||
|
# `allow_localhost` and `server_flag` are on by default in bao, so
|
||||||
|
# they are turned off here, not left out. No `issuer_ref`: the mount
|
||||||
|
# holds one issuer, which is its default.
|
||||||
|
bao write ${lib.escapeShellArg "${agentPkiMountPath}/roles/${agentPkiRoleName}"} \
|
||||||
|
allowed_domains=${lib.escapeShellArg agentCnGlob} \
|
||||||
|
allow_glob_domains=true \
|
||||||
|
allow_bare_domains=false \
|
||||||
|
allow_subdomains=false \
|
||||||
|
allow_wildcard_certificates=false \
|
||||||
|
allow_localhost=false \
|
||||||
|
allow_any_name=false \
|
||||||
|
allow_ip_sans=false \
|
||||||
|
enforce_hostnames=true \
|
||||||
|
server_flag=false \
|
||||||
|
client_flag=true \
|
||||||
|
code_signing_flag=false \
|
||||||
|
email_protection_flag=false \
|
||||||
|
key_usage=DigitalSignature \
|
||||||
|
key_type=ec \
|
||||||
|
key_bits=256 \
|
||||||
|
ttl=${agentPkiLeafTtl} \
|
||||||
|
max_ttl=${agentPkiLeafTtl}
|
||||||
|
|
||||||
|
ca="$(bao read -field=certificate ${lib.escapeShellArg "${agentPkiMountPath}/cert/ca"})"
|
||||||
|
if ! openssl x509 -noout <<<"$ca" >/dev/null 2>&1; then
|
||||||
|
echo "the ${agentPkiMountPath} CA that bao returned does not parse — not caching it" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
tmp="$(mktemp -p ${tlsDir} .agent-ca.XXXXXX)"
|
||||||
|
printf '%s\n' "$ca" > "$tmp"
|
||||||
|
if cmp -s "$tmp" ${agentCaCachePath}; then
|
||||||
|
rm -f "$tmp"
|
||||||
|
else
|
||||||
|
chmod 0644 "$tmp"
|
||||||
|
mv -f "$tmp" ${agentCaCachePath}
|
||||||
|
echo "wrote ${agentCaCachePath}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
${composeListenerBundle}
|
||||||
|
compose_listener_bundle
|
||||||
|
|
||||||
|
machine=${lib.escapeShellArg cfg.machine}
|
||||||
|
if ! systemctl --machine="$machine" is-active --quiet openbao.service; then
|
||||||
|
echo "openbao in $machine is not running; it reads ${listenerClientCaPath} when it starts"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
started="$(systemctl --machine="$machine" show --timestamp=us+utc -p ActiveEnterTimestamp --value openbao.service)"
|
||||||
|
started_us="$(date -u -d "$started" +%s%6N)"
|
||||||
|
written_us="$(stat -c %.6Y ${listenerClientCaPath} | tr -d .)"
|
||||||
|
if [ "$written_us" -le "$started_us" ]; then
|
||||||
|
echo "openbao started after ${listenerClientCaPath} last changed; nothing to pick up"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fail closed: host logins go through this file too.
|
||||||
|
if [ ! -s ${listenerClientCaPath} ] \
|
||||||
|
|| ! cmp -s -n "$(stat -c %s ${clientCaPath})" ${clientCaPath} ${listenerClientCaPath} \
|
||||||
|
|| ! openssl x509 -noout -in ${listenerClientCaPath} >/dev/null 2>&1; then
|
||||||
|
echo "${listenerClientCaPath} is empty, unparseable or does not begin with ${clientCaPath}; not restarting openbao" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
''
|
||||||
|
+ (
|
||||||
|
if baoDeploy.seal == "pkcs11" then
|
||||||
|
''
|
||||||
|
echo "${listenerClientCaPath} changed after openbao started; restarting openbao in $machine (it unseals itself)"
|
||||||
|
systemctl --machine="$machine" restart openbao.service
|
||||||
|
''
|
||||||
|
else
|
||||||
|
''
|
||||||
|
echo "${listenerClientCaPath} changed after openbao started. A restart seals a ${baoDeploy.seal} store, so it is left to you, as root on this host:" >&2
|
||||||
|
echo " systemctl --machine=$machine restart openbao.service # then unseal" >&2
|
||||||
|
''
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
# The CA bind source is written at runtime by a host unit, so the
|
# The CA bind source is written at runtime by a host unit, so the
|
||||||
# container has to start after it — otherwise nspawn sets up a mount
|
# container has to start after it — otherwise nspawn sets up a mount
|
||||||
# over a file that does not exist yet.
|
# over a file that does not exist yet.
|
||||||
|
|
@ -2754,7 +3020,8 @@ in
|
||||||
# /var, systemd owns `${stateDir}` through `StateDirectory=`, and
|
# /var, systemd owns `${stateDir}` through `StateDirectory=`, and
|
||||||
# binding over it is what breaks the unit.
|
# binding over it is what breaks the unit.
|
||||||
bindMounts = {
|
bindMounts = {
|
||||||
# Read-only: `swarm-bao-certs` on the host is the only writer, and
|
# Read-only: host units write it (`swarm-bao-certs`, and
|
||||||
|
# `swarm-bao-agent-pki` for the agent CA and the listener bundle), and
|
||||||
# the store has no reason to modify its own identity.
|
# the store has no reason to modify its own identity.
|
||||||
${tlsDir} = {
|
${tlsDir} = {
|
||||||
hostPath = tlsDir;
|
hostPath = tlsDir;
|
||||||
|
|
@ -2985,12 +3252,15 @@ in
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
# ⚠️ Upstream sets `restartIfChanged = false` on this unit, on
|
# ⚠️ Upstream sets `restartIfChanged = false` on this unit: a restart
|
||||||
# purpose: a restart SEALS the store and disconnects every client.
|
# SEALS the store and disconnects every client. The container around
|
||||||
# So a change to the settings above does NOT take effect on
|
# it does restart on `nixos-rebuild switch` whenever its config
|
||||||
# `nixos-rebuild switch` — it lands in the config file and waits.
|
# (these settings included) changes: nixos-containers sets
|
||||||
# Restarting is an operator action with an unseal on the far side of
|
# `restartTriggers` on `container@${cfg.machine}` and nothing here
|
||||||
# it, which is why nothing here tries to be clever about it.
|
# overrides its `restartIfChanged`. The only in-place restart of this
|
||||||
|
# unit is `swarm-bao-agent-pki` on the host, once, when the listener's
|
||||||
|
# client-CA bundle changed after openbao started, and only under the
|
||||||
|
# self-unsealing `pkcs11` seal.
|
||||||
|
|
||||||
# This container's own journal forwarder, copied from an agent
|
# This container's own journal forwarder, copied from an agent
|
||||||
# container's (nix/agent-modules/otel.nix) because every container
|
# container's (nix/agent-modules/otel.nix) because every container
|
||||||
|
|
|
||||||
|
|
@ -38,33 +38,6 @@ let
|
||||||
# file would be handed to a daemon that cannot use it.
|
# file would be handed to a daemon that cannot use it.
|
||||||
haveHiveClientCa = haveBaoIdentity && deployCfg.swarm-controller.hiveClientCaFile != null;
|
haveHiveClientCa = haveBaoIdentity && deployCfg.swarm-controller.hiveClientCaFile != null;
|
||||||
|
|
||||||
# The authority this daemon issues AGENT client leaves from — a different
|
|
||||||
# question from `hiveClientCaFile` above, which is the authority it *trusts*
|
|
||||||
# hives by. This one it signs with, so it needs the private key too.
|
|
||||||
#
|
|
||||||
# ⚠️ Deliberately NOT the store's own PKI (`glue-bao-tls.nix`'s
|
|
||||||
# `/var/lib/swarm-bao-pki`). A cert-auth role pins its authority by value,
|
|
||||||
# per role, so a role this daemon writes carries whatever authority this
|
|
||||||
# daemon hands it — which is what lets the controller mint from its own CA
|
|
||||||
# on its own host without anything being co-located and without any
|
|
||||||
# existing role changing. `swarm-controller/src/agent_identity.rs`'s module
|
|
||||||
# doc is the long form.
|
|
||||||
agentCaDir = "/var/lib/swarm-controller-agent-ca";
|
|
||||||
|
|
||||||
# No authority named means mint one here. The alternative — leaving agent
|
|
||||||
# identities off until an operator places a CA by hand — is the state where
|
|
||||||
# the whole path is configured and silently does nothing, which is the
|
|
||||||
# failure mode `glue-bao-tls.nix` avoids the same way.
|
|
||||||
selfSignAgentCa = deployCfg.swarm-controller.agentCaFile == null;
|
|
||||||
agentCaCert =
|
|
||||||
if selfSignAgentCa then "${agentCaDir}/ca.pem" else deployCfg.swarm-controller.agentCaFile;
|
|
||||||
agentCaKey =
|
|
||||||
if selfSignAgentCa then "${agentCaDir}/ca-key.pem" else deployCfg.swarm-controller.agentCaKeyFile;
|
|
||||||
|
|
||||||
# Minting an agent's identity means publishing it to the store, so the
|
|
||||||
# authority alone is not enough — same rule `haveHiveClientCa` states.
|
|
||||||
haveAgentCa = haveBaoIdentity && agentCaKey != null;
|
|
||||||
|
|
||||||
# `swarm_secret_client` reads these spellings explicitly rather than
|
# `swarm_secret_client` reads these spellings explicitly rather than
|
||||||
# vaultrs's `VAULT_*` defaults — falling through to those builds a client
|
# vaultrs's `VAULT_*` defaults — falling through to those builds a client
|
||||||
# with no identity and fails at the TLS handshake, naming neither. `%d` and
|
# with no identity and fails at the TLS handshake, naming neither. `%d` and
|
||||||
|
|
@ -88,12 +61,11 @@ let
|
||||||
# to put in each hive's cert-auth role.
|
# to put in each hive's cert-auth role.
|
||||||
SWARM_CONTROLLER_HIVE_CLIENT_CA_FILE = "%d/hive-client-ca.pem";
|
SWARM_CONTROLLER_HIVE_CLIENT_CA_FILE = "%d/hive-client-ca.pem";
|
||||||
}
|
}
|
||||||
// lib.optionalAttrs haveAgentCa {
|
// lib.optionalAttrs haveBaoIdentity {
|
||||||
# The authority agent leaves are ISSUED FROM, so unlike every other
|
# Where agent leaves are issued. The store host sets that mount and
|
||||||
# `*_CA_FILE` here it comes with a key. `%d` for both: the key is
|
# role up from the same two options, which keeps the spellings equal.
|
||||||
# `0600` and root-owned, and this daemon runs unprivileged.
|
SWARM_CONTROLLER_AGENT_PKI_MOUNT = deployCfg.bao.agentPkiMountPath;
|
||||||
SWARM_CONTROLLER_AGENT_CA_FILE = "%d/agent-ca.pem";
|
SWARM_CONTROLLER_AGENT_PKI_ROLE = deployCfg.bao.agentPkiRoleName;
|
||||||
SWARM_CONTROLLER_AGENT_CA_KEY_FILE = "%d/agent-ca-key.pem";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
# What `swarmctl` needs in order to act on authelia from the host.
|
# What `swarmctl` needs in order to act on authelia from the host.
|
||||||
|
|
@ -663,47 +635,6 @@ in
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
agentCaFile = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.str;
|
|
||||||
default = null;
|
|
||||||
example = "/var/lib/swarm-agent-ca/ca.pem";
|
|
||||||
description = ''
|
|
||||||
Authority this daemon **issues** agent client certificates from, so
|
|
||||||
that an agent container can authenticate to the swarm secret store
|
|
||||||
under its own name. Read together with
|
|
||||||
{option}`services.hyperhive.deploy.swarm-controller.agentCaKeyFile`,
|
|
||||||
which is the private key it signs with.
|
|
||||||
|
|
||||||
The mirror image of
|
|
||||||
{option}`services.hyperhive.deploy.swarm-controller.hiveClientCaFile`:
|
|
||||||
that one is an authority this daemon only *trusts by value*, so it is
|
|
||||||
public material and needs no key. This one signs, so it does.
|
|
||||||
|
|
||||||
Leaving this `null` — the default — makes the module mint a
|
|
||||||
self-signed authority in `${agentCaDir}` on first boot and use that.
|
|
||||||
That is the ordinary shape: the store pins an authority per cert-auth
|
|
||||||
role, by value, so the authority agents are issued from does not have
|
|
||||||
to be the store's own PKI and does not have to live on the store's
|
|
||||||
host. Name a file here only when an operator issues agent leaves from
|
|
||||||
somewhere else; doing so turns the self-signing unit off.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
agentCaKeyFile = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.str;
|
|
||||||
default = null;
|
|
||||||
example = "/var/lib/swarm-agent-ca/ca-key.pem";
|
|
||||||
description = ''
|
|
||||||
Private key for
|
|
||||||
{option}`services.hyperhive.deploy.swarm-controller.agentCaFile`.
|
|
||||||
Both or neither — an authority with no key signs nothing, and the
|
|
||||||
daemon refuses to start half-configured rather than looking ready.
|
|
||||||
|
|
||||||
A path, never a value: the key's bytes in a nix expression land in
|
|
||||||
the world-readable nix store, permanently.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
queue = {
|
queue = {
|
||||||
clientSecretFile = lib.mkOption {
|
clientSecretFile = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
|
|
@ -734,10 +665,7 @@ in
|
||||||
services.hyperhive.swarm.otel.journaldUnits = [
|
services.hyperhive.swarm.otel.journaldUnits = [
|
||||||
"swarm-controller"
|
"swarm-controller"
|
||||||
"swarm-controller-credential"
|
"swarm-controller-credential"
|
||||||
]
|
];
|
||||||
# Declared only where the unit exists — an entry for a unit that was
|
|
||||||
# never defined is a collector waiting on a journal that never speaks.
|
|
||||||
++ lib.optional (haveAgentCa && selfSignAgentCa) "swarm-controller-agent-ca";
|
|
||||||
|
|
||||||
users.users.swarm-controller = {
|
users.users.swarm-controller = {
|
||||||
isSystemUser = true;
|
isSystemUser = true;
|
||||||
|
|
@ -824,18 +752,6 @@ in
|
||||||
state directory.
|
state directory.
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
{
|
|
||||||
assertion =
|
|
||||||
deployCfg.swarm-controller.agentCaFile == null || deployCfg.swarm-controller.agentCaKeyFile != null;
|
|
||||||
message = ''
|
|
||||||
services.hyperhive.deploy.swarm-controller.agentCaFile names an
|
|
||||||
authority but agentCaKeyFile is unset.
|
|
||||||
|
|
||||||
The controller does not merely trust this authority, it issues
|
|
||||||
agent client certificates from it, so it needs the private key.
|
|
||||||
Set both, or set neither and let the module mint its own.
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
|
||||||
systemd.services.swarm-controller = {
|
systemd.services.swarm-controller = {
|
||||||
|
|
@ -877,16 +793,7 @@ in
|
||||||
++ lib.optional (
|
++ lib.optional (
|
||||||
haveBaoIdentity && deployCfg.bao.serverCaFile != null
|
haveBaoIdentity && deployCfg.bao.serverCaFile != null
|
||||||
) "bao-ca.pem:${deployCfg.bao.serverCaFile}"
|
) "bao-ca.pem:${deployCfg.bao.serverCaFile}"
|
||||||
++ lib.optional haveHiveClientCa "hive-client-ca.pem:${deployCfg.swarm-controller.hiveClientCaFile}"
|
++ lib.optional haveHiveClientCa "hive-client-ca.pem:${deployCfg.swarm-controller.hiveClientCaFile}";
|
||||||
# The agent authority, key included — same shape and same reason as
|
|
||||||
# the store identity above: the key is root-owned `0600` and this
|
|
||||||
# daemon runs as `swarm-controller`. `swarm-controller-agent-ca`
|
|
||||||
# below is `requiredBy` this unit, so the files exist by the time
|
|
||||||
# systemd resolves these.
|
|
||||||
++ lib.optionals haveAgentCa [
|
|
||||||
"agent-ca.pem:${agentCaCert}"
|
|
||||||
"agent-ca-key.pem:${agentCaKey}"
|
|
||||||
];
|
|
||||||
|
|
||||||
# The placeholder default that makes the above non-fatal.
|
# The placeholder default that makes the above non-fatal.
|
||||||
# `LoadCredential=` takes priority over `SetCredential=`, so this is
|
# `LoadCredential=` takes priority over `SetCredential=`, so this is
|
||||||
|
|
@ -1059,50 +966,5 @@ in
|
||||||
ExecStart = "${pkgs.systemd}/bin/systemctl try-restart swarm-controller.service";
|
ExecStart = "${pkgs.systemd}/bin/systemctl try-restart swarm-controller.service";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# The authority agent client leaves are issued from, minted here when the
|
|
||||||
# operator named none. Shape copied from ./glue-bao-tls.nix's
|
|
||||||
# `swarm-bao-pki`, including the rule that matters most:
|
|
||||||
#
|
|
||||||
# 🩸 Idempotent on ABSENCE, never on content. Re-issuing this CA would
|
|
||||||
# invalidate every agent leaf already published to the store AND every
|
|
||||||
# cert-auth role that pinned it by value, locking every agent container
|
|
||||||
# in the swarm out at once — on a rebuild that changed nothing an
|
|
||||||
# operator asked for.
|
|
||||||
#
|
|
||||||
# `before` + `requiredBy` rather than `after`: the daemon's
|
|
||||||
# `LoadCredential=` names these files by absolute path, and a
|
|
||||||
# `LoadCredential=` pointing at a file that is not there yet is fatal
|
|
||||||
# (`243/CREDENTIALS`), not a slow start.
|
|
||||||
systemd.services.swarm-controller-agent-ca = lib.mkIf (haveAgentCa && selfSignAgentCa) {
|
|
||||||
description = "mint the authority swarm agents' store certificates are issued from";
|
|
||||||
before = [ "swarm-controller.service" ];
|
|
||||||
requiredBy = [ "swarm-controller.service" ];
|
|
||||||
path = [
|
|
||||||
pkgs.openssl
|
|
||||||
pkgs.coreutils
|
|
||||||
];
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "oneshot";
|
|
||||||
RemainAfterExit = true;
|
|
||||||
};
|
|
||||||
script = ''
|
|
||||||
set -euo pipefail
|
|
||||||
install -d -m 0700 ${agentCaDir}
|
|
||||||
|
|
||||||
if [ ! -s ${agentCaCert} ]; then
|
|
||||||
# `pathlen:0` — this authority signs leaves and nothing else. An
|
|
||||||
# intermediate under it would be a second issuer for the one name
|
|
||||||
# space the store matches agents by.
|
|
||||||
openssl req -x509 -newkey rsa:4096 -nodes -sha256 -days 3650 \
|
|
||||||
-keyout ${agentCaKey} -out ${agentCaCert} \
|
|
||||||
-subj "/CN=swarm-agent-ca ${swarmDomain}" \
|
|
||||||
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
|
|
||||||
-addext "keyUsage=critical,keyCertSign,cRLSign"
|
|
||||||
chmod 0600 ${agentCaKey}
|
|
||||||
chmod 0644 ${agentCaCert}
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ let
|
||||||
# The store's units live inside its container, so the gates below have to
|
# The store's units live inside its container, so the gates below have to
|
||||||
# look there rather than at the host's service set.
|
# look there rather than at the host's service set.
|
||||||
baoUnits = machine: machine.containers.swarm-bao.config.systemd.services;
|
baoUnits = machine: machine.containers.swarm-bao.config.systemd.services;
|
||||||
|
|
||||||
|
tlsDir = "/var/lib/swarm-bao-tls";
|
||||||
cases = [
|
cases = [
|
||||||
{
|
{
|
||||||
# The store's seal is spread over six gates — the stanza, the
|
# The store's seal is spread over six gates — the stanza, the
|
||||||
|
|
@ -214,6 +216,71 @@ let
|
||||||
name = "the store advertises a cluster address";
|
name = "the store advertises a cluster address";
|
||||||
ok = lib.hasPrefix "https://" ((baoSettings baoPkcs11).cluster_addr or "");
|
ok = lib.hasPrefix "https://" ((baoSettings baoPkcs11).cluster_addr or "");
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
# The listener trusts the agent CA through a file no cert-auth role
|
||||||
|
# names. At least one listener verifies clients, so an empty set cannot
|
||||||
|
# pass.
|
||||||
|
name = "every client-verifying listener reads the listener-only bundle";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
verifying = lib.filter (l: l ? tls_client_ca_file) (
|
||||||
|
lib.attrValues (baoSettings baoPkcs11).listener
|
||||||
|
);
|
||||||
|
in
|
||||||
|
verifying != [ ]
|
||||||
|
&& lib.all (l: l.tls_client_ca_file == "${tlsDir}/listener-client-ca.pem") verifying;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# The other half of keeping agents out of host roles: those roles pin
|
||||||
|
# the store CA alone. The positive count is the control that the text
|
||||||
|
# searched is the one the roles are written in.
|
||||||
|
name = "host cert-auth roles pin client-ca.pem and never the listener bundle or the agent CA";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
scripts = lib.concatStrings (
|
||||||
|
lib.mapAttrsToList (
|
||||||
|
n: u: lib.optionalString (lib.hasPrefix "swarm-bao-" n) (u.script or "")
|
||||||
|
) baoPkcs11.systemd.services
|
||||||
|
);
|
||||||
|
in
|
||||||
|
lib.hasInfix "certificate=@${tlsDir}/client-ca.pem" scripts
|
||||||
|
&& !(lib.hasInfix "certificate=@${tlsDir}/listener-client-ca.pem" scripts)
|
||||||
|
&& !(lib.hasInfix "certificate=@${tlsDir}/agent-ca.pem" scripts);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# Both writers compose the bundle through one function, which refuses
|
||||||
|
# a result that does not begin with the store CA and replaces the file
|
||||||
|
# only when its bytes change.
|
||||||
|
name = "both bundle writers use the one composer, which keeps the store CA first";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
s = baoPkcs11.systemd.services;
|
||||||
|
composes =
|
||||||
|
u:
|
||||||
|
lib.hasInfix "compose_listener_bundle() {" u.script
|
||||||
|
&& lib.hasInfix "\ncompose_listener_bundle\n" u.script
|
||||||
|
&& lib.hasInfix ''cmp -s -n "$(stat -c %s ${tlsDir}/client-ca.pem)" ${tlsDir}/client-ca.pem "$tmp"'' u.script
|
||||||
|
&& lib.hasInfix ''cmp -s "$tmp" ${tlsDir}/listener-client-ca.pem'' u.script
|
||||||
|
&& lib.hasInfix "mktemp -p ${tlsDir} " u.script;
|
||||||
|
in
|
||||||
|
s ? swarm-bao-agent-pki && composes s.swarm-bao-certs && composes s.swarm-bao-agent-pki;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# openbao reads its client-CA file only at start, so picking up the
|
||||||
|
# agent CA is a restart: automatic where the store unseals itself,
|
||||||
|
# printed where a human has to. The shamir arm is the control.
|
||||||
|
name = "the agent PKI unit restarts openbao under pkcs11 and only prints the step under shamir";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
restart = ''systemctl --machine="$machine" restart openbao.service'';
|
||||||
|
p = baoPkcs11.systemd.services.swarm-bao-agent-pki.script;
|
||||||
|
sh = baoShamir.systemd.services.swarm-bao-agent-pki.script;
|
||||||
|
in
|
||||||
|
lib.hasInfix restart p
|
||||||
|
&& lib.hasInfix ''if [ "$written_us" -le "$started_us" ]; then'' p
|
||||||
|
&& !(lib.hasInfix restart sh)
|
||||||
|
&& lib.hasInfix "# then unseal" sh;
|
||||||
|
}
|
||||||
];
|
];
|
||||||
in
|
in
|
||||||
runGroup "bao-basics" cases
|
runGroup "bao-basics" cases
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,15 @@ let
|
||||||
deploy.swarm-controller.enable = true;
|
deploy.swarm-controller.enable = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Store and controller with an agent PKI mount and role no default could
|
||||||
|
# supply.
|
||||||
|
controllerAgentPkiMarker = hive {
|
||||||
|
deploy.bao.enable = true;
|
||||||
|
deploy.bao.agentPkiMountPath = "pki-agents-marker";
|
||||||
|
deploy.bao.agentPkiRoleName = "swarm-agent-marker";
|
||||||
|
deploy.swarm-controller.enable = true;
|
||||||
|
};
|
||||||
|
|
||||||
# The controller with no store, which is every spread deployment. Nothing
|
# The controller with no store, which is every spread deployment. Nothing
|
||||||
# mints here, so the pairing must leave the paths unset rather than name
|
# mints here, so the pairing must leave the paths unset rather than name
|
||||||
# files this host will never have.
|
# files this host will never have.
|
||||||
|
|
@ -220,6 +229,47 @@ let
|
||||||
in
|
in
|
||||||
lib.hasInfix "cn-marker-not-a-default" role && lib.hasInfix "cn-marker-not-a-default" pki;
|
lib.hasInfix "cn-marker-not-a-default" role && lib.hasInfix "cn-marker-not-a-default" pki;
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
# Agent leaves come from the store's own agent PKI, so the controller
|
||||||
|
# holds no authority of its own: no minting unit, no key, no variable
|
||||||
|
# naming one.
|
||||||
|
name = "the controller holds no agent CA and is told the store's agent PKI mount and role";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
s = baoControllerHere.systemd.services;
|
||||||
|
e = s.swarm-controller.environment;
|
||||||
|
in
|
||||||
|
s ? swarm-controller
|
||||||
|
&& !(s ? swarm-controller-agent-ca)
|
||||||
|
&& !(e ? SWARM_CONTROLLER_AGENT_CA_FILE)
|
||||||
|
&& !(e ? SWARM_CONTROLLER_AGENT_CA_KEY_FILE)
|
||||||
|
&& !(lib.any (c: lib.hasPrefix "agent-ca" c) s.swarm-controller.serviceConfig.LoadCredential)
|
||||||
|
&& (e.SWARM_CONTROLLER_AGENT_PKI_MOUNT or null) == "pki-agents"
|
||||||
|
&& (e.SWARM_CONTROLLER_AGENT_PKI_ROLE or null) == "swarm-agent";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# The controller issues through whatever mount and role it is told, and
|
||||||
|
# its grant names a path. Both read the store's options, which the
|
||||||
|
# marker values prove: no default could supply them.
|
||||||
|
name = "the controller's issue grant and its environment name one mount and role";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
s = controllerAgentPkiMarker.systemd.services;
|
||||||
|
in
|
||||||
|
s.swarm-controller.environment.SWARM_CONTROLLER_AGENT_PKI_MOUNT == "pki-agents-marker"
|
||||||
|
&& s.swarm-controller.environment.SWARM_CONTROLLER_AGENT_PKI_ROLE == "swarm-agent-marker"
|
||||||
|
&& lib.hasInfix ''path "pki-agents-marker/issue/swarm-agent-marker"'' s.swarm-bao-controller-policy.script;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# Absence arm: without a store identity nothing can be issued, so the
|
||||||
|
# variables are not set.
|
||||||
|
name = "a controller with no store leaf is not told an agent PKI";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
e = controllerNoStore.systemd.services.swarm-controller.environment;
|
||||||
|
in
|
||||||
|
!(e ? SWARM_CONTROLLER_AGENT_PKI_MOUNT) && !(e ? SWARM_CONTROLLER_AGENT_PKI_ROLE);
|
||||||
|
}
|
||||||
];
|
];
|
||||||
in
|
in
|
||||||
runGroup "bao-controller" cases
|
runGroup "bao-controller" cases
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,12 @@ let
|
||||||
deploy.bao.natsPkiRoleName = "queue";
|
deploy.bao.natsPkiRoleName = "queue";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# An agent pki role the granter's `roles/swarm-*` does not reach.
|
||||||
|
baoGranterOddAgentRole = hive {
|
||||||
|
deploy.bao.enable = true;
|
||||||
|
deploy.bao.agentPkiRoleName = "agent";
|
||||||
|
};
|
||||||
|
|
||||||
# The store and the token, with no CA to trust. `mkForce` because the PKI
|
# The store and the token, with no CA to trust. `mkForce` because the PKI
|
||||||
# glue supplies one by default here — this is the deployment that brings its
|
# glue supplies one by default here — this is the deployment that brings its
|
||||||
# own certificates and has not named the authority yet, in which nothing can
|
# own certificates and has not named the authority yet, in which nothing can
|
||||||
|
|
@ -145,7 +151,7 @@ let
|
||||||
_: u: (u.environment.BAO_CLIENT_CERT or null) == granterCertFile
|
_: u: (u.environment.BAO_CLIENT_CERT or null) == granterCertFile
|
||||||
) baoGrantWithConsumers.systemd.services;
|
) baoGrantWithConsumers.systemd.services;
|
||||||
|
|
||||||
# The ten units that write a `swarm-*` grant, by name, for the discovery
|
# The eleven units that write a `swarm-*` grant, by name, for the discovery
|
||||||
# control below.
|
# control below.
|
||||||
grantingUnitNames = [
|
grantingUnitNames = [
|
||||||
"swarm-bao-controller-policy"
|
"swarm-bao-controller-policy"
|
||||||
|
|
@ -158,6 +164,7 @@ let
|
||||||
"swarm-bao-forwarder-oidc-policy"
|
"swarm-bao-forwarder-oidc-policy"
|
||||||
"swarm-bao-services-issuer-policy"
|
"swarm-bao-services-issuer-policy"
|
||||||
"swarm-bao-nats-tls-policy"
|
"swarm-bao-nats-tls-policy"
|
||||||
|
"swarm-bao-agent-pki"
|
||||||
];
|
];
|
||||||
|
|
||||||
# Comment lines dropped first: both the HCL and the scripts explain
|
# Comment lines dropped first: both the HCL and the scripts explain
|
||||||
|
|
@ -741,24 +748,24 @@ let
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
# A store host without the granter's pair writes its grants some other
|
# A store host without the granter's pair writes its grants some other
|
||||||
# way, so none of the ten units may exist. Without this arm
|
# way, so none of the eleven units may exist. Without this arm
|
||||||
# `lib.mkIf haveGranter` could be dropped from any of them and every other
|
# `lib.mkIf haveGranter` could be dropped from any of them and every other
|
||||||
# case here would still pass.
|
# case here would still pass.
|
||||||
name = "without the granter's pair none of the ten granting units render";
|
name = "without the granter's pair none of the eleven granting units render";
|
||||||
ok =
|
ok =
|
||||||
let
|
let
|
||||||
s = baoGranterOptOut.systemd.services;
|
s = baoGranterOptOut.systemd.services;
|
||||||
in
|
in
|
||||||
lib.all (unit: !(s ? ${unit})) (grantingUnitNames ++ [ "swarm-bao-granter-role" ])
|
lib.all (unit: !(s ? ${unit})) (grantingUnitNames ++ [ "swarm-bao-granter-role" ])
|
||||||
# The control: the same store with the pair renders all ten.
|
# The control: the same store with the pair renders all eleven.
|
||||||
&& lib.all (unit: baoGrantHere.systemd.services ? ${unit}) grantingUnitNames;
|
&& lib.all (unit: baoGrantHere.systemd.services ? ${unit}) grantingUnitNames;
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
# 🩸 What replaced the silent skip. With no bootstrap token the ten still
|
# 🩸 What replaced the silent skip. With no bootstrap token the eleven still
|
||||||
# render, and a refused granter fails them with the step that fixes it.
|
# render, and a refused granter fails them with the step that fixes it.
|
||||||
# A store host that never named a token is told to name one, since the
|
# A store host that never named a token is told to name one, since the
|
||||||
# unit that sets the granter up renders only where it has.
|
# unit that sets the granter up renders only where it has.
|
||||||
name = "a store host without a bootstrap token renders the ten, each failing loudly with the one-time step";
|
name = "a store host without a bootstrap token renders the eleven, each failing loudly with the one-time step";
|
||||||
ok =
|
ok =
|
||||||
let
|
let
|
||||||
s = baoGranterNoToken.systemd.services;
|
s = baoGranterNoToken.systemd.services;
|
||||||
|
|
@ -1122,8 +1129,8 @@ let
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
# What makes the case above mean something: discovery by the granter's
|
# What makes the case above mean something: discovery by the granter's
|
||||||
# certificate reaches all ten units, and each yields calls.
|
# certificate reaches all eleven units, and each yields calls.
|
||||||
name = "the granter-policy check sees all ten granting units, and parses calls from each";
|
name = "the granter-policy check sees all eleven granting units, and parses calls from each";
|
||||||
ok =
|
ok =
|
||||||
lib.sort lib.lessThan (lib.attrNames granterUnits) == lib.sort lib.lessThan grantingUnitNames
|
lib.sort lib.lessThan (lib.attrNames granterUnits) == lib.sort lib.lessThan grantingUnitNames
|
||||||
&& lib.all (u: baoCalls u.script != [ ]) (lib.attrValues granterUnits)
|
&& lib.all (u: baoCalls u.script != [ ]) (lib.attrValues granterUnits)
|
||||||
|
|
@ -1163,6 +1170,96 @@ let
|
||||||
]
|
]
|
||||||
&& grantFor bootstrapGrants "sys/policies/acl/swarm-controller" == null;
|
&& grantFor bootstrapGrants "sys/policies/acl/swarm-controller" == null;
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
# The controller's whole reach on any PKI mount: one role's issue
|
||||||
|
# endpoint. It cannot rewrite the role, sign a CSR of its choosing or
|
||||||
|
# touch the issuer, so the role's narrowing is the narrowing.
|
||||||
|
name = "the controller's only PKI grant is update on the agent role's issue path";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
cg = grantsIn baoGrantHere.systemd.services.swarm-bao-controller-policy.script;
|
||||||
|
in
|
||||||
|
lib.filter (g: lib.hasInfix "pki" g.path) cg == [
|
||||||
|
{
|
||||||
|
path = "pki-agents/issue/swarm-agent";
|
||||||
|
caps = [ "update" ];
|
||||||
|
}
|
||||||
|
]
|
||||||
|
&& lib.all (p: grantFor cg p == null) [
|
||||||
|
"pki-agents/roles/swarm-agent"
|
||||||
|
"pki-agents/sign/swarm-agent"
|
||||||
|
"pki-agents/sign-verbatim/swarm-agent"
|
||||||
|
"pki-agents/issue/swarm-other"
|
||||||
|
"pki-agents/root/generate/internal"
|
||||||
|
"pki-agents/issuer/default"
|
||||||
|
"pki-agents/config/urls"
|
||||||
|
"pki-agents/keys"
|
||||||
|
"pki/issue/swarm-services"
|
||||||
|
"sys/mounts/pki-agents"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# The engine refuses any name outside the glob, which is what keeps a
|
||||||
|
# host CN out of the controller's reach. Exactly one unit writes a role
|
||||||
|
# on the agent mount, so no second role widens it.
|
||||||
|
name = "the agent PKI role issues client certificates named hive-agent-* and nothing else";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
s = baoGrantHere.systemd.services.swarm-bao-agent-pki.script;
|
||||||
|
roleWriters = lib.filter (u: matches "bao write '?pki-agents/roles/" (u.script or "") != [ ]) (
|
||||||
|
lib.attrValues baoGrantHere.systemd.services
|
||||||
|
);
|
||||||
|
in
|
||||||
|
lib.all (t: lib.hasInfix t s) [
|
||||||
|
"allowed_domains='hive-agent-*'"
|
||||||
|
"allow_glob_domains=true"
|
||||||
|
"allow_bare_domains=false"
|
||||||
|
"allow_subdomains=false"
|
||||||
|
"allow_wildcard_certificates=false"
|
||||||
|
"allow_localhost=false"
|
||||||
|
"allow_any_name=false"
|
||||||
|
"allow_ip_sans=false"
|
||||||
|
"server_flag=false"
|
||||||
|
"client_flag=true"
|
||||||
|
"code_signing_flag=false"
|
||||||
|
"email_protection_flag=false"
|
||||||
|
"ttl=2160h"
|
||||||
|
"max_ttl=2160h"
|
||||||
|
]
|
||||||
|
&& lib.length roleWriters == 1;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# Every agent's role pins this root by value: generated once, after the
|
||||||
|
# tune that stops bao clamping it, and never deleted.
|
||||||
|
name = "the agent root is generated once, after the tune, as a leaf-only CA, and nothing deletes it";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
s = baoGrantHere.systemd.services.swarm-bao-agent-pki.script;
|
||||||
|
tune = "bao secrets tune -max-lease-ttl=262800h pki-agents";
|
||||||
|
generate = "pki-agents/root/generate/internal";
|
||||||
|
before =
|
||||||
|
a: b:
|
||||||
|
lib.stringLength (lib.head (lib.splitString b s))
|
||||||
|
> lib.stringLength (lib.head (lib.splitString a s));
|
||||||
|
in
|
||||||
|
lib.length (lib.splitString generate s) == 2
|
||||||
|
&& lib.hasInfix tune s
|
||||||
|
&& before tune generate
|
||||||
|
&& lib.hasInfix "max_path_length=0" s
|
||||||
|
&& lib.hasInfix "elif [ \"$issuers\" = '{}' ]; then" s
|
||||||
|
&& !(lib.hasInfix "bao delete" s)
|
||||||
|
&& grantFor granterGrants "pki-agents/root" == null;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# The granter writes pki roles through `roles/swarm-*` only.
|
||||||
|
name = "an agent pki role name outside swarm-* is refused, naming the option";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
names = a: lib.hasInfix "services.hyperhive.deploy.bao.agentPkiRoleName" a.message;
|
||||||
|
in
|
||||||
|
lib.any (a: !a.assertion && names a) baoGranterOddAgentRole.assertions
|
||||||
|
&& !(lib.any (a: !a.assertion && names a) baoGrantHere.assertions);
|
||||||
|
}
|
||||||
];
|
];
|
||||||
in
|
in
|
||||||
runGroup "bao-grants" cases
|
runGroup "bao-grants" cases
|
||||||
|
|
|
||||||
|
|
@ -99,15 +99,6 @@ swarm-secret-client.workspace = true
|
||||||
# The appservice calls `matrix_account::agent_token` mints agents' accounts
|
# The appservice calls `matrix_account::agent_token` mints agents' accounts
|
||||||
# with — shared with `swarm-matrix-ctl`, which pins the same device id.
|
# with — shared with `swarm-matrix-ctl`, which pins the same device id.
|
||||||
swarm-matrix-client.workspace = true
|
swarm-matrix-client.workspace = true
|
||||||
# `agent_identity.rs` signs the per-agent client leaf the store authenticates
|
|
||||||
# an agent container by. The one runtime signer in this tree — every other CA
|
|
||||||
# here is a deploy-time `openssl` oneshot — because this one issues per agent
|
|
||||||
# creation and must keep the key it generates in memory, never on disk.
|
|
||||||
rcgen.workspace = true
|
|
||||||
# `rcgen::CertificateParams`'s validity window is a `time::OffsetDateTime`,
|
|
||||||
# which `agent_identity::validity` builds. Same workspace pin every other
|
|
||||||
# holder of a timestamp here uses.
|
|
||||||
time.workspace = true
|
|
||||||
# `otel_http_client.rs`'s `AuthenticatedHttpClient` — an
|
# `otel_http_client.rs`'s `AuthenticatedHttpClient` — an
|
||||||
# `opentelemetry_http::HttpClient` impl authenticated with this crate's own
|
# `opentelemetry_http::HttpClient` impl authenticated with this crate's own
|
||||||
# `swarm-queue-client` identity. Lives in this crate rather than
|
# `swarm-queue-client` identity. Lives in this crate rather than
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
//! One agent's own identity at the swarm's secret store: minted here,
|
//! One agent's own identity at the swarm's secret store: issued by the store,
|
||||||
//! published here, granted here — and, before the job node reports success,
|
//! published here, granted here — and, before the job node reports success,
|
||||||
//! **used** here.
|
//! **used** here.
|
||||||
//!
|
//!
|
||||||
//! The swarm mints the agent's certificate so that no hive ever needs the
|
//! The swarm obtains the agent's certificate so that no hive ever needs the
|
||||||
//! capability to mint one; the hive only carries it down. The controller is
|
//! capability to obtain one; the hive only carries it down. The controller is
|
||||||
//! the swarm-level service that does it because it already logs in to the
|
//! the swarm-level service that asks because it already logs in to the store,
|
||||||
//! store, and its grant already covers exactly the objects written here
|
//! and its grant covers exactly the calls made here (`swarm-bao.nix`'s
|
||||||
//! (`swarm-bao.nix`'s `controllerPolicyText`: `create/update` on
|
//! `controllerPolicyText`: `update` on the agent PKI role's `issue` path, and
|
||||||
//! `secret/data/swarm/agents/*`, on `sys/policies/acl/hive-*`, and on
|
//! `create/update` on `secret/data/swarm/agents/*`, on
|
||||||
//! `auth/cert/certs/hive-*`). No new authority is asked for anywhere.
|
//! `sys/policies/acl/hive-*`, and on `auth/cert/certs/hive-*`).
|
||||||
//!
|
//!
|
||||||
//! **Two credentials, deliberately unrelated.** The certificate reaches the
|
//! **Two credentials, deliberately unrelated.** The certificate reaches the
|
||||||
//! store; the queue secret identifies the agent to the swarm queue. Both sit
|
//! store; the queue secret identifies the agent to the swarm queue. Both sit
|
||||||
|
|
@ -22,14 +22,11 @@
|
||||||
//! four — so [`mint_and_verify`] does not finish on a write. See
|
//! four — so [`mint_and_verify`] does not finish on a write. See
|
||||||
//! [`read_back_as_agent`].
|
//! [`read_back_as_agent`].
|
||||||
//!
|
//!
|
||||||
//! ⚠️ The authority is **not** `/var/lib/swarm-bao-pki/ca-key.pem`. A
|
//! The authority is the store's own agent CA, generated inside its agent PKI
|
||||||
//! cert-auth role pins its authority by value, per role (see
|
//! mount; its key never leaves the store. It signs no host leaf, and no host
|
||||||
//! [`SecretStore::write_cert_role`][swarm_secret_client::SecretStore::write_cert_role]),
|
//! role pins it, so an agent's certificate satisfies only that agent's role.
|
||||||
//! so a role this daemon writes carries whatever authority this daemon hands
|
//! Nothing re-issues a leaf before it expires (the role's `ttl`); until a
|
||||||
//! it — which is what lets the controller mint from its own CA on its own
|
//! renewal path exists, an operator re-runs agent creation.
|
||||||
//! host, with nothing co-located and no existing role changed.
|
|
||||||
|
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use swarm_secret_client::{
|
use swarm_secret_client::{
|
||||||
|
|
@ -37,163 +34,47 @@ use swarm_secret_client::{
|
||||||
client::{DEFAULT_CERT_MOUNT, Settings},
|
client::{DEFAULT_CERT_MOUNT, Settings},
|
||||||
mtls, policy, queue,
|
mtls, policy, queue,
|
||||||
};
|
};
|
||||||
use time::OffsetDateTime;
|
|
||||||
|
|
||||||
/// File holding the authority agent leaves are issued from, as
|
/// The PKI mount agent leaves are issued from, as `swarm-controller.nix` sets
|
||||||
/// `swarm-controller.nix` names it. Public material.
|
/// it from `deploy.bao.agentPkiMountPath`.
|
||||||
pub const ENV_AGENT_CA: &str = "SWARM_CONTROLLER_AGENT_CA_FILE";
|
pub const ENV_AGENT_PKI_MOUNT: &str = "SWARM_CONTROLLER_AGENT_PKI_MOUNT";
|
||||||
|
|
||||||
/// File holding the private key for [`ENV_AGENT_CA`]. 🩸 A path, never a
|
/// The role on [`ENV_AGENT_PKI_MOUNT`] agent leaves are issued through, as
|
||||||
/// value — the key's bytes must not reach a unit file or the nix store.
|
/// `swarm-controller.nix` sets it from `deploy.bao.agentPkiRoleName`.
|
||||||
pub const ENV_AGENT_CA_KEY: &str = "SWARM_CONTROLLER_AGENT_CA_KEY_FILE";
|
pub const ENV_AGENT_PKI_ROLE: &str = "SWARM_CONTROLLER_AGENT_PKI_ROLE";
|
||||||
|
|
||||||
/// How long a minted leaf is good for.
|
/// Where agent leaves are issued: `(mount, role)`, read from `get`.
|
||||||
///
|
|
||||||
/// Short enough that a leaked key is not permanent, long enough that the
|
|
||||||
/// absence of a renewal path is not immediately fatal. Nothing re-mints a leaf
|
|
||||||
/// today, so until a renewal path lands this is the interval after which an
|
|
||||||
/// operator re-runs agent creation. It is deliberately far shorter than the ten
|
|
||||||
/// years
|
|
||||||
/// `glue-bao-tls.nix` gives the store's own CA: that one is an authority
|
|
||||||
/// whose reissue invalidates every leaf under it, this one is a leaf.
|
|
||||||
/// Spelled in hours because `Duration::from_days` is not yet a stable `const
|
|
||||||
/// fn`; `read_policy`'s `RETRY_WINDOW` is the same workaround.
|
|
||||||
const LEAF_LIFETIME: Duration = Duration::from_hours(90 * 24);
|
|
||||||
|
|
||||||
/// How far a leaf is backdated.
|
|
||||||
///
|
|
||||||
/// The verifier is the store, on another machine: a certificate whose
|
|
||||||
/// `notBefore` is this exact instant is refused outright by a clock a second
|
|
||||||
/// behind ours, and the resulting error names a validity window rather than a
|
|
||||||
/// clock.
|
|
||||||
const CLOCK_SKEW: Duration = Duration::from_mins(5);
|
|
||||||
|
|
||||||
/// The authority this daemon issues agent leaves from, loaded once at startup.
|
|
||||||
///
|
|
||||||
/// ⚠️ **No `Debug` derive**, and the key field is private: this struct is
|
|
||||||
/// reachable from `WorkerDeps`, which is formatted nowhere today and is one
|
|
||||||
/// `#[derive(Debug)]` away from being formatted everywhere.
|
|
||||||
pub struct Authority {
|
|
||||||
/// The authority's certificate, PEM. Public material — it is also what
|
|
||||||
/// goes into each agent's cert-auth role and into each agent's published
|
|
||||||
/// credential.
|
|
||||||
ca_pem: String,
|
|
||||||
/// The authority's private key, PEM. Never logged, never published,
|
|
||||||
/// never leaves this struct.
|
|
||||||
key_pem: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Authority {
|
|
||||||
/// Load the authority from the files [`ENV_AGENT_CA`] and
|
|
||||||
/// [`ENV_AGENT_CA_KEY`] name, or `None` when this host was given neither.
|
|
||||||
///
|
|
||||||
/// `None` is a supported deployment, not a failure: it is the state every
|
|
||||||
/// controller is in before an operator has turned agent identities on, and
|
|
||||||
/// `run_swarm_node` reports it as that one node's named failure rather
|
|
||||||
/// than refusing to start the daemon.
|
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// When exactly one of the two variables is set — half an authority signs
|
/// Naming the first of the two variables that is unset or empty.
|
||||||
/// nothing, and silently doing nothing about it is how a host ends up
|
fn agent_pki(get: impl Fn(&str) -> Option<String>) -> Result<(String, String)> {
|
||||||
/// looking configured — or when a named file cannot be read.
|
let required = |var: &str| -> Result<String> {
|
||||||
pub fn from_env() -> Result<Option<Self>> {
|
get(var)
|
||||||
match (
|
.filter(|v| !v.is_empty())
|
||||||
std::env::var_os(ENV_AGENT_CA),
|
.with_context(|| format!("{var} is unset or empty, so no agent leaf can be issued"))
|
||||||
std::env::var_os(ENV_AGENT_CA_KEY),
|
};
|
||||||
) {
|
Ok((
|
||||||
(None, None) => Ok(None),
|
required(ENV_AGENT_PKI_MOUNT)?,
|
||||||
(Some(_), None) => bail!(
|
required(ENV_AGENT_PKI_ROLE)?,
|
||||||
"{ENV_AGENT_CA} is set but {ENV_AGENT_CA_KEY} is not — an authority with no key signs nothing"
|
))
|
||||||
),
|
|
||||||
(None, Some(_)) => bail!(
|
|
||||||
"{ENV_AGENT_CA_KEY} is set but {ENV_AGENT_CA} is not — a key with no certificate is not an authority"
|
|
||||||
),
|
|
||||||
(Some(ca), Some(key)) => {
|
|
||||||
let ca_path = ca.to_string_lossy().into_owned();
|
|
||||||
let key_path = key.to_string_lossy().into_owned();
|
|
||||||
Ok(Some(Self {
|
|
||||||
ca_pem: std::fs::read_to_string(&ca_path).with_context(|| {
|
|
||||||
format!("reading the agent authority {ca_path} (from {ENV_AGENT_CA})")
|
|
||||||
})?,
|
|
||||||
key_pem: std::fs::read_to_string(&key_path).with_context(|| {
|
|
||||||
format!(
|
|
||||||
"reading the agent authority's key {key_path} (from {ENV_AGENT_CA_KEY})"
|
|
||||||
)
|
|
||||||
})?,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build one from PEM already in hand — the constructor a test uses, and
|
/// What the cert-auth role for `agent` is written from.
|
||||||
/// the one that keeps [`Authority::from_env`] the only place this process
|
struct RoleInputs<'a> {
|
||||||
/// reads a key off disk.
|
/// Role name, policy name and the common name the role matches: one string.
|
||||||
#[cfg(test)]
|
name: String,
|
||||||
fn from_pem(ca_pem: String, key_pem: String) -> Self {
|
/// The authority the role pins: the one that signed this very leaf.
|
||||||
Self { ca_pem, key_pem }
|
ca: &'a str,
|
||||||
|
/// The policy document the role attaches.
|
||||||
|
policy: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Issue a client leaf carrying `common_name`, returning `(certificate,
|
fn role_inputs<'a>(agent: &str, credential: &'a mtls::Credential) -> Result<RoleInputs<'a>> {
|
||||||
/// private key)` as PEM.
|
Ok(RoleInputs {
|
||||||
///
|
name: policy::agent_object_name(agent)?,
|
||||||
/// `clientAuth` and nothing else: this certificate authenticates a
|
ca: &credential.ca,
|
||||||
/// principal to the store and must not be usable to *serve* anything.
|
policy: policy::render_agent(agent)?,
|
||||||
///
|
})
|
||||||
/// # Errors
|
|
||||||
/// When the authority's own PEM will not parse, or the leaf will not sign.
|
|
||||||
fn mint_leaf(&self, common_name: &str) -> Result<(String, String)> {
|
|
||||||
let issuer_key = rcgen::KeyPair::from_pem(&self.key_pem)
|
|
||||||
.context("the agent authority's key is not a PEM key that can sign")?;
|
|
||||||
let issuer = rcgen::Issuer::from_ca_cert_pem(&self.ca_pem, issuer_key)
|
|
||||||
.context("the agent authority is not a PEM certificate that can issue")?;
|
|
||||||
|
|
||||||
let (not_before, not_after) = validity(SystemTime::now(), LEAF_LIFETIME)?;
|
|
||||||
let mut params = rcgen::CertificateParams::default();
|
|
||||||
params.distinguished_name = rcgen::DistinguishedName::new();
|
|
||||||
params
|
|
||||||
.distinguished_name
|
|
||||||
.push(rcgen::DnType::CommonName, common_name);
|
|
||||||
params.is_ca = rcgen::IsCa::NoCa;
|
|
||||||
params.use_authority_key_identifier_extension = true;
|
|
||||||
params.key_usages = vec![
|
|
||||||
rcgen::KeyUsagePurpose::DigitalSignature,
|
|
||||||
rcgen::KeyUsagePurpose::KeyEncipherment,
|
|
||||||
];
|
|
||||||
params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ClientAuth];
|
|
||||||
params.not_before = not_before;
|
|
||||||
params.not_after = not_after;
|
|
||||||
|
|
||||||
let leaf_key = rcgen::KeyPair::generate().context("generating the leaf's key")?;
|
|
||||||
let cert = params
|
|
||||||
.signed_by(&leaf_key, &issuer)
|
|
||||||
.with_context(|| format!("signing a leaf for {common_name}"))?;
|
|
||||||
Ok((cert.pem(), leaf_key.serialize_pem()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The validity window of a leaf minted at `now`, backdated by [`CLOCK_SKEW`].
|
|
||||||
///
|
|
||||||
/// A free function taking `now` rather than reading the clock itself, so the
|
|
||||||
/// arithmetic — the part that can be wrong by a factor of sixty — is testable
|
|
||||||
/// without waiting ninety days.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// When the system clock is before the unix epoch, or so far past it that the
|
|
||||||
/// window will not fit a timestamp.
|
|
||||||
fn validity(now: SystemTime, lifetime: Duration) -> Result<(OffsetDateTime, OffsetDateTime)> {
|
|
||||||
let secs = now
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.context("the system clock is before the unix epoch")?
|
|
||||||
.as_secs();
|
|
||||||
let secs = i64::try_from(secs).context("the system clock is past what a timestamp holds")?;
|
|
||||||
let skew = i64::try_from(CLOCK_SKEW.as_secs()).expect("a five-minute constant fits an i64");
|
|
||||||
let life = i64::try_from(lifetime.as_secs()).context("the leaf lifetime does not fit")?;
|
|
||||||
|
|
||||||
let not_before = OffsetDateTime::from_unix_timestamp(secs - skew)
|
|
||||||
.context("the backdated start is not a representable time")?;
|
|
||||||
let not_after = OffsetDateTime::from_unix_timestamp(secs + life)
|
|
||||||
.context("the expiry is not a representable time")?;
|
|
||||||
Ok((not_before, not_after))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many bytes of kernel randomness a queue secret is before encoding.
|
/// How many bytes of kernel randomness a queue secret is before encoding.
|
||||||
|
|
@ -228,49 +109,46 @@ fn generate_queue_secret() -> Result<String> {
|
||||||
|
|
||||||
/// Give `agent` an identity at the store, and prove it works.
|
/// Give `agent` an identity at the store, and prove it works.
|
||||||
///
|
///
|
||||||
/// Five store writes' worth of agreement, then the login that checks it:
|
/// Five store calls' worth of agreement, then the login that checks it:
|
||||||
///
|
///
|
||||||
/// 1. mint a leaf whose common name is [`policy::agent_object_name`];
|
/// 1. have the agent PKI role issue a leaf whose common name is
|
||||||
|
/// [`policy::agent_object_name`]; the store generates its key;
|
||||||
/// 2. publish it at [`mtls::identity_path`], where the agent's hive collects
|
/// 2. publish it at [`mtls::identity_path`], where the agent's hive collects
|
||||||
/// it under the hive's own certificate;
|
/// it under the hive's own certificate;
|
||||||
/// 3. publish a queue secret at [`queue::agent_queue_path`] — the agent's own
|
/// 3. publish a queue secret at [`queue::agent_queue_path`] — the agent's own
|
||||||
/// identity at the swarm queue, minted here so that the credential an agent
|
/// identity at the swarm queue, minted here so that the credential an agent
|
||||||
/// presents names *it* rather than its hive;
|
/// presents names *it* rather than its hive;
|
||||||
/// 4. write the ACL document [`policy::render_agent_with_queue`] renders —
|
/// 4. write the ACL document [`policy::render_agent`] renders — read on this
|
||||||
/// read on this one agent's paths, plus the hive-shared queue credential
|
/// one agent's paths and nothing else;
|
||||||
/// every agent container on `hive` already receives out of band;
|
/// 5. write the cert-auth role that ties the three together, pinning the CA
|
||||||
/// 5. write the cert-auth role that ties the three together.
|
/// the store named as this leaf's issuer.
|
||||||
///
|
///
|
||||||
/// Policy before role, for the reason `read_policy::provision` gives: the role
|
/// Policy before role, for the reason `read_policy::provision` gives: the role
|
||||||
/// names the policy, so the other order leaves a window in which it points at
|
/// names the policy, so the other order leaves a window in which it points at
|
||||||
/// nothing.
|
/// nothing.
|
||||||
///
|
///
|
||||||
/// ⚠️ **Step 3 is idempotent and step 2 is not.** Re-running re-mints the
|
/// ⚠️ **Step 3 is idempotent and steps 1–2 are not.** Re-running issues a
|
||||||
/// certificate — a fresh leaf the agent picks up on its next boot — but leaves
|
/// fresh leaf, picked up on the agent's next boot, but leaves an existing
|
||||||
/// an existing queue secret alone. An agent holds that secret in a live
|
/// queue secret alone: this is re-run against running agents, which hold that
|
||||||
/// connection, and this function is re-run deliberately against agents that
|
/// secret in a live connection. Revoking one means deleting the path.
|
||||||
/// are already running, so replacing it would drop them off the queue.
|
|
||||||
/// Nothing here rotates one; revoking means deleting the path.
|
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Anything that stops one of those five steps, with the step named. A
|
/// Anything that stops one of those five steps, with the step named. A
|
||||||
/// failure here fails the job node and nothing else — the agent is still
|
/// failure here fails the job node and nothing else — the agent is still
|
||||||
/// created, exactly as capable as every agent is today.
|
/// created, without a store identity.
|
||||||
pub async fn mint_and_verify(authority: &Authority, agent: &str, hive: &str) -> Result<()> {
|
pub async fn mint_and_verify(agent: &str, hive: &str) -> Result<()> {
|
||||||
|
let (mount, pki_role) = agent_pki(|k| std::env::var(k).ok())?;
|
||||||
let name = policy::agent_object_name(agent)?;
|
let name = policy::agent_object_name(agent)?;
|
||||||
let path = mtls::identity_path(agent)?;
|
let path = mtls::identity_path(agent)?;
|
||||||
let queue_path = queue::agent_queue_path(agent)?;
|
let queue_path = queue::agent_queue_path(agent)?;
|
||||||
|
|
||||||
let (cert, key) = authority.mint_leaf(&name)?;
|
|
||||||
let credential = mtls::Credential {
|
|
||||||
cert,
|
|
||||||
key,
|
|
||||||
ca: authority.ca_pem.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let store = crate::store::connect()
|
let store = crate::store::connect()
|
||||||
.await
|
.await
|
||||||
.context("logging in to the swarm secret store")?;
|
.context("logging in to the swarm secret store")?;
|
||||||
|
let credential = store
|
||||||
|
.issue_client_certificate(&mount, &pki_role, &name)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("issuing {name}'s certificate from {mount}/issue/{pki_role}"))?;
|
||||||
store
|
store
|
||||||
.write(&path, &credential)
|
.write(&path, &credential)
|
||||||
.await
|
.await
|
||||||
|
|
@ -319,32 +197,39 @@ pub async fn mint_and_verify(authority: &Authority, agent: &str, hive: &str) ->
|
||||||
}
|
}
|
||||||
let queue_credential = wanted;
|
let queue_credential = wanted;
|
||||||
|
|
||||||
|
let inputs = role_inputs(agent, &credential)?;
|
||||||
store
|
store
|
||||||
.write_policy(&name, &policy::render_agent_with_queue(agent, hive)?)
|
.write_policy(&inputs.name, &inputs.policy)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("writing the read policy {name}"))?;
|
.with_context(|| format!("writing the read policy {}", inputs.name))?;
|
||||||
store
|
store
|
||||||
.write_cert_role(DEFAULT_CERT_MOUNT, &name, &authority.ca_pem, &name, &name)
|
.write_cert_role(
|
||||||
|
DEFAULT_CERT_MOUNT,
|
||||||
|
&inputs.name,
|
||||||
|
inputs.ca,
|
||||||
|
&inputs.name,
|
||||||
|
&inputs.name,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("writing the cert-auth role {name}"))?;
|
.with_context(|| format!("writing the cert-auth role {}", inputs.name))?;
|
||||||
tracing::info!(agent, %path, role = %name, "agent store identity published");
|
tracing::info!(agent, %path, role = %name, "agent store identity published");
|
||||||
|
|
||||||
read_back_as_agent(&credential, &name, &path, &queue_path, &queue_credential).await?;
|
read_back_as_agent(&credential, &name, &path, &queue_path, &queue_credential).await?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
agent,
|
agent,
|
||||||
role = %name,
|
role = %name,
|
||||||
"agent store identity verified: the minted leaf logged in and read both its own paths"
|
"agent store identity verified: the issued leaf logged in and read both its own paths"
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The consumer of everything [`mint_and_verify`] wrote: log in **as the
|
/// The consumer of everything [`mint_and_verify`] wrote: log in **as the
|
||||||
/// agent**, with the leaf just minted, and read back both paths just
|
/// agent**, with the leaf just issued, and read back both paths just
|
||||||
/// published.
|
/// published.
|
||||||
///
|
///
|
||||||
/// The address and the store's CA come from this process's own `BAO_*`
|
/// The address and the store's CA come from this process's own `BAO_*`
|
||||||
/// environment; the *identity* deliberately does not — see
|
/// environment; the *identity* deliberately does not — see
|
||||||
/// [`SecretStore::connect_with_identity`]. The freshly minted private key
|
/// [`SecretStore::connect_with_identity`]. The freshly issued private key
|
||||||
/// never touches a filesystem.
|
/// never touches a filesystem.
|
||||||
///
|
///
|
||||||
/// Both paths, not just the certificate's, for the reason this function
|
/// Both paths, not just the certificate's, for the reason this function
|
||||||
|
|
@ -377,7 +262,7 @@ async fn read_back_as_agent(
|
||||||
SecretStore::connect_with_identity(&settings, &identity, role, DEFAULT_CERT_MOUNT)
|
SecretStore::connect_with_identity(&settings, &identity, role, DEFAULT_CERT_MOUNT)
|
||||||
.await
|
.await
|
||||||
.with_context(|| {
|
.with_context(|| {
|
||||||
format!("logging in to the store as {role} with the leaf just minted")
|
format!("logging in to the store as {role} with the leaf just issued")
|
||||||
})?;
|
})?;
|
||||||
let read_back: mtls::Credential = as_agent
|
let read_back: mtls::Credential = as_agent
|
||||||
.read(path)
|
.read(path)
|
||||||
|
|
@ -407,92 +292,10 @@ async fn read_back_as_agent(
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
Authority, CLOCK_SKEW, LEAF_LIFETIME, QUEUE_SECRET_BYTES, generate_queue_secret, validity,
|
ENV_AGENT_PKI_MOUNT, ENV_AGENT_PKI_ROLE, QUEUE_SECRET_BYTES, agent_pki,
|
||||||
|
generate_queue_secret, role_inputs,
|
||||||
};
|
};
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use swarm_secret_client::{mtls, policy};
|
||||||
|
|
||||||
/// A throwaway CA, minted in-process so no test needs a fixture file.
|
|
||||||
fn test_authority() -> Authority {
|
|
||||||
let key = rcgen::KeyPair::generate().expect("a key generates");
|
|
||||||
let mut params = rcgen::CertificateParams::default();
|
|
||||||
params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Constrained(0));
|
|
||||||
params
|
|
||||||
.distinguished_name
|
|
||||||
.push(rcgen::DnType::CommonName, "swarm-agent-ca");
|
|
||||||
let ca = params.self_signed(&key).expect("the CA self-signs");
|
|
||||||
Authority::from_pem(ca.pem(), key.serialize_pem())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_window_is_backdated_by_the_skew_and_as_long_as_the_lifetime() {
|
|
||||||
// The arithmetic that is wrong by a factor of sixty if a unit slips.
|
|
||||||
let now = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
|
|
||||||
let (before, after) = validity(now, LEAF_LIFETIME).expect("a plain instant is fine");
|
|
||||||
assert_eq!(before.unix_timestamp(), 1_700_000_000 - 300);
|
|
||||||
assert_eq!(after.unix_timestamp(), 1_700_000_000 + 90 * 24 * 60 * 60);
|
|
||||||
assert_eq!(CLOCK_SKEW, Duration::from_mins(5));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_minted_leaf_starts_valid_and_expires() {
|
|
||||||
let now = i64::try_from(
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.expect("the test host's clock is after 1970")
|
|
||||||
.as_secs(),
|
|
||||||
)
|
|
||||||
.expect("and before the end of time");
|
|
||||||
let (before, after) = validity(SystemTime::now(), LEAF_LIFETIME).expect("now is fine");
|
|
||||||
assert!(
|
|
||||||
before.unix_timestamp() < now,
|
|
||||||
"a leaf usable only in the future is unusable"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
after.unix_timestamp() > now,
|
|
||||||
"a leaf that has already expired authenticates nothing"
|
|
||||||
);
|
|
||||||
// The rule `docs/swarm/credentials.md` states: no credential's
|
|
||||||
// renewal strategy may read NONE, which starts with it having an end.
|
|
||||||
// A decade-long leaf is that rule broken in a way review would miss.
|
|
||||||
assert!(after.unix_timestamp() - now < 3653 * 24 * 60 * 60);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The four-strings agreement `mint_and_verify` rests on, checked on the
|
|
||||||
/// one of the four this process controls directly: the certificate really
|
|
||||||
/// does carry the common name the cert-auth role will be told to match.
|
|
||||||
#[test]
|
|
||||||
fn the_leaf_carries_the_agents_object_name_as_its_common_name() {
|
|
||||||
let name = swarm_secret_client::policy::agent_object_name("atlas").expect("legal");
|
|
||||||
assert_eq!(name, "hive-agent-atlas");
|
|
||||||
|
|
||||||
let (cert, key) = test_authority().mint_leaf(&name).expect("the leaf signs");
|
|
||||||
assert!(cert.contains("BEGIN CERTIFICATE"), "a PEM certificate");
|
|
||||||
assert!(key.contains("PRIVATE KEY"), "a PEM key");
|
|
||||||
|
|
||||||
// Searched in the SIGNED DER rather than asserted on the params we
|
|
||||||
// built: the claim is that the name reached the bytes a verifier
|
|
||||||
// reads. A byte search rather than an X.509 parse because the whole
|
|
||||||
// crate would otherwise gain a parser dependency for one assertion —
|
|
||||||
// the name is a UTF8String in the subject DN, so it appears verbatim.
|
|
||||||
let body: String = cert
|
|
||||||
.lines()
|
|
||||||
.filter(|l| !l.starts_with("-----"))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("");
|
|
||||||
let der = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, body)
|
|
||||||
.expect("the PEM body is base64");
|
|
||||||
assert!(
|
|
||||||
der.windows(name.len()).any(|w| w == name.as_bytes()),
|
|
||||||
"the common name must be inside the signed certificate"
|
|
||||||
);
|
|
||||||
|
|
||||||
// And the pair is a usable client identity — the first thing
|
|
||||||
// `read_back_as_agent` does with it, in exactly this shape.
|
|
||||||
let mut identity = cert.into_bytes();
|
|
||||||
identity.push(b'\n');
|
|
||||||
identity.extend_from_slice(key.as_bytes());
|
|
||||||
reqwest::Identity::from_pem(&identity).expect("the leaf and its key form a TLS identity");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The alphabet claim the token format rests on: the secret is carried in
|
/// The alphabet claim the token format rests on: the secret is carried in
|
||||||
/// a composite the verifying end splits on `.`, so a secret that could
|
/// a composite the verifying end splits on `.`, so a secret that could
|
||||||
|
|
@ -516,43 +319,65 @@ mod tests {
|
||||||
assert!(!a.contains('='), "{a}");
|
assert!(!a.contains('='), "{a}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A misconfiguration that would otherwise look like "not configured":
|
fn both(k: &str) -> Option<String> {
|
||||||
/// half an authority has to be an error, not a silent `None`.
|
match k {
|
||||||
///
|
ENV_AGENT_PKI_MOUNT => Some("pki-agents".to_owned()),
|
||||||
/// SAFETY: single-threaded mutation of two env vars no other test in this
|
ENV_AGENT_PKI_ROLE => Some("swarm-agent".to_owned()),
|
||||||
/// crate reads, removed again before returning.
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn half_an_authority_is_an_error_and_neither_half_is_absence() {
|
fn the_issue_path_comes_from_the_two_variables_the_module_sets() {
|
||||||
unsafe {
|
assert_eq!(
|
||||||
std::env::remove_var(super::ENV_AGENT_CA);
|
agent_pki(both).expect("both set"),
|
||||||
std::env::remove_var(super::ENV_AGENT_CA_KEY);
|
("pki-agents".to_owned(), "swarm-agent".to_owned())
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
Authority::from_env()
|
|
||||||
.expect("neither set is a supported shape")
|
|
||||||
.is_none(),
|
|
||||||
"a controller with no authority configured is not an error"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
unsafe { std::env::set_var(super::ENV_AGENT_CA, "/nonexistent/ca.pem") }
|
|
||||||
// `.err().expect(..)` rather than `expect_err`: the `Ok` half is an
|
|
||||||
// `Authority`, which deliberately has no `Debug` (it holds a key).
|
|
||||||
let e = Authority::from_env()
|
|
||||||
.err()
|
|
||||||
.expect("a certificate with no key is half an authority");
|
|
||||||
assert!(format!("{e:#}").contains(super::ENV_AGENT_CA_KEY), "{e:#}");
|
|
||||||
|
|
||||||
unsafe {
|
|
||||||
std::env::remove_var(super::ENV_AGENT_CA);
|
|
||||||
std::env::set_var(super::ENV_AGENT_CA_KEY, "/nonexistent/ca-key.pem");
|
|
||||||
}
|
}
|
||||||
// `.err().expect(..)` rather than `expect_err`: the `Ok` half is an
|
|
||||||
// `Authority`, which deliberately has no `Debug` (it holds a key).
|
|
||||||
let e = Authority::from_env()
|
|
||||||
.err()
|
|
||||||
.expect("a key with no certificate is the other half");
|
|
||||||
assert!(format!("{e:#}").contains(super::ENV_AGENT_CA), "{e:#}");
|
|
||||||
|
|
||||||
unsafe { std::env::remove_var(super::ENV_AGENT_CA_KEY) }
|
#[test]
|
||||||
|
fn a_missing_or_empty_pki_variable_is_named() {
|
||||||
|
for var in [ENV_AGENT_PKI_MOUNT, ENV_AGENT_PKI_ROLE] {
|
||||||
|
let unset = agent_pki(|k| if k == var { None } else { both(k) })
|
||||||
|
.expect_err("one variable is unset");
|
||||||
|
assert!(format!("{unset:#}").contains(var), "{unset:#}");
|
||||||
|
let empty = agent_pki(|k| {
|
||||||
|
if k == var {
|
||||||
|
Some(String::new())
|
||||||
|
} else {
|
||||||
|
both(k)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect_err("one variable is empty");
|
||||||
|
assert!(format!("{empty:#}").contains(var), "{empty:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Three of the four strings that must agree, checked where this process
|
||||||
|
/// sets them: role, policy and matched CN are one name; the pinned CA is
|
||||||
|
/// the issuer the store reported for this leaf; the policy is the agent's
|
||||||
|
/// own single stanza.
|
||||||
|
#[test]
|
||||||
|
fn the_role_pins_the_leafs_own_issuer_under_the_agents_name() {
|
||||||
|
let credential = mtls::Credential {
|
||||||
|
cert: "LEAF".to_owned(),
|
||||||
|
key: "KEY".to_owned(),
|
||||||
|
ca: "AGENT-CA".to_owned(),
|
||||||
|
};
|
||||||
|
let inputs = role_inputs("atlas", &credential).expect("legal");
|
||||||
|
assert_eq!(inputs.name, "hive-agent-atlas");
|
||||||
|
assert_eq!(
|
||||||
|
inputs.name,
|
||||||
|
policy::agent_object_name("atlas").expect("legal"),
|
||||||
|
"the CN the leaf is issued for"
|
||||||
|
);
|
||||||
|
assert_eq!(inputs.ca, "AGENT-CA");
|
||||||
|
assert_eq!(inputs.policy, policy::render_agent("atlas").expect("legal"));
|
||||||
|
assert!(!inputs.policy.contains("swarm/hives/"), "{}", inputs.policy);
|
||||||
|
|
||||||
|
// The control: another agent's inputs differ in both name and grant.
|
||||||
|
let other = role_inputs("argus", &credential).expect("legal");
|
||||||
|
assert_ne!(other.name, inputs.name);
|
||||||
|
assert!(!other.policy.contains("agents/atlas/"), "{}", other.policy);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,9 +101,9 @@ enum SwarmNodeKind {
|
||||||
/// report success on a write.
|
/// report success on a write.
|
||||||
///
|
///
|
||||||
/// Carries the hive for a different reason than `TriggerDeploy` does:
|
/// Carries the hive for a different reason than `TriggerDeploy` does:
|
||||||
/// not as an address, but because an agent's ACL document grants read on
|
/// not as an address, but because the agent's queue credential names the
|
||||||
/// its hive's shared queue credential, so the document cannot be rendered
|
/// hive it may take subjects on, so it cannot be written without knowing
|
||||||
/// without knowing which hive the agent belongs to.
|
/// which hive the agent belongs to.
|
||||||
MintAgentIdentity { hive: String, agent: String },
|
MintAgentIdentity { hive: String, agent: String },
|
||||||
/// Make sure `agent` holds a live forge access token in the swarm secret
|
/// Make sure `agent` holds a live forge access token in the swarm secret
|
||||||
/// store, minting one with the forge's admin API when it does not. See
|
/// store, minting one with the forge's admin API when it does not. See
|
||||||
|
|
@ -209,11 +209,6 @@ struct WorkerDeps {
|
||||||
/// connection living there is an accident of construction order, not a
|
/// connection living there is an accident of construction order, not a
|
||||||
/// claim that events are a kind of status.
|
/// claim that events are a kind of status.
|
||||||
queue: Option<async_nats::Client>,
|
queue: Option<async_nats::Client>,
|
||||||
/// The authority agent client leaves are issued from, loaded once at
|
|
||||||
/// startup because it holds a private key and a per-node re-read would be
|
|
||||||
/// a per-node chance to read one. `None` on a host the operator has not
|
|
||||||
/// given an authority — see `agent_identity::Authority::from_env`.
|
|
||||||
agent_ca: Option<std::sync::Arc<agent_identity::Authority>>,
|
|
||||||
/// The wanted-state writer, for nodes that declare what a hive should
|
/// The wanted-state writer, for nodes that declare what a hive should
|
||||||
/// converge an agent to. Shares the status reader's queue connection —
|
/// converge an agent to. Shares the status reader's queue connection —
|
||||||
/// see `wanted_writer`. `None` exactly when no swarm queue is configured
|
/// see `wanted_writer`. `None` exactly when no swarm queue is configured
|
||||||
|
|
@ -319,9 +314,7 @@ async fn run_swarm_node(
|
||||||
Err(e) => Outcome::Failed(format!("{e:#}")),
|
Err(e) => Outcome::Failed(format!("{e:#}")),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
SwarmNodeKind::MintAgentIdentity { hive, agent } => {
|
SwarmNodeKind::MintAgentIdentity { hive, agent } => mint_identity(&agent, &hive).await,
|
||||||
mint_identity(deps.agent_ca.as_deref(), &agent, &hive).await
|
|
||||||
}
|
|
||||||
SwarmNodeKind::MintAgentForgeToken { agent } => mint_forge_token(deps.forge, &agent).await,
|
SwarmNodeKind::MintAgentForgeToken { agent } => mint_forge_token(deps.forge, &agent).await,
|
||||||
SwarmNodeKind::MintAgentMatrixAccount { agent } => {
|
SwarmNodeKind::MintAgentMatrixAccount { agent } => {
|
||||||
mint_matrix_account(deps.matrix_homeserver.as_deref(), &agent).await
|
mint_matrix_account(deps.matrix_homeserver.as_deref(), &agent).await
|
||||||
|
|
@ -347,22 +340,10 @@ async fn run_swarm_node(
|
||||||
|
|
||||||
/// The `MintAgentIdentity` arm, lifted out so `run_swarm_node` stays under
|
/// The `MintAgentIdentity` arm, lifted out so `run_swarm_node` stays under
|
||||||
/// `clippy::too_many_lines`.
|
/// `clippy::too_many_lines`.
|
||||||
async fn mint_identity(
|
async fn mint_identity(agent: &str, hive: &str) -> hive_jobq::scheduler::Outcome {
|
||||||
authority: Option<&agent_identity::Authority>,
|
|
||||||
agent: &str,
|
|
||||||
hive: &str,
|
|
||||||
) -> hive_jobq::scheduler::Outcome {
|
|
||||||
use hive_jobq::scheduler::Outcome;
|
use hive_jobq::scheduler::Outcome;
|
||||||
|
|
||||||
let Some(authority) = authority else {
|
match agent_identity::mint_and_verify(agent, hive).await {
|
||||||
return Outcome::Failed(
|
|
||||||
"no agent certificate authority is configured on this host \
|
|
||||||
(SWARM_CONTROLLER_AGENT_CA_FILE / SWARM_CONTROLLER_AGENT_CA_KEY_FILE unset), \
|
|
||||||
so this agent has no identity at the swarm secret store"
|
|
||||||
.to_owned(),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
match agent_identity::mint_and_verify(authority, agent, hive).await {
|
|
||||||
Ok(()) => Outcome::Done,
|
Ok(()) => Outcome::Done,
|
||||||
Err(e) => Outcome::Failed(format!("{e:#}")),
|
Err(e) => Outcome::Failed(format!("{e:#}")),
|
||||||
}
|
}
|
||||||
|
|
@ -1483,28 +1464,6 @@ fn name_verdict(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The authority agent leaves are issued from, or `None` on a host that was
|
|
||||||
/// given none.
|
|
||||||
///
|
|
||||||
/// Same "log and carry on" shape as `main`'s other optional wiring: a
|
|
||||||
/// controller with no agent authority still serves everything else, and
|
|
||||||
/// `MintAgentIdentity` fails with a named reason rather than this process
|
|
||||||
/// refusing to start. The `Err` arm is worth its own warning — half an
|
|
||||||
/// authority, or a file that will not read, is a host that looks configured
|
|
||||||
/// and mints nothing.
|
|
||||||
fn load_agent_authority() -> Option<Arc<agent_identity::Authority>> {
|
|
||||||
match agent_identity::Authority::from_env() {
|
|
||||||
Ok(authority) => authority.map(Arc::new),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
error = %format!("{e:#}"),
|
|
||||||
"agent certificate authority unusable; agents get no store identity here"
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The sub-DAG one agent creation is: the nodes, and the edges between them.
|
/// The sub-DAG one agent creation is: the nodes, and the edges between them.
|
||||||
///
|
///
|
||||||
/// A function rather than a closure inside [`create_agent`] so the endpoint's
|
/// A function rather than a closure inside [`create_agent`] so the endpoint's
|
||||||
|
|
@ -1589,9 +1548,9 @@ fn declare_agent_job(
|
||||||
//
|
//
|
||||||
// `after_any` on the mint, not `after_ok`: a hive cannot pass down a
|
// `after_any` on the mint, not `after_ok`: a hive cannot pass down a
|
||||||
// certificate the swarm has not published, so the deploy must not
|
// certificate the swarm has not published, so the deploy must not
|
||||||
// overtake the mint — but a host with no authority configured must still
|
// overtake the mint — but a host whose store or agent PKI is not set up
|
||||||
// create agents exactly as it does today. `after_ok` there would turn an
|
// must still create agents. `after_ok` there would turn a store outage
|
||||||
// unconfigured option into an agent nobody runs.
|
// into an agent nobody runs.
|
||||||
//
|
//
|
||||||
// The forge-token mint gets `after_any` for the same reason: a host with
|
// The forge-token mint gets `after_any` for the same reason: a host with
|
||||||
// no forge or no store must still create agents; only that node fails,
|
// no forge or no store must still create agents; only that node fails,
|
||||||
|
|
@ -2300,7 +2259,6 @@ async fn main() -> Result<()> {
|
||||||
auth: auth.clone(),
|
auth: auth.clone(),
|
||||||
forge: forge_client.clone(),
|
forge: forge_client.clone(),
|
||||||
queue: status.as_ref().map(|s| s.queue_client()),
|
queue: status.as_ref().map(|s| s.queue_client()),
|
||||||
agent_ca: load_agent_authority(),
|
|
||||||
wanted: wanted_writer(status.as_ref()),
|
wanted: wanted_writer(status.as_ref()),
|
||||||
matrix_homeserver: configured_matrix_homeserver(),
|
matrix_homeserver: configured_matrix_homeserver(),
|
||||||
};
|
};
|
||||||
|
|
@ -2972,7 +2930,6 @@ mod tests {
|
||||||
auth: None,
|
auth: None,
|
||||||
forge: None,
|
forge: None,
|
||||||
queue: None,
|
queue: None,
|
||||||
agent_ca: None,
|
|
||||||
wanted: None,
|
wanted: None,
|
||||||
matrix_homeserver: None,
|
matrix_homeserver: None,
|
||||||
};
|
};
|
||||||
|
|
@ -3027,7 +2984,6 @@ mod tests {
|
||||||
auth: None,
|
auth: None,
|
||||||
forge: None,
|
forge: None,
|
||||||
queue: None,
|
queue: None,
|
||||||
agent_ca: None,
|
|
||||||
wanted: None,
|
wanted: None,
|
||||||
matrix_homeserver: None,
|
matrix_homeserver: None,
|
||||||
};
|
};
|
||||||
|
|
@ -3052,16 +3008,16 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Third sibling of the two above, and the same deliberate caveat: with
|
/// Third sibling of the two above, and the same deliberate caveat: with
|
||||||
/// no authority configured this reaches only the
|
/// no agent PKI named this reaches only the graceful-absence-is-failure
|
||||||
/// graceful-absence-is-failure branch. The happy path is a live store
|
/// branch, before any network call. The happy path is a live store and a
|
||||||
/// and a real login, which is exactly why it is `mint_and_verify`'s own
|
/// real login, which is exactly why it is `mint_and_verify`'s own job to
|
||||||
/// job to prove it at agent-creation time rather than a unit test's.
|
/// prove it at agent-creation time rather than a unit test's.
|
||||||
///
|
///
|
||||||
/// What this does pin is the degrade: a host that was never given an
|
/// What this does pin is the degrade: a host whose environment does not
|
||||||
/// authority fails this one node with a reason that names the two
|
/// name the agent PKI fails this one node with a reason that names the
|
||||||
/// variables, and creates the agent anyway.
|
/// variable, and creates the agent anyway.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn mint_agent_identity_node_runs_end_to_end_and_fails_without_an_authority() {
|
async fn mint_agent_identity_node_runs_end_to_end_and_fails_without_the_agent_pki_named() {
|
||||||
let mut sched = hive_jobq::scheduler::Scheduler::new(
|
let mut sched = hive_jobq::scheduler::Scheduler::new(
|
||||||
hive_jobq::Graph::new(),
|
hive_jobq::Graph::new(),
|
||||||
hive_jobq::resources::ResourceTable::new(),
|
hive_jobq::resources::ResourceTable::new(),
|
||||||
|
|
@ -3082,7 +3038,6 @@ mod tests {
|
||||||
auth: None,
|
auth: None,
|
||||||
forge: None,
|
forge: None,
|
||||||
queue: None,
|
queue: None,
|
||||||
agent_ca: None,
|
|
||||||
wanted: None,
|
wanted: None,
|
||||||
matrix_homeserver: None,
|
matrix_homeserver: None,
|
||||||
};
|
};
|
||||||
|
|
@ -3101,9 +3056,8 @@ mod tests {
|
||||||
assert_eq!(node.state, hive_jobq::State::Failed);
|
assert_eq!(node.state, hive_jobq::State::Failed);
|
||||||
let error = node.error.as_deref().unwrap_or_default();
|
let error = node.error.as_deref().unwrap_or_default();
|
||||||
assert!(
|
assert!(
|
||||||
error.contains(crate::agent_identity::ENV_AGENT_CA)
|
error.contains(crate::agent_identity::ENV_AGENT_PKI_MOUNT),
|
||||||
&& error.contains(crate::agent_identity::ENV_AGENT_CA_KEY),
|
"the reason must name the variable an operator has to set, got {error:?}"
|
||||||
"the reason must name both variables an operator has to set, got {error:?}"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3135,7 +3089,6 @@ mod tests {
|
||||||
auth: None,
|
auth: None,
|
||||||
forge: None,
|
forge: None,
|
||||||
queue: None,
|
queue: None,
|
||||||
agent_ca: None,
|
|
||||||
wanted: None,
|
wanted: None,
|
||||||
matrix_homeserver: None,
|
matrix_homeserver: None,
|
||||||
};
|
};
|
||||||
|
|
@ -3304,7 +3257,7 @@ mod tests {
|
||||||
.expect("the deploy waits for the mint");
|
.expect("the deploy waits for the mint");
|
||||||
assert!(
|
assert!(
|
||||||
when.accepts(hive_jobq::TerminalState::Failed),
|
when.accepts(hive_jobq::TerminalState::Failed),
|
||||||
"an unconfigured authority must not cancel the deploy; this edge \
|
"a failed or unconfigured agent PKI issue must not cancel the deploy; this edge \
|
||||||
has to be `after_any`, not `after_ok`"
|
has to be `after_any`, not `after_ok`"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,12 @@
|
||||||
|
|
||||||
use rustify_derive::Endpoint;
|
use rustify_derive::Endpoint;
|
||||||
use serde::{Serialize, de::DeserializeOwned};
|
use serde::{Serialize, de::DeserializeOwned};
|
||||||
use vaultrs::client::{Client, VaultClient, VaultClientSettingsBuilder};
|
use vaultrs::{
|
||||||
|
api::pki::{requests::GenerateCertificateRequest, responses::GenerateCertificateResponse},
|
||||||
|
client::{Client, VaultClient, VaultClientSettingsBuilder},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{Error, path::MOUNT};
|
use crate::{Error, mtls, path::MOUNT};
|
||||||
|
|
||||||
/// The store's address.
|
/// The store's address.
|
||||||
pub const ENV_ADDR: &str = "BAO_ADDR";
|
pub const ENV_ADDR: &str = "BAO_ADDR";
|
||||||
|
|
@ -261,6 +264,30 @@ impl SecretStore {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Have the PKI role `role` on `mount` issue a client certificate for
|
||||||
|
/// `common_name`, returning it with its key and the issuing CA.
|
||||||
|
///
|
||||||
|
/// The store generates the key: it exists in the store's answer and in the
|
||||||
|
/// returned value, nowhere else. What the certificate may carry (names,
|
||||||
|
/// usages, lifetime) is the role's to decide, so nothing but the name is
|
||||||
|
/// asked for here.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`Error::Vault`] when the token's policy does not cover
|
||||||
|
/// `<mount>/issue/<role>` or the role refuses `common_name`, and
|
||||||
|
/// [`Error::IncompleteIssue`] when the answer lacks any of the three.
|
||||||
|
pub async fn issue_client_certificate(
|
||||||
|
&self,
|
||||||
|
mount: &str,
|
||||||
|
role: &str,
|
||||||
|
common_name: &str,
|
||||||
|
) -> Result<mtls::Credential, Error> {
|
||||||
|
let issued =
|
||||||
|
vaultrs::api::exec_with_result(&self.inner, issue_request(mount, role, common_name))
|
||||||
|
.await?;
|
||||||
|
credential_from_issue(issued)
|
||||||
|
}
|
||||||
|
|
||||||
/// The names of every cert-auth role under `mount`, or none when the
|
/// The names of every cert-auth role under `mount`, or none when the
|
||||||
/// mount has no roles at all.
|
/// mount has no roles at all.
|
||||||
///
|
///
|
||||||
|
|
@ -296,6 +323,45 @@ struct WriteAclPolicy {
|
||||||
policy: String,
|
policy: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The request [`SecretStore::issue_client_certificate`] sends.
|
||||||
|
///
|
||||||
|
/// The name stays out of the SANs so the certificate carries exactly one name,
|
||||||
|
/// the one the cert-auth role matches. PKCS#8 because that is the key shape
|
||||||
|
/// every reader of the published identity already parses.
|
||||||
|
fn issue_request(mount: &str, role: &str, common_name: &str) -> GenerateCertificateRequest {
|
||||||
|
GenerateCertificateRequest {
|
||||||
|
mount: mount.to_owned(),
|
||||||
|
role: role.to_owned(),
|
||||||
|
common_name: Some(common_name.to_owned()),
|
||||||
|
exclude_cn_from_sans: Some(true),
|
||||||
|
private_key_format: Some("pkcs8".to_owned()),
|
||||||
|
..GenerateCertificateRequest::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The store's answer, moved straight into the redacting
|
||||||
|
/// [`mtls::Credential`]. `GenerateCertificateResponse` derives `Debug` and
|
||||||
|
/// holds the private key, so it is consumed here and never formatted.
|
||||||
|
///
|
||||||
|
/// `issuing_ca` rather than `ca_chain` because it is the one certificate a
|
||||||
|
/// cert-auth role pins: the authority that signed this leaf.
|
||||||
|
fn credential_from_issue(issued: GenerateCertificateResponse) -> Result<mtls::Credential, Error> {
|
||||||
|
for (field, value) in [
|
||||||
|
("certificate", &issued.certificate),
|
||||||
|
("private_key", &issued.private_key),
|
||||||
|
("issuing_ca", &issued.issuing_ca),
|
||||||
|
] {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return Err(Error::IncompleteIssue(field));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(mtls::Credential {
|
||||||
|
cert: issued.certificate,
|
||||||
|
key: issued.private_key,
|
||||||
|
ca: issued.issuing_ca,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -408,4 +474,84 @@ mod tests {
|
||||||
the store's copy of the document disagree with its own name"
|
the store's copy of the document disagree with its own name"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The controller's grant names exactly this path with `update`, which is
|
||||||
|
/// what a POST needs; any other path or verb is a 403.
|
||||||
|
#[test]
|
||||||
|
fn an_issue_request_posts_to_the_roles_issue_path() {
|
||||||
|
use rustify::endpoint::Endpoint as _;
|
||||||
|
|
||||||
|
let request = issue_request("pki-agents", "swarm-agent", "hive-agent-atlas");
|
||||||
|
assert_eq!(request.path(), "pki-agents/issue/swarm-agent");
|
||||||
|
assert!(
|
||||||
|
matches!(request.method(), rustify::enums::RequestMethod::POST),
|
||||||
|
"{:?}",
|
||||||
|
request.method()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_issue_request_asks_for_the_name_and_nothing_the_role_decides() {
|
||||||
|
use rustify::endpoint::Endpoint as _;
|
||||||
|
|
||||||
|
let body = issue_request("pki-agents", "swarm-agent", "hive-agent-atlas")
|
||||||
|
.body()
|
||||||
|
.expect("the body serialises")
|
||||||
|
.expect("an issue request sends one");
|
||||||
|
let sent: serde_json::Value = serde_json::from_slice(&body).expect("JSON");
|
||||||
|
assert_eq!(sent["common_name"], "hive-agent-atlas");
|
||||||
|
assert_eq!(sent["exclude_cn_from_sans"], true);
|
||||||
|
assert_eq!(sent["private_key_format"], "pkcs8");
|
||||||
|
for decided_by_the_role in ["ttl", "alt_names", "ip_sans", "uri_sans", "other_sans"] {
|
||||||
|
assert!(
|
||||||
|
sent.get(decided_by_the_role)
|
||||||
|
.is_none_or(serde_json::Value::is_null),
|
||||||
|
"{decided_by_the_role} is the role's to set: {sent}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn issued() -> GenerateCertificateResponse {
|
||||||
|
GenerateCertificateResponse {
|
||||||
|
ca_chain: None,
|
||||||
|
certificate: "LEAF".to_owned(),
|
||||||
|
expiration: None,
|
||||||
|
issuing_ca: "AGENT-CA".to_owned(),
|
||||||
|
private_key: "SECRET-KEY-BYTES".to_owned(),
|
||||||
|
private_key_type: "ec".to_owned(),
|
||||||
|
serial_number: "01".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_complete_answer_becomes_the_published_credential() {
|
||||||
|
let credential = credential_from_issue(issued()).expect("every field is present");
|
||||||
|
assert_eq!(credential.cert, "LEAF");
|
||||||
|
assert_eq!(credential.key, "SECRET-KEY-BYTES");
|
||||||
|
assert_eq!(credential.ca, "AGENT-CA", "the role pins the issuing CA");
|
||||||
|
assert!(
|
||||||
|
!format!("{credential:?}").contains("SECRET-KEY-BYTES"),
|
||||||
|
"the key must not reach a formatted value"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_answer_missing_any_part_of_the_identity_is_refused_by_name() {
|
||||||
|
type Blank = fn(&mut GenerateCertificateResponse);
|
||||||
|
let cases: [(&str, Blank); 3] = [
|
||||||
|
("certificate", |r| r.certificate.clear()),
|
||||||
|
("private_key", |r| r.private_key = " \n".to_owned()),
|
||||||
|
("issuing_ca", |r| r.issuing_ca.clear()),
|
||||||
|
];
|
||||||
|
for (field, blank) in cases {
|
||||||
|
let mut answer = issued();
|
||||||
|
blank(&mut answer);
|
||||||
|
let e = credential_from_issue(answer).expect_err("an incomplete identity");
|
||||||
|
assert!(
|
||||||
|
matches!(e, Error::IncompleteIssue(f) if f == field),
|
||||||
|
"blanking {field} gave {e:?}"
|
||||||
|
);
|
||||||
|
assert!(!e.to_string().contains("SECRET-KEY-BYTES"), "{e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ use crate::{
|
||||||
///
|
///
|
||||||
/// A flat leaf under the agent's prefix, like its controller-minted siblings
|
/// A flat leaf under the agent's prefix, like its controller-minted siblings
|
||||||
/// [`crate::queue::agent_queue_path`] and [`crate::mtls::identity_path`], so
|
/// [`crate::queue::agent_queue_path`] and [`crate::mtls::identity_path`], so
|
||||||
/// the agent's own read stanza ([`crate::policy::render_agent_with_queue`])
|
/// the agent's own read stanza ([`crate::policy::render_agent`])
|
||||||
/// already covers it.
|
/// already covers it.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|
@ -89,7 +89,7 @@ mod tests {
|
||||||
// policy is rendered elsewhere. If the path ever moved out from under
|
// policy is rendered elsewhere. If the path ever moved out from under
|
||||||
// it, the agent's fetch would 403 at boot, naming neither.
|
// it, the agent's fetch would 403 at boot, naming neither.
|
||||||
let path = agent_token_path("atlas").expect("legal");
|
let path = agent_token_path("atlas").expect("legal");
|
||||||
let policy = crate::policy::render_agent_with_queue("atlas", "pr1ma").expect("legal");
|
let policy = crate::policy::render_agent("atlas").expect("legal");
|
||||||
let covered = policy.lines().any(|line| {
|
let covered = policy.lines().any(|line| {
|
||||||
line.strip_prefix("path \"")
|
line.strip_prefix("path \"")
|
||||||
.and_then(|rest| rest.split_once("\" {"))
|
.and_then(|rest| rest.split_once("\" {"))
|
||||||
|
|
@ -104,7 +104,7 @@ mod tests {
|
||||||
// Control for the test above: the prefix match must actually be
|
// Control for the test above: the prefix match must actually be
|
||||||
// discriminating, or it proves nothing.
|
// discriminating, or it proves nothing.
|
||||||
let path = agent_token_path("atlas").expect("legal");
|
let path = agent_token_path("atlas").expect("legal");
|
||||||
let policy = crate::policy::render_agent_with_queue("argus", "pr1ma").expect("legal");
|
let policy = crate::policy::render_agent("argus").expect("legal");
|
||||||
let covered = policy.lines().any(|line| {
|
let covered = policy.lines().any(|line| {
|
||||||
line.strip_prefix("path \"")
|
line.strip_prefix("path \"")
|
||||||
.and_then(|rest| rest.split_once("\" {"))
|
.and_then(|rest| rest.split_once("\" {"))
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,11 @@ pub enum Error {
|
||||||
/// CA bundle did not parse.
|
/// CA bundle did not parse.
|
||||||
#[error("building the TLS identity: {0}")]
|
#[error("building the TLS identity: {0}")]
|
||||||
Tls(#[source] reqwest::Error),
|
Tls(#[source] reqwest::Error),
|
||||||
|
|
||||||
|
/// The store answered an issue request with a field empty that a usable
|
||||||
|
/// identity needs. Names the field, never its value.
|
||||||
|
#[error("the store issued a certificate whose {0} is empty")]
|
||||||
|
IncompleteIssue(&'static str),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<vaultrs::error::ClientError> for Error {
|
impl From<vaultrs::error::ClientError> for Error {
|
||||||
|
|
|
||||||
|
|
@ -70,13 +70,12 @@ pub fn hive_object_name(hive: &str) -> Result<String, Error> {
|
||||||
/// client ids; this is a second identifier family leaning on it, which is why
|
/// client ids; this is a second identifier family leaning on it, which is why
|
||||||
/// the fragment list is what to read before renaming either.
|
/// the fragment list is what to read before renaming either.
|
||||||
///
|
///
|
||||||
/// ⚠️ The controller's and publisher's subjects are *not* covered by that: their
|
/// Host principals' **common names** are operator-set options
|
||||||
/// policy names are literals outside `hive-`, but their **common names** are
|
/// (`deploy.bao.controllerCommonName`, `secretPublisherCommonName`, …) that
|
||||||
/// operator-set options (`deploy.bao.controllerCommonName`,
|
/// may spell this prefix, and that is harmless: an agent's cert-auth role pins
|
||||||
/// `secretPublisherCommonName`) that nothing here can see, and an operator may
|
/// the store's agent CA (`deploy.bao.agentPkiMountPath`), which signs no host
|
||||||
/// spell one `hive-agent-atlas`. Whichever change first mints an agent leaf
|
/// leaf, and every host role pins `deploy.bao.clientCaFile`, which signs no
|
||||||
/// owes the assertion that neither starts with this prefix — `swarm.nix`'s
|
/// agent leaf. A CN match alone logs nobody in.
|
||||||
/// `certAuthCns` is where the mirror-image check for hive names lives.
|
|
||||||
pub const AGENT_PREFIX: &str = "hive-agent-";
|
pub const AGENT_PREFIX: &str = "hive-agent-";
|
||||||
|
|
||||||
/// The policy and cert-auth role name for `agent`, and the common name of the
|
/// The policy and cert-auth role name for `agent`, and the common name of the
|
||||||
|
|
@ -203,43 +202,6 @@ pub fn render_agent(agent: &str) -> Result<String, Error> {
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render `agent`'s policy document: read on that one agent's credentials and
|
|
||||||
/// on the hive's shared queue credential.
|
|
||||||
///
|
|
||||||
/// Extends [`render_agent`] with a second stanza granting read on
|
|
||||||
/// `swarm/hives/<hive>/queue/agent`. The queue credential is **hive-shared,
|
|
||||||
/// not per-agent** — every agent in a hive authenticates to the queue with the
|
|
||||||
/// same client secret (`queue.rs:1-8`), so a policy scoped strictly to
|
|
||||||
/// `agents/<agent>/*` cannot read it and an in-container pull would fail. That
|
|
||||||
/// hive-shared credential is already handed to every agent container on that
|
|
||||||
/// hive by the host today, so this grant adds no new authority — it merely
|
|
||||||
/// makes the existing capability reachable through the agent's own token
|
|
||||||
/// instead of requiring the credential to be delivered out of band.
|
|
||||||
///
|
|
||||||
/// ⚠️ **Every agent in a hive can read that hive's queue credential.** This is
|
|
||||||
/// not new authority (the host already provides this exact value to all agents
|
|
||||||
/// on the hive), but it is a documented property: an agent policy grants read
|
|
||||||
/// on a path shared across every agent on its hive, not on a path unique to
|
|
||||||
/// that agent alone.
|
|
||||||
///
|
|
||||||
/// Read-only, for the same reason [`render_agent`]'s is: an agent that could
|
|
||||||
/// write credentials could hand itself an identity it was never issued.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// [`Error::PathSegment`] when `agent` or `hive` holds anything but
|
|
||||||
/// `[A-Za-z0-9_-]` — both are interpolated into policy paths, so a name that
|
|
||||||
/// could close a stanza could grant itself anything.
|
|
||||||
pub fn render_agent_with_queue(agent: &str, hive: &str) -> Result<String, Error> {
|
|
||||||
checked_segment("agent", agent)?;
|
|
||||||
let agent_stanza = read_stanza(&format!(
|
|
||||||
"{MOUNT}/data/{ROOT}/{}/{agent}/*",
|
|
||||||
<&str>::from(Kind::Agent)
|
|
||||||
));
|
|
||||||
let queue_path = crate::queue::agent_client_path(hive)?;
|
|
||||||
let queue_stanza = read_stanza(&format!("{MOUNT}/data/{queue_path}"));
|
|
||||||
Ok(format!("{agent_stanza}{queue_stanza}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -430,6 +392,22 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_agents_document_is_exactly_its_own_read_stanza() {
|
||||||
|
// Pinned byte for byte: an added stanza (a hive's queue credential, a
|
||||||
|
// second agent) is exactly what a presence check misses.
|
||||||
|
assert_eq!(
|
||||||
|
render_agent("atlas").expect("legal"),
|
||||||
|
"path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n"
|
||||||
|
);
|
||||||
|
// The control: the pin discriminates between agents.
|
||||||
|
assert!(
|
||||||
|
!render_agent("other")
|
||||||
|
.expect("legal")
|
||||||
|
.contains("agents/atlas/")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_agents_grant_is_read_only() {
|
fn an_agents_grant_is_read_only() {
|
||||||
// An agent that could write its own credentials could hand itself an
|
// An agent that could write its own credentials could hand itself an
|
||||||
|
|
@ -517,102 +495,6 @@ mod tests {
|
||||||
assert!(hive.contains("path \"secret/data/swarm/agents/*\""));
|
assert!(hive.contains("path \"secret/data/swarm/agents/*\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_agents_document_with_queue_grants_both_paths() {
|
|
||||||
// The happy path: the document grants read on the agent's own namespace
|
|
||||||
// and on the hive's queue credential.
|
|
||||||
let p = render_agent_with_queue("atlas", "pr1ma").expect("legal");
|
|
||||||
assert!(
|
|
||||||
p.contains("path \"secret/data/swarm/agents/atlas/*\""),
|
|
||||||
"must grant the agent's own path: {p}"
|
|
||||||
);
|
|
||||||
let expected_queue_path = format!(
|
|
||||||
"path \"{MOUNT}/data/{}\"",
|
|
||||||
crate::queue::agent_client_path("pr1ma").expect("legal")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
p.contains(&expected_queue_path),
|
|
||||||
"must grant the hive's queue credential: {p}"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
p.matches("path \"").count(),
|
|
||||||
2,
|
|
||||||
"two stanzas, one for the agent and one for the queue: {p}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_agents_document_with_queue_is_read_only() {
|
|
||||||
// An agent that could write the queue credential could hand every agent
|
|
||||||
// on its hive an identity they were never issued.
|
|
||||||
let p = render_agent_with_queue("atlas", "pr1ma").expect("legal");
|
|
||||||
for capability in ["create", "update", "delete", "list", "sudo", "patch"] {
|
|
||||||
assert!(!p.contains(capability), "must not grant {capability}: {p}");
|
|
||||||
}
|
|
||||||
assert!(p.contains("capabilities = [\"read\"]"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_agent_name_with_traversal_in_the_queue_variant_is_refused() {
|
|
||||||
// The agent parameter is an injection surface in both renderers, so
|
|
||||||
// refusing a traversal here proves the new one validates it.
|
|
||||||
assert!(
|
|
||||||
render_agent_with_queue("atlas/*\" { capabilities = [\"root\"] }", "pr1ma").is_err()
|
|
||||||
);
|
|
||||||
assert!(render_agent_with_queue("", "pr1ma").is_err());
|
|
||||||
// The control: legal names still work.
|
|
||||||
assert!(render_agent_with_queue("a-b_C9", "pr1ma").is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_hive_name_with_traversal_in_the_queue_variant_is_refused() {
|
|
||||||
// The hive parameter is a second injection surface that only the queue
|
|
||||||
// variant introduces, so this test proves that new parameter is
|
|
||||||
// validated. A name that could close the stanza could grant the agent
|
|
||||||
// anything.
|
|
||||||
assert!(
|
|
||||||
render_agent_with_queue("atlas", "pr1ma/*\" { capabilities = [\"root\"] }").is_err()
|
|
||||||
);
|
|
||||||
assert!(render_agent_with_queue("atlas", "").is_err());
|
|
||||||
assert!(
|
|
||||||
render_agent_with_queue("atlas", "../services/swarm-grafana").is_err(),
|
|
||||||
"a path traversal that could reach a different kind"
|
|
||||||
);
|
|
||||||
// The control: legal names still work.
|
|
||||||
assert!(render_agent_with_queue("atlas", "a-b_C9").is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_queue_variant_does_not_widen_the_agent_stanza() {
|
|
||||||
// The queue grant must not cause the agent stanza to widen from
|
|
||||||
// `agents/<agent>/*` to `agents/*` — that would give every agent every
|
|
||||||
// other agent's credentials.
|
|
||||||
let p = render_agent_with_queue("atlas", "pr1ma").expect("legal");
|
|
||||||
assert!(
|
|
||||||
!p.contains("swarm/agents/*"),
|
|
||||||
"must not grant the whole agent prefix: {p}"
|
|
||||||
);
|
|
||||||
assert!(p.contains("swarm/agents/atlas/*"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_queue_variant_does_not_grant_the_whole_hive_prefix() {
|
|
||||||
// The queue stanza must grant only the queue credential path, not
|
|
||||||
// `hives/<hive>/*` — the latter would give the agent read on every
|
|
||||||
// secret of the hive that hosts it.
|
|
||||||
let p = render_agent_with_queue("atlas", "pr1ma").expect("legal");
|
|
||||||
assert!(
|
|
||||||
!p.contains("swarm/hives/*"),
|
|
||||||
"must not grant the whole hive prefix: {p}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!p.contains("swarm/hives/pr1ma/*"),
|
|
||||||
"must not grant the hive's whole path: {p}"
|
|
||||||
);
|
|
||||||
let expected_queue_path = crate::queue::agent_client_path("pr1ma").expect("legal");
|
|
||||||
assert!(p.contains(&expected_queue_path));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_agent_roles_come_back_and_without_their_prefix() {
|
fn only_agent_roles_come_back_and_without_their_prefix() {
|
||||||
let roles: Vec<String> = [
|
let roles: Vec<String> = [
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue