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

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;
}
''}
'';
};
}