hyperhive/nix/modules/hive-gateway.nix
atlas d4409b27a3 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.
2026-06-01 23:24:47 +02:00

833 lines
37 KiB
Nix

{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.hyperhive.gateway;
hyperhiveDomain = config.services.hyperhive.domain;
matrixCfg = config.services.hyperhive.matrix;
forgeCfg = config.services.hyperhive.forge;
networkCfg = config.services.hyperhive.network;
# Static error pages for `/agent/<name>/` mishaps.
# Useful pages of nginx's default 404/502 for routes
# we've already special-cased. See `docs/gateway.md::Per-agent
# error pages` for the design rationale + page-vs-status semantics.
agentErrorPagesDir = pkgs.runCommand "hyperhive-agent-error-pages" { } ''
mkdir -p $out
cat > $out/not-found.html <<'EOF'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>agent not found hyperhive</title>
<style>
body { background: #1e1e2e; color: #cdd6f4; font: 14px/1.5 -apple-system, system-ui, sans-serif; margin: 0; padding: 4rem 1rem; text-align: center; }
h1 { color: #cba6f7; font-size: 1.5rem; margin: 0 0 0.5rem; }
p { max-width: 32rem; margin: 0.5rem auto; color: #a6adc8; }
code { background: #313244; color: #f5c2e7; padding: 0.1rem 0.35rem; border-radius: 0.2rem; }
a { color: #89b4fa; }
</style>
</head>
<body>
<h1> agent not found</h1>
<p>No agent matches the requested <code>/agent/&lt;name&gt;/</code> path on this hive.</p>
<p>Operator: check the agent name in <a href="/">the dashboard</a>.</p>
</body>
</html>
EOF
cat > $out/unreachable.html <<'EOF'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>agent unreachable hyperhive</title>
<style>
body { background: #1e1e2e; color: #cdd6f4; font: 14px/1.5 -apple-system, system-ui, sans-serif; margin: 0; padding: 4rem 1rem; text-align: center; }
h1 { color: #f9e2af; font-size: 1.5rem; margin: 0 0 0.5rem; }
p { max-width: 32rem; margin: 0.5rem auto; color: #a6adc8; }
code { background: #313244; color: #f5c2e7; padding: 0.1rem 0.35rem; border-radius: 0.2rem; }
a { color: #89b4fa; }
</style>
</head>
<body>
<h1> agent unreachable</h1>
<p>The agent's harness web server isn't responding. Container restarting, or the agent crashed.</p>
<p>Operator: <a href="/">dashboard</a> check the container status / journal; the page will recover on retry once the harness is back up.</p>
</body>
</html>
EOF
'';
in
{
# Single nginx in front of every hyperhive web surface — dashboard,
# per-agent UIs (sub-path), forge + matrix (sub-domain), .well-known
# delegations. Container `hive-gateway`, shared host netns,
# state-free. Full vhost map + discovery flow + design rationale in
# `docs/gateway.md`.
options.services.hyperhive.gateway = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Run hive-gateway a single nginx in front of every hyperhive
surface. On by default: the gateway hosts the matrix GUI static
dist (when `services.hyperhive.matrix.gui.enable` is true) and
proxies everything else to hive-c0re's dashboard upstream. Set
`services.hyperhive.gateway.enable = false` to bypass nginx
entirely and reach hive-c0re directly on its dashboard port
(7000 by default).
v0 is HTTP-only; TLS / public-domain shape is tracked
separately.
'';
};
port = lib.mkOption {
type = lib.types.port;
default = 80;
example = 8080;
description = ''
TCP port the gateway listens on. Default 80 (canonical web
port). nginx inside the container binds <1024 because the
container's init runs as root; if 80 is already taken on the
host (existing nginx, traefik, etc.) override to an unused
port like 8080 or move the conflicting service.
'';
};
upstreamHost = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = ''
Host the gateway proxies non-static requests to. Defaults to
`127.0.0.1` because the gateway container shares the host
netns, so loopback resolves directly to hive-c0re.
'';
};
upstreamPort = lib.mkOption {
type = lib.types.port;
default = 7000;
description = ''
TCP port the gateway proxies non-static requests to. Defaults
to `7000` (hive-c0re's out-of-the-box dashboard port). Operators
who change `services.hyperhive.c0re.dashboardPort` should set
`upstreamPort` to match kept as a hardcoded default rather
than a cross-reference to keep this module's options eval
independent of c0re's option tree shape.
'';
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Open `port` in the host firewall. Off by default (secure-by-default).
Flip to `true` to expose the gateway to
the operator's browser / external clients required for any
out-of-host reach, since the agents themselves talk to
hive-c0re via the per-agent unix sockets and don't need the
nginx vhost. Leave off when running behind another reverse
proxy (e.g. caddy / traefik on the host) that handles TLS
termination + forwards to `port`.
**Note**: this used to default to `true`. Add
`services.hyperhive.gateway.openFirewall = true;` to your host
config if external reach stopped working after a recent upgrade.
'';
};
localHostsEntry = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Add an `/etc/hosts` entry mapping `services.hyperhive.domain`
to `127.0.0.1` on the host. Useful for local deployments +
tests where there's no real DNS for `services.hyperhive.domain`
but the operator (or browser-based tests) want to hit
`http://''${services.hyperhive.domain}` to exercise the
gateway shape. Off by default operators running with real
DNS shouldn't have a stale `/etc/hosts` entry sticking
around. Requires `services.hyperhive.domain` to be set.
'';
};
selfSignedTls = lib.mkOption {
type = lib.types.bool;
default = true;
example = false;
description = ''
Generate a self-signed TLS cert at first gateway boot and
listen on `httpsPort` (default 443) with it on every vhost.
On by default because matrix-dart-sdk (the SDK behind
FluffyChat + several other Matrix clients) hardcodes
`https://<host>/.well-known/matrix/client` for homeserver
discovery and refuses to fall back to plain http without
TLS the browser client just won't connect.
Self-signed means browsers will show a "not secure" warning
on first visit; the operator clicks through once per
browser. For production deployments, set this to `false`
and front the gateway with a reverse proxy (caddy, traefik,
or nginx with ACME) that does proper TLS termination.
The cert is regenerated on demand if the file is missing
but never rotated automatically; delete
`/var/lib/hive-gateway/tls/cert.pem` inside the gateway
container to force a fresh one.
See `docs/gateway.md` ("Self-signed TLS").
'';
};
httpsPort = lib.mkOption {
type = lib.types.port;
default = 443;
example = 8443;
description = ''
TCP port for the TLS-terminated vhosts when `selfSignedTls`
is enabled. Default 443. Setting `selfSignedTls = false`
renders this option inert (the gateway listens on `port`
only).
'';
};
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 {
assertions = [
{
assertion = !cfg.localHostsEntry || hyperhiveDomain != null;
message = ''
services.hyperhive.gateway.localHostsEntry = true requires
services.hyperhive.domain to be set. Either pin a hostname
or leave `localHostsEntry` at its default of false.
'';
}
];
# 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
# and cover the fresh-boot window before c0re has run.
#
# /run/hive-agent — per-agent UDS socket dir, written by c0re's
# set_nspawn_flags when agents start.
# /var/lib/hyperhive — hyperhive state dir, created by c0re on
# first run. Also pre-seed agents.conf with an empty-but-valid
# header so nginx can start + include the file before c0re writes
# its first real content (f = create-if-absent, no overwrite).
systemd.tmpfiles.rules = [
"d /run/hive-agent 0755 root root - -"
"d /var/lib/hyperhive 0755 root root - -"
"d /var/lib/hyperhive/gateway 0755 root root - -"
"f /var/lib/hyperhive/gateway/agents.conf 0644 root root - # Generated by hive-c0re do not edit.\n"
];
containers.hive-gateway = {
autoStart = true;
ephemeral = false;
# Share host netns — nginx then binds host-level ports directly,
# `localhost` upstream resolution reaches hive-c0re without any
# port-forward dance, and the firewall config below is the only
# layer that matters.
privateNetwork = false;
# Bind-mount the per-agent socket dir so nginx inside the gateway
# container can `connect(2)` to the UDS upstreams.
# Read-only (we just connect; harness writes the socket inside
# the agent's own container). Host-side dir is pre-created by a
# tmpfiles rule so nspawn always finds a source at boot.
bindMounts."/run/hive-agent" = {
hostPath = "/run/hive-agent";
isReadOnly = true;
};
# Bind-mount ONLY the gateway-specific subdir of the hyperhive
# state dir. Scoped to /var/lib/hyperhive/gateway/ rather than
# the whole parent so the gateway container can't read forge
# tokens or other files that may live at the parent level.
# c0re writes agents.conf under this subdir and triggers an nginx
# reload from the host via systemd-run after each write.
# Pre-created by a tmpfiles rule.
bindMounts."/run/hive-state" = {
hostPath = "/var/lib/hyperhive/gateway";
isReadOnly = true;
};
config =
{ pkgs, ... }:
let
tlsDir = "/var/lib/hive-gateway/tls";
tlsCert = "${tlsDir}/cert.pem";
tlsKey = "${tlsDir}/key.pem";
# Listen addresses every vhost shares. Plain http on `cfg.port`
# always; `cfg.httpsPort` with TLS sits beside it when
# `cfg.selfSignedTls` is on. See `docs/gateway.md`
# ("Self-signed TLS") for the cert lifecycle.
vhostListen = [
{
addr = "0.0.0.0";
port = cfg.port;
}
]
++ lib.optional cfg.selfSignedTls {
addr = "0.0.0.0";
port = cfg.httpsPort;
ssl = true;
};
# nixos `services.nginx.virtualHosts.<name>` ssl attrs to mix
# into each vhost when self-signed TLS is on. `addSSL = true`
# is what gates `ssl_certificate` directive emission in the
# nixos nginx module (`hasSSL` checks addSSL / onlySSL /
# forceSSL). The actual ssl listen is the explicit entry
# with `ssl = true` in `vhostListen` — the nixos module's
# auto-listen-generation only kicks in when `listen` is
# empty, so the explicit listen wins and there's no
# duplicate-listen risk. Empty otherwise so the http-only
# path stays identical.
vhostTls = lib.optionalAttrs cfg.selfSignedTls {
addSSL = true;
sslCertificate = tlsCert;
sslCertificateKey = tlsKey;
};
# Public-facing scheme + port-suffix for URLs the gateway
# mints into responses (well-known JSON, the deprecated
# `<hive>/matrix/*` 301 redirect, future absolute-URL needs).
# When self-signed TLS is on, prefer `https://<host>` (matrix-
# spec compliance) — 443 elides the port. Otherwise fall back
# to the plain-http listen with the bare port. See
# `docs/gateway.md` ("Self-signed TLS"). Shared at this scope
# Shared to avoid repetition.
publicScheme = if cfg.selfSignedTls then "https" else "http";
publicPort = if cfg.selfSignedTls then cfg.httpsPort else cfg.port;
publicPortDefault = if cfg.selfSignedTls then 443 else 80;
publicPortSuffix = if publicPort == publicPortDefault then "" else ":${toString publicPort}";
in
{
system.stateVersion = "26.05";
# Ensure a valid self-signed cert exists before nginx starts.
# nginx `Requires=` this via `requiredBy`, so systemd refuses
# to start nginx until the script succeeds. ALWAYS runs (no
# ConditionPathExists) and is idempotent — that's necessary
# to reconcile broken state left over from prior failed
# boots (a 0700 dir from a stale UMask, a truncated cert
# from an interrupted oneshot, etc.) which a guarded-on-
# missing-cert script would silently skip and leave broken.
# Cert covers the bare hive domain plus `*.${hyperhiveDomain}`
# so the matrix + forge sub-domains are valid under the same
# cert. See `docs/gateway.md` ("Self-signed TLS").
systemd.services.hive-gateway-self-signed-cert = lib.mkIf cfg.selfSignedTls {
description = "Ensure self-signed TLS cert for hive-gateway";
wantedBy = [ "multi-user.target" ];
before = [ "nginx.service" ];
requiredBy = [ "nginx.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [
pkgs.openssl
pkgs.coreutils
];
script =
let
subjectCN = if hyperhiveDomain != null then hyperhiveDomain else "hyperhive.local";
# `subjectAltName` covers the bare hive + the canonical
# sub-domains. Includes a wildcard `*.${domain}` so any
# future sub-domain we mint (per-agent UI under a
# sub-domain, etc.) inherits the cert without a rebuild.
sanLines = lib.concatStringsSep "," (
[ "DNS:${subjectCN}" ] ++ lib.optional (hyperhiveDomain != null) "DNS:*.${hyperhiveDomain}"
);
in
''
set -eu
mkdir -p ${tlsDir}
# 0755 on BOTH the cert dir and its parent so the
# nginx user can traverse the full path. The parent
# `/var/lib/hive-gateway` lands at 0700 by default
# (systemd StateDirectory / mkdir umask depending on
# which service created it first), which on its own
# blocks traversal. Re-applied every boot in case a
# prior run left a tighter mode behind.
chmod 0755 ${builtins.dirOf tlsDir}
chmod 0755 ${tlsDir}
# Generate the cert when EITHER the cert or key is
# missing/empty, OR the cert fails an openssl parse
# catches truncated / corrupt leftovers from a previous
# interrupted run AND the "cert clean but key absent"
# edge case (argus 🟡 on the first revision) which
# otherwise tripped `chmod 0600 ${tlsKey}` below with
# ENOENT under `set -eu`. The whole oneshot is safe to
# re-run; a healthy cert+key pair is left alone.
if [ ! -s ${tlsCert} ] || [ ! -s ${tlsKey} ] || ! openssl x509 -in ${tlsCert} -noout >/dev/null 2>&1; then
echo "generating fresh self-signed cert at ${tlsCert}"
openssl req -x509 -newkey rsa:4096 -nodes -sha256 -days 3650 \
-keyout ${tlsKey} \
-out ${tlsCert} \
-subj "/CN=${subjectCN}" \
-addext "subjectAltName=${sanLines}"
fi
# Key owned by root:nginx, mode 0640 so nginx-pre-start
# (which runs `nginx -t` as the nginx user, not root)
# can read it. A 0600 root:root key passes the master-
# process load (master starts as root) but fails the
# pre-start config test with `BIO_new_file()
# Permission denied`, blocking the unit from starting
# at all. Cert is world-readable.
chown root:nginx ${tlsKey}
chmod 0640 ${tlsKey}
chmod 0644 ${tlsCert}
'';
};
# nginx reload is triggered from the HOST side by hive-c0re
# via `systemd-run --machine=hive-gateway nginx -s reload`
# after each agents.conf write. A path unit watching the
# bind-mounted file inside the container was tried first
# (A path unit inside the container was tried but IN_MOVED_TO from an atomic rename on the host
# does not propagate across the nspawn mount-namespace boundary,
# does not cross the mount-namespace boundary. Host-side trigger is the
# correct approach.
services.nginx = {
enable = true;
recommendedProxySettings = true;
recommendedOptimisation = true;
# Accept-header SPA fallback: navigations
# (`Accept: text/html,...`) fall to index.html, asset
# fetches (Accept *anything else*) fall to a sentinel
# nonexistent path → `try_files` returns 404. Pattern
# detailed in `docs/gateway.md` ("SPA fallback").
appendHttpConfig = lib.optionalString (matrixCfg.enable && matrixCfg.gui.enable) ''
map $http_accept $matrix_spa_target {
default "/__matrix_spa_no_html_fallback";
"~*text/html" "/index.html";
}
'';
virtualHosts = {
"_" = vhostTls // {
listen = vhostListen;
locations =
# `<hive>/matrix/*` → 301 → `matrix.<hive>/$1`
# (fluffychat moved to sub-domain root; this
# keeps bookmarks + deep-links working during the
# transition). See `docs/gateway.md` for the vhost
# map.
lib.optionalAttrs (matrixCfg.enable && matrixCfg.gui.enable && matrixCfg.gatewayHost != null) (
let
target = "${publicScheme}://${matrixCfg.gatewayHost}${publicPortSuffix}";
in
{
"/matrix/" = {
extraConfig = ''
rewrite ^/matrix/(.*)$ ${target}/$1 permanent;
'';
};
}
)
//
# `.well-known/matrix/{client,server}` discovery JSON.
# Points clients at `matrixCfg.gatewayHost` (sub-domain
# vhost) when set; falls back to direct `<hive>:<httpPort>`
# when no gateway target. CORS `*` per matrix spec.
# See `docs/gateway.md` "Discovery flow" for the full
# client-bootstrap sequence.
lib.optionalAttrs (matrixCfg.enable && hyperhiveDomain != null) (
let
clientBaseUrl =
if matrixCfg.gatewayHost != null then
"${publicScheme}://${matrixCfg.gatewayHost}${publicPortSuffix}"
else
"${publicScheme}://${hyperhiveDomain}:${toString matrixCfg.httpPort}";
serverHostPort =
if matrixCfg.gatewayHost != null then
"${matrixCfg.gatewayHost}${publicPortSuffix}"
else
"${hyperhiveDomain}:${toString matrixCfg.httpPort}";
in
{
"= /.well-known/matrix/client" = {
extraConfig = ''
default_type application/json;
add_header Access-Control-Allow-Origin *;
return 200 '{"m.homeserver":{"base_url":"${clientBaseUrl}"}}';
'';
};
"= /.well-known/matrix/server" = {
extraConfig = ''
default_type application/json;
return 200 '{"m.server":"${serverHostPort}"}';
'';
};
}
)
//
# `/agent/` catch-all: hits when an operator
# requests `/agent/<unknown>/...`. Without this the
# request falls through to `/` (c0re dashboard) and
# returns 404 with no useful context. Custom 404
# page instead. Per-agent `location /agent/<name>/`
# blocks live in `/run/hive-state/agents.conf` —
# nginx picks them up via the `include` in
# `extraConfig` below; the catch-all only matches
# names that aren't in that file (nginx longest-
# prefix-match: `/agent/atlas/` beats `/agent/`).
{
"/agent/" = {
extraConfig = ''
error_page 404 = /__hive_agent_not_found;
return 404;
'';
};
# Internal static-file locations the error_page
# directives above point at. `internal` keeps
# operators from hitting the file directly (only
# nginx's error-handling can reach it); `alias`
# serves the exact file regardless of request URI.
"= /__hive_agent_not_found" = {
extraConfig = ''
internal;
alias ${agentErrorPagesDir}/not-found.html;
default_type text/html;
'';
};
"= /__hive_agent_unreachable" = {
extraConfig = ''
internal;
alias ${agentErrorPagesDir}/unreachable.html;
default_type text/html;
'';
};
}
// {
# Everything else proxies to hive-c0re. Upgrade
# 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;
'';
};
};
# Per-agent location blocks, generated at runtime by
# hive-c0re and written to /var/lib/hyperhive/gateway/agents.conf
# on the host. The bind-mount at /run/hive-state/ exposes
# that file here. nginx parses `include` at config-load
# time so a reload (triggered by c0re via systemd-run
# after each agents.conf write) picks up new or removed
# agents without a nixos-rebuild. nginx's longest-prefix-
# match rule ensures `/agent/<name>/` from this file beats
# 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;
}
''}
'';
};
}
//
# Forge sub-domain vhost. `server_name =
# forge.domain`, proxies all `/` → forgejo. Tuned for
# git: `client_max_body_size 1G`, `proxy_read_timeout 1h`
# (multi-GB clones). SSH stays direct on `forge.sshPort`.
# See `docs/gateway.md`.
lib.optionalAttrs (forgeCfg.enable or false && forgeCfg.behindGateway or false) {
"${forgeCfg.domain}" = vhostTls // {
listen = vhostListen;
locations."/" = {
proxyPass = "http://127.0.0.1:${toString forgeCfg.httpPort}/";
proxyWebsockets = true;
extraConfig = ''
proxy_buffering off;
client_max_body_size 1G;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
'';
};
};
}
//
# Matrix sub-domain vhost. `server_name =
# matrixCfg.gatewayHost`. `/_matrix/*` → tuwunel (CORS *,
# 50M body cap, 1h long-poll timeout). `/` serves
# fluffychat or 404 if GUI off. nginx
# longer-prefix-wins puts `/_matrix/` ahead of `/`.
# See `docs/gateway.md`.
lib.optionalAttrs (matrixCfg.enable && matrixCfg.gatewayHost != null) {
"${matrixCfg.gatewayHost}" = vhostTls // {
listen = vhostListen;
locations = {
"/_matrix/" = {
proxyPass = "http://127.0.0.1:${toString matrixCfg.httpPort}";
proxyWebsockets = true;
extraConfig = ''
proxy_buffering off;
client_max_body_size 50M;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
add_header Access-Control-Allow-Origin *;
'';
};
}
// lib.optionalAttrs (matrixCfg.gui.enable) (
{
# fluffychat at sub-domain root, SPA-fallback via
# the Accept-header `$matrix_spa_target` map.
"/" = {
alias = "${matrixCfg.gui.package}/";
extraConfig = ''
try_files $uri $uri/ $matrix_spa_target =404;
'';
};
}
// lib.optionalAttrs (hyperhiveDomain != null) {
# FluffyChat boot-config pre-fill so the client's
# `.well-known/matrix/client` lookup hits the
# right delegation endpoint.
"= /config.json" = {
extraConfig = ''
default_type application/json;
return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}';
'';
};
}
)
// lib.optionalAttrs (!matrixCfg.gui.enable) {
"/" = {
return = "404";
};
};
};
};
};
# Hive-internal DNS resolver, co-located in the
# gateway container — single
# front-door for both DNS and HTTP, saves a sibling
# container. Listens on the bridge interface from
# `services.hyperhive.network`; authoritative for the hive
# domain + sub-domains, forwards everything else upstream.
# No-op when `network.enable = false`.
services.dnsmasq = lib.mkIf networkCfg.enable {
enable = true;
# Don't substitute the container's /etc/resolv.conf —
# the gateway uses the host's resolver for its own
# outbound traffic; dnsmasq is purely for incoming
# queries from agent containers.
resolveLocalQueries = false;
settings = {
# Bind only on the bridge interface (and lo for
# health-checks). Outside hosts can't even see the
# listener.
interface = [
networkCfg.bridgeName
"lo"
];
bind-interfaces = true;
port = 53;
# Don't read /etc/resolv.conf — we control upstream
# explicitly to dodge dependency on the gateway
# container's own resolver state.
no-resolv = true;
server = networkCfg.upstreamDns;
# Hive authoritative records — answer queries for the
# hive domain + its sub-domains with the bridge IP
# (where nginx is reachable from container netns once
# #14 lands; today it's the host loopback alias and
# works in either shape).
#
# The forge / matrix entries are redundant in the
# common case where `forge.domain` /
# `matrix.gatewayHost` are sub-domains of
# `hyperhive.domain` — dnsmasq's `/<domain>/` rule
# already matches sub-domains.
# Kept explicit because operators can override either
# to a cross-domain hostname (e.g.
# `forge.domain = "git.example.com"`); listing them
# explicitly keeps that case routed without needing
# an extra config block.
address = [
"/${hyperhiveDomain}/${networkCfg.bridgeIp}"
]
++ lib.optional (
(forgeCfg.enable or false) && (forgeCfg.behindGateway or false)
) "/${forgeCfg.domain}/${networkCfg.bridgeIp}"
++ lib.optional (
matrixCfg.enable && matrixCfg.gatewayHost != null
) "/${matrixCfg.gatewayHost}/${networkCfg.bridgeIp}";
};
};
};
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [ cfg.port ] ++ lib.optional cfg.selfSignedTls cfg.httpsPort;
};
# `/etc/hosts` entries for local dev — bare hive domain + any
# sub-domain modules that are on. `lib.unique` dedupes if any
# sub-domain happens to equal another. See `docs/gateway.md`
# ("Local dev").
networking.hosts = lib.mkIf (cfg.localHostsEntry && hyperhiveDomain != null) {
"127.0.0.1" = lib.unique (
[ hyperhiveDomain ]
++ lib.optional (
(config.services.hyperhive.forge.enable or false)
&& (config.services.hyperhive.forge.behindGateway or false)
) config.services.hyperhive.forge.domain
++ lib.optional (matrixCfg.enable && matrixCfg.gatewayHost != null) matrixCfg.gatewayHost
);
};
};
}