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

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