swarm-controller: CreateIdentity node, auth-bridge client, POST /api/agents

This commit is contained in:
damocles 2026-08-16 22:10:04 +02:00 committed by mara
commit d30f149338
5 changed files with 289 additions and 55 deletions

View file

@ -321,6 +321,45 @@ async fn mint_token(http: &reqwest::Client, cfg: &QueueConfig) -> Result<CachedT
})
}
/// Build the HTTP client used to reach `cfg.token_endpoint`, trusting
/// `cfg.ca_file` when set. Shared by [`connect`]'s auth callback and by
/// [`mint_token_for`] — anything presenting this identity's credentials to
/// the token endpoint needs the swarm's own CA trusted the same way, so the
/// trust-anchor logic lives here once rather than once per caller.
fn build_http_client(cfg: &QueueConfig) -> Result<reqwest::Client, Error> {
let mut builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(10));
if let Some(path) = &cfg.ca_file {
let pem = std::fs::read(path).map_err(|source| Error::CaFile {
path: path.display().to_string(),
source,
})?;
let cert = reqwest::Certificate::from_pem(&pem).map_err(|source| Error::CaParse {
path: path.display().to_string(),
source,
})?;
builder = builder.add_root_certificate(cert);
}
builder.build().map_err(Error::HttpClient)
}
/// Mint a fresh token for `cfg`'s identity and hand back just the string —
/// no caching, a fresh HTTP client per call.
///
/// Public because the queue connection is not the only thing this identity
/// authenticates: "one identity per principal" means a caller that already
/// holds a [`QueueConfig`] for its queue connection authenticates anywhere
/// else it needs to prove who it is from the exact same client, rather than
/// a second identity being provisioned per destination. No caching here
/// unlike [`connect`]'s callback: that one exists because `async-nats` reruns
/// its callback per reconnect *attempt*, a hot path this isn't — a caller
/// outside that loop (e.g. `swarm-controller::auth`'s bridge client) mints
/// per call, same as this crate did before the reconnect-storm fix added the
/// cache.
pub async fn mint_token_for(cfg: &QueueConfig) -> Result<String, Error> {
let http = build_http_client(cfg)?;
Ok(mint_token(&http, cfg).await?.token)
}
/// Fail fast unless the client is actually connected.
///
/// **Call this before every `JetStream` request.** `retry_on_initial_connect`
@ -355,36 +394,14 @@ pub fn ensure_connected(client: &async_nats::Client) -> Result<(), Error> {
/// token expires, the controller keeps serving, its status data quietly stops
/// updating, and nothing says so until someone reads a dashboard.
pub async fn connect(cfg: QueueConfig) -> Result<async_nats::Client, Error> {
// A timeout, because this client runs INSIDE the auth callback: a token
// endpoint that accepts the connection and then never answers would hang
// the callback, and with it the connection attempt that invoked it, with
// no retry and nothing in the log to say why. Failing fast lets
// Trusts `cfg.ca_file` when set (the swarm's own CA, when the token
// endpoint is signed by it) — see `build_http_client`. A timeout too,
// because this client runs INSIDE the auth callback: a token endpoint
// that accepts the connection and then never answers would hang the
// callback, and with it the connection attempt that invoked it, with no
// retry and nothing in the log to say why. Failing fast lets
// `async-nats` do what it already does well — back off and try again.
// 10s is generous for a form POST to a local IdP.
let mut builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(10));
// The swarm's own CA, when the token endpoint is signed by it. ADDED, not
// substituted: `add_root_certificate` extends the default set rather than
// replacing it, so a swarm can front authelia publicly and still have
// this work.
//
// Failing here rather than falling back to the platform roots is the
// point — an operator who named a CA file wants that anchor, and a
// silent fallback would turn their typo into `UnknownIssuer` five layers
// away, inside an auth callback, on a retry loop.
if let Some(path) = &cfg.ca_file {
let pem = std::fs::read(path).map_err(|source| Error::CaFile {
path: path.display().to_string(),
source,
})?;
let cert = reqwest::Certificate::from_pem(&pem).map_err(|source| Error::CaParse {
path: path.display().to_string(),
source,
})?;
builder = builder.add_root_certificate(cert);
}
let http = builder.build().map_err(Error::HttpClient)?;
let http = build_http_client(&cfg)?;
let url = cfg.url.clone();
// Shared across every invocation of the callback below, which is the