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:
parent
4f684dc7c3
commit
d4409b27a3
6 changed files with 550 additions and 37 deletions
224
hive-c0re/src/bin/hive_gateway_auth.rs
Normal file
224
hive-c0re/src/bin/hive_gateway_auth.rs
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue