hive-sock-client: each attempt now bounds connect (5s), write (10s) and the wait for the response (60s by default). The response bound is per call through the new `request_within`, which hive-agent's serve-loop `Recv` uses with its 180s long-poll plus 30s headroom. A response timeout is terminal rather than retried: the server holds the request, so a retry re-sends something it may still act on and multiplies the wait by the backoff schedule. Outbound HTTP: the matrix login/whoami clients in swarm-controller and hive-c0re's dashboard (5s connect, 30s request), the authelia-bridge client (5s/30s; ensuring an identity runs an argon2 hash first) and the ci-runner forge calls (5s/15s, config_pr_poll's forge budget) get a connect_timeout and a request timeout. Timeout errors name the bound that fired. hive-agent's unix-socket extra web proxy bounds the connect (5s) and the wait for the response head (30s, the http sibling's budget); the body read stays unbounded. Refs #4723
161 lines
6.8 KiB
Rust
161 lines
6.8 KiB
Rust
//! Client for `swarm-authelia-bridge` — this daemon's only access to the
|
|
//! swarm's authelia users database, in either direction (see that crate's
|
|
//! README for why the file cannot be touched from here).
|
|
//!
|
|
//! Authenticated with THIS daemon's own queue OIDC identity
|
|
//! (`SWARM_CONTROLLER_OIDC_*`, the same one `swarm-queue-client` mints for
|
|
//! the queue connection) — "one identity per principal" means a second
|
|
//! op that needs to prove who this process is reuses the identity it
|
|
//! already has rather than provisioning a new one. A fresh token is
|
|
//! minted per call for the same reason the queue client mints one per
|
|
//! connection attempt: no window in which this process holds a token
|
|
//! that outlives its intended use.
|
|
//!
|
|
//! `None` when this deployment did not wire a bridge up — the bridge only
|
|
//! exists on hosts that also run `swarm-authelia`, so a controller split
|
|
//! from it can neither create an identity nor read the roster.
|
|
//!
|
|
//! Each caller answers that absence in its own terms rather than this
|
|
//! module inventing a shared one: `SwarmNodeKind::CreateIdentity` fails
|
|
//! the job explicitly, and the roster endpoint refuses. Neither substitutes
|
|
//! an empty answer, which is the failure mode a default here would create.
|
|
|
|
use anyhow::{Context, Result};
|
|
use swarm_authelia_bridge_sock::{BridgeRequest, BridgeResponse};
|
|
|
|
/// Bound on reaching the bridge.
|
|
const HTTP_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
|
/// Bound on one whole bridge call, body included. Ensuring an identity has
|
|
/// the bridge shell out to `authelia` for an argon2 hash before it answers,
|
|
/// so this is looser than a plain API call needs.
|
|
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
|
|
|
/// A configured connection to `swarm-authelia-bridge`.
|
|
#[derive(Clone)]
|
|
pub struct AuthBridge {
|
|
http: reqwest::Client,
|
|
base_url: String,
|
|
queue_cfg: swarm_queue_client::QueueConfig,
|
|
}
|
|
|
|
impl AuthBridge {
|
|
/// Read `SWARM_CONTROLLER_AUTH_BRIDGE_URL`; `Ok(None)` when unset.
|
|
///
|
|
/// The queue identity (`SWARM_CONTROLLER_OIDC_*`) is not optional once
|
|
/// the bridge URL is set: the nix module sets `queueEnv` unconditionally
|
|
/// for every controller (the queue is required, not just co-located
|
|
/// service), so a bridge URL with no queue identity to authenticate
|
|
/// with is a deployment bug, not an absent-feature case — hence the
|
|
/// hard error rather than a second `None`.
|
|
pub fn from_env() -> Result<Option<Self>> {
|
|
let Ok(base_url) = std::env::var("SWARM_CONTROLLER_AUTH_BRIDGE_URL") else {
|
|
return Ok(None);
|
|
};
|
|
let queue_cfg = swarm_queue_client::QueueConfig::from_env("SWARM_CONTROLLER")
|
|
.context("reading the queue OIDC identity the auth bridge authenticates with")?
|
|
.ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"SWARM_CONTROLLER_AUTH_BRIDGE_URL is set but SWARM_CONTROLLER_OIDC_* is \
|
|
not — the bridge is authenticated with this daemon's queue identity, so \
|
|
that identity must exist first"
|
|
)
|
|
})?;
|
|
let http = reqwest::Client::builder()
|
|
.connect_timeout(HTTP_CONNECT_TIMEOUT)
|
|
.timeout(HTTP_TIMEOUT)
|
|
.build()
|
|
.context("building the swarm-authelia-bridge HTTP client")?;
|
|
Ok(Some(Self {
|
|
http,
|
|
base_url,
|
|
queue_cfg,
|
|
}))
|
|
}
|
|
|
|
/// Idempotently ensure `name` exists as an authelia subject.
|
|
pub async fn ensure_agent_identity(&self, name: &str) -> Result<BridgeResponse> {
|
|
self.request(&BridgeRequest::EnsureAgentIdentity {
|
|
name: name.to_owned(),
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// The swarm's agent roster, straight from the identity store.
|
|
///
|
|
/// Returns the names rather than the whole [`BridgeResponse`]: every
|
|
/// other variant is a protocol error for this request, and a caller that
|
|
/// had to match them would be free to treat one as an empty roster. An
|
|
/// empty roster and a bridge that answered something else are different
|
|
/// facts, and only one of them is a valid render.
|
|
pub async fn list_agent_identities(&self) -> Result<Vec<String>> {
|
|
match self.request(&BridgeRequest::ListAgentIdentities).await? {
|
|
BridgeResponse::Agents { agents } => Ok(agents
|
|
.into_iter()
|
|
.map(|entry| entry.name.into_string())
|
|
.collect()),
|
|
other => {
|
|
anyhow::bail!("swarm-authelia-bridge answered a roster request with {other:?}")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One authenticated round-trip to the bridge.
|
|
///
|
|
/// A fresh token per call, deliberately — see the module doc. Shared by
|
|
/// every operation so the auth and the error handling cannot drift
|
|
/// between them; a second copy is how one path ends up treating a 500 as
|
|
/// an answer.
|
|
async fn request(&self, req: &BridgeRequest) -> Result<BridgeResponse> {
|
|
// `mint_token_for` builds its own HTTP client (trusting the queue's
|
|
// configured CA, if any) — deliberately not `self.http`, which is
|
|
// the bridge's own client and has nothing to do with authelia's
|
|
// token endpoint's trust anchors.
|
|
let token = swarm_queue_client::mint_token_for(&self.queue_cfg, None, None)
|
|
.await
|
|
.context("minting a bearer token for swarm-authelia-bridge")?;
|
|
|
|
let response = self
|
|
.http
|
|
.post(format!("{}/requests", self.base_url))
|
|
.bearer_auth(token)
|
|
.json(req)
|
|
.send()
|
|
.await
|
|
.map_err(|e| {
|
|
let what = if e.is_connect() && e.is_timeout() {
|
|
format!(
|
|
"calling swarm-authelia-bridge: connect timed out after {HTTP_CONNECT_TIMEOUT:?}"
|
|
)
|
|
} else if e.is_timeout() {
|
|
format!("calling swarm-authelia-bridge: timed out after {HTTP_TIMEOUT:?}")
|
|
} else {
|
|
"calling swarm-authelia-bridge".to_owned()
|
|
};
|
|
anyhow::Error::new(e).context(what)
|
|
})?;
|
|
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
if !status.is_success() {
|
|
anyhow::bail!("swarm-authelia-bridge refused the request ({status}): {body}");
|
|
}
|
|
|
|
serde_json::from_str(&body).context("parsing swarm-authelia-bridge's response")
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The common case: no bridge wired up for this deployment.
|
|
#[test]
|
|
fn absent_url_is_not_an_error() {
|
|
assert!(std::env::var("SWARM_CONTROLLER_AUTH_BRIDGE_URL").is_err());
|
|
assert!(
|
|
AuthBridge::from_env()
|
|
.expect("absent is not an error")
|
|
.is_none()
|
|
);
|
|
}
|
|
}
|