Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-09-27 19:30:38 +02:00
commit 6170e74a31
16 changed files with 894 additions and 925 deletions

View file

@ -101,9 +101,9 @@ enum SwarmNodeKind {
/// report success on a write.
///
/// Carries the hive for a different reason than `TriggerDeploy` does:
/// not as an address, but because an agent's ACL document grants read on
/// its hive's shared queue credential, so the document cannot be rendered
/// without knowing which hive the agent belongs to.
/// not as an address, but because the agent's queue credential names the
/// hive it may take subjects on, so it cannot be written without knowing
/// which hive the agent belongs to.
MintAgentIdentity { hive: String, agent: String },
/// 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
@ -209,11 +209,6 @@ struct WorkerDeps {
/// connection living there is an accident of construction order, not a
/// claim that events are a kind of status.
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
/// converge an agent to. Shares the status reader's queue connection —
/// 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:#}")),
},
},
SwarmNodeKind::MintAgentIdentity { hive, agent } => {
mint_identity(deps.agent_ca.as_deref(), &agent, &hive).await
}
SwarmNodeKind::MintAgentIdentity { hive, agent } => mint_identity(&agent, &hive).await,
SwarmNodeKind::MintAgentForgeToken { agent } => mint_forge_token(deps.forge, &agent).await,
SwarmNodeKind::MintAgentMatrixAccount { agent } => {
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
/// `clippy::too_many_lines`.
async fn mint_identity(
authority: Option<&agent_identity::Authority>,
agent: &str,
hive: &str,
) -> hive_jobq::scheduler::Outcome {
async fn mint_identity(agent: &str, hive: &str) -> hive_jobq::scheduler::Outcome {
use hive_jobq::scheduler::Outcome;
let Some(authority) = authority else {
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 {
match agent_identity::mint_and_verify(agent, hive).await {
Ok(()) => Outcome::Done,
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.
///
/// 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
// certificate the swarm has not published, so the deploy must not
// overtake the mint — but a host with no authority configured must still
// create agents exactly as it does today. `after_ok` there would turn an
// unconfigured option into an agent nobody runs.
// overtake the mint — but a host whose store or agent PKI is not set up
// must still create agents. `after_ok` there would turn a store outage
// into an agent nobody runs.
//
// 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,
@ -2300,7 +2259,6 @@ async fn main() -> Result<()> {
auth: auth.clone(),
forge: forge_client.clone(),
queue: status.as_ref().map(|s| s.queue_client()),
agent_ca: load_agent_authority(),
wanted: wanted_writer(status.as_ref()),
matrix_homeserver: configured_matrix_homeserver(),
};
@ -2972,7 +2930,6 @@ mod tests {
auth: None,
forge: None,
queue: None,
agent_ca: None,
wanted: None,
matrix_homeserver: None,
};
@ -3027,7 +2984,6 @@ mod tests {
auth: None,
forge: None,
queue: None,
agent_ca: None,
wanted: None,
matrix_homeserver: None,
};
@ -3052,16 +3008,16 @@ mod tests {
}
/// Third sibling of the two above, and the same deliberate caveat: with
/// no authority configured this reaches only the
/// graceful-absence-is-failure branch. The happy path is a live store
/// and a real login, which is exactly why it is `mint_and_verify`'s own
/// job to prove it at agent-creation time rather than a unit test's.
/// no agent PKI named this reaches only the graceful-absence-is-failure
/// branch, before any network call. The happy path is a live store and a
/// real login, which is exactly why it is `mint_and_verify`'s own job to
/// 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
/// authority fails this one node with a reason that names the two
/// variables, and creates the agent anyway.
/// What this does pin is the degrade: a host whose environment does not
/// name the agent PKI fails this one node with a reason that names the
/// variable, and creates the agent anyway.
#[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(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),
@ -3082,7 +3038,6 @@ mod tests {
auth: None,
forge: None,
queue: None,
agent_ca: None,
wanted: None,
matrix_homeserver: None,
};
@ -3101,9 +3056,8 @@ mod tests {
assert_eq!(node.state, hive_jobq::State::Failed);
let error = node.error.as_deref().unwrap_or_default();
assert!(
error.contains(crate::agent_identity::ENV_AGENT_CA)
&& error.contains(crate::agent_identity::ENV_AGENT_CA_KEY),
"the reason must name both variables an operator has to set, got {error:?}"
error.contains(crate::agent_identity::ENV_AGENT_PKI_MOUNT),
"the reason must name the variable an operator has to set, got {error:?}"
);
}
@ -3135,7 +3089,6 @@ mod tests {
auth: None,
forge: None,
queue: None,
agent_ca: None,
wanted: None,
matrix_homeserver: None,
};
@ -3304,7 +3257,7 @@ mod tests {
.expect("the deploy waits for the mint");
assert!(
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`"
);
}