feat(gateway): PAM auth against host — close #1010

Adds opt-in HTTP Basic auth to the hive-gateway backed by the host PAM
stack + group membership check.

New binary `hive-gateway-auth` (hive-c0re workspace):
- Axum HTTP service on 127.0.0.1:7002 (host loopback)
- Decodes Basic credentials, authenticates via pam_unix.so
- Checks membership in `hyperhive-operator` group (or custom)
- Returns 200 / 401 / 403; nginx `auth_request` consumes these

New options under `services.hyperhive.gateway.auth`:
- `enable`      — off by default
- `port`        — auth service port (default 7002)
- `realm`       — WWW-Authenticate realm string (default "hyperhive")
- `group`       — required host group (default "hyperhive-operator")
- `pamService`  — PAM service name (default "hive-gateway")

Host-side NixOS wiring:
- `users.groups.hyperhive-operator` declared when default group used
- `/etc/pam.d/hive-gateway` emitted via `security.pam.services`
- `systemd.services.hive-gateway-auth` runs the auth binary as root
  (needs /etc/shadow access for pam_unix.so)

Gateway container nginx wiring:
- `location = /__hive_gateway_auth` — internal proxy to auth service
- `auth_request /__hive_gateway_auth` on the `"/"` proxy location
- `@hive_auth_required` named location adds WWW-Authenticate: Basic
  header on 401 so browsers display a login prompt

Workspace deps: pam = "0.8"; flake.nix: linux-pam added to
nativeBuildInputs so pkg-config can find libpam at build time.
This commit is contained in:
atlas 2026-06-01 22:35:11 +02:00
commit d4409b27a3
6 changed files with 550 additions and 37 deletions

View file

