From 25d2951d1e03be0781787ded4876a2a97f0ec0eb Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 1 Jun 2026 22:50:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20htpasswd=20Basic=20auth=20?= =?UTF-8?q?=E2=80=94=20close=20#1010?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the earlier PAM+binary approach with nginx's built-in `auth_basic` module. No new binary, no new systemd service, no PAM. New option `services.hyperhive.gateway.auth`: - `enable` — off by default - `htpasswdFile` — host path to an htpasswd file (required when enable) - `realm` — WWW-Authenticate realm string (default "hyperhive"); restricted to `strMatching "[^\"$]*"` to prevent nginx config injection When enabled: - the parent directory of `htpasswdFile` is bind-mounted read-only into the gateway container at `/run/gateway-auth/` - the `"/"` proxy location gets `auth_basic` + `auth_basic_user_file` Create credentials: `htpasswd -Bc /path/to/file alice` (BCrypt). See `docs/gateway.md` ("HTTP Basic auth") for the full setup guide. --- Cargo.lock | 219 ++++-------------------- Cargo.toml | 1 - docs/gateway.md | 41 +++++ flake.nix | 1 - hive-c0re/Cargo.toml | 2 - hive-c0re/src/bin/hive_gateway_auth.rs | 224 ------------------------- nix/modules/hive-gateway.nix | 172 ++++++------------- 7 files changed, 128 insertions(+), 532 deletions(-) delete mode 100644 hive-c0re/src/bin/hive_gateway_auth.rs diff --git a/Cargo.lock b/Cargo.lock index d7599556..73b73db2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,7 +120,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -199,7 +199,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -210,7 +210,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -303,26 +303,6 @@ 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" @@ -404,16 +384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", - "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", + "shlex", ] [[package]] @@ -477,16 +448,6 @@ 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" @@ -518,7 +479,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -655,7 +616,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -678,7 +639,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn", ] [[package]] @@ -689,7 +650,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -793,7 +754,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1024,7 +985,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1106,12 +1067,6 @@ 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" @@ -1247,9 +1202,7 @@ dependencies = [ "base64", "clap", "hive-sh4re", - "libc", "listenfd", - "pam", "reqwest", "rusqlite", "serde", @@ -1686,15 +1639,6 @@ 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" @@ -1772,12 +1716,6 @@ 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" @@ -1879,7 +1817,7 @@ checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1917,7 +1855,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2098,12 +2036,6 @@ 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" @@ -2131,16 +2063,6 @@ 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" @@ -2213,40 +2135,6 @@ 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" @@ -2406,7 +2294,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn", ] [[package]] @@ -2468,7 +2356,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2500,7 +2388,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.2", + "rustc-hash", "rustls", "socket2", "thiserror 2.0.18", @@ -2520,7 +2408,7 @@ dependencies = [ "lru-slab", "rand 0.9.4", "ring", - "rustc-hash 2.1.2", + "rustc-hash", "rustls", "rustls-pki-types", "slab", @@ -2674,7 +2562,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2795,7 +2683,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.117", + "syn", ] [[package]] @@ -2973,7 +2861,7 @@ dependencies = [ "quote", "ruma-identifiers-validation", "serde", - "syn 2.0.117", + "syn", "toml", ] @@ -3007,12 +2895,6 @@ 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" @@ -3111,7 +2993,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn", ] [[package]] @@ -3163,7 +3045,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3174,7 +3056,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3266,12 +3148,6 @@ 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" @@ -3384,17 +3260,6 @@ 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" @@ -3423,7 +3288,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3476,7 +3341,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3487,7 +3352,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3567,7 +3432,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3757,7 +3622,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3904,16 +3769,6 @@ 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" @@ -4066,7 +3921,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wasm-bindgen-shared", ] @@ -4204,7 +4059,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4215,7 +4070,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4452,7 +4307,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn 2.0.117", + "syn", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -4468,7 +4323,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -4553,7 +4408,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -4574,7 +4429,7 @@ checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4594,7 +4449,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -4615,7 +4470,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -4648,7 +4503,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 127503cd..b8ac38ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,6 @@ 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/docs/gateway.md b/docs/gateway.md index d8336fce..5d8631d5 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -339,3 +339,44 @@ the nginx config get custom error pages. Other gateway routes (forge / matrix / fluffychat) get nginx defaults — extending the custom-error pattern there is a separate follow-up. +## HTTP Basic auth + +`services.hyperhive.gateway.auth.enable = true` gates every request to +the main vhost (`_`) behind HTTP Basic auth. nginx's built-in `auth_basic` +module validates credentials; no extra service or host-side daemon is +required. + +**Setup:** + +```nix +services.hyperhive.gateway.auth = { + enable = true; + htpasswdFile = "/etc/hyperhive/gateway.htpasswd"; + # realm = "hyperhive"; # optional, default shown +}; +``` + +Create the htpasswd file on the host: + +```sh +# Create new file with first user (BCrypt, recommended): +htpasswd -Bc /etc/hyperhive/gateway.htpasswd alice + +# Add subsequent users: +htpasswd -B /etc/hyperhive/gateway.htpasswd bob +``` + +The file must be readable by the `nginx` user inside the container +(`chmod 0644`). The module bind-mounts the file's parent directory +read-only into the container at `/run/gateway-auth/`; nginx reads +`/run/gateway-auth/`. + +**What is not gated:** per-agent UI routes emitted into `agents.conf` +(served under `/agent//`) inherit no auth from `/` — nginx +applies `auth_basic` per-location. Full per-agent coverage is a +follow-up. + +**Realm:** the `WWW-Authenticate: Basic realm="..."` string browsers +display in the credential dialog. Defaults to `"hyperhive"`. Must not +contain `"` or `$`. + diff --git a/flake.nix b/flake.nix index 728dda84..6d746ab6 100644 --- a/flake.nix +++ b/flake.nix @@ -101,7 +101,6 @@ 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 3f96af5a..e0ff4406 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -12,8 +12,6 @@ base64.workspace = true 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 deleted file mode 100644 index deb69322..00000000 --- a/hive-c0re/src/bin/hive_gateway_auth.rs +++ /dev/null @@ -1,224 +0,0 @@ -//! `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 dafc7ec0..995edf49 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -200,59 +200,43 @@ 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. + HTTP basic auth on the gateway using an htpasswd file. When + enabled, every request to the gateway's main vhost requires a + valid username and password from the htpasswd file at + `services.hyperhive.gateway.auth.htpasswdFile`. nginx's built-in + `auth_basic` module handles credential validation — no extra + service or host-side daemon required. Off by default. + + Create the file with: `htpasswd -Bc /path/to/gateway.htpasswd ` + (BCrypt recommended; `-c` creates a new file). Subsequent users: + `htpasswd -B /path/to/gateway.htpasswd `. ''; - port = lib.mkOption { - type = lib.types.port; - default = 7002; + htpasswdFile = lib.mkOption { + type = lib.types.path; + example = "/etc/hyperhive/gateway.htpasswd"; 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. + Path on the **host** to an htpasswd-format file whose + `username:hashed-password` entries nginx uses for Basic auth. + The parent directory is bind-mounted read-only into the gateway + container at `/run/gateway-auth/`. The file must be readable by + the `nginx` user inside the container (mode 0644 recommended). + + Create with: `htpasswd -Bc `. BCrypt (`-B`) is + strongly preferred over the legacy MD5/SHA1 algorithms. + + Required when `enable = true`. ''; }; realm = lib.mkOption { - type = lib.types.str; + type = lib.types.strMatching "[^\"$]*"; 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. + header when credentials are absent or rejected. Must not + contain `"` or `$` (nginx string metacharacters). ''; }; }; @@ -269,53 +253,16 @@ in or leave `localHostsEntry` at its default of false. ''; } + { + assertion = !cfg.auth.enable || cfg.auth ? htpasswdFile; + message = '' + services.hyperhive.gateway.auth.enable = true requires + services.hyperhive.gateway.auth.htpasswdFile to be set. + Create an htpasswd file with: htpasswd -Bc /path/to/file + ''; + } ]; - # 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 @@ -363,6 +310,16 @@ in hostPath = "/var/lib/hyperhive/gateway"; isReadOnly = true; }; + # When auth is enabled, bind-mount the parent directory of the + # htpasswd file read-only into the container at /run/gateway-auth/. + # nginx's `auth_basic_user_file` points at the file inside that dir. + # Using the parent directory (not the file itself) because nspawn + # bind-mounts need a pre-existing destination — binding a directory + # is always safe; nginx picks the file up by name inside. + bindMounts."/run/gateway-auth" = lib.mkIf cfg.auth.enable { + hostPath = builtins.dirOf cfg.auth.htpasswdFile; + isReadOnly = true; + }; config = { pkgs, ... }: let @@ -616,11 +573,10 @@ 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. + # When auth is enabled, nginx's built-in `auth_basic` + # validates credentials against the htpasswd file + # bind-mounted at `/run/gateway-auth/`. No extra + # service or host-side daemon required. "/" = { proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}"; proxyWebsockets = true; @@ -628,27 +584,11 @@ in proxy_buffering off; proxy_read_timeout 1d; ${lib.optionalString cfg.auth.enable '' - auth_request /__hive_gateway_auth; - error_page 401 = @hive_auth_required; + auth_basic "${cfg.auth.realm}"; + auth_basic_user_file /run/gateway-auth/${builtins.baseNameOf cfg.auth.htpasswdFile}; ''} ''; }; - } - # 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; - ''; - }; }; # Per-agent location blocks, generated at runtime by # hive-c0re and written to /var/lib/hyperhive/gateway/agents.conf @@ -661,18 +601,6 @@ 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; - } - ''} ''; }; }