`swarm.*` is what a hive needs to be a *client* of the swarm. For the
controller that is the quick-links it publishes, the client id it is
registered under, how stale a snapshot may read, and its packages. Where
its socket lives and where the three credentials it reads sit are
decisions of the machine running it, so socketPath, forgeTokenFile,
authBridgeUrl and queue.clientSecretFile move to
`deploy.swarm-controller.*`.
⚠️ `deploy.swarm-controller`, not `deploy.hive-controller`. Both exist on
main — the latter is hive-c0re's namespace, where `tls.*` lives — and a
definition on the wrong one lands on a live unrelated option instead of
failing. The shim table is anchored on both sides for that reason.
`queue.clientSecretFile` is a nested field, not a top-level option: it
moves out of `queue` and leaves natsUrl and tokenEndpoint behind, so the
far side needs `deploy.swarm-controller.queue.clientSecretFile`. That is
the `forge.sso` split shape, applied again rather than reinvented — the
endpoints a client dials are swarm-wide, the secret beside them is a path
on one host.
local-defaults.nix set that secret from INSIDE
`config.services.hyperhive.swarm = { ... }`, where a bare `controller.`
prefix means `swarm.controller`. Left there it would still resolve —
through the rename — and warn on every evaluation of a single-host swarm,
which is the same defect an earlier slice fixed for the matrix module. It
moves out into its own `deploy` statement beside the controller's
`enable`. `queue.natsUrl` stays bare: it is a stayer.
swarm-ui.nix read `socketPath` through its own `controllerCfg` alias.
With that repointed the binding had no reader left, so it goes. Unlike
the dead `natsCfg` an earlier slice removed, this one was live until this
commit — the move orphaned it. `deployCfg` was already bound there.
Prose that named a moved option by its full path is requalified: the
assertion message for the queue secret (operator-facing, and inside
`config` where an options-block sweep would miss it) and
`swarm-controller/README.md`'s socket-path line. Left bare on purpose:
`docs/swarm/README.md` and the README's own "never point `socketPath` at
a directory that carries anything else" both name the FEATURE, not a
path.
module-eval configures a hive through all four OLD paths and asserts a
rendered effect for EACH of them — the unit's socket env, its auth-bridge
env, and both credential paths in `LoadCredential`. Asserting all four
rather than one means a rename that resolves but stops reaching the
module is caught per-option, not only where a single assertion happens to
look.
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
|
|
cfg = config.services.hyperhive.swarm.ui;
|
|
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 `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;
|
|
|
|
# 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.
|
|
'')
|
|
];
|
|
|
|
options.services.hyperhive.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 = "${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:${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;
|
|
'';
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|