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:
parent
6170e74a31
commit
fc97c237dc
10 changed files with 234 additions and 201 deletions
|
|
@ -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.
|
||||
|
||||
```bash
|
||||
# On the swarm-controller host: give ruth her store identity. <hive> is the
|
||||
# name of the hive she runs on.
|
||||
swarmctl agent mint-identity ruth --hive <hive>
|
||||
# On the swarm-controller host: give ruth her store identity.
|
||||
swarmctl agent mint-identity ruth
|
||||
|
||||
# On ruth's hive: re-apply her container config, which is when hive-c0re
|
||||
# hands the new identity to the container.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
```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
|
||||
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
|
||||
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
|
||||
its queue connection. The certificate half isn't: the agent gets a fresh leaf
|
||||
and picks it up on its next boot.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
**Usage:** `swarmctl agent mint-identity [OPTIONS] --hive <HIVE> <NAME>`
|
||||
**Usage:** `swarmctl agent mint-identity [OPTIONS] <NAME>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
|
|
@ -100,9 +100,6 @@ Queues and returns, the same way `agent create` does — watch the swarm UI's jo
|
|||
|
||||
###### **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.
|
||||
|
||||
Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`.
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ fn generate_queue_secret() -> Result<String> {
|
|||
/// 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
|
||||
/// 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 name = policy::agent_object_name(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)
|
||||
.await
|
||||
.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.
|
||||
// An object whose `hive` disagrees with the hive this node was invoked
|
||||
// with would grant its holder subjects on the wrong hive, so it is
|
||||
// corrected — but by rewriting the two name fields around the *same*
|
||||
// `value`, which is a correction no live connection notices.
|
||||
// The secret survives a re-run. An object naming a different agent is
|
||||
// corrected by rewriting the name around the *same* `value`, which no live
|
||||
// connection notices.
|
||||
let wanted = queue::AgentCredential {
|
||||
value: match &existing {
|
||||
Some(existing) => existing.value.clone(),
|
||||
None => generate_queue_secret()?,
|
||||
},
|
||||
agent: agent.to_owned(),
|
||||
hive: hive.to_owned(),
|
||||
};
|
||||
if existing.as_ref() == Some(&wanted) {
|
||||
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}"))?;
|
||||
tracing::info!(
|
||||
agent,
|
||||
hive,
|
||||
%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.
|
||||
corrected = existing.is_some(),
|
||||
"agent queue credential published"
|
||||
|
|
@ -280,9 +276,9 @@ async fn read_back_as_agent(
|
|||
.read(queue_path)
|
||||
.await
|
||||
.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 verifying end will grant subjects from, so a mismatch here is the
|
||||
// same class of fault as an unreadable path.
|
||||
// The agent is compared as well as the secret: the verifying end refuses
|
||||
// an object naming a different agent than the path, so a mismatch here is
|
||||
// the same class of fault as an unreadable path.
|
||||
if read_back != *queue_credential {
|
||||
bail!("the store returned a different object at {queue_path} than the one just published");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,11 +100,10 @@ enum SwarmNodeKind {
|
|||
/// `agent_identity::mint_and_verify` — including why this node does not
|
||||
/// report success on a write.
|
||||
///
|
||||
/// Carries the hive for a different reason than `TriggerDeploy` does:
|
||||
/// 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 },
|
||||
/// Carries no hive: neither the certificate, the queue credential nor
|
||||
/// the agent's policy names one, so an agent keeps one store identity
|
||||
/// whichever hive it runs on.
|
||||
MintAgentIdentity { 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
|
||||
/// `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
|
||||
/// 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`.
|
||||
/// Carries the hive for the same reason `TriggerDeploy`/`MintAgentIdentity`
|
||||
/// do: the wanted-state bucket is keyed per hive.
|
||||
/// Carries the hive because the wanted-state bucket is keyed per hive.
|
||||
SetAgentWanted { hive: String, agent: String },
|
||||
/// Tell `hive` to rebuild `agent`, by publishing on the swarm's deploy
|
||||
/// 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::AddRepoMember { agent }
|
||||
| SwarmNodeKind::InitAgentConfigRepo { agent }
|
||||
| SwarmNodeKind::MintAgentIdentity { agent }
|
||||
| SwarmNodeKind::MintAgentForgeToken { agent }
|
||||
| SwarmNodeKind::MintAgentMatrixAccount { agent } => {
|
||||
serde_json::json!({ "agent": agent })
|
||||
}
|
||||
SwarmNodeKind::TriggerDeploy { hive, agent }
|
||||
| SwarmNodeKind::MintAgentIdentity { hive, agent }
|
||||
| SwarmNodeKind::SetAgentWanted { hive, agent } => {
|
||||
serde_json::json!({ "agent": agent, "hive": hive })
|
||||
}
|
||||
|
|
@ -314,7 +312,7 @@ async fn run_swarm_node(
|
|||
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::MintAgentMatrixAccount { agent } => {
|
||||
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
|
||||
/// `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;
|
||||
|
||||
match agent_identity::mint_and_verify(agent, hive).await {
|
||||
match agent_identity::mint_and_verify(agent).await {
|
||||
Ok(()) => Outcome::Done,
|
||||
Err(e) => Outcome::Failed(format!("{e:#}")),
|
||||
}
|
||||
|
|
@ -1512,7 +1510,6 @@ fn declare_agent_job(
|
|||
// authelia.
|
||||
let mint_identity = b
|
||||
.node(SwarmNodeKind::MintAgentIdentity {
|
||||
hive: hive.to_owned(),
|
||||
agent: agent.to_owned(),
|
||||
})
|
||||
.after_ok(create_identity);
|
||||
|
|
@ -1670,20 +1667,6 @@ async fn get_agent_config_pr(
|
|||
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`.
|
||||
#[derive(Clone, Debug, Serialize, ToSchema)]
|
||||
struct MintAgentIdentityResponse {
|
||||
|
|
@ -1712,10 +1695,9 @@ struct MintAgentIdentityResponse {
|
|||
post,
|
||||
path = "/api/agents/{name}/identity",
|
||||
params(("name" = String, Path, description = "agent name")),
|
||||
request_body = MintAgentIdentityRequest,
|
||||
responses(
|
||||
(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),
|
||||
),
|
||||
tag = "agents"
|
||||
|
|
@ -1723,28 +1705,12 @@ struct MintAgentIdentityResponse {
|
|||
async fn mint_agent_identity(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<MintAgentIdentityRequest>,
|
||||
) -> Result<Json<MintAgentIdentityResponse>, problem_details::ProblemDetails> {
|
||||
// Both names are interpolated into store paths and policy documents
|
||||
// downstream, so both are validated here as well as there.
|
||||
// The name is interpolated into store paths and policy documents
|
||||
// downstream, so it is validated here as well as there.
|
||||
let agent = hive_types::Ident::parse(&name)
|
||||
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
|
||||
.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`:
|
||||
// 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| {
|
||||
vec![
|
||||
b.node(SwarmNodeKind::MintAgentIdentity {
|
||||
hive: hive.clone(),
|
||||
agent: agent.clone(),
|
||||
})
|
||||
.guid(),
|
||||
|
|
@ -2803,39 +2768,6 @@ mod tests {
|
|||
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
|
||||
/// 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
|
||||
|
|
@ -2848,9 +2780,6 @@ mod tests {
|
|||
super::mint_agent_identity(
|
||||
axum::extract::State(state),
|
||||
axum::extract::Path("../beta".to_owned()),
|
||||
axum::Json(super::MintAgentIdentityRequest {
|
||||
hive: "pr1ma".to_owned(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect_err("a traversal in the agent name must be refused");
|
||||
|
|
@ -2876,12 +2805,9 @@ mod tests {
|
|||
let queued = super::mint_agent_identity(
|
||||
axum::extract::State(state),
|
||||
axum::extract::Path("atlas".to_owned()),
|
||||
axum::Json(super::MintAgentIdentityRequest {
|
||||
hive: "pr1ma".to_owned(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("a hive in the roster must be accepted");
|
||||
.expect("a valid agent name must be accepted");
|
||||
|
||||
let guard = sched
|
||||
.lock()
|
||||
|
|
@ -2893,10 +2819,9 @@ mod tests {
|
|||
assert!(
|
||||
matches!(
|
||||
&node.payload,
|
||||
SwarmNodeKind::MintAgentIdentity { hive, agent }
|
||||
if hive == "pr1ma" && agent == "atlas"
|
||||
SwarmNodeKind::MintAgentIdentity { agent } if 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
|
||||
);
|
||||
// The id the operator is told to watch has to be the node that was
|
||||
|
|
@ -3025,7 +2950,6 @@ mod tests {
|
|||
let id = sched
|
||||
.append(
|
||||
SwarmNodeKind::MintAgentIdentity {
|
||||
hive: "pr1ma".to_owned(),
|
||||
agent: "atlas".to_owned(),
|
||||
},
|
||||
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
|
||||
/// a certificate the swarm has not published, so the deploy message must
|
||||
/// not leave before the mint is terminal.
|
||||
|
|
|
|||
155
swarm-queue-client/src/agent_token.rs
Normal file
155
swarm-queue-client/src/agent_token.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -178,6 +178,10 @@ pub mod wanted;
|
|||
/// inside it. See the module doc for why the two must not merge.
|
||||
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
|
||||
/// repository has changed. One writer, many readers — every hive subscribes.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@
|
|||
//! reaching the store and nothing else; deriving a queue identity from it would
|
||||
//! couple the two credentials' lifetimes, so that renewing one would mean
|
||||
//! 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};
|
||||
|
||||
|
|
@ -57,14 +60,11 @@ pub fn agent_queue_path(agent: &str) -> Result<String, Error> {
|
|||
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
|
||||
/// composite principal string. Hive and agent names draw from the same
|
||||
/// alphabet (`hive_types::Ident`, `[a-z0-9-]`), so a principal spelled
|
||||
/// `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.
|
||||
/// No hive: an agent's identity is not tied to one, and the subjects the
|
||||
/// verifier grants are keyed on the agent alone. Objects written with a `hive`
|
||||
/// field still decode, because unknown fields are ignored.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AgentCredential {
|
||||
/// The secret itself. Named to match [`Credential::value`] and
|
||||
|
|
@ -74,13 +74,6 @@ pub struct AgentCredential {
|
|||
|
||||
/// The agent this secret authenticates.
|
||||
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.
|
||||
|
|
@ -198,7 +191,6 @@ mod tests {
|
|||
let c = AgentCredential {
|
||||
value: "s3cr3t".to_owned(),
|
||||
agent: "atlas".to_owned(),
|
||||
hive: "alpha".to_owned(),
|
||||
};
|
||||
let json = serde_json::to_string(&c).expect("serialises");
|
||||
assert_eq!(
|
||||
|
|
@ -212,29 +204,39 @@ mod tests {
|
|||
let json = serde_json::to_value(AgentCredential {
|
||||
value: "s3cr3t".to_owned(),
|
||||
agent: "atlas".to_owned(),
|
||||
hive: "alpha".to_owned(),
|
||||
})
|
||||
.expect("serialises");
|
||||
assert_eq!(json["value"], "s3cr3t");
|
||||
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
|
||||
/// credential — a verifier holding `None` for the hive can only guess at
|
||||
/// the subjects to grant, and guessing is the failure this shape exists to
|
||||
/// prevent.
|
||||
/// A stored object may carry a `hive` field. It is ignored, and the object
|
||||
/// decodes.
|
||||
#[test]
|
||||
fn an_agent_object_missing_a_principal_does_not_decode() {
|
||||
assert!(
|
||||
serde_json::from_str::<AgentCredential>(r#"{"value":"s","agent":"atlas"}"#).is_err()
|
||||
fn a_stored_agent_object_that_still_names_a_hive_decodes() {
|
||||
let c: AgentCredential =
|
||||
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!(
|
||||
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
|
||||
/// a hive-shared credential where a per-agent one was meant.
|
||||
#[test]
|
||||
|
|
@ -249,7 +251,6 @@ mod tests {
|
|||
let agent_json = serde_json::to_string(&AgentCredential {
|
||||
value: "s".to_owned(),
|
||||
agent: "atlas".to_owned(),
|
||||
hive: "alpha".to_owned(),
|
||||
})
|
||||
.expect("serialises");
|
||||
assert!(serde_json::from_str::<Credential>(&agent_json).is_err());
|
||||
|
|
|
|||
|
|
@ -51,13 +51,6 @@ struct CreateAgentResponse {
|
|||
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`.
|
||||
#[derive(Deserialize)]
|
||||
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
|
||||
/// 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
|
||||
// operator waits for. The controller validates both again.
|
||||
// operator waits for. The controller validates it again.
|
||||
let name = parse_ident(name, "agent name")?;
|
||||
let hive = parse_ident(hive, "hive")?;
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.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(
|
||||
socket,
|
||||
&format!("/api/agents/{name}/identity"),
|
||||
&MintIdentityRequest { hive: &hive },
|
||||
&serde_json::json!({}),
|
||||
"mint-identity",
|
||||
))?;
|
||||
|
||||
println!("queued: job node {}", resp.node_id);
|
||||
println!(
|
||||
"agent {name:?} will have its identity re-minted on hive {hive:?} once the job graph \
|
||||
runs; `swarmctl` does not wait for it. An existing queue secret is kept as it is; the \
|
||||
store certificate is re-minted and the agent picks the new one up on its next boot"
|
||||
"agent {name:?} will have its identity re-minted once the job graph runs; `swarmctl` \
|
||||
does not wait for it. An existing queue secret is kept as it is; the store \
|
||||
certificate is re-minted and the agent picks the new one up on its next boot"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -242,16 +242,6 @@ struct AgentMintForgeTokenArgs {
|
|||
struct AgentMintIdentityArgs {
|
||||
/// Name of an agent that already exists.
|
||||
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.
|
||||
///
|
||||
/// Supplied by the nix module that installs this binary, from the same
|
||||
|
|
@ -357,7 +347,7 @@ fn main() -> Result<()> {
|
|||
command: AgentVerb::MintIdentity(args),
|
||||
} => {
|
||||
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.
|
||||
Verb::Agent {
|
||||
|
|
@ -732,19 +722,9 @@ mod tests {
|
|||
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]
|
||||
fn the_backfill_verb_takes_an_agent_and_a_hive() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"swarmctl",
|
||||
"agent",
|
||||
"mint-identity",
|
||||
"scribe",
|
||||
"--hive",
|
||||
"alpha",
|
||||
])
|
||||
fn the_backfill_verb_takes_an_agent_and_no_hive() {
|
||||
let cli = Cli::try_parse_from(["swarmctl", "agent", "mint-identity", "scribe"])
|
||||
.expect("the minimal form parses");
|
||||
let Verb::Agent {
|
||||
command: AgentVerb::MintIdentity(args),
|
||||
|
|
@ -753,12 +733,18 @@ mod tests {
|
|||
panic!("expected `agent mint-identity`");
|
||||
};
|
||||
assert_eq!(args.name, "scribe");
|
||||
assert_eq!(args.hive, "alpha");
|
||||
assert!(args.controller_socket.is_none());
|
||||
|
||||
assert!(
|
||||
Cli::try_parse_from(["swarmctl", "agent", "mint-identity", "scribe"]).is_err(),
|
||||
"an omitted hive must not be defaulted"
|
||||
Cli::try_parse_from([
|
||||
"swarmctl",
|
||||
"agent",
|
||||
"mint-identity",
|
||||
"scribe",
|
||||
"--hive",
|
||||
"a"
|
||||
])
|
||||
.is_err(),
|
||||
"the identity has no hive, so the verb must not take one"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue