hyperhive/nix/host-modules/hive-gateway/vhosts.nix
atlas 0dc2e6b64f feat(3167): the swarm UI vhost, behind an authelia subrequest
Serves the static bundle on the swarm apex and gates it with
auth_request - the first one in this gateway, everything else being
auth_basic + htpasswd.

Header set measured against the pinned authelia (4.39.20) rather than
copied from an example: X-Original-URL and X-Original-Method are present
as literals and are what the auth-request implementation reads, while
X-Forwarded-Uri does not appear in that binary at all - sending it would
look like configuration and be dead weight. The endpoint is
/api/authz/auth-request; /api/verify is the legacy path older examples
show.

auth_request_set captures the return URL BEFORE the error_page jump: in
the 401 handler $request_uri is the internal one, so building the link
there sends the operator back to the auth subrequest rather than the
page they asked for.

Authorisation is the access_control rule from the previous commit, not
this subrequest: auth_request answers 'is there a session'.
2026-08-12 17:40:24 +02:00

526 lines
21 KiB
Nix

# nginx virtual-host tree for the gateway: the `_` default server
# (dashboard, per-agent routing, matrix discovery), the forge, matrix
# and authelia sub-domain vhosts, and the Accept-header SPA map for the
# matrix GUI. Pure function — called from ./default.nix with the
# outer-scope config values as arguments; returns
# `{ virtualHosts, appendHttpConfig }`.
{
lib,
cfg, # services.hyperhive.gateway
forgeCfg,
matrixCfg,
autheliaCfg, # services.hyperhive.swarm.authelia
uiCfg, # services.hyperhive.swarm.ui
hyperhiveDomain,
dashboardDist,
swaggerUiTheme, # nix/packages/swagger-ui-theme.nix: has index.html + hyperhive-theme.css
errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized, ssoUnavailable }
tlsCert,
tlsKey,
svcCert, # swarm-services leaf, for names the hive CA cannot sign
svcKey,
swarmServiceDomains, # which vhosts those are (../swarm.nix derives it)
}:
let
# The gateway always terminates TLS: self-signed is the implicit
# floor when neither `tls.certDir` nor ACME is set, so there is no
# http-only mode. Listen addresses every vhost shares — plain http
# on `cfg.port` plus TLS on `cfg.httpsPort`. See `docs/gateway.md`
# ("TLS modes").
vhostListen = [
{
addr = "0.0.0.0";
port = cfg.port;
}
{
addr = "0.0.0.0";
port = cfg.httpsPort;
ssl = true;
}
];
# nixos `services.nginx.virtualHosts.<name>` ssl attrs merged
# into each vhost. For ACME mode: `enableACME` + `addSSL` —
# NixOS's ACME integration manages the cert lifecycle and sets
# ssl_certificate automatically. For self-signed / certDir:
# explicit cert paths.
vhostTls =
if cfg.tls.acme.enable then
{
addSSL = true;
enableACME = true;
}
else
{
addSSL = true;
sslCertificate = tlsCert;
sslCertificateKey = tlsKey;
};
# TLS attrs for one vhost, by name. A swarm service's name may sit
# outside this hive's domain — and then the hive CA is
# name-constrained out of it, so its vhost must serve the
# swarm-services leaf instead. Everything else keeps the hive leaf.
#
# Only in self-signed mode: with ACME or an operator cert there is a
# single issuer that already covers every name, and a second pair
# would be a cert nobody asked for.
vhostTlsFor =
host:
if !cfg.tls.acme.enable && cfg.tls.certDir == null && builtins.elem host swarmServiceDomains then
{
addSSL = true;
sslCertificate = svcCert;
sslCertificateKey = svcKey;
}
else
vhostTls;
# 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):
# always `https://<host>` (matrix-spec compliance) — the canonical
# 443 elides the port. See `docs/gateway.md` ("Self-signed TLS").
publicScheme = "https";
publicPort = cfg.httpsPort;
publicPortSuffix = if publicPort == 443 then "" else ":${toString publicPort}";
# Security headers added at the server scope on every vhost.
# nginx's add_header inheritance rule: a location that defines its
# own add_header does NOT inherit the server-level ones. Any
# location with its own add_header (e.g. CORS on /.well-known or
# /_matrix/) must repeat the security headers explicitly — see those
# locations below. HTML-serving and proxy locations that carry no
# add_header of their own pick these up from the server scope
# automatically.
hstsDirectives = lib.concatStringsSep "; " (
[ "max-age=${toString cfg.hsts.maxAge}" ]
++ lib.optional cfg.hsts.includeSubDomains "includeSubDomains"
);
securityHeaders = ''
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
${lib.optionalString cfg.hsts.enable ''add_header Strict-Transport-Security "${hstsDirectives}" always;''}
'';
# 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`. Empty attrset when the
# forge isn't behind the gateway.
forgeVhost = lib.optionalAttrs (forgeCfg.behindGateway or false) {
"${forgeCfg.domain}" = (vhostTlsFor forgeCfg.domain) // {
listen = vhostListen;
extraConfig = securityHeaders;
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;
'';
};
};
};
# Authelia sub-domain vhost. `server_name = authelia.domain`, all of
# `/` → authelia. Empty attrset unless THIS host runs the container:
# every hive knows the swarm's `authelia.url`, but only the one
# serving it may claim the name — a client hive declaring this vhost
# would answer for a service it does not run.
#
# ⚠️ The server name must be exactly `autheliaCfg.domain`, not a
# near-miss: authelia validates `authelia_url ⊂ session cookie domain`
# at STARTUP, so a mismatch is a container that refuses to boot rather
# than a login that misbehaves.
#
# ⚠️ And deliberately NO `dashboardAuth` here. That block is the
# gateway's `auth_basic`; applying it to the SSO provider would put
# the login page behind the login mechanism it exists to replace.
autheliaVhost = lib.optionalAttrs autheliaCfg.enable {
"${autheliaCfg.domain}" = (vhostTlsFor autheliaCfg.domain) // {
listen = vhostListen;
extraConfig = securityHeaders;
locations."/" = {
proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/";
proxyWebsockets = true;
extraConfig = ''
proxy_buffering off;
# authelia decides by the ORIGINAL request, not by the hop it
# sees the login redirect and the session cookie's domain
# both derive from these. Without them every request looks
# like it arrived at 127.0.0.1 over plain http.
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# A dead upstream here means "not bootstrapped" far more often
# than "misconfigured proxy", and a bare 502 says the opposite.
proxy_intercept_errors on;
error_page 502 503 504 = /__hive_sso_unavailable;
'';
};
locations."= /__hive_sso_unavailable" = {
extraConfig = ''
internal;
alias ${errorPages.ssoUnavailable};
default_type text/html;
'';
};
};
};
# Swarm UI vhost — the swarm's front page, on the swarm apex, and the
# FIRST `auth_request` anywhere in this gateway (everything else is
# `auth_basic` + htpasswd).
#
# ⚠️ `auth_request` answers "is there a session", not "is this an
# operator". The operator-only part is authelia's `access_control`
# rule (../swarm-authelia.nix) requiring `group:operators` — agents
# are getting authelia accounts of their own, and without that rule a
# session alone would open this page.
#
# ⚠️ Failure mode here is LOCKED OUT, not unprotected: a subrequest
# that wrongly denies takes the whole UI away. That is the reason the
# redirect target and the header set below are copied from a measured
# source rather than from an example.
swarmUiVhost = lib.optionalAttrs uiCfg.enable {
"${uiCfg.domain}" = (vhostTlsFor uiCfg.domain) // {
listen = vhostListen;
extraConfig = securityHeaders;
locations = {
"/" = {
root = "${uiCfg.package}";
extraConfig = ''
auth_request /__hive_authelia;
# Captured BEFORE the error_page jump: inside the 401 handler
# `$request_uri` is the internal one, so building the return
# link there sends the operator back to the auth subrequest
# instead of the page they asked for.
auth_request_set $target_url $scheme://$http_host$request_uri;
error_page 401 =302 https://${autheliaCfg.domain}/?rd=$target_url;
# SPA: any path the bundle routes client-side is served the
# entry document rather than a 404 from the filesystem.
try_files $uri /index.html;
'';
};
# The subrequest itself. `auth-request` is the implementation
# name authelia exposes under `/api/authz/`; `/api/verify` is the
# LEGACY path every older example shows.
#
# Header set measured against the pinned binary (4.39.20), not
# copied: `X-Original-URL` and `X-Original-Method` are present as
# literals and are what this implementation reads —
# `X-Forwarded-Uri` does not appear in it at all, so sending it
# would look like configuration and be dead weight.
"= /__hive_authelia" = {
proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/api/authz/auth-request";
extraConfig = ''
internal;
# A subrequest carries no body, and forwarding one here makes
# authelia read a payload it will never use.
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
'';
};
};
};
};
# 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`. Empty attrset when matrix has no gateway host.
matrixVhost = lib.optionalAttrs (matrixCfg.enable && matrixCfg.gatewayHost != null) {
"${matrixCfg.gatewayHost}" = (vhostTlsFor matrixCfg.gatewayHost) // {
listen = vhostListen;
extraConfig = securityHeaders;
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;
${securityHeaders}
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;
'';
};
}
// {
# FluffyChat boot-config pre-fill so the client's
# `.well-known/matrix/client` lookup hits the
# right delegation endpoint. `domain` is required, so
# this is always present.
"= /config.json" = {
extraConfig = ''
default_type application/json;
return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}';
'';
};
}
)
// lib.optionalAttrs (!matrixCfg.gui.enable) {
"/" = {
return = "404";
};
};
};
};
# `<hive>/matrix/*` → 301 → `matrix.<hive>/$1` (legacy deep-link
# shim during the fluffychat sub-domain move). See `docs/gateway.md`.
matrixRedirectLocations =
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` when set; falls back to direct
# `<hive>:<httpPort>`. CORS `*` per matrix spec. The `m.server`
# port-8448 carve-out is documented inline. See `docs/gateway.md`.
wellKnownLocations = lib.optionalAttrs matrixCfg.enable (
let
clientBaseUrl =
if matrixCfg.gatewayHost != null then
"${publicScheme}://${matrixCfg.gatewayHost}${publicPortSuffix}"
else
"${publicScheme}://${hyperhiveDomain}:${toString matrixCfg.httpPort}";
# `m.server` is NOT a URL: per the matrix server-server spec
# (Resolving Server Names) a delegated host with NO port resolves
# to the federation default 8448 (after the SRV check) — the
# https-implies-443 rule does NOT apply here. So the port must be
# explicit even when it's the HTTPS default; `publicPortSuffix`
# (which drops :443) is right for the client base_url above but
# wrong for federation delegation. Without this, peers federate to
# <gatewayHost>:8448 (closed) while the endpoint actually lives on
# the gateway's 443 vhost. See docs/gateway.md discovery flow.
serverHostPort =
if matrixCfg.gatewayHost != null then
"${matrixCfg.gatewayHost}:${toString publicPort}"
else
"${hyperhiveDomain}:${toString matrixCfg.httpPort}";
in
{
"= /.well-known/matrix/client" = {
extraConfig = ''
default_type application/json;
${securityHeaders}
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 404 + the two internal error-page targets it
# points at. Per-agent `location /agent/<name>/` blocks live in the
# runtime-generated `/var/lib/hive-gateway/conf/agents.conf` (included via
# `extraConfig` on the vhost); nginx longest-prefix-match makes a
# real `/agent/<name>/` beat this catch-all. `internal` keeps the
# error pages reachable only through nginx's error handling.
agentLocations = {
"/agent/" = {
extraConfig = ''
error_page 404 = /__hive_agent_not_found;
return 404;
'';
};
"= /__hive_agent_not_found" = {
extraConfig = ''
internal;
alias ${errorPages.notFound};
default_type text/html;
'';
};
"= /__hive_agent_unreachable" = {
extraConfig = ''
internal;
alias ${errorPages.unreachable};
default_type text/html;
'';
};
};
# Shared auth block — separate locations don't inherit auth_basic, so
# each dashboard location (`/`, `/api/`) needs it or that surface is
# unauthed. `/webhook/` is intentionally excluded: Forgejo cannot
# send HTTP Basic credentials with webhook deliveries, and the HMAC
# secret (`X-Hub-Signature-256`) protects those endpoints instead.
dashboardAuth = lib.optionalString cfg.auth.enable ''
auth_basic "${cfg.auth.realm}";
auth_basic_user_file /var/lib/hive-gateway/conf/gateway.htpasswd;
# `=401` keeps the status 401 so the login dialog shows; the
# internal page explains `hivectl gateway create-user`.
error_page 401 =401 /__hive_auth_unauthorized;
'';
# Dashboard: nginx static-serves the dist, c0re is API-only. Routing
# is by PATH, never content-type. c0re serves exactly three prefixes —
# `/api/` (all dashboard data + actions + the SSE streams), `/webhook/`
# (knowledge push + config-PR approval triggers, HMAC-guarded), and
# `/health/` (liveness + readiness) — so those proxy to c0re and
# everything else serves the dist with an SPA fallback to index.html.
# Path routing is deterministic where an Accept-header split would make
# the SAME url behave differently by content-type (e.g. `/api/state`
# fetched with `Accept: text/html` wrongly getting index.html). A new
# top-level c0re route prefix (beyond /api + /webhook + /health) needs
# a matching location added here.
dashboardProxyLocation = {
"/" = {
root = dashboardDist;
extraConfig = ''
try_files $uri /index.html;
${dashboardAuth}
'';
};
"/api/" = {
proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}";
proxyWebsockets = true;
extraConfig = ''
# off + 1d keep the SSE streams (/api/dashboard/stream,
# /api/build-logs/id/{id}/stream) live.
proxy_buffering off;
proxy_read_timeout 1d;
${dashboardAuth}
'';
};
"/webhook/" = {
# No dashboardAuth here: Forgejo cannot send HTTP Basic credentials
# with webhook deliveries. HMAC (X-Hub-Signature-256) is the auth
# for these endpoints; hive-c0re verifies it in the handler.
proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}";
};
"/health/" = {
# No dashboardAuth here either, for a different reason than
# /webhook/: an external uptime monitor generally can't do
# interactive HTTP Basic. The endpoints themselves are scoped to
# status + warning kind/message (see hive-c0re/src/dashboard/
# health.rs) — no tokens, no agent detail — so exposing them
# unauthenticated isn't a new secret surface.
proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}";
};
};
# Swagger UI: nginx hosts the FULL themed dist (`swaggerUiTheme` —
# vendored Swagger UI + our overlay, see nix/packages/swagger-ui-
# dist.nix + swagger-ui-theme.nix) straight from the store, with NO
# fallback to hive-c0re at all — per the operator's shape: "core
# should not need the swagger ui at all if it is hosted in gateway"
# / "core only hosts the json". Only `/api/openapi.json`
# (the live-generated spec `index.html` fetches; not under this
# prefix) still proxies to c0re via "/api/" below — that's the one
# thing that has to stay dynamic.
#
# Prefix location, not exact-match: wins over "/api/" on plain
# prefix length (no ordering/`=` needed), and now needs to cover
# every file in the tree (bundle.js, maps, favicons, …), not just
# our 2 override files — hive-c0re no longer serves any of this as
# a fallback once its own `utoipa-swagger-ui` mount is removed.
# `= /api/docs` (no trailing slash) issues the same redirect
# `utoipa-swagger-ui`'s router used to: that mount is going away
# too, so nginx has to own it now, or the H0M3 hub's own `/api/docs`
# link (no trailing slash) would 404 once hive-c0re drops the route.
swaggerUiLocations = {
"= /api/docs" = {
extraConfig = ''
return 301 /api/docs/;
'';
};
"/api/docs/" = {
alias = "${swaggerUiTheme}/";
extraConfig = ''
index index.html;
${dashboardAuth}
'';
};
};
in
{
# Accept-header SPA map for the matrix GUI only (see docs/gateway.md
# "SPA fallback"): text/html → index.html, else a sentinel so
# try_files falls through to 404. The dashboard doesn't use an
# Accept-header map — it routes by path (see dashboardProxyLocation).
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 =
matrixRedirectLocations
// wellKnownLocations
// agentLocations
// dashboardProxyLocation
// swaggerUiLocations
// lib.optionalAttrs cfg.auth.enable {
# Internal-only target for the 401 error_page above.
# `internal` prevents direct client access; `alias` serves
# the pre-built HTML from the Nix store.
"= /__hive_auth_unauthorized" = {
extraConfig = ''
internal;
alias ${errorPages.unauthorized};
default_type text/html;
'';
};
};
# Per-agent location blocks, generated at runtime by
# hive-c0re and written to /var/lib/hive-gateway/conf/agents.conf
# on the host — the same machine nginx runs on. nginx parses
# `include` at config-load time so a reload (triggered by c0re
# 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 = securityHeaders + ''
include /var/lib/hive-gateway/conf/agents.conf;
'';
};
}
// forgeVhost
// autheliaVhost
// matrixVhost
// swarmUiVhost;
}