Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c22db5eb57 | ||
|
|
4007fc965d | ||
|
|
7f9e65e923 |
7 changed files with 201 additions and 18 deletions
|
|
@ -43,7 +43,10 @@ let
|
||||||
|
|
||||||
# What a reader calls itself to the store. The hive's name, because a bao
|
# What a reader calls itself to the store. The hive's name, because a bao
|
||||||
# cert-auth role matches on the CN — this is an interface, not a label.
|
# cert-auth role matches on the CN — this is an interface, not a label.
|
||||||
clientCn = if hyperhiveCfg.hiveName != null then hyperhiveCfg.hiveName else cfg.domain;
|
# No fallback: `hiveName` is asserted set for every hyperhive host, which is
|
||||||
|
# the same condition this file's `config` is gated on. A fallback here reads
|
||||||
|
# as a second supported spelling and there is no such thing.
|
||||||
|
clientCn = hyperhiveCfg.hiveName;
|
||||||
|
|
||||||
# $1 dir $2 basename $3 CN $4 SAN or "" $5 EKU
|
# $1 dir $2 basename $3 CN $4 SAN or "" $5 EKU
|
||||||
signLeaf = pkgs.writeShellScript "swarm-bao-sign-leaf" ''
|
signLeaf = pkgs.writeShellScript "swarm-bao-sign-leaf" ''
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,12 @@ in
|
||||||
services.hyperhive.deploy.swarm-controller = {
|
services.hyperhive.deploy.swarm-controller = {
|
||||||
baoClientCertFile = lib.mkDefault "${pkiDir}/controller.pem";
|
baoClientCertFile = lib.mkDefault "${pkiDir}/controller.pem";
|
||||||
baoClientKeyFile = lib.mkDefault "${pkiDir}/controller-key.pem";
|
baoClientKeyFile = lib.mkDefault "${pkiDir}/controller-key.pem";
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (baoDeploy.clientCaFile != null) {
|
||||||
|
# The authority the store already trusts hives by, handed to the daemon
|
||||||
|
# that has to write it into each hive's role. One file, two readers —
|
||||||
|
# a second copy would authenticate hives the store does not.
|
||||||
|
hiveClientCaFile = lib.mkDefault baoDeploy.clientCaFile;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,11 @@ let
|
||||||
deployCfg.swarm-controller.baoClientCertFile != null
|
deployCfg.swarm-controller.baoClientCertFile != null
|
||||||
&& deployCfg.swarm-controller.baoClientKeyFile != null;
|
&& deployCfg.swarm-controller.baoClientKeyFile != null;
|
||||||
|
|
||||||
|
# Creating a hive's role means writing to the store, so the CA alone is not
|
||||||
|
# enough — without an identity there is nothing to write it with, and the
|
||||||
|
# file would be handed to a daemon that cannot use it.
|
||||||
|
haveHiveClientCa = haveBaoIdentity && deployCfg.swarm-controller.hiveClientCaFile != 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
|
||||||
|
|
@ -47,6 +52,13 @@ let
|
||||||
# the self-signed one ./glue-bao-tls.nix mints, which is why that file
|
# the self-signed one ./glue-bao-tls.nix mints, which is why that file
|
||||||
# names this path rather than leaving it to a default.
|
# names this path rather than leaving it to a default.
|
||||||
BAO_CACERT = "%d/bao-ca.pem";
|
BAO_CACERT = "%d/bao-ca.pem";
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs haveHiveClientCa {
|
||||||
|
# Not a `BAO_` name: that family is `swarm_secret_client`'s, and
|
||||||
|
# `BAO_CACERT` above is the *server* CA. This is the authority the
|
||||||
|
# store should trust hives by, which the controller reads as a value
|
||||||
|
# to put in each hive's cert-auth role.
|
||||||
|
SWARM_CONTROLLER_HIVE_CLIENT_CA_FILE = "%d/hive-client-ca.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.
|
||||||
|
|
@ -529,6 +541,31 @@ in
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
hiveClientCaFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
example = "/var/lib/swarm-ca/root.pem";
|
||||||
|
description = ''
|
||||||
|
Authority whose leaves are hive client certificates, so that this
|
||||||
|
daemon can create each hive's cert-auth role. The store matches a
|
||||||
|
presented certificate against the role's copy of this, which is why the
|
||||||
|
role carries the authority itself rather than a path to it.
|
||||||
|
|
||||||
|
The same material as {option}`services.hyperhive.deploy.bao.clientCaFile`,
|
||||||
|
named separately because that option is the **store's**: it says which
|
||||||
|
readers the store trusts, on the host that runs the store. A controller
|
||||||
|
runs anywhere, so it names its own copy, and a glue module supplies this
|
||||||
|
as a `mkDefault` where the two are co-located.
|
||||||
|
|
||||||
|
Public material — a certificate authority, not a key — so unlike every
|
||||||
|
other credential here it leaks nothing. It stays a path anyway, because
|
||||||
|
the file it names is the one the store already installs.
|
||||||
|
|
||||||
|
`null` leaves hive roles uncreated, which is the state a swarm is in
|
||||||
|
before anyone has onboarded a hive.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
queue = {
|
queue = {
|
||||||
clientSecretFile = lib.mkOption {
|
clientSecretFile = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
|
|
@ -686,7 +723,8 @@ 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}";
|
||||||
|
|
||||||
# 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
|
||||||
|
|
|
||||||
|
|
@ -268,6 +268,20 @@ let
|
||||||
# files this host will never have.
|
# files this host will never have.
|
||||||
controllerNoStore = hive { deploy.swarm-controller.enable = true; };
|
controllerNoStore = hive { deploy.swarm-controller.enable = true; };
|
||||||
|
|
||||||
|
# The two authorities told apart. A deployment that self-signs both ends
|
||||||
|
# points `clientCaFile` and `serverCaFile` at one file, so on the fixture
|
||||||
|
# above the CA a hive is issued from and the CA the store is verified by are
|
||||||
|
# the same string — and a case wiring either into the other's slot passes.
|
||||||
|
# This is the deployment where they differ, which is what makes the arm
|
||||||
|
# below able to fail at all.
|
||||||
|
controllerTwoCas = hive {
|
||||||
|
deploy.bao.enable = true;
|
||||||
|
deploy.bao.bootstrapTokenFile = "/run/secrets/bao-bootstrap.token";
|
||||||
|
deploy.swarm-controller.enable = true;
|
||||||
|
deploy.bao.clientCaFile = lib.mkForce "/etc/pki/hive-clients-ca.pem";
|
||||||
|
deploy.bao.serverCaFile = lib.mkForce "/etc/pki/store-server-ca.pem";
|
||||||
|
};
|
||||||
|
|
||||||
# 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, and it separates
|
# own certificates and has not named the authority yet, and it separates
|
||||||
|
|
@ -621,6 +635,40 @@ let
|
||||||
&& builtins.elem "bao-client.pem:/var/lib/swarm-bao-pki/controller.pem" s.swarm-controller.serviceConfig.LoadCredential
|
&& builtins.elem "bao-client.pem:/var/lib/swarm-bao-pki/controller.pem" s.swarm-controller.serviceConfig.LoadCredential
|
||||||
&& builtins.elem "bao-client-key.pem:/var/lib/swarm-bao-pki/controller-key.pem" s.swarm-controller.serviceConfig.LoadCredential;
|
&& builtins.elem "bao-client-key.pem:/var/lib/swarm-bao-pki/controller-key.pem" s.swarm-controller.serviceConfig.LoadCredential;
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
# A hive's cert-auth role carries the authority by value, so the daemon
|
||||||
|
# has to be handed the file rather than a path into the store's own
|
||||||
|
# directory it cannot read.
|
||||||
|
#
|
||||||
|
# ⚠️ The LoadCredential source is asserted, not just the `%d` name, for
|
||||||
|
# the reason the arm above gives — and here the wrong file is a
|
||||||
|
# *plausible* one: `deploy.bao.serverCaFile` is the CA a reader checks
|
||||||
|
# the store's certificate with, evaluates fine in this slot, and would
|
||||||
|
# make every hive role trust the wrong authority.
|
||||||
|
name = "the controller is handed the CA hives are issued from";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
s = controllerTwoCas.systemd.services;
|
||||||
|
m = controllerTwoCas.services.hyperhive;
|
||||||
|
in
|
||||||
|
(s.swarm-controller.environment.SWARM_CONTROLLER_HIVE_CLIENT_CA_FILE or null)
|
||||||
|
== "%d/hive-client-ca.pem"
|
||||||
|
&& builtins.elem "hive-client-ca.pem:/etc/pki/hive-clients-ca.pem" s.swarm-controller.serviceConfig.LoadCredential
|
||||||
|
&& !builtins.elem "hive-client-ca.pem:/etc/pki/store-server-ca.pem" s.swarm-controller.serviceConfig.LoadCredential
|
||||||
|
&& m.deploy.swarm-controller.hiveClientCaFile == m.deploy.bao.clientCaFile;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# Absence arm for the one above: without a store identity there is
|
||||||
|
# nothing to write a role with, so handing over the authority would be
|
||||||
|
# giving a file to a daemon that cannot act on it.
|
||||||
|
name = "a controller with no store leaf is given no hive CA either";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
s = controllerNoStore.systemd.services;
|
||||||
|
in
|
||||||
|
!(s.swarm-controller.environment ? SWARM_CONTROLLER_HIVE_CLIENT_CA_FILE)
|
||||||
|
&& !(lib.any (c: lib.hasPrefix "hive-client-ca" c) s.swarm-controller.serviceConfig.LoadCredential);
|
||||||
|
}
|
||||||
{
|
{
|
||||||
# Absence arm for the one above, and what makes it mean anything: a
|
# Absence arm for the one above, and what makes it mean anything: a
|
||||||
# controller with no leaf gets no store environment at all rather than
|
# controller with no leaf gets no store environment at all rather than
|
||||||
|
|
|
||||||
|
|
@ -1623,11 +1623,15 @@ async fn main() -> Result<()> {
|
||||||
let state_forge = keep_forge_for_state(forge_client, webhook_secret.clone());
|
let state_forge = keep_forge_for_state(forge_client, webhook_secret.clone());
|
||||||
|
|
||||||
let hives = load_hives();
|
let hives = load_hives();
|
||||||
// Before serving, because a hive whose policy does not exist cannot read
|
// Before serving, because a hive whose role does not exist cannot log in,
|
||||||
// anything this daemon writes for it. Same "log and carry on" shape as
|
// and one whose policy does not exist logs in able to read nothing —
|
||||||
// every connect above.
|
// either way it cannot collect what this daemon writes for it. Policy
|
||||||
read_policy::ensure_hive_policies(&hives.iter().map(|h| h.name.clone()).collect::<Vec<_>>())
|
// first: the role names the policy, so writing it the other way round
|
||||||
.await;
|
// leaves a window where the role points at nothing. Same "log and carry
|
||||||
|
// on" shape as every connect above.
|
||||||
|
let hive_names: Vec<String> = hives.iter().map(|h| h.name.clone()).collect();
|
||||||
|
read_policy::ensure_hive_policies(&hive_names).await;
|
||||||
|
read_policy::ensure_hive_roles(&hive_names).await;
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
hives: Arc::new(hives),
|
hives: Arc::new(hives),
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,28 @@
|
||||||
//! Writing the policy a hive's own certificate logs in with.
|
//! Provisioning a hive's access to the swarm's secret store: the policy its
|
||||||
|
//! certificate carries, and the cert-auth role that hands it that policy.
|
||||||
//!
|
//!
|
||||||
//! The controller writes a credential; the hive fetches it with its own
|
//! The controller writes a credential; the hive fetches it with its own
|
||||||
//! certificate. Nothing said which paths that certificate may read, so the
|
//! certificate. Without the role the certificate authenticates to nothing, and
|
||||||
//! read half of every delivery answers 403.
|
//! without the policy the token it gets may read nothing — either way the read
|
||||||
|
//! half of a delivery answers 403.
|
||||||
//!
|
//!
|
||||||
//! A startup pass, not a hook: the document is the same for every hive and
|
//! Both are startup passes rather than hooks. The policy document is the same
|
||||||
//! does not depend on which agents exist ([`swarm_secret_client::policy`]
|
//! for every hive and depends on nothing ([`swarm_secret_client::policy`]
|
||||||
//! explains why it is that wide), so there is nothing to keep in step with
|
//! explains why it is that wide), and the hive list is loaded once because a
|
||||||
//! anything. Per-hive failures are reported and skipped — one unwritable
|
//! config change means a redeploy. Per-hive failures are reported and skipped —
|
||||||
//! policy should not take down a daemon that serves everything else, and the
|
//! one hive that cannot be provisioned should not take down a daemon that
|
||||||
//! next start retries it.
|
//! serves everything else, and the next start retries it.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use swarm_secret_client::{SecretStore, policy};
|
use swarm_secret_client::{SecretStore, client::DEFAULT_CERT_MOUNT, policy};
|
||||||
|
|
||||||
|
/// File holding the authority hives are issued from, as
|
||||||
|
/// `swarm-controller.nix` names it.
|
||||||
|
pub const ENV_HIVE_CLIENT_CA: &str = "SWARM_CONTROLLER_HIVE_CLIENT_CA_FILE";
|
||||||
|
|
||||||
/// Give every hive in `hives` the read policy its certificate will carry.
|
/// Give every hive in `hives` the read policy its certificate will carry.
|
||||||
///
|
///
|
||||||
/// Does nothing when this deployment has no store identity, which is the
|
/// Does nothing when this deployment has no store to reach, which is the
|
||||||
/// shape a controller without a secret store runs in.
|
/// shape a controller without a secret store runs in.
|
||||||
pub async fn ensure_hive_policies(hives: &[String]) {
|
pub async fn ensure_hive_policies(hives: &[String]) {
|
||||||
if hives.is_empty() {
|
if hives.is_empty() {
|
||||||
|
|
@ -46,3 +52,48 @@ async fn write_one(store: &SecretStore, hive: &str) -> Result<()> {
|
||||||
tracing::info!(hive, policy = %name, "hive read policy in place");
|
tracing::info!(hive, policy = %name, "hive read policy in place");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create each hive's cert-auth role, so the certificate it already holds logs
|
||||||
|
/// in and carries the policy written above.
|
||||||
|
///
|
||||||
|
/// Does nothing when no authority is configured, which is the deployment that
|
||||||
|
/// has not onboarded a hive yet.
|
||||||
|
pub async fn ensure_hive_roles(hives: &[String]) {
|
||||||
|
let Some(path) = std::env::var_os(ENV_HIVE_CLIENT_CA) else {
|
||||||
|
tracing::info!("no hive certificate authority configured; hive roles are not managed here");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let path = path.to_string_lossy().into_owned();
|
||||||
|
let ca = match std::fs::read_to_string(&path) {
|
||||||
|
Ok(ca) => ca,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(path, error = %e, var = ENV_HIVE_CLIENT_CA, "reading the hive authority failed; hive roles are not created");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let store = match crate::store::connect().await {
|
||||||
|
Ok(store) => store,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "connecting to the swarm secret store failed; hive roles are not created");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for hive in hives {
|
||||||
|
if let Err(e) = write_role(&store, hive, &ca).await {
|
||||||
|
tracing::warn!(hive, error = %format!("{e:#}"), "creating this hive's store role failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The role for one hive: its own certificate's subject, its own policy.
|
||||||
|
async fn write_role(store: &SecretStore, hive: &str, ca: &str) -> Result<()> {
|
||||||
|
let name = policy::hive_object_name(hive)?;
|
||||||
|
store
|
||||||
|
// The subject is the hive's name: `glue-bao-tls.nix` mints a hive's
|
||||||
|
// client leaf with its name as the CN, and cert auth matches on that.
|
||||||
|
.write_cert_role(DEFAULT_CERT_MOUNT, &name, ca, hive, &name)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("writing the cert-auth role {name}"))?;
|
||||||
|
tracing::info!(hive, role = %name, "hive store role in place");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,39 @@ impl SecretStore {
|
||||||
vaultrs::sys::policy::set(&self.inner, name, policy).await?;
|
vaultrs::sys::policy::set(&self.inner, name, policy).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create or replace the cert-auth role `name`, so that a certificate
|
||||||
|
/// issued by `certificate` and carrying `common_name` logs in with
|
||||||
|
/// `policy`.
|
||||||
|
///
|
||||||
|
/// `certificate` is the authority by value, not a path: the store keeps
|
||||||
|
/// its own copy inside the role and never reads a file of ours.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`Error::Vault`] when the token's policy does not cover
|
||||||
|
/// `auth/<mount>/certs/<name>`, or the store rejects the authority.
|
||||||
|
pub async fn write_cert_role(
|
||||||
|
&self,
|
||||||
|
mount: &str,
|
||||||
|
name: &str,
|
||||||
|
certificate: &str,
|
||||||
|
common_name: &str,
|
||||||
|
policy: &str,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let mut opts =
|
||||||
|
vaultrs::api::auth::cert::requests::CreateCaCertificateRoleRequest::builder();
|
||||||
|
opts.allowed_common_names(vec![common_name.to_owned()])
|
||||||
|
.token_policies(vec![policy.to_owned()]);
|
||||||
|
vaultrs::auth::cert::ca_cert_role::set(
|
||||||
|
&self.inner,
|
||||||
|
mount,
|
||||||
|
name,
|
||||||
|
certificate,
|
||||||
|
Some(&mut opts),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue