Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/host-modules/swarm-ui.nix
atlas 92559af8e7 gateway: serve the hive dashboard on <hive>.hives.<swarm> behind authelia, drop htpasswd
The dashboard, its API, the swagger UI and the agent routes move to their
own vhost, `swarm.dashboardDomain` = `<hive>.hives.<swarm.domain>`, under
the swarm domain so authelia's session cookie reaches it. `/`, `/api/` and
`/api/docs/` ask authelia through `auth_request`, and authelia's
`operatorsOnly` rule for each hive's dashboard name lets only the admins
group in. The hive domain keeps matrix discovery and redirects every other
path to the new name.

The name rides as a SAN on the hive's own gateway leaf: the per-hive bao
role allows it, and hive-tls asks for it and checks coverage.

HTTP Basic auth is gone: the htpasswd writer and its test, the
`hivectl gateway` verbs and host-socket requests, the `gateway.auth`
options (now a removed-option error), the unauthorized page and the bcrypt
dependency. tmpfiles deletes a leftover gateway.htpasswd, which held
bcrypt hashes at 0644.

c0re hands out dashboard and agent URLs on the new name
(HYPERHIVE_DASHBOARD_DOMAIN), and the swarm controller's hive roster
carries each hive's dashboard name for the swarm UI's hive list.

Closes #3469
2026-10-15 21:44:27 +02:00

342 lines
16 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
# `swarm.dashboardDomain`) — 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
deployCfg = config.services.hyperhive.deploy;
gatewayCfg = config.services.hyperhive.gateway;
autheliaCfg = config.services.hyperhive.swarm.authelia;
# 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 `deployCfg.swarm-ui.package`'s own.
# Detection + CSS-generation is shared (`./stylix-theme.nix`).
#
# No package-copy derivation: this vhost has exactly one location
# serving `deployCfg.swarm-ui.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/docs/`; `/api/` takes `authRequest` with its own 401
# handler) 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.
authRequest = ''
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;
'';
loginRedirect = "https://${autheliaCfg.domain}/?rd=$target_url";
swarmAuthRequest = ''
${authRequest}
error_page 401 =302 ${loginRedirect};
'';
swarmCfg = config.services.hyperhive.swarm;
hiveDomain = config.services.hyperhive.domain;
# The swarm apex this UI answers on. Total on a null domain (`.invalid`,
# RFC 2606) so `hive-network.nix`'s required-domain assertion is what fires,
# rather than a coercion error from here — same idiom as
# `swarm-otel-service.nix`'s `domainBase`.
apex = if swarmCfg.domain == null then "swarm.invalid" else swarmCfg.domain;
in
{
# No `enable` of its own: the UI is on wherever
# `services.hyperhive.deploy.swarm-controller.enable` is, because it reaches
# that daemon over its unix socket. There is no `domain` option: the UI
# answers on the swarm apex, which the swarm already declares once.
imports = [
(lib.mkRemovedOptionModule [ "services" "hyperhive" "swarm" "ui" "domain" ] ''
The swarm UI answers on services.hyperhive.swarm.domain and nothing
else. It shares that name with the swarm-controller it fronts — they
are one service to a reader and to a certificate — so a second option
only ever created two spellings of one address.
Removed rather than aliased: a config still setting it was pinning a
name the controller's own public URL never used, and silently keeping
the alias would preserve exactly that mismatch.
'')
];
# ⚠️ `services.hyperhive.swarm.ui` is not a namespace. Its `package` lives
# under `deploy.swarm-ui`, and the `swarm.ui` paths survive purely as
# rename/removal entries in ./deploy.nix.
options.services.hyperhive.deploy.swarm-ui.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 `deploy.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 deployCfg.swarm-controller.enable {
assertions = [
{
# The hive domain has its own vhost (matrix discovery, the redirect
# to the dashboard). 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 swarm UI is
# sometimes a redirect", which is far harder to read than an
# eval failure naming both options.
assertion = apex != hiveDomain;
message = ''
services.hyperhive.swarm.domain (${apex}) must differ from
services.hyperhive.domain (${hiveDomain}) — the hive domain is
already served by the gateway's own vhost for it (matrix
discovery, the redirect to the dashboard), 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.
'';
}
];
# 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:admins` 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 = [ apex ];
# Gateway only — static files served by nginx, no container of its
# own and so nothing to resolve.
services.hyperhive.gateway.enable = lib.mkDefault true;
# 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.
#
# ⚠️ `auth_request` answers "is there a session", not "is this an
# operator". The operator-only part is authelia's `access_control`
# rules (`operatorsOnly` in ./swarm-authelia.nix): `group:admins`, then
# a `deny` for everyone else. Agents have authelia accounts of their
# own, and without those rules 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."${apex}" =
(builtins.removeAttrs (gatewayCfg.lib.tlsFor apex) [ "addSSL" ])
// {
forceSSL = true;
listen = gatewayCfg.lib.listen;
extraConfig = gatewayCfg.lib.securityHeaders;
locations = {
"/" = {
root = "${deployCfg.swarm-ui.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 `deployCfg.swarm-ui.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:${deployCfg.swarm-controller.socketPath}:";
# `Remote-User` from authelia's answer, set here so it replaces
# any the client sent: swarm-controller takes it as the sender of
# the text the operator types to an agent (`agent_input.rs`).
# A refused caller that sent an `Authorization` header (`swarmctl
# --swarm`) gets the 401 itself; a browser still gets the login.
# The bearer token stops here and never reaches the controller.
extraConfig = ''
${authRequest}
error_page 401 = @swarm_api_unauthorized;
auth_request_set $authelia_user $upstream_http_remote_user;
proxy_set_header Remote-User $authelia_user;
proxy_set_header Authorization "";
# The SSE streams (terminal, agent state, subagent terminal)
# must not sit in nginx's buffer. The controller's 15s
# keep-alive keeps them under nginx's 60s default read timeout.
proxy_buffering off;
'';
};
"@swarm_api_unauthorized".extraConfig = ''
if ($http_authorization) {
return 401;
}
return 302 ${loginRedirect};
'';
# ⚠️ THE ONE PROXIED LOCATION ON THIS VHOST WITH NO `auth_request`,
# 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:${deployCfg.swarm-controller.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` tells the
# browser nothing about *who* (only `/api/` passes it on, to the
# controller), 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;
'';
};
};
};
};
}