add swarm-authelia-bridge: the only thing allowed to write swarm-authelia's users database
This commit is contained in:
parent
16d578e692
commit
fb5d461e52
10 changed files with 876 additions and 0 deletions
247
swarm-authelia-bridge/src/main.rs
Normal file
247
swarm-authelia-bridge/src/main.rs
Normal file
|
|
@ -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<Self> {
|
||||
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<String> {
|
||||
std::env::var(name).with_context(|| format!("{name} is unset"))
|
||||
}
|
||||
|
||||
fn read_secret_file(path: &str) -> Result<String> {
|
||||
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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<BridgeRequest>,
|
||||
) -> 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<BridgeResponse> {
|
||||
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 <pw>`: `/proc/<pid>/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<String> {
|
||||
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()
|
||||
)
|
||||
})
|
||||
}
|
||||
Loading…
Reference in a new issue