From 030eef09484742d021564d15509cbf7ef1d2be8c Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 13 Aug 2026 15:24:21 +0200 Subject: [PATCH] refactor(3202): the swarm UI declares its own vhost and dns name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last of the four. The vhost, its `auth_request` block and the swarm apex's dns record move into swarm-ui.nix; vhosts.nix drops `uiCfg`, `controllerCfg` and `autheliaCfg` and is now 259 lines of hive surface with no swarm service in it. Also collapses a THIRD copy of the per-service list. `networking.hosts` restated every service's name with its own copy of that service's guard, after the vhosts and the dnsmasq records had each done the same. It asks the same question — which names does this host answer for — so it now reads the same answer: a service added later lands in /etc/hosts with no edit, and cannot land there under a different condition than it used for DNS. The `forceSSL`-not-`addSSL` comment travels intact: it records that authelia answers an http auth subrequest with 400 and nginx's auth_request only understands 2xx/401/403, so the scheme is load-bearing for this vhost and no other. --- nix/host-modules/hive-gateway/default.nix | 28 ++--- nix/host-modules/hive-gateway/dnsmasq.nix | 10 -- nix/host-modules/hive-gateway/vhosts.nix | 116 +------------------- nix/host-modules/swarm-ui.nix | 123 ++++++++++++++++++++++ 4 files changed, 134 insertions(+), 143 deletions(-) diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index 5ef0ab9b..b6fe8e03 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -22,9 +22,6 @@ let # same list rather than each deciding what "a swarm service" means. swarmServiceDomains = config.services.hyperhive.swarm.serviceDomains; matrixCfg = config.services.hyperhive.swarm.matrix; - autheliaCfg = config.services.hyperhive.swarm.authelia; - uiCfg = config.services.hyperhive.swarm.ui; - controllerCfg = config.services.hyperhive.swarm.controller; networkCfg = config.services.hyperhive.network; # Dashboard SPA dist, static-served by nginx. @@ -95,9 +92,6 @@ let cfg errorPages matrixCfg - autheliaCfg - uiCfg - controllerCfg hyperhiveDomain dashboardDist swaggerUiTheme @@ -379,7 +373,6 @@ in lib cfg networkCfg - uiCfg hyperhiveDomain ; }; @@ -393,19 +386,18 @@ in ]; }; - # `/etc/hosts` entries for local dev — bare hive domain + any - # sub-domain modules that are on. `lib.unique` dedupes if any - # sub-domain happens to equal another. See `docs/gateway.md` + # `/etc/hosts` entries for local dev — the bare hive domain plus + # every name a service module contributed. See `docs/gateway.md` # ("Local dev"). + # + # This used to restate the per-service list a THIRD time (after the + # vhosts and the dnsmasq records), with its own copy of each + # service's guard. It is the same question — "which names does this + # host answer for" — so it reads the same answer; a service added + # later lands here with no edit, and cannot land here with a + # different condition than it used for DNS. networking.hosts = lib.mkIf cfg.localHostsEntry { - "127.0.0.1" = lib.unique ( - [ hyperhiveDomain ] - ++ lib.optional (config.services.hyperhive.swarm.forge.behindGateway or false - ) config.services.hyperhive.swarm.forge.domain - ++ lib.optional (matrixCfg.enable && matrixCfg.gatewayHost != null) matrixCfg.gatewayHost - ++ lib.optional autheliaCfg.enable autheliaCfg.domain - ++ lib.optional uiCfg.enable uiCfg.domain - ); + "127.0.0.1" = lib.unique ([ hyperhiveDomain ] ++ cfg.localNames); }; }; } diff --git a/nix/host-modules/hive-gateway/dnsmasq.nix b/nix/host-modules/hive-gateway/dnsmasq.nix index bf0d6766..e5de27f5 100644 --- a/nix/host-modules/hive-gateway/dnsmasq.nix +++ b/nix/host-modules/hive-gateway/dnsmasq.nix @@ -9,7 +9,6 @@ lib, cfg, # services.hyperhive.gateway networkCfg, - uiCfg, hyperhiveDomain, }: { @@ -56,15 +55,6 @@ address = [ "/${hyperhiveDomain}/${networkCfg.bridgeIp}" ] - # The swarm UI's name is the swarm APEX by default — a sibling of - # the three above, not a child of anything this resolver already - # answers for, so the `//` rule does not cover it. - # - # Published to agents deliberately (mara: publishing it is fine). - # Reachability is not the access control here: the vhost's - # `auth_request` + authelia's `group:operators` rule are, and an - # agent that resolves the name still cannot open the page. - ++ lib.optional uiCfg.enable "/${uiCfg.domain}/${networkCfg.bridgeIp}" # Names contributed by the modules that own them # (`gateway.localNames`). Same address as everything above — the # bridge IP is the gateway's answer for anything it fronts, and a diff --git a/nix/host-modules/hive-gateway/vhosts.nix b/nix/host-modules/hive-gateway/vhosts.nix index 6f6c79a0..b8166520 100644 --- a/nix/host-modules/hive-gateway/vhosts.nix +++ b/nix/host-modules/hive-gateway/vhosts.nix @@ -8,9 +8,6 @@ lib, cfg, # services.hyperhive.gateway matrixCfg, - autheliaCfg, # services.hyperhive.swarm.authelia - uiCfg, # services.hyperhive.swarm.ui - controllerCfg, # services.hyperhive.swarm.controller hyperhiveDomain, dashboardDist, swaggerUiTheme, # nix/packages/swagger-ui-theme.nix: has index.html + hyperhive-theme.css @@ -36,115 +33,6 @@ let publicPort = cfg.httpsPort; publicPortSuffix = if publicPort == 443 then "" else ":${toString publicPort}"; - # Swarm UI vhost — the swarm's front page, on the swarm apex, 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 - # are getting 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 the reason the - # redirect target and the header set below are copied from a measured - # source rather than from 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. `vhostListen` 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. - # Shared with every swarm-UI-vhost location below (`/`, `/api/`, - # `/api/docs/`) — auth_request does not inherit across sibling - # locations, so each one that should be operator-gated repeats this - # verbatim rather than only the page itself being protected while its - # own API and API docs are reachable unauthenticated. - 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; - ''; - - swarmUiVhost = lib.optionalAttrs uiCfg.enable { - "${uiCfg.domain}" = (builtins.removeAttrs (vhostTlsFor uiCfg.domain) [ "addSSL" ]) // { - forceSSL = true; - listen = vhostListen; - extraConfig = securityHeaders; - locations = { - "/" = { - root = "${uiCfg.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` — see that block's - # comment for why core-equivalent (here, swarm-controller) - # does not also mount its own copy. - "= /api/docs" = { - extraConfig = '' - return 301 /api/docs/; - ''; - }; - "/api/docs/" = { - alias = "${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; - ''; - }; - }; - }; - }; - # `/matrix/*` → 301 → `matrix./$1` (legacy deep-link # shim during the fluffychat sub-domain move). See `docs/gateway.md`. matrixRedirectLocations = @@ -367,7 +255,5 @@ in include /var/lib/hive-gateway/conf/agents.conf; ''; }; - } - - // swarmUiVhost; + }; } diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index 18820162..87c8f8a1 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -13,6 +13,24 @@ }: 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 @@ -93,5 +111,110 @@ in ''; } ]; + + # 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; + ''; + }; + }; + }; }; }