hyperhive/nix/host-modules/swarm-ui.nix
müde 8a0ecb307b gateway: pin the Host header when dialing swarm services by name
verifiedProxyTo (43ae164d) verified TLS but left Host to nixpkgs'
recommendedProxySettings, which sets Host to the CALLING vhost, not
the target. Since every consumer resolves back to this same gateway,
nginx picks the vhost to answer by Host header (not by the SNI
proxy_ssl_name already sends) — so every auth subrequest looped back
into its own vhost's auth_request, recursing until nginx's subrequest
depth limit turned it into a 500, on every domain gated by SSO.

Pin Host (and the rest of the header set nixpkgs' recommended include
would otherwise still be the one to set) inside verifiedProxyTo, and
set recommendedProxySettings = false on each of the four call sites so
nixpkgs' own copy — appended after a location's extraConfig — can't
clobber it back.
2026-08-27 20:04:40 +02:00

324 lines
15 KiB
Nix

# The swarm-level web UI: a static bundle served by the gateway's nginx,
# behind authelia. Distinct from the per-hive dashboard (hive-c0re's, on
# the hive domain) — this one answers for the swarm apex and is the
# operator's view across hives.
#
# Options only. The vhost itself is declared in hive-gateway/vhosts.nix
# alongside forge/matrix/authelia: a service module says *how* it is
# reached, the gateway says *whether this host serves it*.
{
lib,
config,
pkgs,
...
}:
let
cfg = config.services.hyperhive.swarm.ui;
gatewayCfg = config.services.hyperhive.gateway;
autheliaCfg = config.services.hyperhive.swarm.authelia;
controllerCfg = config.services.hyperhive.swarm.controller;
# Same stylix auto-theming `hive-c0re/theme.nix` gives the dashboard —
# this UI was left out of that overlay entirely (an operator with a
# stylix-themed host saw the dashboard in their own colours but this
# UI still on the default Catppuccin palette), because nothing here
# ever served a themed `colors.css` in place of `cfg.package`'s own.
# Detection + CSS-generation is shared (`./stylix-theme.nix`).
#
# No package-copy derivation: this vhost has exactly one location
# serving `cfg.package` (unlike hive-c0re's `servedFrontend`, which
# backs both the dashboard root AND every per-agent gateway route, so
# a single swapped tree covers both) — an `= /static/colors.css`
# exact-match location overriding just that one file, same idiom
# every other single-path override on this vhost already uses
# (`/api/whoami`, `/api/docs`), is simpler than copying the whole
# static tree to change one file inside it. mara, on review: "i
# thought we just swap a css file via nginx config?" — yes, and this
# is that.
stylixTheme = import ./stylix-theme.nix { inherit lib config pkgs; };
inherit (stylixTheme) stylixThemeColors themedColorsCss;
# Repeated verbatim by every location that should be operator-gated
# (`/`, `/api/`, `/api/docs/`) rather than set once on the server:
# nginx's `auth_request` does NOT inherit across sibling locations, so
# setting it only on the page would leave this UI's own API and API
# docs reachable without a session.
swarmAuthRequest = ''
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;
'';
swarmCfg = config.services.hyperhive.swarm;
hiveDomain = config.services.hyperhive.domain;
in
{
options.services.hyperhive.swarm.ui = {
enable = lib.mkOption {
type = lib.types.bool;
default = swarmCfg.controller.enable;
defaultText = lib.literalExpression "services.hyperhive.swarm.controller.enable";
example = true;
description = ''
Serve the swarm UI from this host.
Derived from `swarm.controller.enable` rather than from
`enableRequiredServices`: the UI is a view onto the controller's
state and reaches it over that daemon's unix socket, so the host
that runs the controller is the host that can serve the UI. A
hive that merely *uses* a swarm has nothing to serve here.
'';
};
domain = lib.mkOption {
type = lib.types.str;
default = if swarmCfg.domain == null then "swarm.invalid" else swarmCfg.domain;
defaultText = lib.literalExpression "services.hyperhive.swarm.domain";
example = "swarm.example.com";
description = ''
Host name the swarm UI answers on. Defaults to the swarm apex
itself the swarm's front page is the swarm's name.
An option rather than a hardcoded derivation so a hive can pin a
different name, the same way `swarm.forge.domain` and
`swarm.matrix.gatewayHost` can.
Total on a null swarm domain (`.invalid`, RFC 2606) so the
required-domain assertion is what fires rather than a coercion
error naming this option same reasoning as
`hive-network.nix`'s.
'';
};
package = lib.mkOption {
type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-ui";
description = ''
Static build of the swarm UI. nginx serves this store path
directly there is no server-side component beyond the
controller's own API.
Wired by default from this flake's own package set (see
`flake.nix`), the same way `swarm.controller.package` is. There
is deliberately **no overlay** in this project, so a
`pkgs.swarm-ui` default here would name an attribute that does
not exist on any real deployment.
'';
};
};
config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) {
assertions = [
{
# The `_` default server already answers for the hive domain
# (dashboard, per-agent routes). A second vhost claiming the same
# server_name is not an error to nginx — it picks one and logs a
# conflict — so the failure would surface as "the dashboard is
# sometimes the swarm UI", which is far harder to read than an
# eval failure naming both options.
assertion = cfg.domain != hiveDomain;
message = ''
services.hyperhive.swarm.ui.domain (${cfg.domain}) must differ
from services.hyperhive.domain (${hiveDomain}) the hive
domain is already served by the gateway's default vhost
(dashboard + agent routes), and two vhosts claiming one
server_name silently resolve to whichever nginx picks.
Set services.hyperhive.swarm.domain to a name distinct from
this hive's, or pin swarm.ui.domain explicitly.
'';
}
];
# The swarm UI's own gateway surface. Published to agents on the
# bridge deliberately: reachability is not the access control here —
# the `auth_request` below and authelia's `group:operators` rule
# are, and an agent that resolves the name still cannot open the
# page.
#
# The apex is a SIBLING of `forge.<swarm>` / `chat.<swarm>`, not a
# child of anything the resolver already answers for, so the
# `/<hive domain>/` rule does not cover it and this record is what
# makes the name resolve at all.
services.hyperhive.gateway.localNames = [ cfg.domain ];
# This UI's own swagger docs, always same-origin (`/api/docs/` below)
# so — unlike authelia/matrix/forge's entries — this one needs no
# host name and is never conditional on anything but this module
# being enabled at all. See
# `services.hyperhive.swarm.controller.links`'s description.
services.hyperhive.swarm.controller.links = [
{
label = "API docs";
icon = "🧬";
url = "/api/docs/";
}
];
# The swarm's front page, 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
# have 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 why the
# redirect target and the header set below come from a measured
# source rather than an example.
#
# ⚠️ `forceSSL`, not `addSSL` like every other vhost — not a
# hardening preference, the only way this page works at all.
# authelia answers the auth subrequest for an `http://` target with
# **400**, and nginx's `auth_request` only understands 2xx/401/403,
# so a plain-http visit dies as "auth request unexpected status:
# 400" with no hint a login exists. The shared listen set binds :80,
# so without this the door is open on a port the lock cannot work
# on. Serving forge or matrix over http is merely insecure rather
# than broken, so they keep `addSSL` and the asymmetry stays local
# to the vhost whose correctness depends on the scheme.
# `removeAttrs` because nixos asserts on a vhost declaring both.
services.nginx.virtualHosts."${cfg.domain}" =
(builtins.removeAttrs (gatewayCfg.lib.tlsFor cfg.domain) [ "addSSL" ])
// {
forceSSL = true;
listen = gatewayCfg.lib.listen;
extraConfig = gatewayCfg.lib.securityHeaders;
locations = {
"/" = {
root = "${cfg.package}";
extraConfig = ''
${swarmAuthRequest}
# 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;
'';
};
}
// lib.optionalAttrs (stylixThemeColors != null) {
# Stylix theming (see the `let` block above): overrides just
# this one file from `cfg.package`'s own static root, rather
# than copying the whole tree to change one file inside it —
# nginx resolves the more specific `=` exact match over the
# `/` prefix root above, so this simply doesn't exist (falling
# through to the package's own untouched colors.css) when
# there's no active palette. Same `auth_request` gate as every
# other location here — no reason for this one path to have a
# different failure mode than the page that loads it.
"= /static/colors.css" = {
alias = "${themedColorsCss stylixThemeColors}";
extraConfig = swarmAuthRequest;
};
}
// {
# swarm-controller's whole HTTP surface, including the live
# `/api/openapi.json` spec — proxied untouched (no URI segment
# after the socket path, same "pass the request through as-is"
# shape as the per-hive dashboard's own `/api/` proxy) so the
# path swarm-controller registered a route at is the path
# nginx forwards, no prefix-stripping to keep in sync by hand.
"/api/" = {
proxyPass = "http://unix:${controllerCfg.socketPath}:";
extraConfig = swarmAuthRequest;
};
# ⚠️ THE ONE LOCATION ON THIS VHOST WITH NO `swarmAuthRequest`,
# and that is deliberate rather than an omission.
#
# A forge webhook is a machine POST carrying an HMAC signature
# and no session cookie. An authelia auth-request subrequest
# authenticates a *browser session*; there is nothing here for it
# to check, so guarding this location would not make it safer, it
# would make it permanently unreachable.
#
# What replaces it: swarm-controller verifies the
# `X-Hub-Signature-256` HMAC over the raw body before looking at
# anything else, and answers 401 on any mismatch. That check is
# the access control for this path — see the `webhook` module.
#
# Scoped to `/webhook/forge/` rather than `/webhook/` so the
# carve-out is exactly as wide as the endpoint that justifies it:
# a future `/webhook/<something-else>` does not inherit the
# bypass by living under a shared prefix.
"/webhook/forge/" = {
proxyPass = "http://unix:${controllerCfg.socketPath}:";
};
# Swagger UI: same "nginx hosts the themed dist straight from
# the store, only /api/openapi.json is dynamic" shape as the
# per-hive gateway's `swaggerUiLocations`.
"= /api/docs" = {
extraConfig = ''
return 301 /api/docs/;
'';
};
# Session identity for the header's profile menu: initials
# avatar + "who is this" line. `auth_request` above
# only ever answers yes/no — it never forwards *who* — so the
# frontend has no other way to learn this. Same-origin proxy
# straight to authelia's own `GET /api/user/info`
# (session-cookie authenticated) rather than new
# swarm-controller code: the cookie is already valid here (the
# session cookie's domain is the swarm's, shared across every
# vhost under it — see swarm-authelia.nix), so this is a pure
# pass-through with nothing for a daemon to add.
#
# `=` exact match, not a prefix, so proxy_pass's own URI part
# (`/api/user/info`) REPLACES the matched request URI rather
# than being appended to it — same substitution shape as
# `/__hive_authelia` below, just not `internal` since the
# frontend calls this one directly.
"= /api/whoami" = {
proxyPass = "https://${autheliaCfg.domain}/api/user/info";
# nixpkgs appends its OWN `Host $host` after extraConfig,
# which would override verifiedProxyTo's — see the comment
# on verifiedProxyTo in hive-gateway/vhost-lib.nix.
recommendedProxySettings = false;
extraConfig = ''
${swarmAuthRequest}
${gatewayCfg.lib.verifiedProxyTo autheliaCfg.domain}
'';
};
"/api/docs/" = {
alias = "${gatewayCfg.swaggerUiTheme}/";
extraConfig = ''
index index.html;
${swarmAuthRequest}
'';
};
# 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 = "https://${autheliaCfg.domain}/api/authz/auth-request";
# nixpkgs appends its OWN `Host $host` after extraConfig,
# which would override verifiedProxyTo's — see the comment
# on verifiedProxyTo in hive-gateway/vhost-lib.nix.
recommendedProxySettings = false;
extraConfig = ''
internal;
${gatewayCfg.lib.verifiedProxyTo autheliaCfg.domain}
# 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;
'';
};
};
};
};
}