feat(gateway): htpasswd Basic auth — close #1010

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.
This commit is contained in:
atlas 2026-06-01 22:50:04 +02:00
commit 25d2951d1e
7 changed files with 128 additions and 532 deletions

219
Cargo.lock generated
View file

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

View file

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

View file

@ -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/<filename>`.
**What is not gated:** per-agent UI routes emitted into `agents.conf`
(served under `/agent/<name>/`) 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 `$`.

View file

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

View file

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

View file

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

@ -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 <username>`
(BCrypt recommended; `-c` creates a new file). Subsequent users:
`htpasswd -B /path/to/gateway.htpasswd <user2>`.
'';
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 <file> <username>`. 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 <username>
'';
}
];
# 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
@ -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;
}
''}
'';
};
}