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

218
Cargo.lock generated
View file

@ -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]]

View file

@ -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"] }

View file

@ -101,6 +101,7 @@
pkgs.git
pkgs.sqlite
pkgs.pkg-config
pkgs.linux-pam # libpam.so.0 for hive-gateway-auth
];
}
);

View file

@ -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

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
}

View file

@ -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 <user>` 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;
}
''}
'';
};
}