Per mara on the PR: all deps go into workspace level so versions stay consistent. async-nats, data-encoding, nkeys and nats-jwt are new [workspace.dependencies] entries; sha2 and futures-util now come from there. The crate had asked for sha2 0.10 while the workspace standard is 0.11, and for the futures facade where the workspace carries futures-util. Both resolved without adding a Cargo.lock entry - each was already in the tree via something else - so the drift was invisible in the lock and would only have surfaced as two versions of a hashing crate in one binary.
181 lines
7.1 KiB
Rust
181 lines
7.1 KiB
Rust
//! 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/<pid>/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 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,
|
|
}
|
|
|
|
/// 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<String> {
|
|
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)?;
|
|
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.
|
|
let granted = match &req.connect_opts.auth_token {
|
|
Some(token) => introspect::is_active(
|
|
&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");
|
|
false
|
|
}),
|
|
None => false,
|
|
};
|
|
tracing::info!(
|
|
user_nkey = %req.user_nkey,
|
|
server_id = %req.server_id.id,
|
|
granted,
|
|
"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 = if granted {
|
|
respond::grant(&issuer, &args.account, &req.server_id.id, &req.user_nkey)
|
|
} else {
|
|
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")
|
|
}
|