Watch
0
0
Fork
You've already forked hyperhive
0

swarm-queue-client: one agent-token spelling, and no hive in AgentCredential

`swarm_queue_client::agent_token::format_agent_token` / `parse_agent_token`
are the spelling an agent presents its own queue secret in,
`swarm-agent.<agent>.<secret>`, and the one the auth-callout responder
reads back. The prefix is what separates it from an OIDC access token,
which may itself contain `.`. Parsing distinguishes "not an agent token"
(no prefix) from "a malformed one"; the error names the problem and never
the value. The module is store-free, so the agent formats its token
without linking the secret-store client.

`swarm_secret_client::queue::AgentCredential` loses `hive`: an agent's
identity is not tied to a hive, and nothing reads the field. Objects
already in the store carry it and still decode, since unknown fields are
ignored; a test parses one. The controller stops writing it.

With the credential no longer naming a hive, and the agent's policy
naming none since #4762, nothing in the mint consumes one. `hive` goes
from `mint_and_verify`, from the `MintAgentIdentity` node, and from
`POST /api/agents/{name}/identity`, which now takes no body and no longer
checks a hive against the roster; a caller that still sends one is not
refused, the body is ignored. `swarmctl agent mint-identity` loses
`--hive`, so passing it is now a usage error.
This commit is contained in:
atlas 2026-09-26 01:05:53 +02:00
commit fc97c237dc
10 changed files with 234 additions and 201 deletions

View file

@ -33,9 +33,8 @@ its identity then. Ruth doesn't: hive-c0re creates her on its own at
startup, so she needs her identity minted by hand, once. startup, so she needs her identity minted by hand, once.
```bash ```bash
# On the swarm-controller host: give ruth her store identity. <hive> is the # On the swarm-controller host: give ruth her store identity.
# name of the hive she runs on. swarmctl agent mint-identity ruth
swarmctl agent mint-identity ruth --hive <hive>
# On ruth's hive: re-apply her container config, which is when hive-c0re # On ruth's hive: re-apply her container config, which is when hive-c0re
# hands the new identity to the container. # hands the new identity to the container.

View file

@ -101,12 +101,10 @@ mint therefore never receives one, and nothing will ever come back around to
it. Re-run the mint for one agent with: it. Re-run the mint for one agent with:
```sh ```sh
swarmctl agent mint-identity <agent> --hive <hive> swarmctl agent mint-identity <agent>
``` ```
`--hive` has no default: neither the CLI nor the controller keeps a roster of The queue secret half is idempotent — an agent that already has one keeps exactly the
which agent runs where, and the credentials this mints name a hive. The queue
secret half is idempotent — an agent that already has one keeps exactly the
value it holds, so running this against an already-migrated agent doesn't drop value it holds, so running this against an already-migrated agent doesn't drop
its queue connection. The certificate half isn't: the agent gets a fresh leaf its queue connection. The certificate half isn't: the agent gets a fresh leaf
and picks it up on its next boot. and picks it up on its next boot.

View file

@ -92,7 +92,7 @@ Queue a re-mint of an existing agent's identity at the swarm's secret store.
Queues and returns, the same way `agent create` does — watch the swarm UI's job view for the outcome. Queues and returns, the same way `agent create` does — watch the swarm UI's job view for the outcome.
**Usage:** `swarmctl agent mint-identity [OPTIONS] --hive <HIVE> <NAME>` **Usage:** `swarmctl agent mint-identity [OPTIONS] <NAME>`
###### **Arguments:** ###### **Arguments:**
@ -100,9 +100,6 @@ Queues and returns, the same way `agent create` does — watch the swarm UI's jo
###### **Options:** ###### **Options:**
* `--hive <HIVE>` — The hive that agent runs on.
Required, and deliberately not defaulted: the credentials this mints name a hive, and neither this CLI nor the controller keeps a roster of which agent is on which hive. Naming the wrong one gives the agent an identity scoped to a hive it doesn't run on. The controller checks the value against the swarm's hive roster and names the known hives if it misses.
* `--controller-socket <PATH>` — swarm-controller's unix socket. * `--controller-socket <PATH>` — swarm-controller's unix socket.
Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`. Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`.

View file

