diff --git a/docs/networking/gateway.md b/docs/networking/gateway.md index ce28b694..81250056 100644 --- a/docs/networking/gateway.md +++ b/docs/networking/gateway.md @@ -22,7 +22,7 @@ You rarely switch it on yourself. `gateway.enable` defaults to off, and every mo | `/metrics` | the queue exporter's `/metrics` on loopback, behind an authelia `auth_request`; every other path, `/` included, returns `403`, so `/varz` and `/connz` are never reachable | `swarm-nats.nix`, the queue's host | | `grafana.`, `metrics.`, `logs.`, `otel.`, `bao.` | the matching swarm service | that service's module → [`swarm/services.md`](../swarm/services.md) | -A service module declares its vhost as `services.hyperhive.gateway.vhosts.`: the locations, `forceSSL` and any `extraConfig` it needs. The gateway renders it into `services.nginx.virtualHosts.` and adds the `listen` set, the certificate for that name and the [security headers](#security-headers) (ahead of the vhost's own `extraConfig`), and drops `addSSL` from a vhost that sets `forceSSL`. The eval fails for a vhost that sets `listen`, `addSSL`, `onlySSL`, `enableACME`, `useACMEHost`, `sslCertificate` or `sslCertificateKey` itself, and for any vhost declared while `gateway.enable` is false. A name with a certificate from outside the gateway's leaves (the queue's) sets `certificate = { cert; key; }` instead. +A service module declares its vhost as `services.hyperhive.gateway.vhosts.`: the locations, `forceSSL` and any `extraConfig` it needs. The gateway renders it into `services.nginx.virtualHosts.` and adds the `listen` set, the certificate for that name and the [security headers](#security-headers) (ahead of the vhost's own `extraConfig`), and drops `addSSL` from a vhost that sets `forceSSL`. The eval fails for a vhost that sets `listen`, `addSSL`, `onlySSL`, `enableACME`, `useACMEHost`, `sslCertificate` or `sslCertificateKey` itself, and for any vhost declared while `gateway.enable` is false. A name with a certificate from outside the gateway's leaves (the queue's) sets `certificate = { cert; key; }` instead. `hive-gateway/vhosts.nix` declares the gateway's own vhosts (`_`, the dashboard and the hive domain) the same way; the catch-all sets `securityHeaders = false` because it sends no response, and the gateway renders a vhost's `default` at `mkDefault` priority. **The hive's own vhosts**: the dashboard's, named `.hives.` (`swarm.dashboardDomain`), and one named for the hive domain. They exist only on a host that runs a hive (`deploy.hive-controller.enable`); a host without a hive has none of these rows: diff --git a/nix/host-modules/hive-gateway/declared-vhosts.nix b/nix/host-modules/hive-gateway/declared-vhosts.nix index 46136354..47d333f7 100644 --- a/nix/host-modules/hive-gateway/declared-vhosts.nix +++ b/nix/host-modules/hive-gateway/declared-vhosts.nix @@ -32,6 +32,7 @@ let own = removeAttrs declared [ "_module" "certificate" + "securityHeaders" ]; base = if declared.certificate != null then @@ -48,7 +49,13 @@ let // own // { listen = cfg.lib.listen; - extraConfig = cfg.lib.securityHeaders + (own.extraConfig or ""); + extraConfig = + lib.optionalString declared.securityHeaders cfg.lib.securityHeaders + (own.extraConfig or ""); + } + // lib.optionalAttrs (own ? default) { + # At `mkDefault` priority, so an operator can turn this vhost's + # `default` off with a plain definition. + default = lib.mkDefault own.default; }; in { diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index 2a2e7256..012e4e84 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -114,7 +114,7 @@ let caBundle = "${config.services.hyperhive.deploy.hive-controller.tls.stateDir}/trust-bundle.pem"; }; - nginxTree = import ./vhosts.nix { + ownVhosts = import ./vhosts.nix { gwLib = cfg.lib; inherit (config.services.hyperhive.swarm) dashboardDomain; autheliaDomain = config.services.hyperhive.swarm.authelia.domain; @@ -145,6 +145,9 @@ in # file's `let` and are not option surface. services.hyperhive.gateway.lib = vhostLib; + # The gateway's own vhosts, rendered the way every service's are. + services.hyperhive.gateway.vhosts = ownVhosts.vhosts; + assertions = [ { assertion = !(cfg.tls.acme.enable && cfg.tls.certDir != null); @@ -392,7 +395,6 @@ in recommendedTlsSettings = true; recommendedGzipSettings = true; recommendedOptimisation = true; - inherit (nginxTree) virtualHosts; # Route the error log through syslog so each line keeps its own # severity. The module default is `stderr`, and systemd files that diff --git a/nix/host-modules/hive-gateway/options.nix b/nix/host-modules/hive-gateway/options.nix index 446413d6..ecf9994c 100644 --- a/nix/host-modules/hive-gateway/options.nix +++ b/nix/host-modules/hive-gateway/options.nix @@ -142,6 +142,15 @@ in # Every other attribute is an nginx vhost option, passed through # to `services.nginx.virtualHosts.` as written. freeformType = lib.types.attrsOf lib.types.anything; + options.securityHeaders = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Whether the gateway puts its security headers ahead of this + vhost's `extraConfig`. Off only for a vhost that answers no + request with a response, such as the catch-all. + ''; + }; options.certificate = lib.mkOption { type = lib.types.nullOr ( lib.types.submodule { @@ -178,7 +187,10 @@ in - the TLS attributes for that name (`gateway.lib.tlsFor`, or `certificate` below), with `addSSL` dropped when the vhost sets `forceSSL`; - - `securityHeaders`, placed ahead of the vhost's own `extraConfig`. + - `securityHeaders`, placed ahead of the vhost's own `extraConfig` + unless the vhost sets `securityHeaders = false`; + - `default`, when a vhost sets it, at `mkDefault` priority, so an + operator's own `default` can win. A vhost that sets `listen`, `addSSL`, `onlySSL`, `enableACME`, `useACMEHost`, `sslCertificate` or `sslCertificateKey` itself fails diff --git a/nix/host-modules/hive-gateway/vhosts.nix b/nix/host-modules/hive-gateway/vhosts.nix index 601faa9a..49d19291 100644 --- a/nix/host-modules/hive-gateway/vhosts.nix +++ b/nix/host-modules/hive-gateway/vhosts.nix @@ -1,18 +1,17 @@ -# nginx virtual-host tree for the **hive's own** gateway surface: the `_` -# default server, which answers unmatched `Host` with a bare 444; the -# dashboard's vhost (`swarm.dashboardDomain`), which carries the dashboard -# behind authelia, per-agent routing and the swagger UI; and the vhost -# named for the hive domain, which carries matrix discovery, the legacy -# `/matrix/*` redirect and a redirect of everything else to the -# dashboard's name. +# The gateway's own vhosts, as `services.hyperhive.gateway.vhosts` +# declarations (rendered by ./declared-vhosts.nix): the `_` default server, +# which answers unmatched `Host` with a bare 444; the dashboard's vhost +# (`swarm.dashboardDomain`), which carries the dashboard behind authelia, +# per-agent routing and the swagger UI; and the vhost named for the hive +# domain, which carries matrix discovery, the legacy `/matrix/*` +# redirect and a redirect of everything else to the dashboard's name. # # Scope: a swarm service declares its own vhost and dns name in its own -# module, as `services.hyperhive.gateway.vhosts.` (rendered by -# ./declared-vhosts.nix). That keeps a service's gateway surface next to +# module, the same way. That keeps a service's gateway surface next to # the service, and keeps this file to the surface the hive itself serves. # # Pure function — called from ./default.nix with the outer-scope config -# values as arguments; returns `{ virtualHosts }`. +# values as arguments; returns `{ vhosts }`. { lib, cfg, # services.hyperhive.gateway @@ -28,14 +27,10 @@ gwLib, # `services.hyperhive.gateway.lib` — ./vhost-lib.nix's kit, via the option }: let - # The kit's three members, bound to the names this file already used. # Read through `gwLib` (the published option) rather than importing - # ./vhost-lib.nix directly: a service module declaring its own vhost - # gets the same object, so "the forge vhost listens where the gateway - # listens" is true by construction and not by review. + # ./vhost-lib.nix directly, so the headers on the matrix discovery + # locations are the ones every other vhost gets. inherit (gwLib) securityHeaders; - vhostListen = gwLib.listen; - vhostTlsFor = gwLib.tlsFor; # Public-facing scheme + port-suffix for URLs the gateway # mints into responses (well-known JSON, the deprecated @@ -238,7 +233,7 @@ let }; in { - virtualHosts = { + vhosts = { # The catch-all, and now *only* a catch-all: anything whose `Host` # matches no vhost gets an immediate 444 (close without a response) # rather than being served the hive's dashboard. @@ -247,21 +242,22 @@ in # so it can never match a request by name — it serves traffic solely # by being `default_server`. # - # ⚠️ It still needs TLS attrs. It listens on the https port, so a + # ⚠️ It still gets TLS attrs from the gateway. It listens on the https port, so a # client connecting by IP completes a TLS handshake *before* nginx # can look at `Host` and reject it; with no cert the vhost fails to # load. The certificate will not match what such a client asked for # — that is unavoidable and correct: nothing can present a valid # cert for a name the operator never issued one for. # - # `mkDefault` per the operator: an operator with their own + # The gateway renders `default` at `mkDefault` priority: an operator with their own # `default = true` vhost must be able to win without fighting # priorities. The assertion in ./default.nix catches the case where # they add one *without* turning this off, which nginx would # otherwise only report at runtime as a failed config test. - "_" = (vhostTlsFor "_") // { - listen = vhostListen; - default = lib.mkDefault true; + "_" = { + default = true; + # A response to attach headers to is exactly what this vhost refuses. + securityHeaders = false; extraConfig = '' return 444; ''; @@ -281,11 +277,9 @@ in # # `forceSSL` for the reason ../swarm-ui.nix gives: authelia answers the # subrequest for an `http://` target with 400, which `auth_request` - # cannot read. `removeAttrs` because nixos asserts on a vhost declaring - # both. - ${dashboardDomain} = (builtins.removeAttrs (vhostTlsFor dashboardDomain) [ "addSSL" ]) // { + # cannot read. + ${dashboardDomain} = { forceSSL = true; - listen = vhostListen; locations = agentLocations // dashboardProxyLocation // swaggerUiLocations // autheliaLocation; # Per-agent location blocks, generated at runtime by # hive-c0re and written to /var/lib/hive-gateway/conf/agents.conf @@ -295,7 +289,7 @@ in # agents without a nixos-rebuild. nginx's longest-prefix- # match rule ensures `/agent//` from this file beats # the `/agent/` catch-all above. - extraConfig = securityHeaders + '' + extraConfig = '' include /var/lib/hive-gateway/conf/agents.conf; ''; }; @@ -303,8 +297,7 @@ in # The hive domain's own vhost: matrix discovery, which federation # reads off the server name, and a redirect of every other path to the # dashboard's name, so a bookmarked dashboard or agent link still lands. - ${hyperhiveDomain} = (vhostTlsFor hyperhiveDomain) // { - listen = vhostListen; + ${hyperhiveDomain} = { locations = matrixRedirectLocations // wellKnownLocations @@ -313,7 +306,6 @@ in return 301 ${publicScheme}://${dashboardDomain}${publicPortSuffix}$request_uri; ''; }; - extraConfig = securityHeaders; }; }; } diff --git a/nix/module-eval/core-toggle.nix b/nix/module-eval/core-toggle.nix index 00e0c0a4..d782051f 100644 --- a/nix/module-eval/core-toggle.nix +++ b/nix/module-eval/core-toggle.nix @@ -430,6 +430,55 @@ let && lib.elem "h1.hives.t.local" allLocal.services.hyperhive.gateway.localNames && allLocal.systemd.services.hive-c0re.environment.HYPERHIVE_DASHBOARD_DOMAIN == "h1.hives.t.local"; } + { + # The gateway's own three vhosts are declared under `gateway.vhosts` like + # a service's, so the gateway's listen set and issuer choice reach them + # by the same path. The catch-all keeps its bare `return 444;` (no + # headers: it sends no response) and the dashboard keeps its exact + # location set. + name = "the gateway's own vhosts render with its listen set, issuer choice, headers and locations"; + ok = + let + gw = allLocal.services.hyperhive.gateway; + vh = allLocal.services.nginx.virtualHosts; + listenOf = map (l: { + inherit (l) addr port; + ssl = l.ssl or false; + }); + sameListen = v: listenOf v.listen == listenOf gw.lib.listen; + sameCert = name: v: v.sslCertificate == (gw.lib.tlsFor name).sslCertificate; + catchAll = vh."_"; + dash = vh."h1.hives.t.local"; + hive = vh."h1.t.local"; + in + sameListen catchAll + && sameCert "_" catchAll + && catchAll.addSSL + && catchAll.default + && catchAll.extraConfig == "return 444;\n" + && sameListen dash + && sameCert "h1.hives.t.local" dash + && dash.forceSSL + && !(dash.addSSL or false) + && dash.extraConfig == gw.lib.securityHeaders + "include /var/lib/hive-gateway/conf/agents.conf;\n" + && + lib.attrNames dash.locations == [ + "/" + "/agent/" + "/api/" + "/api/docs/" + "/health/" + "= /__hive_agent_not_found" + "= /__hive_agent_unreachable" + "= /__hive_authelia" + "= /api/docs" + ] + && sameListen hive + && sameCert "h1.t.local" hive + && hive.addSSL + && hive.extraConfig == gw.lib.securityHeaders + && hive.locations ? "/"; + } { # The file held bcrypt hashes at 0644; a host carrying one from before # gets it deleted, and nothing names it for nginx.