//! Auth-callout responder for the swarm's NATS queue. //! //! `nix/host-modules/swarm-nats.nix` configures `nats-server` with an //! `auth_callout` block and no responder, which is the fail-closed state: the //! server answers `"auth_required":true` and admits nobody. This binary is what //! makes it able to say *yes*. //! //! It connects as the one callout-exempt user (by nkey, never by name — the //! server refuses to start if that entry carries a username), subscribes to //! `$SYS.REQ.USER.AUTH`, validates the presented bearer token against //! authelia's introspection endpoint, and answers with a NATS user JWT signed //! by the account key. A rejection is answered explicitly: silence is //! indistinguishable from the responder being down, and the queue is the //! swarm's control path. //! //! # Secrets //! //! Every credential is taken as a **path**, never a value. Two reasons, both //! previously learned the hard way here: a value in nix config is rendered into //! the world-readable store, and a value in `argv` is readable by anyone via //! `/proc//cmdline`, which is `0444`. Paths are not secrets, so passing //! them as flags is fine. use std::path::PathBuf; use anyhow::Context; use clap::Parser; use futures_util::StreamExt; mod introspect; mod policy; mod request; mod respond; /// Subject the NATS server publishes authorization requests on. const AUTH_SUBJECT: &str = "$SYS.REQ.USER.AUTH"; #[derive(Debug, Parser)] #[command( name = "swarm-nats-auth", about = "Auth-callout responder for the swarm NATS queue" )] struct Args { /// NATS server to connect to. #[arg(long, default_value = "nats://127.0.0.1:4222")] nats_url: String, /// Path to the seed of the callout-exempt user this responder connects as. /// Its public half is `services.hyperhive.swarm.nats.calloutUserPublicKey`. #[arg(long)] user_seed_file: PathBuf, /// Path to the account signing seed used to sign issued user JWTs. Its /// public half is `services.hyperhive.swarm.nats.calloutIssuerPublicKey`. #[arg(long)] issuer_seed_file: PathBuf, /// Authelia's OIDC introspection endpoint. #[arg(long)] introspection_url: String, /// `OAuth2` client id this responder introspects as. Must match /// `services.hyperhive.swarm.nats.clientId`, whose default this mirrors. #[arg(long, default_value = "swarm-nats")] client_id: String, /// Account an admitted client is placed in. Must name an entry in the /// server's own `accounts` block — in server-config mode the account is /// resolved by *name*, so a value the server does not know is a grant it /// refuses. The module passes its `clientAccount`; the default mirrors it. #[arg(long, default_value = "APP")] account: String, /// Path to this responder's own OIDC client secret. #[arg(long)] client_secret_file: PathBuf, /// Client-id prefix that marks a hive. `swarm-authelia.nix` mints one /// machine client per roster entry as `hive-`, while the KV key is /// the bare `` — this is the contract between the two, declared /// rather than inferred from the shape of an id. #[arg(long, default_value = "hive-")] hive_client_prefix: String, /// Client ids allowed to read every hive's status. Repeatable. The /// default is the swarm controller, which is the only reader that exists. #[arg(long = "reader-client", default_values_t = [String::from("swarm-controller")])] reader_clients: Vec, /// Additional subjects a hive may publish to, with `{hive}` standing for /// its own name. Repeatable, empty by default. /// /// The extension point for a second stream published by the same /// `hive-` identity — lifecycle notices, say. Without it, adding one /// means changing this responder; with it, a deployment says so and the /// subject still lands inside that hive's own namespace. #[arg(long = "hive-publish-subject")] hive_publish_subjects: Vec, } /// Read a secret file and strip surrounding whitespace. /// /// The trim matters: an `echo`-created seed file ends in a newline, and an /// nkey seed with a trailing byte is not a seed — it fails at parse with a /// message about encoding rather than about the file, which sends you looking /// in the wrong place. The value is never logged, and the error deliberately /// names only the path. fn read_secret(path: &std::path::Path) -> anyhow::Result { let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; let trimmed = raw.trim(); if trimmed.is_empty() { anyhow::bail!("{} is empty", path.display()); } Ok(trimmed.to_owned()) } #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), ) .init(); let args = Args::parse(); let client_secret = read_secret(&args.client_secret_file)?; // The bucket is NOT a flag. `swarm_queue_client::status`'s own docs say // why: reader and writer must name the same bucket, and an option is a // way for two deployments to disagree about which one that is. This // responder is the third end that names it, so it takes the same // constant rather than a copy of the literal. // Fails the process rather than warning: a policy that cannot express a // per-hive namespace is not a policy this responder should run with, and // the queue's fail-closed state (no responder) is a legible outage where a // silently over-broad grant is not. let policy = policy::Policy::new( args.hive_client_prefix.clone(), swarm_queue_client::status::BUCKET.to_owned(), args.reader_clients.clone(), args.hive_publish_subjects.clone(), )?; let http = reqwest::Client::new(); let issuer = nkeys::KeyPair::from_seed(&read_secret(&args.issuer_seed_file)?) .context("parse the account signing seed")?; let user_seed = read_secret(&args.user_seed_file)?; let client = async_nats::ConnectOptions::with_nkey(user_seed) .name("swarm-nats-auth") .connect(&args.nats_url) .await .with_context(|| format!("connect to {}", args.nats_url))?; let mut requests = client .subscribe(AUTH_SUBJECT) .await .with_context(|| format!("subscribe to {AUTH_SUBJECT}"))?; tracing::info!( nats_url = %args.nats_url, subject = AUTH_SUBJECT, "swarm-nats-auth: connected, awaiting authorization requests" ); while let Some(msg) = requests.next().await { // Decode failures are logged and dropped, never propagated: this loop // is the swarm's login path, and exiting on one malformed payload // would let any client take authentication down for everyone. let req = match request::decode(&msg.payload) { Ok(req) => req, Err(e) => { tracing::warn!(error = ?e, "undecodable auth request, ignoring"); continue; } }; // No token is a denial, not an error: an anonymous connect is a // normal thing for a client to attempt and an abnormal thing to // grant. Introspection is only reached once something was presented. // // The caller is an identity or nothing — see `introspect`'s module // docs. There is no "admitted, identity unknown" branch to write here // because there is no such value to receive. let caller = match &req.connect_opts.auth_token { Some(token) => introspect::identify_caller( &http, &args.introspection_url, &args.client_id, &client_secret, token, ) .await // An introspection that could not be *made* is a denial too. The // failure modes of an HTTP call are exactly the conditions under // which an attacker would most like this to fall open. .unwrap_or_else(|e| { tracing::warn!(error = ?e, "introspection failed; denying"); None }), None => None, }; // Admission said who; the policy says what. A caller the `IdP` // vouches for but no rule matches is denied — see `policy`'s module // docs for why that is deny and not "connect with nothing". let permissions = caller.as_deref().and_then(|id| policy.permissions(id)); if let (Some(id), None) = (caller.as_deref(), permissions.as_ref()) { // Loud, and the one case an operator has to be able to find: a // valid credential refused by our own policy. The alternative is // a client that authenticates fine and mysteriously cannot work. tracing::warn!( caller = %id, "authenticated client matches no policy rule; denying" ); } // The client id is an identifier, not a credential, and it is the // only thing tying a connection in this log to a hive. tracing::info!( user_nkey = %req.user_nkey, server_id = %req.server_id.id, granted = permissions.is_some(), caller = caller.as_deref().unwrap_or("-"), "auth request" ); // Always reply, including on a denial. A server that hears nothing // cannot tell a refusing responder from a dead one, so silence turns // every rejection into a 2s timeout and hides an outage inside what // looks like ordinary denials. let Some(reply_to) = msg.reply.clone() else { tracing::warn!("auth request had no reply subject; dropping"); continue; }; let token = match &permissions { Some(permissions) => respond::grant( &issuer, &args.account, &req.server_id.id, &req.user_nkey, permissions, ), None => respond::deny(&issuer, &req.server_id.id, &req.user_nkey), }; if let Err(e) = client.publish(reply_to, token.into()).await { tracing::warn!(error = ?e, "failed to publish auth response"); } } anyhow::bail!("subscription to {AUTH_SUBJECT} ended") }