diff --git a/Cargo.lock b/Cargo.lock index 164bfbc7..d7599556 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,7 +120,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -199,7 +199,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -210,7 +210,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -303,6 +303,26 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "lazy_static", + "lazycell", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 2.0.117", +] + [[package]] name = "bitflags" version = "2.11.1" @@ -384,7 +404,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", - "shlex", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", ] [[package]] @@ -448,6 +477,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", +] + [[package]] name = "clap" version = "4.6.1" @@ -479,7 +518,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -616,7 +655,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -639,7 +678,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -650,7 +689,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -754,7 +793,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -985,7 +1024,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1067,6 +1106,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "gloo-timers" version = "0.3.0" @@ -1204,6 +1249,7 @@ dependencies = [ "hive-sh4re", "libc", "listenfd", + "pam", "reqwest", "rusqlite", "serde", @@ -1640,6 +1686,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1717,6 +1772,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1818,7 +1879,7 @@ checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1856,7 +1917,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2037,6 +2098,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2064,6 +2131,16 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2136,6 +2213,40 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "pam" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ab553c52103edb295d8f7d6a3b593dc22a30b1fb99643c777a8f36915e285ba" +dependencies = [ + "libc", + "memchr", + "pam-macros", + "pam-sys", + "users", +] + +[[package]] +name = "pam-macros" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c94f3b9b97df3c6d4e51a14916639b24e02c7d15d1dba686ce9b1118277cb811" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pam-sys" +version = "1.0.0-alpha5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce9484729b3e52c0bacdc5191cb6a6a5f31ef4c09c5e4ab1209d3340ad9e997b" +dependencies = [ + "bindgen", + "libc", +] + [[package]] name = "parking" version = "2.2.1" @@ -2295,7 +2406,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -2357,7 +2468,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2389,7 +2500,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "socket2", "thiserror 2.0.18", @@ -2409,7 +2520,7 @@ dependencies = [ "lru-slab", "rand 0.9.4", "ring", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "rustls-pki-types", "slab", @@ -2563,7 +2674,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2684,7 +2795,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn", + "syn 2.0.117", ] [[package]] @@ -2862,7 +2973,7 @@ dependencies = [ "quote", "ruma-identifiers-validation", "serde", - "syn", + "syn 2.0.117", "toml", ] @@ -2896,6 +3007,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -2994,7 +3111,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.117", ] [[package]] @@ -3046,7 +3163,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3057,7 +3174,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3149,6 +3266,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -3261,6 +3384,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -3289,7 +3423,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3342,7 +3476,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3353,7 +3487,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3433,7 +3567,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3623,7 +3757,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3770,6 +3904,16 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "users" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4227e95324a443c9fcb06e03d4d85e91aabe9a5a02aa818688b6918b6af486" +dependencies = [ + "libc", + "log", +] + [[package]] name = "utf-8" version = "0.7.6" @@ -3922,7 +4066,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -4060,7 +4204,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4071,7 +4215,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4308,7 +4452,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -4324,7 +4468,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -4409,7 +4553,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4430,7 +4574,7 @@ checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4450,7 +4594,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4471,7 +4615,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4504,7 +4648,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b8ac38ca..127503cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ must_use_candidate = "allow" [workspace.dependencies] anyhow = "1" +pam = "0.8" axum = { version = "0.8", features = ["ws"] } base64 = "0.22" clap = { version = "4", features = ["derive"] } diff --git a/flake.nix b/flake.nix index 6d746ab6..728dda84 100644 --- a/flake.nix +++ b/flake.nix @@ -101,6 +101,7 @@ pkgs.git pkgs.sqlite pkgs.pkg-config + pkgs.linux-pam # libpam.so.0 for hive-gateway-auth ]; } ); diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index ac70638e..3f96af5a 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -13,6 +13,7 @@ reqwest.workspace = true clap.workspace = true hive-sh4re.workspace = true libc = "0.2" +pam.workspace = true listenfd = "1" rusqlite.workspace = true serde.workspace = true diff --git a/hive-c0re/src/bin/hive_gateway_auth.rs b/hive-c0re/src/bin/hive_gateway_auth.rs new file mode 100644 index 00000000..deb69322 --- /dev/null +++ b/hive-c0re/src/bin/hive_gateway_auth.rs @@ -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/` 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::(); + 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::(); + 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, + 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 +} diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index ef7c5c28..dafc7ec0 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -198,6 +198,65 @@ in ''; }; + auth = { + enable = lib.mkEnableOption '' + HTTP basic auth on the gateway using host PAM. When enabled, every + request to the gateway's main vhost requires a valid username and + password from the host's user database. The user must also be a + member of the `services.hyperhive.gateway.auth.group` host group + (default: `hyperhive-operator`). A small `hive-gateway-auth` + systemd service runs on the host, listens on loopback at + `services.hyperhive.gateway.auth.port`, and performs the PAM + authentication. nginx inside the gateway container calls it via + `auth_request` (the container shares the host netns, so loopback + is reachable directly). Off by default — local / single-operator + setups may not need authentication. + ''; + + port = lib.mkOption { + type = lib.types.port; + default = 7002; + description = '' + TCP port for the `hive-gateway-auth` service on the host's + loopback interface. nginx's `auth_request` sub-request is + sent here. Change when 7002 is already in use. + ''; + }; + + realm = lib.mkOption { + type = lib.types.str; + default = "hyperhive"; + example = "my-hive"; + description = '' + HTTP Basic auth `realm` value sent in the `WWW-Authenticate` + header when credentials are absent or rejected. + ''; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "hyperhive-operator"; + example = "admins"; + description = '' + Host Unix group every authenticated user must belong to. + Create the group and add operator accounts before enabling + auth. When using the default value, the group is + automatically defined on the host by this module. + ''; + }; + + pamService = lib.mkOption { + type = lib.types.str; + default = "hive-gateway"; + description = '' + PAM service name. A matching `/etc/pam.d/hive-gateway` file + is defined by this module when using the default value. Set to + an existing service (e.g. `"login"`) to reuse a custom PAM + stack instead of the generated one. + ''; + }; + }; + }; config = lib.mkIf cfg.enable { @@ -212,6 +271,52 @@ in } ]; + # HOST-SIDE: PAM auth service for the gateway. + # Defined here so it co-locates with the nginx wiring below. + # All three blocks are gated on `cfg.auth.enable`. + + # Declare the hyperhive-operator group on the host so operators + # can `usermod -aG hyperhive-operator ` out-of-the-box. + # Only created when the default group name is in use; custom + # groups are assumed to be managed externally. + users.groups = lib.mkIf (cfg.auth.enable && cfg.auth.group == "hyperhive-operator") { + hyperhive-operator = { }; + }; + + # PAM service used by `hive-gateway-auth`. Only emits the generated + # `/etc/pam.d/hive-gateway` when the operator uses the default + # service name, to avoid clobbering a custom PAM config they may + # have defined elsewhere. + security.pam.services.hive-gateway = lib.mkIf (cfg.auth.enable && cfg.auth.pamService == "hive-gateway") { + text = '' + # hive-gateway: authenticate via host Unix passwords, then check + # group membership in ${cfg.auth.group}. + auth required pam_unix.so + auth required pam_succeed_if.so user ingroup ${cfg.auth.group} + account required pam_unix.so + ''; + }; + + # `hive-gateway-auth` systemd service. Runs as root so it can + # call pam_unix.so against /etc/shadow (root-only readable). + # Bound to 127.0.0.1 — the gateway container shares the host + # netns, so it's reachable from nginx without any port-forward. + systemd.services.hive-gateway-auth = lib.mkIf cfg.auth.enable { + description = "hive-gateway HTTP basic auth validator"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + ExecStart = '' + ${config.services.hyperhive.c0re.package}/bin/hive-gateway-auth \ + --listen 127.0.0.1:${toString cfg.auth.port} \ + --pam-service ${lib.escapeShellArg cfg.auth.pamService} \ + --group ${lib.escapeShellArg cfg.auth.group} + ''; + Restart = "on-failure"; + RestartSec = 2; + }; + }; + # Ensure bind-mount sources exist at host boot before the gateway # container's first start. nspawn would auto-create missing dirs # tmpfiles rules make the intent explicit @@ -511,12 +616,37 @@ in # headers stay set so SSE (`/dashboard/stream`, # `/events/stream`) + websocket (`/screen/ws`) # endpoints keep working transparently. + # When auth is enabled, `auth_request` sub-requests + # `/__hive_gateway_auth` before proxying. The 401 + # named-location handler (in vhost `extraConfig`) + # adds the `WWW-Authenticate` header so browsers + # show a login prompt. "/" = { proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}"; proxyWebsockets = true; extraConfig = '' proxy_buffering off; proxy_read_timeout 1d; + ${lib.optionalString cfg.auth.enable '' + auth_request /__hive_gateway_auth; + error_page 401 = @hive_auth_required; + ''} + ''; + }; + } + # Internal auth sub-request location. Forwards the + # `Authorization` header to `hive-gateway-auth` on + # the host loopback; body is stripped (auth is + # header-only). nginx reuses this location for every + # `auth_request /__hive_gateway_auth;` directive. + // lib.optionalAttrs cfg.auth.enable { + "= /__hive_gateway_auth" = { + extraConfig = '' + internal; + proxy_pass http://127.0.0.1:${toString cfg.auth.port}/; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URI $request_uri; ''; }; }; @@ -531,6 +661,18 @@ in # the `/agent/` catch-all above. extraConfig = '' include /run/hive-state/agents.conf; + ${lib.optionalString cfg.auth.enable '' + # Named location for 401 responses from `auth_request`. + # nginx does not propagate upstream `WWW-Authenticate` + # headers automatically on auth failure, so we emit + # it here. `always` ensures the header is added even + # when nginx would otherwise suppress it on error + # responses. + location @hive_auth_required { + add_header WWW-Authenticate 'Basic realm="${cfg.auth.realm}"' always; + return 401; + } + ''} ''; }; }