# 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, ... }: let cfg = config.services.hyperhive.swarm.ui; gatewayCfg = config.services.hyperhive.gateway; autheliaCfg = config.services.hyperhive.swarm.authelia; controllerCfg = config.services.hyperhive.swarm.controller; # 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 { options.services.hyperhive.swarm.ui = { enable = lib.mkOption { type = lib.types.bool; default = swarmCfg.controller.enable; defaultText = lib.literalExpression "services.hyperhive.swarm.controller.enable"; example = true; description = '' Serve the swarm UI from this host. Derived from `swarm.controller.enable` rather than from `enableRequiredServices`: the UI is a view onto the controller's state and reaches it over that daemon's unix socket, so the host that runs the controller is the host that can serve the UI. A hive that merely *uses* a swarm has nothing to serve here. ''; }; 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 && cfg.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 ]; # 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; ''; }; # 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; }; # 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/; ''; }; "/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 = "http://127.0.0.1:${toString autheliaCfg.port}/api/authz/auth-request"; extraConfig = '' internal; # 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; ''; }; }; }; }; }