@ -136,7 +136,7 @@ fn generate_queue_secret() -> Result<String> {
/// 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, without a store identity. /// created, without a store identity.
pub async fn mint_and_verify(agent: &str, hive: &str) -> Result<()> { pub async fn mint_and_verify(agent: &str) -> Result<()> {
let (mount, pki_role) = agent_pki(|k| std::env::var(k).ok())?; 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)?;
@ -161,18 +161,15 @@ pub async fn mint_and_verify(agent: &str, hive: &str) -> Result<()> {
.read_optional(&queue_path) .read_optional(&queue_path)
.await .await
.with_context(|| format!("checking whether {queue_path} already holds a credential"))?; .with_context(|| format!("checking whether {queue_path} already holds a credential"))?;
// The secret survives a re-run; the principal it names does not get to. // The secret survives a re-run. An object naming a different agent is
// An object whose `hive` disagrees with the hive this node was invoked // corrected by rewriting the name around the *same* `value`, which no live
// with would grant its holder subjects on the wrong hive, so it is // connection notices.
// corrected — but by rewriting the two name fields around the *same*
// `value`, which is a correction no live connection notices.
let wanted = queue::AgentCredential { let wanted = queue::AgentCredential {
value: match &existing { value: match &existing {
Some(existing) => existing.value.clone(), Some(existing) => existing.value.clone(),
None => generate_queue_secret()?, None => generate_queue_secret()?,
}, },
agent: agent.to_owned(), agent: agent.to_owned(),
hive: hive.to_owned(),
}; };
if existing.as_ref() == Some(&wanted) { if existing.as_ref() == Some(&wanted) {
tracing::info!( tracing::info!(
@ -187,9 +184,8 @@ pub async fn mint_and_verify(agent: &str, hive: &str) -> Result<()> {
.with_context(|| format!("publishing the agent queue credential at {queue_path}"))?; .with_context(|| format!("publishing the agent queue credential at {queue_path}"))?;
tracing::info!( tracing::info!(
agent, agent,
hive,
%queue_path, %queue_path,
// Never "rotated": the secret is the same one, only the names // Never "rotated": the secret is the same one, only the name
// around it moved. // around it moved.
corrected = existing.is_some(), corrected = existing.is_some(),
"agent queue credential published" "agent queue credential published"
@ -280,9 +276,9 @@ async fn read_back_as_agent(
.read(queue_path) .read(queue_path)
.await .await
.with_context(|| format!("reading {queue_path} back under {role}'s own token"))?; .with_context(|| format!("reading {queue_path} back under {role}'s own token"))?;
// The two name fields are compared as well as the secret: they are what // The agent is compared as well as the secret: the verifying end refuses
// the verifying end will grant subjects from, so a mismatch here is the // an object naming a different agent than the path, so a mismatch here is
// same class of fault as an unreadable path. // the same class of fault as an unreadable path.
if read_back != *queue_credential { if read_back != *queue_credential {
bail!("the store returned a different object at {queue_path} than the one just published"); bail!("the store returned a different object at {queue_path} than the one just published");
} }

View file

@ -100,11 +100,10 @@ enum SwarmNodeKind {
/// `agent_identity::mint_and_verify` — including why this node does not /// `agent_identity::mint_and_verify` — including why this node does not
/// report success on a write. /// report success on a write.
/// ///
/// Carries the hive for a different reason than `TriggerDeploy` does: /// Carries no hive: neither the certificate, the queue credential nor
/// not as an address, but because the agent's queue credential names the /// the agent's policy names one, so an agent keeps one store identity
/// hive it may take subjects on, so it cannot be written without knowing /// whichever hive it runs on.
/// which hive the agent belongs to. MintAgentIdentity { 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
/// `forge::agent_token` — including why a rotation is a delete then a /// `forge::agent_token` — including why a rotation is a delete then a
@ -124,8 +123,7 @@ enum SwarmNodeKind {
/// Declare `agent` on `hive` as `Paused` in the swarm's wanted-state /// Declare `agent` on `hive` as `Paused` in the swarm's wanted-state
/// store, so a freshly created agent does not start driving turns the /// store, so a freshly created agent does not start driving turns the
/// moment it's deployed — the operator has to explicitly flip it to `Up`. /// moment it's deployed — the operator has to explicitly flip it to `Up`.
/// Carries the hive for the same reason `TriggerDeploy`/`MintAgentIdentity` /// Carries the hive because the wanted-state bucket is keyed per hive.
/// do: the wanted-state bucket is keyed per hive.
SetAgentWanted { hive: String, agent: String }, SetAgentWanted { hive: String, agent: String },
/// Tell `hive` to rebuild `agent`, by publishing on the swarm's deploy /// Tell `hive` to rebuild `agent`, by publishing on the swarm's deploy
/// subject. The one node kind whose effect leaves this host. /// subject. The one node kind whose effect leaves this host.
@ -166,12 +164,12 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
| SwarmNodeKind::CreateForgeUser { agent } | SwarmNodeKind::CreateForgeUser { agent }
| SwarmNodeKind::AddRepoMember { agent } | SwarmNodeKind::AddRepoMember { agent }
| SwarmNodeKind::InitAgentConfigRepo { agent } | SwarmNodeKind::InitAgentConfigRepo { agent }
| SwarmNodeKind::MintAgentIdentity { agent }
| SwarmNodeKind::MintAgentForgeToken { agent } | SwarmNodeKind::MintAgentForgeToken { agent }
| SwarmNodeKind::MintAgentMatrixAccount { agent } => { | SwarmNodeKind::MintAgentMatrixAccount { agent } => {
serde_json::json!({ "agent": agent }) serde_json::json!({ "agent": agent })
} }
SwarmNodeKind::TriggerDeploy { hive, agent } SwarmNodeKind::TriggerDeploy { hive, agent }
| SwarmNodeKind::MintAgentIdentity { hive, agent }
| SwarmNodeKind::SetAgentWanted { hive, agent } => { | SwarmNodeKind::SetAgentWanted { hive, agent } => {
serde_json::json!({ "agent": agent, "hive": hive }) serde_json::json!({ "agent": agent, "hive": hive })
} }
@ -314,7 +312,7 @@ async fn run_swarm_node(
Err(e) => Outcome::Failed(format!("{e:#}")), Err(e) => Outcome::Failed(format!("{e:#}")),
}, },
}, },
SwarmNodeKind::MintAgentIdentity { hive, agent } => mint_identity(&agent, &hive).await, SwarmNodeKind::MintAgentIdentity { agent } => mint_identity(&agent).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
@ -340,10 +338,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(agent: &str, hive: &str) -> hive_jobq::scheduler::Outcome { async fn mint_identity(agent: &str) -> hive_jobq::scheduler::Outcome {
use hive_jobq::scheduler::Outcome; use hive_jobq::scheduler::Outcome;
match agent_identity::mint_and_verify(agent, hive).await { match agent_identity::mint_and_verify(agent).await {
Ok(()) => Outcome::Done, Ok(()) => Outcome::Done,
Err(e) => Outcome::Failed(format!("{e:#}")), Err(e) => Outcome::Failed(format!("{e:#}")),
} }
@ -1512,7 +1510,6 @@ fn declare_agent_job(
// authelia. // authelia.
let mint_identity = b let mint_identity = b
.node(SwarmNodeKind::MintAgentIdentity { .node(SwarmNodeKind::MintAgentIdentity {
hive: hive.to_owned(),
agent: agent.to_owned(), agent: agent.to_owned(),
}) })
.after_ok(create_identity); .after_ok(create_identity);
@ -1670,20 +1667,6 @@ async fn get_agent_config_pr(
Ok(Json(cache.get(&name))) Ok(Json(cache.get(&name)))
} }
/// Body of `POST /api/agents/{name}/identity`.
#[derive(Deserialize, ToSchema)]
struct MintAgentIdentityRequest {
/// The hive this agent belongs to.
///
/// Required, for the same reason [`CreateAgentRequest`]'s is: the
/// credentials this mints name a hive, and the controller has nowhere to
/// look one up — agents are created on hives at runtime and this daemon
/// keeps no roster of which agent is where. An operator naming the wrong
/// one would hand the agent subjects on a hive it does not run on, so it
/// is asked for rather than guessed at.
hive: String,
}
/// Success body of `POST /api/agents/{name}/identity`. /// Success body of `POST /api/agents/{name}/identity`.
#[derive(Clone, Debug, Serialize, ToSchema)] #[derive(Clone, Debug, Serialize, ToSchema)]
struct MintAgentIdentityResponse { struct MintAgentIdentityResponse {
@ -1712,10 +1695,9 @@ struct MintAgentIdentityResponse {
post, post,
path = "/api/agents/{name}/identity", path = "/api/agents/{name}/identity",
params(("name" = String, Path, description = "agent name")), params(("name" = String, Path, description = "agent name")),
request_body = MintAgentIdentityRequest,
responses( responses(
(status = 200, description = "mint queued", body = MintAgentIdentityResponse), (status = 200, description = "mint queued", body = MintAgentIdentityResponse),
(status = 400, description = "`name` or `hive` is not a valid identifier, or `hive` is not in this swarm (problem+json)", body = String), (status = 400, description = "`name` is not a valid identifier (problem+json)", body = String),
(status = 500, description = "the job could not be queued (problem+json)", body = String), (status = 500, description = "the job could not be queued (problem+json)", body = String),
), ),
tag = "agents" tag = "agents"
@ -1723,28 +1705,12 @@ struct MintAgentIdentityResponse {
async fn mint_agent_identity( async fn mint_agent_identity(
State(state): State<AppState>, State(state): State<AppState>,
Path(name): Path<String>, Path(name): Path<String>,
Json(req): Json<MintAgentIdentityRequest>,
) -> Result<Json<MintAgentIdentityResponse>, problem_details::ProblemDetails> { ) -> Result<Json<MintAgentIdentityResponse>, problem_details::ProblemDetails> {
// Both names are interpolated into store paths and policy documents // The name is interpolated into store paths and policy documents
// downstream, so both are validated here as well as there. // downstream, so it is validated here as well as there.
let agent = hive_types::Ident::parse(&name) let agent = hive_types::Ident::parse(&name)
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))? .map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
.into_string(); .into_string();
let hive = hive_types::Ident::parse(&req.hive)
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
.into_string();
if !state.hives.iter().any(|h| h.name == hive) {
let known: Vec<&str> = state.hives.iter().map(|h| h.name.as_str()).collect();
let known = if known.is_empty() {
"(none configured)".to_owned()
} else {
known.join(", ")
};
return Err(error_problem(
axum::http::StatusCode::BAD_REQUEST,
&format!("hive {hive:?} is not in this swarm — known hives: {known}"),
));
}
// No reserved-name or collision warnings here, unlike `create_agent`: // No reserved-name or collision warnings here, unlike `create_agent`:
// those answer "is this name available", and this route is only ever // those answer "is this name available", and this route is only ever
@ -1757,7 +1723,6 @@ async fn mint_agent_identity(
.insert_job(None, |b| { .insert_job(None, |b| {
vec![ vec![
b.node(SwarmNodeKind::MintAgentIdentity { b.node(SwarmNodeKind::MintAgentIdentity {
hive: hive.clone(),
agent: agent.clone(), agent: agent.clone(),
}) })
.guid(), .guid(),
@ -2803,39 +2768,6 @@ mod tests {
assert_eq!(resp.change, super::ForgeAdminChange::AlreadyAdmin); assert_eq!(resp.change, super::ForgeAdminChange::AlreadyAdmin);
} }
/// The backfill route's roster check, asserted by effect for the same
/// reason its sibling above is: a refusal that queued first would still
/// re-mint the agent's certificate, which every running agent on the
/// named hive picks up on its next boot.
#[tokio::test]
async fn a_backfill_for_a_hive_outside_the_roster_queues_nothing() {
let (state, sched) = state_with_roster();
let err = super::mint_agent_identity(
axum::extract::State(state),
axum::extract::Path("atlas".to_owned()),
axum::Json(super::MintAgentIdentityRequest {
hive: "pr1maa".to_owned(),
}),
)
.await
.expect_err("a hive outside the roster must be refused");
let rendered = format!("{err:?}");
assert!(
rendered.contains("pr1ma"),
"the refusal should name the known hives, got: {rendered}"
);
let queued = sched
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.graph()
.nodes()
.count();
assert_eq!(queued, 0, "a refused backfill must queue no work");
}
/// A name that is not an identifier is refused before it can reach a /// A name that is not an identifier is refused before it can reach a
/// store path. `create_agent` gets this for free from the roster check /// store path. `create_agent` gets this for free from the roster check
/// on the hive; the agent name has no roster to check against, so this /// on the hive; the agent name has no roster to check against, so this
@ -2848,9 +2780,6 @@ mod tests {
super::mint_agent_identity( super::mint_agent_identity(
axum::extract::State(state), axum::extract::State(state),
axum::extract::Path("../beta".to_owned()), axum::extract::Path("../beta".to_owned()),
axum::Json(super::MintAgentIdentityRequest {
hive: "pr1ma".to_owned(),
}),
) )
.await .await
.expect_err("a traversal in the agent name must be refused"); .expect_err("a traversal in the agent name must be refused");
@ -2876,12 +2805,9 @@ mod tests {
let queued = super::mint_agent_identity( let queued = super::mint_agent_identity(
axum::extract::State(state), axum::extract::State(state),
axum::extract::Path("atlas".to_owned()), axum::extract::Path("atlas".to_owned()),
axum::Json(super::MintAgentIdentityRequest {
hive: "pr1ma".to_owned(),
}),
) )
.await .await
.expect("a hive in the roster must be accepted"); .expect("a valid agent name must be accepted");
let guard = sched let guard = sched
.lock() .lock()
@ -2893,10 +2819,9 @@ mod tests {
assert!( assert!(
matches!( matches!(
&node.payload, &node.payload,
SwarmNodeKind::MintAgentIdentity { hive, agent } SwarmNodeKind::MintAgentIdentity { agent } if agent == "atlas"
if hive == "pr1ma" && agent == "atlas"
), ),
"the one node must be the mint, carrying both names: {:?}", "the one node must be the mint, for the named agent: {:?}",
node.payload node.payload
); );
// The id the operator is told to watch has to be the node that was // The id the operator is told to watch has to be the node that was
@ -3025,7 +2950,6 @@ mod tests {
let id = sched let id = sched
.append( .append(
SwarmNodeKind::MintAgentIdentity { SwarmNodeKind::MintAgentIdentity {
hive: "pr1ma".to_owned(),
agent: "atlas".to_owned(), agent: "atlas".to_owned(),
}, },
Vec::new(), Vec::new(),
@ -3192,25 +3116,6 @@ mod tests {
); );
} }
/// The node carries the hive, and the viewer has to see it. The `data`
/// match is an or-pattern on purpose (see its own comment), and this is
/// the assertion that the new variant joined the two-field arm rather
/// than the agent-only one — a viewer silently missing the hive is the
/// failure that comment describes having already happened once.
#[test]
fn a_mint_node_renders_both_the_agent_and_the_hive() {
use hive_jobq_wire::WireNode as _;
let kind = SwarmNodeKind::MintAgentIdentity {
hive: "pr1ma".to_owned(),
agent: "atlas".to_owned(),
};
assert_eq!(kind.label(), "mint_agent_identity");
let data = kind.data(1);
assert_eq!(data["agent"], "atlas");
assert_eq!(data["hive"], "pr1ma");
}
/// The ordering the operator's ruling requires: a hive cannot pass down /// The ordering the operator's ruling requires: a hive cannot pass down
/// a certificate the swarm has not published, so the deploy message must /// a certificate the swarm has not published, so the deploy message must
/// not leave before the mint is terminal. /// not leave before the mint is terminal.

View file

@ -0,0 +1,155 @@
//! The one spelling of the token an agent presents its own queue secret in,
//! shared by the agent that formats it and the auth-callout responder that
//! parses it back:
//! [`AGENT_TOKEN_PREFIX`](crate::agent_token::AGENT_TOKEN_PREFIX), the agent's
//! name, `.`, the secret.
//!
//! The secret itself lives at `swarm/agents/<agent>/queue` in the swarm's
//! secret store (`swarm_secret_client::queue`). This module knows nothing of
//! the store, so an agent formats its token without linking a store client.
/// Marks an `auth_token` as an agent's own credential.
///
/// An OIDC access token may itself contain `.`, so the `<agent>.<secret>`
/// shape alone does not tell the two apart. Authelia's access tokens start
/// `authelia_at_`.
pub const AGENT_TOKEN_PREFIX: &str = "swarm-agent.";
/// An agent's own credential as presented at the queue.
///
/// No `Debug`: `secret` is the credential itself.
pub struct AgentToken<'a> {
/// The agent the presenter claims to be. Unproven until `secret` is
/// checked against that agent's stored credential.
pub agent: &'a str,
/// The presented secret.
pub secret: &'a str,
}
/// A token that is not `<agent>.<secret>` after its prefix, or parts that
/// would not make one. Carries the reason only: a token holds a secret.
#[derive(Debug, thiserror::Error)]
#[error("malformed agent token: {0}")]
pub struct Malformed(pub &'static str);
/// Spell `agent`'s credential as the token it presents at the queue.
///
/// # Errors
/// [`Malformed`] when `agent` or `secret` is empty or holds anything outside
/// `[A-Za-z0-9_-]`. Either would make [`parse_agent_token`] split the token
/// differently than it was joined.
pub fn format_agent_token(agent: &str, secret: &str) -> Result<String, Malformed> {
check_agent(agent)?;
check_secret(secret)?;
Ok(format!("{AGENT_TOKEN_PREFIX}{agent}.{secret}"))
}
/// Read a presented token back into an [`AgentToken`].
///
/// `None` when `token` does not start with [`AGENT_TOKEN_PREFIX`]: it is some
/// other kind of token, not a malformed one of these. `Some(Err(_))` when it
/// does and is not exactly `<agent>.<secret>` after it.
#[must_use]
pub fn parse_agent_token(token: &str) -> Option<Result<AgentToken<'_>, Malformed>> {
let rest = token.strip_prefix(AGENT_TOKEN_PREFIX)?;
Some(
rest.split_once('.')
.ok_or(Malformed("no `.` between the agent and the secret"))
.and_then(|(agent, secret)| {
check_agent(agent)?;
check_secret(secret)?;
Ok(AgentToken { agent, secret })
}),
)
}
fn is_segment(s: &str) -> bool {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
/// The alphabet a store path segment allows, so the name cannot widen a
/// subject with `.`, `*` or `>`, or address another agent's path.
fn check_agent(agent: &str) -> Result<(), Malformed> {
if is_segment(agent) {
Ok(())
} else {
Err(Malformed("the agent is not a single [A-Za-z0-9_-] segment"))
}
}
/// The alphabet the controller mints secrets in, base64url without padding.
/// The error never carries the value.
fn check_secret(secret: &str) -> Result<(), Malformed> {
if is_segment(secret) {
Ok(())
} else {
Err(Malformed(
"the secret is empty or holds a byte outside [A-Za-z0-9_-]",
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_agent_token_round_trips() {
let token = format_agent_token("atlas", "Ab9_-z").expect("legal");
assert_eq!(token, "swarm-agent.atlas.Ab9_-z");
let parsed = parse_agent_token(&token)
.expect("carries the prefix")
.expect("well-formed");
assert_eq!(parsed.agent, "atlas");
assert_eq!(parsed.secret, "Ab9_-z");
}
/// Anything without the prefix belongs to the OIDC path, including a
/// token that happens to look like `<agent>.<secret>`.
#[test]
fn a_token_without_the_prefix_is_not_an_agent_token() {
for token in ["authelia_at_abc.def", "atlas.s3cr3t", "", "swarm-agent"] {
assert!(parse_agent_token(token).is_none(), "{token:?}");
}
}
#[test]
fn a_malformed_agent_token_is_refused() {
for token in [
"swarm-agent.",
"swarm-agent.atlas",
"swarm-agent.atlas.",
"swarm-agent..s3cr3t",
"swarm-agent.atlas.s3.cr3t",
"swarm-agent.at*las.s3cr3t",
"swarm-agent.at>las.s3cr3t",
"swarm-agent.at/las.s3cr3t",
"swarm-agent.atlas.s3cr3t=",
"swarm-agent.atlas.s3 cr3t",
] {
assert!(
matches!(parse_agent_token(token), Some(Err(_))),
"{token:?} must be refused"
);
}
}
/// What formats always parses back into the same two parts.
#[test]
fn formatting_refuses_what_parsing_would_split_differently() {
assert!(format_agent_token("at.las", "s3cr3t").is_err());
assert!(format_agent_token("atlas", "s3.cr3t").is_err());
assert!(format_agent_token("atlas", "").is_err());
assert!(format_agent_token("", "s3cr3t").is_err());
}
#[test]
fn a_malformed_secret_is_not_echoed_in_the_error() {
let Some(Err(e)) = parse_agent_token("swarm-agent.atlas.hunter2!") else {
panic!("must be refused");
};
assert!(!e.to_string().contains("hunter2"), "{e}");
}
}

View file

@ -178,6 +178,10 @@ pub mod wanted;
/// inside it. See the module doc for why the two must not merge. /// inside it. See the module doc for why the two must not merge.
pub mod agent_status; pub mod agent_status;
/// The token an agent presents its own queue secret in. Store-free, so an agent
/// formats it without linking the secret-store client.
pub mod agent_token;
/// The subject the swarm controller publishes on when the hive-wide knowledge /// The subject the swarm controller publishes on when the hive-wide knowledge
/// repository has changed. One writer, many readers — every hive subscribes. /// repository has changed. One writer, many readers — every hive subscribes.
/// ///

View file

@ -22,6 +22,9 @@
//! reaching the store and nothing else; deriving a queue identity from it would //! reaching the store and nothing else; deriving a queue identity from it would
//! couple the two credentials' lifetimes, so that renewing one would mean //! couple the two credentials' lifetimes, so that renewing one would mean
//! renewing the other. //! renewing the other.
//!
//! The token an agent presents that secret in at the queue is spelled by
//! `swarm_queue_client::agent_token`, which needs no store client.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -57,14 +60,11 @@ pub fn agent_queue_path(agent: &str) -> Result<String, Error> {
Ok(format!("{prefix}/queue")) Ok(format!("{prefix}/queue"))
} }
/// What [`agent_queue_path`] holds: the secret, and the principal it proves. /// What [`agent_queue_path`] holds: the secret, and the agent it proves.
/// ///
/// Both names ride **in the object** rather than being parsed back out of a /// No hive: an agent's identity is not tied to one, and the subjects the
/// composite principal string. Hive and agent names draw from the same /// verifier grants are keyed on the agent alone. Objects written with a `hive`
/// alphabet (`hive_types::Ident`, `[a-z0-9-]`), so a principal spelled /// field still decode, because unknown fields are ignored.
/// `hive-<hive>-agent-<agent>` parses two ways for a name containing `-agent-`
/// — and an ambiguous principal parse in an authorisation path is a caller that
/// authenticates fine and is handed somebody else's grant.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentCredential { pub struct AgentCredential {
/// The secret itself. Named to match [`Credential::value`] and /// The secret itself. Named to match [`Credential::value`] and
@ -74,13 +74,6 @@ pub struct AgentCredential {
/// The agent this secret authenticates. /// The agent this secret authenticates.
pub agent: String, pub agent: String,
/// The hive that agent belongs to.
///
/// Here because the verifying end has no roster to look it up in, and
/// because the subjects an agent is granted are hive-templated — without
/// this field the verifier would know *who* is connecting and not *where*.
pub hive: String,
} }
/// What the path holds: the client secret, plus the client id it belongs to. /// What the path holds: the client secret, plus the client id it belongs to.
@ -198,7 +191,6 @@ mod tests {
let c = AgentCredential { let c = AgentCredential {
value: "s3cr3t".to_owned(), value: "s3cr3t".to_owned(),
agent: "atlas".to_owned(), agent: "atlas".to_owned(),
hive: "alpha".to_owned(),
}; };
let json = serde_json::to_string(&c).expect("serialises"); let json = serde_json::to_string(&c).expect("serialises");
assert_eq!( assert_eq!(
@ -212,29 +204,39 @@ mod tests {
let json = serde_json::to_value(AgentCredential { let json = serde_json::to_value(AgentCredential {
value: "s3cr3t".to_owned(), value: "s3cr3t".to_owned(),
agent: "atlas".to_owned(), agent: "atlas".to_owned(),
hive: "alpha".to_owned(),
}) })
.expect("serialises"); .expect("serialises");
assert_eq!(json["value"], "s3cr3t"); assert_eq!(json["value"], "s3cr3t");
assert_eq!(json["agent"], "atlas"); assert_eq!(json["agent"], "atlas");
assert_eq!(json["hive"], "alpha"); assert!(json.get("hive").is_none(), "{json}");
} }
/// Neither name is optional. An object missing one is not a usable /// A stored object may carry a `hive` field. It is ignored, and the object
/// credential — a verifier holding `None` for the hive can only guess at /// decodes.
/// the subjects to grant, and guessing is the failure this shape exists to
/// prevent.
#[test] #[test]
fn an_agent_object_missing_a_principal_does_not_decode() { fn a_stored_agent_object_that_still_names_a_hive_decodes() {
assert!( let c: AgentCredential =
serde_json::from_str::<AgentCredential>(r#"{"value":"s","agent":"atlas"}"#).is_err() serde_json::from_str(r#"{"value":"s3cr3t","agent":"atlas","hive":"alpha"}"#)
.expect("a stored object carrying `hive` decodes");
assert_eq!(
c,
AgentCredential {
value: "s3cr3t".to_owned(),
agent: "atlas".to_owned(),
}
); );
}
/// The agent is required: it is what the verifier checks the presented
/// name against.
#[test]
fn an_agent_object_missing_its_agent_does_not_decode() {
assert!(serde_json::from_str::<AgentCredential>(r#"{"value":"s"}"#).is_err());
assert!( assert!(
serde_json::from_str::<AgentCredential>(r#"{"value":"s","hive":"alpha"}"#).is_err() serde_json::from_str::<AgentCredential>(r#"{"value":"s","hive":"alpha"}"#).is_err()
); );
} }
/// The two kinds are different objects at different paths, and neither
/// decodes as the other — the property that keeps a reader from picking up /// decodes as the other — the property that keeps a reader from picking up
/// a hive-shared credential where a per-agent one was meant. /// a hive-shared credential where a per-agent one was meant.
#[test] #[test]
@ -249,7 +251,6 @@ mod tests {
let agent_json = serde_json::to_string(&AgentCredential { let agent_json = serde_json::to_string(&AgentCredential {
value: "s".to_owned(), value: "s".to_owned(),
agent: "atlas".to_owned(), agent: "atlas".to_owned(),
hive: "alpha".to_owned(),
}) })
.expect("serialises"); .expect("serialises");
assert!(serde_json::from_str::<Credential>(&agent_json).is_err()); assert!(serde_json::from_str::<Credential>(&agent_json).is_err());

View file

@ -51,13 +51,6 @@ struct CreateAgentResponse {
warnings: Vec<String>, warnings: Vec<String>,
} }
/// Body of `POST /api/agents/{name}/identity`. Mirrors the controller's own
/// `MintAgentIdentityRequest` — see this module's doc comment.
#[derive(Serialize)]
struct MintIdentityRequest<'a> {
hive: &'a str,
}
/// Success body of `POST /api/agents/{name}/identity`. /// Success body of `POST /api/agents/{name}/identity`.
#[derive(Deserialize)] #[derive(Deserialize)]
struct MintIdentityResponse { struct MintIdentityResponse {
@ -117,11 +110,10 @@ pub(crate) fn parse_ident(value: &str, what: &str) -> Result<String> {
/// ///
/// Synchronous for the same reason [`create`] is, and built on the same /// Synchronous for the same reason [`create`] is, and built on the same
/// round trip. /// round trip.
pub(crate) fn mint_identity(socket: &Path, name: &str, hive: &str) -> Result<()> { pub(crate) fn mint_identity(socket: &Path, name: &str) -> Result<()> {
// Client-side first, so a typo is a local error rather than a 400 the // Client-side first, so a typo is a local error rather than a 400 the
// operator waits for. The controller validates both again. // operator waits for. The controller validates it again.
let name = parse_ident(name, "agent name")?; let name = parse_ident(name, "agent name")?;
let hive = parse_ident(hive, "hive")?;
let rt = tokio::runtime::Builder::new_current_thread() let rt = tokio::runtime::Builder::new_current_thread()
.enable_io() .enable_io()
@ -130,15 +122,15 @@ pub(crate) fn mint_identity(socket: &Path, name: &str, hive: &str) -> Result<()>
let resp: MintIdentityResponse = rt.block_on(post( let resp: MintIdentityResponse = rt.block_on(post(
socket, socket,
&format!("/api/agents/{name}/identity"), &format!("/api/agents/{name}/identity"),
&MintIdentityRequest { hive: &hive }, &serde_json::json!({}),
"mint-identity", "mint-identity",
))?; ))?;
println!("queued: job node {}", resp.node_id); println!("queued: job node {}", resp.node_id);
println!( println!(
"agent {name:?} will have its identity re-minted on hive {hive:?} once the job graph \ "agent {name:?} will have its identity re-minted once the job graph runs; `swarmctl` \
runs; `swarmctl` does not wait for it. An existing queue secret is kept as it is; the \ does not wait for it. An existing queue secret is kept as it is; the store \
store certificate is re-minted and the agent picks the new one up on its next boot" certificate is re-minted and the agent picks the new one up on its next boot"
); );
Ok(()) Ok(())
} }

View file

@ -242,16 +242,6 @@ struct AgentMintForgeTokenArgs {
struct AgentMintIdentityArgs { struct AgentMintIdentityArgs {
/// Name of an agent that already exists. /// Name of an agent that already exists.
name: String, name: String,
/// The hive that agent runs on.
///
/// Required, and deliberately not defaulted: the credentials this mints
/// name a hive, and neither this CLI nor the controller keeps a roster of
/// which agent is on which hive. Naming the wrong one gives the agent an
/// identity scoped to a hive it doesn't run on. The controller checks
/// the value against the swarm's hive roster and names the known hives if
/// it misses.
#[arg(long, value_name = "HIVE")]
hive: String,
/// swarm-controller's unix socket. /// swarm-controller's unix socket.
/// ///
/// Supplied by the nix module that installs this binary, from the same /// Supplied by the nix module that installs this binary, from the same
@ -357,7 +347,7 @@ fn main() -> Result<()> {
command: AgentVerb::MintIdentity(args), command: AgentVerb::MintIdentity(args),
} => { } => {
let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?; let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?;
agent::mint_identity(&socket, &args.name, &args.hive) agent::mint_identity(&socket, &args.name)
} }
// Same socket-resolution reasoning as `Create` above. // Same socket-resolution reasoning as `Create` above.
Verb::Agent { Verb::Agent {
@ -732,20 +722,10 @@ mod tests {
assert!(args.controller_socket.is_none()); assert!(args.controller_socket.is_none());
} }
/// The backfill verb takes the same two names as `create`, and `--hive`
/// is required on it for the same reason: it is an address nobody can
/// infer.
#[test] #[test]
fn the_backfill_verb_takes_an_agent_and_a_hive() { fn the_backfill_verb_takes_an_agent_and_no_hive() {
let cli = Cli::try_parse_from([ let cli = Cli::try_parse_from(["swarmctl", "agent", "mint-identity", "scribe"])
"swarmctl", .expect("the minimal form parses");
"agent",
"mint-identity",
"scribe",
"--hive",
"alpha",
])
.expect("the minimal form parses");
let Verb::Agent { let Verb::Agent {
command: AgentVerb::MintIdentity(args), command: AgentVerb::MintIdentity(args),
} = cli.command } = cli.command
@ -753,12 +733,18 @@ mod tests {
panic!("expected `agent mint-identity`"); panic!("expected `agent mint-identity`");
}; };
assert_eq!(args.name, "scribe"); assert_eq!(args.name, "scribe");
assert_eq!(args.hive, "alpha");
assert!(args.controller_socket.is_none()); assert!(args.controller_socket.is_none());
assert!( assert!(
Cli::try_parse_from(["swarmctl", "agent", "mint-identity", "scribe"]).is_err(), Cli::try_parse_from([
"an omitted hive must not be defaulted" "swarmctl",
"agent",
"mint-identity",
"scribe",
"--hive",
"a"
])
.is_err(),
"the identity has no hive, so the verb must not take one"
); );
} }