diff --git a/Cargo.lock b/Cargo.lock index bae5012b..6571d5b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4555,6 +4555,29 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "swarm-authelia-bridge" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "reqwest 0.13.1", + "serde", + "serde_json", + "swarm-authelia-bridge-sock", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "swarm-authelia-bridge-sock" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "swarm-controller" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 2f0ff283..cd4cfce6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,8 @@ members = [ "hive-sock-client", "hive-types", "hivectl", + "swarm-authelia-bridge", + "swarm-authelia-bridge-sock", "swarm-controller", "swarm-nats-auth", "swarm-queue-client", @@ -85,6 +87,7 @@ hive-host-sock = { path = "hive-host-sock" } hive-priv-sock = { path = "hive-priv-sock" } hive-sock-client = { path = "hive-sock-client" } hive-types = { path = "hive-types" } +swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" } swarm-queue-client = { path = "swarm-queue-client" } thiserror = "2" tower-http = { version = "0.7", features = ["fs"] } diff --git a/swarm-authelia-bridge-sock/Cargo.toml b/swarm-authelia-bridge-sock/Cargo.toml new file mode 100644 index 00000000..5f86c108 --- /dev/null +++ b/swarm-authelia-bridge-sock/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "swarm-authelia-bridge-sock" +version.workspace = true +edition.workspace = true +readme = "README.md" + +[dependencies] +serde.workspace = true + +[dev-dependencies] +serde_json.workspace = true + +[lints] +workspace = true diff --git a/swarm-authelia-bridge-sock/README.md b/swarm-authelia-bridge-sock/README.md new file mode 100644 index 00000000..d15c417b --- /dev/null +++ b/swarm-authelia-bridge-sock/README.md @@ -0,0 +1,23 @@ +# swarm-authelia-bridge-sock + +Wire types for the **`swarm-authelia-bridge` socket** — the contract between +`swarm-authelia-bridge` (server, runs alongside `swarm-authelia`) and +`swarm-controller` (client). + +## Why it's its own crate + +Same rationale as `hive-priv-sock` (which this mirrors in spirit, though the +transport differs — this bridge is network-facing HTTP, not a unix socket, +since it has to reach a possibly-split-host `swarm-controller`): the bridge +is a narrowly-scoped, unprivileged-but-file-owning helper, and splitting the +wire contract out of any larger crate keeps both its own dependency +footprint and its interface small enough to audit at a glance. No server or +client logic here, only the request/response shapes both sides import. + +## Shape + +One operation today: idempotently ensure an agent exists as an authelia +subject. Deliberately **not** a wholesale-replace-the-file API — the bridge +owns both `users.json` (canonical) and rendering `users.yml` internally; a +caller only ever asks for one user to exist, never sends rendered YAML or a +file blob. See `swarm-authelia-bridge/README.md` for the helper itself. diff --git a/swarm-authelia-bridge-sock/src/lib.rs b/swarm-authelia-bridge-sock/src/lib.rs new file mode 100644 index 00000000..c0fb0b60 --- /dev/null +++ b/swarm-authelia-bridge-sock/src/lib.rs @@ -0,0 +1,110 @@ +//! Wire types for the `swarm-authelia-bridge` socket. +//! +//! Both `swarm-authelia-bridge` (server) and `swarm-controller` (client) +//! import these so the shapes stay in sync. No server or client protocol +//! logic lives here, only the JSON contract carried as the body of the +//! bridge's one HTTP endpoint (`POST /requests`, bearer-authenticated) — +//! see `swarm-authelia-bridge`'s own docs for the transport. +//! +//! # Why a bridge at all, and why this shape +//! +//! `swarm-authelia`'s users database (`users.yml`) is owned by the +//! `authelia-swarm` system user, a different uid than `swarm-controller`'s +//! own — so `swarm-controller` cannot write it directly without either root +//! (`CAP_CHOWN`) or a shared group, both rejected for the same reason +//! `swarmctl`'s own README already rejected them for this exact file. The +//! fix taken instead: run this bridge's own +//! systemd unit as `User = "authelia-swarm";` — the literal name +//! `swarm-authelia.nix` already derives, resolved by systemd at start, no +//! numeric uid ever hand-pinned into nix eval — so the bridge simply *owns* +//! the file it writes. Fully ordinary permissions, no capabilities, no root. +//! +//! **Per-operation, not wholesale-replace.** [`BridgeRequest::EnsureAgentIdentity`] +//! asks for one user to exist; the bridge owns both `users.json` (canonical) +//! and rendering `users.yml` internally. A caller never sends rendered YAML +//! or a file blob — that would invite a last-writer-wins race between +//! independent callers and duplicate the rendering logic on both sides of +//! the wire. + +use serde::{Deserialize, Serialize}; + +/// A request to the bridge. One variant today — see the module doc for why +/// this isn't a file-replace API. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum BridgeRequest { + /// Idempotently ensure `name` exists as an authelia subject — + /// `swarm-controller`'s `SwarmNodeKind::CreateIdentity` node's entire + /// job. Idempotence is load-bearing (agent creation's own design + /// consensus): re-running this for an agent that already has an + /// identity is a + /// genuine no-op, reported as [`BridgeResponse::AlreadyExists`] — no + /// password re-mint, no `users.yml` rewrite, nothing for authelia's + /// `file.watch` to react to. + EnsureAgentIdentity { + /// The agent's name — becomes the authelia username verbatim. The + /// bridge validates this server-side (same conservative charset + /// `swarmctl::users::validate_username` already enforces); this + /// crate carries the wire shape only, not the validation rule. + name: String, + }, +} + +/// The bridge's answer to a [`BridgeRequest`]. +/// +/// `#[serde(tag = "status")]` rather than a bare `Result`-shaped wrapper: an +/// external tag reads directly as one of three named outcomes on the wire +/// (`{"status":"created",...}`), with no separate "was this an error" +/// boolean to keep in sync with which variant it is. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum BridgeResponse { + /// The agent had no identity yet; one was minted and `users.yml` was + /// rewritten. + Created, + /// The agent already had an identity. No write happened — the + /// idempotent no-op path. + AlreadyExists, + /// The request was rejected or the write failed. Carries a message + /// for the caller to log/propagate, not a typed error enum: the + /// failure modes here (bad username, authelia binary failed, disk + /// full) have no caller-actionable distinction today — see + /// `PrivResponse` in `hive-priv-sock` for the same reasoning. + Error { message: String }, +} + +#[cfg(test)] +mod tests { + use super::{BridgeRequest, BridgeResponse}; + + /// Pins the external-tag wire shape — a reader off the wire (or a log + /// line) should be able to tell the three outcomes apart without + /// cross-referencing this crate's source. + #[test] + fn response_variants_tag_on_status() { + let created = serde_json::to_value(BridgeResponse::Created).unwrap(); + assert_eq!(created, serde_json::json!({"status": "created"})); + + let exists = serde_json::to_value(BridgeResponse::AlreadyExists).unwrap(); + assert_eq!(exists, serde_json::json!({"status": "already_exists"})); + + let err = serde_json::to_value(BridgeResponse::Error { + message: "boom".to_owned(), + }) + .unwrap(); + assert_eq!( + err, + serde_json::json!({"status": "error", "message": "boom"}) + ); + } + + #[test] + fn request_round_trips() { + let req = BridgeRequest::EnsureAgentIdentity { + name: "atlas".to_owned(), + }; + let json = serde_json::to_string(&req).unwrap(); + let back: BridgeRequest = serde_json::from_str(&json).unwrap(); + let BridgeRequest::EnsureAgentIdentity { name } = back; + assert_eq!(name, "atlas"); + } +} diff --git a/swarm-authelia-bridge/Cargo.toml b/swarm-authelia-bridge/Cargo.toml new file mode 100644 index 00000000..4e7fb493 --- /dev/null +++ b/swarm-authelia-bridge/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "swarm-authelia-bridge" +version.workspace = true +edition.workspace = true +readme = "README.md" + +[[bin]] +name = "swarm-authelia-bridge" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +axum.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +swarm-authelia-bridge-sock.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true + +[lints] +workspace = true diff --git a/swarm-authelia-bridge/README.md b/swarm-authelia-bridge/README.md new file mode 100644 index 00000000..c9d040e6 --- /dev/null +++ b/swarm-authelia-bridge/README.md @@ -0,0 +1,41 @@ +# swarm-authelia-bridge + +The only thing allowed to write `swarm-authelia`'s users database. + +## Why this exists + +`swarm-controller`'s `CreateIdentity` job needs to add an agent as an +authelia subject, but `swarm-controller` runs unprivileged and does not own +`users.yml` — a different uid does (`authelia-swarm`, the user +`services.authelia.instances.swarm` runs as). Root/`CAP_CHOWN`/a shared +group were all examined and rejected during this crate's design thread — +see `swarmctl/README.md`'s identical analysis of this same file, written +before this crate existed. + +The fix: run **this** process as `User = "authelia-swarm";` instead — +inside the `swarm-authelia` container, alongside authelia itself — so it +simply owns the file it writes. No elevated privilege anywhere. + +## Shape + +- One endpoint, `POST /requests`, body = `swarm-authelia-bridge-sock`'s + `BridgeRequest` verbatim — one variant today (`EnsureAgentIdentity`, + idempotently ensure an agent exists as an authelia subject). +- Bearer-authenticated via authelia's own OIDC token introspection (RFC + 7662), against `swarm-controller`'s **existing** machine-client identity + (already minted for the queue connection) — no new credential. +- No restart of authelia after a write — relies on + `authentication_backend.file.watch`, confirmed working against the + pinned 4.39.20 build during this design work. +- Network-facing rather than unix-socket-only: `swarm-authelia`'s container + shares the host netns, so the same listener serves both a co-located + `swarm-controller` (loopback) and a split-host one (bind wider + firewall) + with no separate transport. + +## Known limitation + +`swarmctl` still writes an independent `users.json`/`users.yml` for human +accounts, assuming co-location with `swarm-controller`'s host. Two +canonical stores for the same physical file is a real seam, not solved +here — routing `swarmctl` through this bridge too is a plausible follow-up, +out of scope for the agent-identity slice this crate shipped with. diff --git a/swarm-authelia-bridge/src/introspect.rs b/swarm-authelia-bridge/src/introspect.rs new file mode 100644 index 00000000..575e28d9 --- /dev/null +++ b/swarm-authelia-bridge/src/introspect.rs @@ -0,0 +1,80 @@ +//! Validating a presented bearer token against authelia. +//! +//! Own copy of `swarm-nats-auth::introspect`'s shape (RFC 7662 token +//! introspection), not a shared dependency — `swarm-nats-auth` has no lib +//! target to import, and this is a handful of lines; factor out if a third +//! consumer shows up. Same verdict rule: `active` is the whole answer, and +//! everything that isn't an explicit `active: true` is a denial (network +//! error, timeout, non-2xx, unparseable body) — the failure modes of an +//! HTTP call are exactly the conditions an attacker would like this to +//! fall open under. +//! +//! Authenticates the introspection call itself with **this bridge's own** +//! client credentials (a resource server introspecting a token proves its +//! own identity to the `IdP`, per RFC 7662) — a separate authelia machine +//! client from the token being checked (`swarm-controller`'s). + +use std::time::Duration; + +use anyhow::{Context, Result}; +use serde::Deserialize; + +/// Generous relative to `swarm-nats-auth`'s 1.5s (that one is bounded by +/// the NATS server's own 2s `auth_callout` timeout; an HTTP request here +/// has no such external deadline to stay under), but still bounded — a +/// hung introspection call must not wedge a `CreateIdentity` job forever. +pub const INTROSPECTION_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Debug, Deserialize)] +struct IntrospectionResponse { + active: bool, +} + +/// Ask authelia whether `token` is currently valid. `Ok(true)` only on a +/// 2xx body with `active: true`; every other outcome is `Ok(false)` +/// (logged) or `Err` when the call could not be made at all — callers +/// must treat both as a denial. +pub async fn is_active( + http: &reqwest::Client, + url: &str, + client_id: &str, + client_secret: &str, + token: &str, +) -> Result { + let resp = http + .post(url) + .basic_auth(client_id, Some(client_secret)) + .form(&[("token", token)]) + .timeout(INTROSPECTION_TIMEOUT) + .send() + .await + .context("introspection request")?; + + let status = resp.status(); + if !status.is_success() { + tracing::warn!(%status, "introspection returned non-2xx; denying"); + return Ok(false); + } + let body: IntrospectionResponse = match resp.json().await { + Ok(b) => b, + Err(e) => { + tracing::warn!(error = ?e, "introspection body did not parse; denying"); + return Ok(false); + } + }; + Ok(body.active) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_active_true_deserializes_to_a_grant() { + let yes: IntrospectionResponse = serde_json::from_str(r#"{"active":true}"#).unwrap(); + assert!(yes.active); + let no: IntrospectionResponse = serde_json::from_str(r#"{"active":false}"#).unwrap(); + assert!(!no.active); + assert!(serde_json::from_str::(r#"{"sub":"someone"}"#).is_err()); + } +} diff --git a/swarm-authelia-bridge/src/main.rs b/swarm-authelia-bridge/src/main.rs new file mode 100644 index 00000000..555adcb0 --- /dev/null +++ b/swarm-authelia-bridge/src/main.rs @@ -0,0 +1,247 @@ +//! `swarm-authelia-bridge` — the only thing allowed to write +//! `swarm-authelia`'s users database. +//! +//! Runs **inside the `swarm-authelia` container**, as +//! `User = "authelia-swarm";` (the literal name +//! `swarm-authelia.nix`'s `unitName` already derives) — the same user +//! authelia's own instance runs as, and therefore the file's actual +//! owner. That is the whole answer to "how does an unprivileged +//! `swarm-controller` write a file it doesn't own": it doesn't — this +//! process does, sidestepping the uid boundary instead of bridging it +//! with root/`CAP_CHOWN`/a shared group (all examined and rejected — see +//! `swarmctl/README.md`'s own identical analysis of this same file). +//! +//! Network-facing, not unix-socket-only: `swarm-authelia`'s container +//! shares the host netns (`privateNetwork = false`, same as `hive-forge`/ +//! `hive-matrix`), so one listener serves a co-located `swarm-controller` +//! (loopback) and a split-host one (bind wider, firewall it) with the +//! same code path — no separate transport for the cross-host case. +//! Bearer-authenticated via authelia's own OIDC token introspection (RFC +//! 7662) against `swarm-controller`'s **existing** machine-client +//! identity (already minted for the queue connection) — no new +//! credential to mint or deliver, and always local/fast to verify since +//! this process runs right next to the authelia it asks. +//! +//! One endpoint, `POST /requests`, body = +//! [`swarm_authelia_bridge_sock::BridgeRequest`] verbatim — one variant +//! today (`EnsureAgentIdentity`, idempotently ensure an agent exists as +//! an authelia subject). Not a wholesale-replace-the-file API — this +//! process owns rendering `users.yml` internally; see `store` module. + +mod introspect; +mod store; + +use std::sync::Arc; + +use anyhow::{Context, Result, bail}; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use axum::{Json, Router}; +use swarm_authelia_bridge_sock::{BridgeRequest, BridgeResponse}; + +/// Where the introspecting bearer token is authenticated *to* — this +/// bridge's own authelia machine client, distinct from +/// `swarm-controller`'s (the token being checked). Set by the nix module +/// that provisions this bridge's own client alongside authelia. +struct Config { + bind: String, + store_path: std::path::PathBuf, + users_file: std::path::PathBuf, + authelia_bin: std::path::PathBuf, + introspection_url: String, + client_id: String, + client_secret: String, +} + +impl Config { + fn from_env() -> Result { + Ok(Self { + bind: env_var("SWARM_AUTHELIA_BRIDGE_BIND")?, + store_path: env_var("SWARM_AUTHELIA_BRIDGE_STORE")?.into(), + users_file: env_var("SWARM_AUTHELIA_BRIDGE_USERS_FILE")?.into(), + authelia_bin: env_var("SWARM_AUTHELIA_BRIDGE_AUTHELIA_BIN")?.into(), + introspection_url: env_var("SWARM_AUTHELIA_BRIDGE_INTROSPECTION_URL")?, + client_id: env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_ID")?, + client_secret: read_secret_file(&env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE")?)?, + }) + } +} + +fn env_var(name: &str) -> Result { + std::env::var(name).with_context(|| format!("{name} is unset")) +} + +fn read_secret_file(path: &str) -> Result { + let raw = + std::fs::read_to_string(path).with_context(|| format!("reading secret file {path}"))?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + bail!("secret file {path} is empty"); + } + Ok(trimmed.to_owned()) +} + +struct AppState { + config: Config, + http: reqwest::Client, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .init(); + + let config = Config::from_env()?; + let bind = config.bind.clone(); + let state = Arc::new(AppState { + config, + http: reqwest::Client::new(), + }); + + let app = Router::new() + .route("/requests", post(handle_request)) + .with_state(state); + + let listener = tokio::net::TcpListener::bind(&bind) + .await + .with_context(|| format!("binding {bind}"))?; + tracing::info!(%bind, "swarm-authelia-bridge listening"); + axum::serve(listener, app) + .await + .context("serving swarm-authelia-bridge") +} + +/// The single endpoint — body is the wire-crate's [`BridgeRequest`] +/// verbatim (JSON), not a per-operation REST route. One variant today, +/// but this is what keeps a second operation from needing a new route + +/// a new extractor shape: it just becomes a new match arm below. +async fn handle_request( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Response { + if let Err(resp) = authorize(&state, &headers).await { + return resp; + } + let BridgeRequest::EnsureAgentIdentity { name } = req; + match handle(&state, name).await { + Ok(body) => (StatusCode::OK, Json(body)).into_response(), + Err(e) => { + let detail = format!("{e:#}"); + tracing::warn!(error = %detail, "ensure_identity failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(BridgeResponse::Error { message: detail }), + ) + .into_response() + } + } +} + +/// Extract + introspect the bearer token. `Err` carries the response to +/// return outright (401 for anything short of an active token) — kept +/// separate from `handle`'s error path, which is "the op itself failed," +/// a different case from "the caller was never let in." +async fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), Response> { + let deny = || { + ( + StatusCode::UNAUTHORIZED, + Json(BridgeResponse::Error { + message: "missing or invalid bearer token".to_owned(), + }), + ) + .into_response() + }; + let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) else { + return Err(deny()); + }; + let Ok(auth) = auth.to_str() else { + return Err(deny()); + }; + let Some(token) = auth.strip_prefix("Bearer ") else { + return Err(deny()); + }; + match introspect::is_active( + &state.http, + &state.config.introspection_url, + &state.config.client_id, + &state.config.client_secret, + token, + ) + .await + { + Ok(true) => Ok(()), + Ok(false) => Err(deny()), + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), "introspection call failed; denying"); + Err(deny()) + } + } +} + +async fn handle(state: &AppState, name: String) -> Result { + store::validate_username(&name)?; + let cfg = &state.config; + let mut user_store = store::load_store(&cfg.store_path, &cfg.users_file)?; + if user_store.users.contains_key(&name) { + return Ok(BridgeResponse::AlreadyExists); + } + let generated = generate_password(&cfg.authelia_bin).await?; + user_store.users.insert( + name.clone(), + store::User { + displayname: name.clone(), + password: generated, + email: None, + groups: Vec::new(), + }, + ); + store::publish(&cfg.store_path, &cfg.users_file, &user_store)?; + tracing::info!(agent = %name, "created authelia identity"); + Ok(BridgeResponse::Created) +} + +/// Mint a password digest via the **configured** authelia — the argon2 +/// parameters baked into a hash have to match the verifier's, same +/// reasoning `swarmctl::generate_password` documents (this is +/// deliberately a fresh async port of that function, not a shared one — +/// `swarmctl` is a separate binary crate with no lib target). +/// +/// `--random`, not `--password `: `/proc//cmdline` is +/// world-readable, so a password on argv would be readable by any local +/// process for the call's lifetime. Only the digest is kept — the +/// plaintext is generated and immediately discarded, since nothing reads +/// it back today (still an open question what an agent's login actually +/// uses this for). +async fn generate_password(bin: &std::path::Path) -> Result { + let out = tokio::process::Command::new(bin) + .args(["crypto", "hash", "generate", "argon2", "--random"]) + .output() + .await + .with_context(|| format!("running {}", bin.display()))?; + if !out.status.success() { + bail!( + "{} failed ({}): {}", + bin.display(), + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ); + } + let stdout = String::from_utf8(out.stdout).context("authelia printed non-UTF-8 output")?; + stdout + .lines() + .find_map(|line| line.trim().strip_prefix("Digest:")) + .map(|v| v.trim().to_owned()) + .filter(|v| !v.is_empty()) + .with_context(|| { + format!( + "could not parse {}'s output: missing 'Digest:'", + bin.display() + ) + }) +} diff --git a/swarm-authelia-bridge/src/store.rs b/swarm-authelia-bridge/src/store.rs new file mode 100644 index 00000000..15e8ec9b --- /dev/null +++ b/swarm-authelia-bridge/src/store.rs @@ -0,0 +1,312 @@ +//! The canonical user store this bridge owns, and the authelia users +//! database rendered from it. +//! +//! Deliberately its own copy, not shared code with `swarmctl::users` even +//! though the YAML shape is identical (same "factor out later if +//! duplication actually bites" reasoning as `swarm-controller::forge` +//! mirroring `hive-c0re::forge` — see the agent-identity-at-swarm-level +//! design thread). One real difference from `swarmctl`'s copy: this +//! store lives **wherever this +//! bridge runs** (inside the `swarm-authelia` container, alongside +//! `authelia-swarm`'s own state), not under `swarm-controller`'s state +//! dir — the two are not guaranteed to be the same host once +//! `swarm-authelia` and `swarm-controller` split across hosts, and this +//! bridge only ever runs where `swarm-authelia` does. +//! +//! ⚠️ **Known limitation, not solved here**: `swarmctl` still writes its +//! own independent `users.json`/`users.yml` for human accounts, assuming +//! co-location with `swarm-controller`'s host. Two independent canonical +//! stores for the same physical `users.yml` is a real seam — tracked as a +//! follow-up (route `swarmctl` through this bridge too), not attempted in +//! this slice, whose scope is agent identities only. + +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::fs::{self, File, Permissions}; +use std::io::Write as _; +use std::os::unix::fs::PermissionsExt as _; +use std::path::Path; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +/// What the `swarm-authelia` module's first-boot unit writes into +/// `users.yml` when there is no database yet — same constant/shape +/// `swarmctl::users::SEED_USERS_FILE` uses, since both write the same +/// physical file format. +pub const SEED_USERS_FILE: &str = "users: {}"; + +/// The canonical store, serialised as JSON. `BTreeMap` for a stable, +/// diffable render — same rationale as `swarmctl::users::UserStore`. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct UserStore { + pub users: BTreeMap, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct User { + pub displayname: String, + /// The argon2 **digest**, never a plaintext password. + pub password: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub groups: Vec, +} + +/// Same conservative charset `swarmctl::users::validate_username` already +/// enforces — usernames are YAML map keys, log lines, and access-control +/// subjects, so keeping them to plain ASCII means nothing downstream ever +/// has to reason about a name that needs quoting. +pub fn validate_username(name: &str) -> Result<()> { + if name.is_empty() || name.len() > 64 { + bail!("username must be 1..=64 characters, got {}", name.len()); + } + if !name.starts_with(|c: char| c.is_ascii_alphanumeric()) { + bail!("username must start with an ASCII letter or digit: {name:?}"); + } + if let Some(bad) = name + .chars() + .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))) + { + bail!("username may only contain [A-Za-z0-9._-], found {bad:?} in {name:?}"); + } + Ok(()) +} + +/// Reject control characters rather than escape them — same reasoning as +/// `swarmctl::users::reject_control_chars`: a display name or email +/// carrying one is a bug or an injection attempt in every real case. +fn reject_control_chars(field: &str, value: &str) -> Result<()> { + if let Some(c) = value.chars().find(|c| c.is_control()) { + bail!( + "{field} contains control character U+{:04X}; refusing to write it", + c as u32 + ); + } + Ok(()) +} + +/// A double-quoted YAML scalar — everything is quoted, including values +/// that would be fine bare, since an argon2 digest alone contains `$`, +/// `=`, `,` and `/`. Control characters are excluded upstream, so `"` and +/// `\` are the complete escape set. +fn quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + _ => out.push(c), + } + } + out.push('"'); + out +} + +/// Render the store as authelia's block-style YAML users database. +/// Fallible for the same reason `swarmctl::users::render_yaml` is: it +/// re-validates every value it is about to emit, so "no control character +/// ever reaches the file" is a property of the one path that writes it. +pub fn render_yaml(store: &UserStore) -> Result { + if store.users.is_empty() { + return Ok(format!("{SEED_USERS_FILE}\n")); + } + let mut out = String::from("users:\n"); + for (name, user) in &store.users { + validate_username(name)?; + reject_control_chars("displayname", &user.displayname)?; + reject_control_chars("password digest", &user.password)?; + writeln!(out, " {name}:")?; + writeln!(out, " displayname: {}", quote(&user.displayname))?; + writeln!(out, " password: {}", quote(&user.password))?; + if let Some(email) = &user.email { + reject_control_chars("email", email)?; + writeln!(out, " email: {}", quote(email))?; + } + if !user.groups.is_empty() { + writeln!(out, " groups:")?; + for group in &user.groups { + reject_control_chars("group", group)?; + writeln!(out, " - {}", quote(group))?; + } + } + } + Ok(out) +} + +/// Load the canonical store, or start an empty one if this bridge has +/// never written a user. Same overwrite guard as +/// `swarmctl::load_store`: starting empty means the next write +/// **overwrites** `users_file`, which is only safe when that file is +/// still the untouched first-boot seed. +pub fn load_store(store_path: &Path, users_file: &Path) -> Result { + match fs::read_to_string(store_path) { + Ok(raw) => serde_json::from_str(&raw) + .with_context(|| format!("parsing the user store at {}", store_path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + match fs::read_to_string(users_file) { + Ok(existing) if !is_untouched_seed(&existing) => bail!( + "no user store at {} but {} already holds users — refusing to \ + overwrite it", + store_path.display(), + users_file.display() + ), + Ok(_) | Err(_) => Ok(UserStore::default()), + } + } + Err(e) => Err(e).with_context(|| format!("reading {}", store_path.display())), + } +} + +/// Whether an existing `users.yml` is safe to take over — i.e. it is the +/// untouched first-boot seed. Empty counts too: a zero-byte file holds +/// nothing to lose. +fn is_untouched_seed(contents: &str) -> bool { + let trimmed = contents.trim(); + trimmed.is_empty() || trimmed == SEED_USERS_FILE +} + +/// Write the store + the rendered users file. **No restart** (the load- +/// bearing difference from `swarmctl::publish`): this bridge relies on +/// authelia's `authentication_backend.file.watch`, confirmed working +/// against the pinned 4.39.20 build during this design work — a +/// restart would drop every active SSO session, which mara ruled out for +/// agent creation specifically (not a rare, human-initiated event). +pub fn publish(store_path: &Path, users_file: &Path, store: &UserStore) -> Result<()> { + let rendered = render_yaml(store)?; + let store_json = serde_json::to_string_pretty(store).context("serialising the user store")?; + // Store first — if the store lands and the users file doesn't, the + // next run re-renders and repairs it. The other order loses a user. + write_atomic(store_path, &format!("{store_json}\n"))?; + write_atomic(users_file, &rendered) +} + +/// Replace `path`'s contents atomically. Unlike `swarmctl::write_atomic`, +/// this does **not** need to preserve a foreign owner via `chown` — this +/// process runs as `users_file`'s own owning user (see the module doc: +/// the whole point of this bridge is running as `authelia-swarm`), so the +/// temp file it creates is already correctly owned. Preserves the +/// existing mode, same reasoning as `swarmctl`'s version (conservative +/// default for a file of password hashes). +fn write_atomic(path: &Path, contents: &str) -> Result<()> { + let dir = path + .parent() + .with_context(|| format!("{} has no parent directory", path.display()))?; + fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + + let name = path + .file_name() + .with_context(|| format!("{} has no file name", path.display()))?; + let tmp = dir.join(format!( + ".{}.swarm-authelia-bridge.tmp", + name.to_string_lossy() + )); + + let mode = fs::metadata(path) + .ok() + .map_or(0o600, |meta| meta.permissions().mode() & 0o7777); + + let mut file = File::create(&tmp).with_context(|| format!("creating {}", tmp.display()))?; + file.write_all(contents.as_bytes()) + .with_context(|| format!("writing {}", tmp.display()))?; + file.sync_all() + .with_context(|| format!("flushing {}", tmp.display()))?; + drop(file); + + fs::set_permissions(&tmp, Permissions::from_mode(mode)) + .with_context(|| format!("setting mode on {}", tmp.display()))?; + fs::rename(&tmp, path).with_context(|| format!("renaming {} into place", tmp.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn user(password: &str) -> User { + User { + displayname: "atlas".to_owned(), + password: password.to_owned(), + email: None, + groups: Vec::new(), + } + } + + #[test] + fn an_empty_store_renders_the_seed_document() { + let out = render_yaml(&UserStore::default()).expect("renders"); + assert_eq!(out, "users: {}\n"); + assert!(is_untouched_seed(&out)); + } + + #[test] + fn an_argon2_digest_survives_quoting() { + let digest = "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$aGFzaA+/w=="; + let mut store = UserStore::default(); + store.users.insert("atlas".to_owned(), user(digest)); + let out = render_yaml(&store).expect("renders"); + assert!(out.contains(&format!("password: \"{digest}\""))); + } + + #[test] + fn usernames_outside_the_conservative_set_are_refused() { + for bad in ["", "-leading", "has space", "quote\"d", "sla/sh"] { + assert!( + validate_username(bad).is_err(), + "{bad:?} should be rejected" + ); + } + for good in ["atlas", "svc-agent_1", "a.b"] { + validate_username(good).unwrap_or_else(|e| panic!("{good:?} rejected: {e}")); + } + } + + #[test] + fn a_control_character_is_refused_not_escaped() { + let mut u = user("$argon2id$x"); + u.displayname = "bad\nname".to_owned(); + let mut store = UserStore::default(); + store.users.insert("atlas".to_owned(), u); + let err = render_yaml(&store).expect_err("must refuse"); + assert!(err.to_string().contains("control character")); + } + + /// `write_atomic` round-trips through a real temp dir — the property + /// under test is the rename-into-place, not just the render. + #[test] + fn publish_writes_both_files_and_the_store_reloads() { + let dir = tempdir(); + let store_path = dir.join("users.json"); + let users_file = dir.join("users.yml"); + fs::write(&users_file, SEED_USERS_FILE).unwrap(); + + let mut store = UserStore::default(); + store.users.insert("atlas".to_owned(), user("$argon2id$x")); + publish(&store_path, &users_file, &store).expect("publish"); + + let reloaded = load_store(&store_path, &users_file).expect("reload"); + assert!(reloaded.users.contains_key("atlas")); + let yaml = fs::read_to_string(&users_file).unwrap(); + assert!(yaml.contains("atlas")); + + fs::remove_dir_all(&dir).ok(); + } + + /// Test-only temp dir under the OS temp root — disposable scratch for + /// one test's lifetime, not durable state (see `state-not-tmp`: this + /// is exactly the legitimate use, not a place we persist anything). + fn tempdir() -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "swarm-authelia-bridge-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).unwrap(); + dir + } +}