@ -0,0 +1,224 @@
//! `hive-gateway-auth` — host-side HTTP basic auth validator for the hive gateway.
//!
//! Runs as a systemd service on the host. The gateway container's nginx
//! calls this via `auth_request` for every incoming request; this binary
//! validates the `Authorization: Basic` credentials against the host's PAM
//! stack and checks that the authenticated user is a member of the
//! `hyperhive-operator` group (configurable via `--group`).
//!
//! Listens on `127.0.0.1:PORT` (default 7002, host loopback only — the
//! gateway container shares the host netns, so it can reach this directly).
//!
//! Response codes nginx cares about:
//! - `200 OK` — auth passed; nginx proxies the request upstream.
//! - `401 Unauthorized` — missing/invalid credentials; nginx returns 401
//! with a `WWW-Authenticate: Basic realm="…"` header added by the nginx
//! config. The body from this service is discarded by nginx.
//! - `403 Forbidden` — valid credentials but not in the required group.
use std::net::SocketAddr;
use std::str::FromStr as _;
use anyhow::{Context as _, Result};
use axum::Router;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::routing::any;
use base64::Engine as _;
use clap::Parser;
#[derive(Parser)]
#[command(
name = "hive-gateway-auth",
about = "Host-side HTTP basic-auth validator for hive-gateway"
)]
struct Cli {
/// Address to listen on. Bind to 127.0.0.1 (loopback-only) so
/// only the gateway container (shared host netns) can reach it.
#[arg(long, default_value = "127.0.0.1:7002")]
listen: String,
/// PAM service name. A matching `/etc/pam.d/<service>` file must
/// exist on the host and include `pam_unix.so` for password auth.
#[arg(long, default_value = "hive-gateway")]
pam_service: String,
/// Host group that every authenticated user must belong to.
#[arg(long, default_value = "hyperhive-operator")]
group: String,
}
#[derive(Clone)]
struct AppState {
pam_service: String,
required_group: String,
}
#[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 cli = Cli::parse();
let addr = SocketAddr::from_str(&cli.listen)
.with_context(|| format!("invalid --listen address: {}", cli.listen))?;
let state = AppState {
pam_service: cli.pam_service,
required_group: cli.group,
};
let app = Router::new()
.route("/{*path}", any(handle_auth))
.route("/", any(handle_auth))
.with_state(state);
tracing::info!(addr = %addr, "hive-gateway-auth listening");
let listener = tokio::net::TcpListener::bind(addr)
.await
.with_context(|| format!("bind {addr}"))?;
axum::serve(listener, app).await.context("serve")?;
Ok(())
}
/// Validate the `Authorization: Basic` header. Returns the decoded
/// `(username, password)` pair, or `None` when the header is absent,
/// malformed, or not Basic-scheme.
fn parse_basic_auth(headers: &HeaderMap) -> Option<(String, String)> {
let value = headers.get("authorization")?.to_str().ok()?;
let encoded = value.strip_prefix("Basic ")?;
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.ok()?;
let s = String::from_utf8(decoded).ok()?;
let (user, pass) = s.split_once(':')?;
Some((user.to_owned(), pass.to_owned()))
}
/// Check whether `username` is a member of `group` by reading the host's
/// group database. Uses `getgrouplist(3)` (via `nix::unistd`) if available,
/// or falls back to scanning `/etc/group` entries directly.
///
/// Returns `true` when the user's primary GID matches OR when the user
/// appears in the supplementary member list of the target group.
fn user_in_group(username: &str, group_name: &str) -> bool {
use std::ffi::CString;
// SAFETY: all `libc` calls here follow the documented calling
// conventions for `getpwnam_r` / `getgrnam_r` / `getgrouplist`.
unsafe {
// Resolve the target group's GID.
let c_group = match CString::new(group_name) {
Ok(s) => s,
Err(_) => return false,
};
let mut grp_storage = std::mem::zeroed::<libc::group>();
let mut grp_ptr: *mut libc::group = std::ptr::null_mut();
let mut buf = vec![0i8; 4096];
let ret = libc::getgrnam_r(
c_group.as_ptr(),
&mut grp_storage,
buf.as_mut_ptr(),
buf.len(),
&mut grp_ptr,
);
if ret != 0 || grp_ptr.is_null() {
return false; // group not found
}
let target_gid = (*grp_ptr).gr_gid;
// Resolve the user's primary GID.
let c_user = match CString::new(username) {
Ok(s) => s,
Err(_) => return false,
};
let mut pwd_storage = std::mem::zeroed::<libc::passwd>();
let mut pwd_ptr: *mut libc::passwd = std::ptr::null_mut();
let mut pwd_buf = vec![0i8; 4096];
let ret = libc::getpwnam_r(
c_user.as_ptr(),
&mut pwd_storage,
pwd_buf.as_mut_ptr(),
pwd_buf.len(),
&mut pwd_ptr,
);
if ret != 0 || pwd_ptr.is_null() {
return false; // user not found
}
// Primary group match.
if (*pwd_ptr).pw_gid == target_gid {
return true;
}
// Scan gr_mem list for the username.
let mut mem = (*grp_ptr).gr_mem;
if mem.is_null() {
return false;
}
while !(*mem).is_null() {
let member = std::ffi::CStr::from_ptr(*mem);
if member.to_bytes() == username.as_bytes() {
return true;
}
mem = mem.add(1);
}
false
}
}
/// Authenticate `username` / `password` against the host PAM stack using
/// the configured service. Returns `true` on success. The PAM call is
/// synchronous and blocking — runs on the tokio thread pool via
/// `spawn_blocking`.
fn pam_authenticate_sync(service: &str, username: &str, password: &str) -> bool {
let mut client = match pam::Client::with_password(service) {
Ok(c) => c,
Err(e) => {
tracing::warn!(service, error = %e, "pam: client init failed");
return false;
}
};
client
.conversation_mut()
.set_credentials(username, password);
if let Err(e) = client.authenticate() {
tracing::debug!(service, username, error = %e, "pam: authenticate failed");
return false;
}
if let Err(e) = client.account_mgmt() {
tracing::debug!(service, username, error = %e, "pam: account_mgmt failed");
return false;
}
true
}
async fn handle_auth(
State(state): State<AppState>,
headers: HeaderMap,
) -> StatusCode {
let Some((username, password)) = parse_basic_auth(&headers) else {
return StatusCode::UNAUTHORIZED;
};
// PAM blocks — run off the async executor.
let service = state.pam_service.clone();
let user_clone = username.clone();
let pass_clone = password.clone();
let authed =
tokio::task::spawn_blocking(move || pam_authenticate_sync(&service, &user_clone, &pass_clone))
.await
.unwrap_or(false);
if !authed {
tracing::info!(username, "auth: bad credentials");
return StatusCode::UNAUTHORIZED;
}
if !user_in_group(&username, &state.required_group) {
tracing::info!(username, group = %state.required_group, "auth: user not in required group");
return StatusCode::FORBIDDEN;
}
tracing::debug!(username, "auth: ok");
StatusCode::OK
}