`package` (the daemon build) and `swarmctlPackage` (the operator CLI
installed beside it) are host decisions, so they join the
`deploy.swarm-controller` block that already holds `enable`, the socket
path and the three local-disk credentials.
⚠️ The target namespace is spelled out in the shim comment because
getting it wrong here does not fail. `deploy.hive-controller` also
exists — it is hive-c0re's, carrying `tls.*` and `statusPublish.*`, and
is referenced from four modules. A rename pointing at it would land on
a live, unrelated option and evaluate cleanly. The module already warned
about this above its own deploy block; the warning now sits next to the
entries it governs too.
`swarm-ui.nix`'s package description cited `swarm.controller.package` as
the precedent for "wired from flake.nix rather than defaulted to pkgs".
That was true when written and stops being true here, so it moves with
it — it lives in a module this slice already finished, which is exactly
where a per-module sweep stops looking.
These were the last two `services.hyperhive.swarm.*` writers in
flake.nix; that file now has none.
Riding along, and not a defect fix: `controllerOldPath` spelled `enable`
the NEW way while every other entry used the old one, so six of the
seven controller shims were exercised and the seventh was not. It is
named for testing old paths, so it now uses the old path throughout.
Unlike the nats fixture there was no comment claiming otherwise — this
is coverage the fixture was already shaped to provide.
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 `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 (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;
|
|
'';
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|