Same rule as the four movers before it. What makes this one different is that `package` was the LAST option under `services.hyperhive.swarm.ui` — `enable` moved in an earlier slice — so the namespace now holds nothing and survives only as its two rename entries. Two consequences worth naming: flake.nix set `swarm.ui.package` directly. Left alone the shim would have kept it working, which is exactly the trap: this repo's own flake would then be setting the option through its own deprecation shim and warning on every eval. It is repointed here. Four sibling assignments in that file name movers this commit does not touch; they move with theirs. The `cfg` alias is deleted. With the one code reader repointed it bound nothing but an empty attrset — which still evaluates, so nothing would have failed; it would just have sat there implying `swarm.ui` still held something. Three comments naming `cfg.package` are repointed with it. `uiOldPath` sets both old paths, so dropping either rename fails the eval. Its case reads the vhost's rendered `root` rather than the option, because that is the half a resolving-but-unwired shim would break. Not touched: swarm-ui.nix's description says its default is wired "the same way `swarm.controller.package` is", which is true until the controller moves. It belongs to that commit, not this one. Refs #3772.
311 lines
15 KiB
Nix
311 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
|
|
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/`, `/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;
|
|
|
|
# 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.nix`'s
|
|
# `domainBase`.
|
|
apex = if swarmCfg.domain == null then "swarm.invalid" else swarmCfg.domain;
|
|
in
|
|
{
|
|
# `enable` moved to `services.hyperhive.deploy.swarm-ui.enable` — see
|
|
# ./deploy.nix, where it still derives from the controller's own deploy
|
|
# toggle for the same reason. `domain` is gone too: 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` no longer exists as a namespace. `enable`
|
|
# moved in an earlier slice and `package` in this one, and those were its
|
|
# only two options — so both paths survive purely as renames 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 `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 && deployCfg.swarm-ui.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 = 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 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.
|
|
'';
|
|
}
|
|
];
|
|
|
|
# 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 = [ apex ];
|
|
|
|
# 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."${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}:";
|
|
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:${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` 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;
|
|
'';
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|