# 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; 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 { # `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. What stays here is what the UI IS: its # domain and wiring. options.services.hyperhive.swarm.ui = { 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 && 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 = 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.` / `chat.`, not a # child of anything the resolver already answers for, so the # `//` 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/` 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; ''; }; }; }; }; }