diff --git a/docs/networking/gateway.md b/docs/networking/gateway.md index 60ff6e72..ce28b694 100644 --- a/docs/networking/gateway.md +++ b/docs/networking/gateway.md @@ -22,6 +22,8 @@ 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. + **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: | URL | upstream | when | diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index d7a51e8e..4ab98ebb 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -261,9 +261,8 @@ in # This service's own gateway surface: the vhost that fronts it and # the name the hive resolver answers for. Declared here rather than # in the gateway so the forge's public face lives with the forge — - # the gateway supplies the primitives (`lib.listen`, `lib.tlsFor`, - # `lib.securityHeaders`) and never needs to know this service by - # name. + # the gateway adds `listen`, the certificate and the security headers + # (`gateway.vhosts`) and never needs to know this service by name. services.hyperhive.gateway.localNames = [ cfg.domain ]; services.hyperhive.gateway.enable = lib.mkDefault true; @@ -274,10 +273,8 @@ in # git: `client_max_body_size 1G`, `proxy_read_timeout 1h` (multi-GB # clones). SSH stays direct on `forge.sshPort`. See # `docs/networking/gateway.md`. - services.nginx.virtualHosts = { - "${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // { - listen = gatewayCfg.lib.listen; - extraConfig = gatewayCfg.lib.securityHeaders; + services.hyperhive.gateway.vhosts = { + "${cfg.domain}" = { locations = { "/" = { proxyPass = "http://127.0.0.1:${toString cfg.httpPort}/"; diff --git a/nix/host-modules/hive-gateway/declared-vhosts.nix b/nix/host-modules/hive-gateway/declared-vhosts.nix new file mode 100644 index 00000000..46136354 --- /dev/null +++ b/nix/host-modules/hive-gateway/declared-vhosts.nix @@ -0,0 +1,85 @@ +# Renders `services.hyperhive.gateway.vhosts` into +# `services.nginx.virtualHosts`: each declared vhost keeps what its module +# wrote and gets the gateway's `listen`, the TLS attributes for its name and +# the security headers. +# +# Its own module rather than part of ./default.nix because the +# "declared while the gateway is off" assertion has to hold when +# `gateway.enable` is false, where ./default.nix defines nothing. +{ lib, config, ... }: +let + cfg = config.services.hyperhive.gateway; + + # What the gateway owns on a vhost. A declared vhost naming one of these + # fails the eval. + gatewayOwned = [ + "listen" + "addSSL" + "onlySSL" + "enableACME" + "useACMEHost" + "sslCertificate" + "sslCertificateKey" + ]; + + offenders = lib.concatMap ( + name: map (attr: "${name}.${attr}") (lib.filter (a: cfg.vhosts.${name} ? ${a}) gatewayOwned) + ) (lib.attrNames cfg.vhosts); + + render = + name: declared: + let + own = removeAttrs declared [ + "_module" + "certificate" + ]; + base = + if declared.certificate != null then + { + sslCertificate = declared.certificate.cert; + sslCertificateKey = declared.certificate.key; + } + else + cfg.lib.tlsFor name; + # nixos asserts on a vhost declaring both `addSSL` and `forceSSL`. + tls = if own.forceSSL or false then removeAttrs base [ "addSSL" ] else base; + in + tls + // own + // { + listen = cfg.lib.listen; + extraConfig = cfg.lib.securityHeaders + (own.extraConfig or ""); + }; +in +{ + config = lib.mkMerge [ + { + assertions = [ + { + assertion = cfg.enable || cfg.vhosts == { }; + message = '' + services.hyperhive.gateway.vhosts declares ${lib.concatStringsSep ", " (lib.attrNames cfg.vhosts)} + while services.hyperhive.gateway.enable is false, so nothing + would serve them. Enable the gateway, or drop the service that + declares the vhost. + ''; + } + { + assertion = offenders == [ ]; + message = '' + services.hyperhive.gateway.vhosts sets attributes the gateway owns: + ${lib.concatStringsSep ", " offenders} + + The gateway sets the listen set and the TLS attributes of every + vhost it fronts. Declare only the vhost's own locations and + options; to serve a certificate the gateway does not pick, set + `certificate` on the vhost. + ''; + } + ]; + } + (lib.mkIf cfg.enable { + services.nginx.virtualHosts = lib.mapAttrs render cfg.vhosts; + }) + ]; +} diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index 19166321..2a2e7256 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -6,7 +6,8 @@ # namespace of its own. # Full vhost map + discovery flow + design rationale in # `docs/networking/gateway.md`. Layout: ./options.nix (option declarations), -# ./vhosts.nix (the nginx virtual-host tree), ./error-pages.nix +# ./vhosts.nix (the gateway's own nginx virtual hosts), ./declared-vhosts.nix +# (renders the vhosts service modules declare under `gateway.vhosts`), ./error-pages.nix # (styled static pages), ./dns.nix + ./dnsmasq.nix (the resolver, which # has an enable of its own because a host can need hive names to resolve # without serving a vhost). @@ -134,6 +135,7 @@ in imports = [ ./options.nix ./dns.nix + ./declared-vhosts.nix ]; config = lib.mkIf cfg.enable { diff --git a/nix/host-modules/hive-gateway/options.nix b/nix/host-modules/hive-gateway/options.nix index 2391d13e..446413d6 100644 --- a/nix/host-modules/hive-gateway/options.nix +++ b/nix/host-modules/hive-gateway/options.nix @@ -136,6 +136,61 @@ in ''; }; + vhosts = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.submodule { + # Every other attribute is an nginx vhost option, passed through + # to `services.nginx.virtualHosts.` as written. + freeformType = lib.types.attrsOf lib.types.anything; + options.certificate = lib.mkOption { + type = lib.types.nullOr ( + lib.types.submodule { + options = { + cert = lib.mkOption { + type = lib.types.str; + description = "Path of the certificate nginx serves for this name."; + }; + key = lib.mkOption { + type = lib.types.str; + description = "Path of the private key for `cert`. A path, never a value."; + }; + }; + } + ); + default = null; + description = '' + A certificate pair to serve instead of the one the gateway + picks for this name (`gateway.lib.tlsFor`). For a name whose + certificate is issued outside the gateway's own leaves. + ''; + }; + } + ); + default = { }; + internal = true; + description = '' + The nginx vhosts that sit behind this gateway, by server name. A + service module declares its own — locations, `forceSSL`, extra + config — and the gateway renders it into + `services.nginx.virtualHosts.` with the parts it owns: + + - `listen`, the gateway's port pair; + - 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`. + + A vhost that sets `listen`, `addSSL`, `onlySSL`, `enableACME`, + `useACMEHost`, `sslCertificate` or `sslCertificateKey` itself fails + the eval: those are the gateway's, and a service that sets them + can bind a port or present a certificate the rest of the gateway + does not. + + A vhost declared while the gateway is off fails the eval too: the + name would otherwise be silently unserved. + ''; + }; + lib = { listen = lib.mkOption { type = lib.types.listOf (lib.types.attrsOf lib.types.raw); @@ -144,11 +199,10 @@ in description = '' Read-only: the `listen` set every vhost in front of this gateway shares (plain http on `port`, TLS on `httpsPort`). - Published so a service module can declare its own vhost - without restating the port pair — a vhost that binds a - different set is reachable on a port the gateway does not - consider its own, which is the kind of drift nobody notices - until one name behaves differently from the rest. + Applied to every vhost declared under `vhosts`, and read by + the gateway's own vhosts, so no vhost binds a different + set — one that did would be reachable on a port the gateway + does not consider its own. ''; }; @@ -163,8 +217,8 @@ in service's: a swarm service's name can sit outside this hive's domain, and the hive leaf's role refuses it, so that vhost must serve the swarm-services leaf while everything else - keeps the hive leaf. A service module calls this instead of - deciding — deciding is how the vhost and the cert stop + keeps the hive leaf. The gateway calls this for every vhost it + fronts — a vhost choosing its own is how the vhost and the cert stop agreeing. ''; }; diff --git a/nix/host-modules/hive-gateway/vhost-lib.nix b/nix/host-modules/hive-gateway/vhost-lib.nix index b9cd9952..54e20a06 100644 --- a/nix/host-modules/hive-gateway/vhost-lib.nix +++ b/nix/host-modules/hive-gateway/vhost-lib.nix @@ -2,18 +2,17 @@ # attrs, and the security headers every vhost in front of this gateway # needs. # -# Split out of ./vhosts.nix so it has one home and two consumers. Today -# only ./vhosts.nix reads it; it is published as -# `services.hyperhive.gateway.lib` (see ./options.nix) so a service -# module can declare its OWN vhost without the gateway having to know -# that service by name. `vhosts.nix` reads the published value rather -# than calling this file directly — one source, not a copy that agrees -# by inspection. +# Split out of ./vhosts.nix so it has one home and several consumers. +# ./vhosts.nix and ./declared-vhosts.nix (which renders the vhosts service +# modules declare under `gateway.vhosts`) read it through +# `services.hyperhive.gateway.lib` (see ./options.nix) rather than calling +# this file directly — one source, not a copy that agrees by inspection. A +# service module reads `verifiedProxyTo`, `errorPages` and `securityHeaders` +# from there for the locations it writes. # # Pure function of the gateway's config + resolved cert paths; returns # an attrset, evaluates no options itself. Everything here is *gateway* -# knowledge — which port pair to bind, which issuer covers a name — and -# stays here even once the service vhosts move out to their own modules. +# knowledge — which port pair to bind, which issuer covers a name. { lib, cfg, # services.hyperhive.gateway diff --git a/nix/host-modules/hive-gateway/vhosts.nix b/nix/host-modules/hive-gateway/vhosts.nix index 8cbf56fe..601faa9a 100644 --- a/nix/host-modules/hive-gateway/vhosts.nix +++ b/nix/host-modules/hive-gateway/vhosts.nix @@ -7,10 +7,9 @@ # dashboard's name. # # Scope: a swarm service declares its own vhost and dns name in its own -# module, built from the kit this file also consumes -# (`services.hyperhive.gateway.lib`, ./vhost-lib.nix). That keeps a -# service's gateway surface next to the service, and keeps this file to -# the surface the hive itself serves. +# module, as `services.hyperhive.gateway.vhosts.` (rendered by +# ./declared-vhosts.nix). 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 }`. diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 8dc158ce..bc91a1f5 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -477,10 +477,8 @@ in # be served at the *server name*, which is the hive domain — it is # the hive answering "where is my homeserver", not the homeserver # answering for itself. - services.nginx.virtualHosts = lib.optionalAttrs (cfg.gatewayHost != null) { - "${cfg.gatewayHost}" = (gatewayCfg.lib.tlsFor cfg.gatewayHost) // { - listen = gatewayCfg.lib.listen; - extraConfig = gatewayCfg.lib.securityHeaders; + services.hyperhive.gateway.vhosts = lib.optionalAttrs (cfg.gatewayHost != null) { + "${cfg.gatewayHost}" = { locations = { "/_matrix/" = { # No URI after the socket, so tuwunel gets the request URI as the diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 43e26110..3cdab389 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -800,9 +800,7 @@ in # # ⚠️ And deliberately NO `auth_request` here: it would put the login # page behind the login it serves. - services.nginx.virtualHosts."${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // { - listen = gatewayCfg.lib.listen; - extraConfig = gatewayCfg.lib.securityHeaders; + services.hyperhive.gateway.vhosts."${cfg.domain}" = { locations."/" = { proxyPass = "http://127.0.0.1:${toString cfg.port}/"; proxyWebsockets = true; diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index 06e0c8a5..d81b136d 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -2716,40 +2716,35 @@ in # netns), so no port opens anywhere. `auth_request` only proves a # session; restricting it to `admins` is ./swarm-authelia.nix's rule # for this name. `forceSSL` because authelia answers a plain-http - # subrequest with 400, and `removeAttrs` because nixos refuses - # `addSSL` beside it. - services.nginx.virtualHosts.${cfg.ui.domain} = - (builtins.removeAttrs (hyperhiveCfg.gateway.lib.tlsFor cfg.ui.domain) [ "addSSL" ]) - // { - forceSSL = true; - listen = hyperhiveCfg.gateway.lib.listen; - extraConfig = hyperhiveCfg.gateway.lib.securityHeaders; - locations = { - "/" = { - proxyPass = "http://127.0.0.1:${toString baoDeploy.uiProxyPort}"; - extraConfig = swarmAuthRequest; - }; + # subrequest with 400. + services.hyperhive.gateway.vhosts.${cfg.ui.domain} = { + forceSSL = true; + locations = { + "/" = { + proxyPass = "http://127.0.0.1:${toString baoDeploy.uiProxyPort}"; + extraConfig = swarmAuthRequest; + }; - "= /__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; - ${hyperhiveCfg.gateway.lib.verifiedProxyTo autheliaCfg.domain} - 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; - ''; - }; + "= /__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; + ${hyperhiveCfg.gateway.lib.verifiedProxyTo autheliaCfg.domain} + 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; + ''; }; }; + }; # THE delivery unit for the forwarder's own OIDC client secret, copied # in shape from ./swarm-otel.nix's `swarm-bao-otel-oidc`: a cert login diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index 552439ed..a0dbb236 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -558,9 +558,7 @@ in # Websockets: Grafana Live streams panel updates over one, and without # the upgrade headers dashboards load and then never refresh — which # reads as stale data rather than as a proxy fault. - services.nginx.virtualHosts."${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // { - listen = gatewayCfg.lib.listen; - extraConfig = gatewayCfg.lib.securityHeaders; + services.hyperhive.gateway.vhosts."${cfg.domain}" = { # Grafana serves its Prometheus exposition to anyone, signed in or not # (`metrics.enabled` below, no `basic_auth_*`), so this location's auth # is the only gate on it. `=` is enough: nginx matches the decoded, diff --git a/nix/host-modules/swarm-nats.nix b/nix/host-modules/swarm-nats.nix index 3859db5c..70372e2d 100644 --- a/nix/host-modules/swarm-nats.nix +++ b/nix/host-modules/swarm-nats.nix @@ -465,65 +465,56 @@ in # nothing else on this name: the monitoring endpoint (`/varz`, `/connz`) # is never proxied, and every other path is 403. # - # ⚠️ Not `gateway.lib.tlsFor`, and the name is not in - # `swarm.serviceDomains`. In self-signed mode this vhost serves the - # queue's own leaf (issued for exactly this name, `server_flag` set), so - # a host whose only swarm service is the queue never fronts a services - # name and its gateway does not wait for the swarm-services leaf. + # ⚠️ In self-signed mode this vhost serves the queue's own leaf + # (`certificate`; issued for exactly this name, `server_flag` set), not + # the gateway's pick for the name, and the name is not in + # `swarm.serviceDomains`. # # `forceSSL` for the reason ./swarm-victorialogs.nix gives: authelia # answers a plain-http auth subrequest with 400. - services.nginx.virtualHosts."${cfg.domain}" = - ( - if gatewayServesQueueLeaf then - { - sslCertificate = "${gatewayTlsDir}/cert.pem"; - sslCertificateKey = "${gatewayTlsDir}/key.pem"; - } - else - builtins.removeAttrs (gatewayCfg.lib.tlsFor cfg.domain) [ "addSSL" ] - ) - // { - forceSSL = true; - listen = gatewayCfg.lib.listen; - extraConfig = gatewayCfg.lib.securityHeaders; - locations = { - "/".return = "403"; + services.hyperhive.gateway.vhosts."${cfg.domain}" = { + certificate = lib.mkIf gatewayServesQueueLeaf { + cert = "${gatewayTlsDir}/cert.pem"; + key = "${gatewayTlsDir}/key.pem"; + }; + forceSSL = true; + locations = { + "/".return = "403"; - # The collector requests exactly this URL, declared as - # `publishedScrapeTargets.nats` in ./swarm-otel-service.nix; the - # two must agree, since that string is also the token's audience. - # Authelia's rules for it admit the collector's client and deny - # every other subject (`metricsRules` in ./swarm-authelia.nix). - "= /metrics" = { - proxyPass = "http://127.0.0.1:${toString cfg.metricsPort}/metrics"; - extraConfig = '' - auth_request /__nats_metrics_authz; - ''; - }; + # The collector requests exactly this URL, declared as + # `publishedScrapeTargets.nats` in ./swarm-otel-service.nix; the + # two must agree, since that string is also the token's audience. + # Authelia's rules for it admit the collector's client and deny + # every other subject (`metricsRules` in ./swarm-authelia.nix). + "= /metrics" = { + proxyPass = "http://127.0.0.1:${toString cfg.metricsPort}/metrics"; + extraConfig = '' + auth_request /__nats_metrics_authz; + ''; + }; - # The subrequest, same shape as the forge's `= /__forge_metrics_authz` - # (./hive-forge/default.nix): no `error_page 401 =302`, so a scraper - # gets the 401 rather than a login page parsed as metrics. - "= /__nats_metrics_authz" = { - proxyPass = "https://${autheliaCfg.domain}/api/authz/auth-request"; - # nixpkgs appends its OWN `Host $host` after extraConfig, which - # would override verifiedProxyTo's — see hive-gateway/vhost-lib.nix. - recommendedProxySettings = false; - extraConfig = '' - internal; - ${gatewayCfg.lib.verifiedProxyTo autheliaCfg.domain} - 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; - ''; - }; + # The subrequest, same shape as the forge's `= /__forge_metrics_authz` + # (./hive-forge/default.nix): no `error_page 401 =302`, so a scraper + # gets the 401 rather than a login page parsed as metrics. + "= /__nats_metrics_authz" = { + proxyPass = "https://${autheliaCfg.domain}/api/authz/auth-request"; + # nixpkgs appends its OWN `Host $host` after extraConfig, which + # would override verifiedProxyTo's — see hive-gateway/vhost-lib.nix. + recommendedProxySettings = false; + extraConfig = '' + internal; + ${gatewayCfg.lib.verifiedProxyTo autheliaCfg.domain} + 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; + ''; }; }; + }; # Puts a pair at the paths the vhost above names, before nginx starts: # nginx refuses a config naming a missing cert file, and that takes the diff --git a/nix/host-modules/swarm-otel.nix b/nix/host-modules/swarm-otel.nix index 4d7f4af9..c9310c72 100644 --- a/nix/host-modules/swarm-otel.nix +++ b/nix/host-modules/swarm-otel.nix @@ -358,9 +358,7 @@ in # every sibling swarm service: TLS terminates here, then plain http to # the co-located container over loopback (shared netns, like the store # this collector writes to). - services.nginx.virtualHosts."${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // { - listen = gatewayCfg.lib.listen; - extraConfig = gatewayCfg.lib.securityHeaders; + services.hyperhive.gateway.vhosts."${cfg.domain}" = { locations = # One name for the whole collector, and the hive is a path under # it. The alternative — a vhost per hive — needs a certificate, diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index acb63ce2..35566bb0 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -179,165 +179,161 @@ in # 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" ]) + # The gateway drops `addSSL` from a vhost that sets `forceSSL`. + services.hyperhive.gateway.vhosts."${apex}" = { + forceSSL = true; + locations = { + "/" = { + root = "${deployCfg.swarm-controller.ui.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 `deployCfg.swarm-controller.ui.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; + }; + } // { - forceSSL = true; - listen = gatewayCfg.lib.listen; - extraConfig = gatewayCfg.lib.securityHeaders; - locations = { - "/" = { - root = "${deployCfg.swarm-controller.ui.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 `deployCfg.swarm-controller.ui.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}:"; - # `Remote-User` from authelia's answer, set here so it replaces - # any the client sent: swarm-controller takes it as the sender of - # the text the operator types to an agent (`agent_input.rs`). - # A refused caller that sent an `Authorization` header (`swarmctl - # --swarm`) gets the 401 itself; a browser still gets the login. - # The bearer token stops here and never reaches the controller. - extraConfig = '' - ${authRequest} - error_page 401 = @swarm_api_unauthorized; - auth_request_set $authelia_user $upstream_http_remote_user; - proxy_set_header Remote-User $authelia_user; - proxy_set_header Authorization ""; - # The SSE streams (terminal, agent state, subagent terminal) - # must not sit in nginx's buffer. The controller's 15s - # keep-alive keeps them under nginx's 60s default read timeout. - proxy_buffering off; - ''; - }; - "@swarm_api_unauthorized".extraConfig = '' - if ($http_authorization) { - return 401; - } - return 302 ${loginRedirect}; + # 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}:"; + # `Remote-User` from authelia's answer, set here so it replaces + # any the client sent: swarm-controller takes it as the sender of + # the text the operator types to an agent (`agent_input.rs`). + # A refused caller that sent an `Authorization` header (`swarmctl + # --swarm`) gets the 401 itself; a browser still gets the login. + # The bearer token stops here and never reaches the controller. + extraConfig = '' + ${authRequest} + error_page 401 = @swarm_api_unauthorized; + auth_request_set $authelia_user $upstream_http_remote_user; + proxy_set_header Remote-User $authelia_user; + proxy_set_header Authorization ""; + # The SSE streams (terminal, agent state, subagent terminal) + # must not sit in nginx's buffer. The controller's 15s + # keep-alive keeps them under nginx's 60s default read timeout. + proxy_buffering off; + ''; + }; + "@swarm_api_unauthorized".extraConfig = '' + if ($http_authorization) { + return 401; + } + return 302 ${loginRedirect}; + ''; + # ⚠️ THE ONE PROXIED LOCATION ON THIS VHOST WITH NO `auth_request`, + # 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:${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` tells the + # browser nothing about *who* (only `/api/` passes it on, to the + # controller), 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; ''; - # ⚠️ THE ONE PROXIED LOCATION ON THIS VHOST WITH NO `auth_request`, - # 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:${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` tells the - # browser nothing about *who* (only `/api/` passes it on, to the - # controller), 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; - ''; - }; }; }; + }; }; } diff --git a/nix/host-modules/swarm-victorialogs.nix b/nix/host-modules/swarm-victorialogs.nix index 9d0998c1..549a9fc2 100644 --- a/nix/host-modules/swarm-victorialogs.nix +++ b/nix/host-modules/swarm-victorialogs.nix @@ -313,157 +313,153 @@ in # Authenticated front door onto the loopback-only store — see the # file-top comment for why this is safe to add without touching the # store's own (still unauthenticated, still loopback) listener at all. - # `removeAttrs`/`forceSSL`: same asymmetry `swarm-ui.nix` documents — + # `forceSSL`: same asymmetry `swarm-ui.nix` documents — # authelia answers a plain-http auth subrequest with 400, which # `auth_request` cannot read as anything but a broken check, so this # vhost needs `forceSSL` rather than the `addSSL` every unauthenticated # sibling vhost uses. - services.nginx.virtualHosts."${cfg.domain}" = - (builtins.removeAttrs (gatewayCfg.lib.tlsFor cfg.domain) [ "addSSL" ]) - // { - forceSSL = true; - listen = gatewayCfg.lib.listen; - extraConfig = gatewayCfg.lib.securityHeaders; - locations = { - "/" = { - proxyPass = "http://127.0.0.1:${toString cfg.port}/"; - extraConfig = swarmAuthRequest; - }; + services.hyperhive.gateway.vhosts."${cfg.domain}" = { + forceSSL = true; + locations = { + "/" = { + proxyPass = "http://127.0.0.1:${toString cfg.port}/"; + extraConfig = swarmAuthRequest; + }; - # The swarm collector's ingest route. `=` so it outranks the `/` - # prefix above — without a more specific location it would ride that - # catch-all, and that is the whole hazard here. - # - # ⚠️ Deliberately NOT `swarmAuthRequest`. That block ends in - # `error_page 401 =302`, which is right for a browser and wrong for a - # pusher: handed a redirect it follows the redirect and POSTs its - # batch at a login page, which answers 200. Ingest then reports - # healthy while storing nothing. A machine route lets the 401 reach - # the client unchanged. - # - # `auth_request` does not inherit across sibling locations (see the - # note on `swarmAuthRequest` above), so naming it here is required - # rather than redundant. - "= /insert/opentelemetry/v1/logs" = { - proxyPass = "http://127.0.0.1:${toString cfg.port}/insert/opentelemetry/v1/logs"; - extraConfig = '' - auth_request /__hive_authelia; - ''; - }; + # The swarm collector's ingest route. `=` so it outranks the `/` + # prefix above — without a more specific location it would ride that + # catch-all, and that is the whole hazard here. + # + # ⚠️ Deliberately NOT `swarmAuthRequest`. That block ends in + # `error_page 401 =302`, which is right for a browser and wrong for a + # pusher: handed a redirect it follows the redirect and POSTs its + # batch at a login page, which answers 200. Ingest then reports + # healthy while storing nothing. A machine route lets the 401 reach + # the client unchanged. + # + # `auth_request` does not inherit across sibling locations (see the + # note on `swarmAuthRequest` above), so naming it here is required + # rather than redundant. + "= /insert/opentelemetry/v1/logs" = { + proxyPass = "http://127.0.0.1:${toString cfg.port}/insert/opentelemetry/v1/logs"; + extraConfig = '' + auth_request /__hive_authelia; + ''; + }; - # The read side of that same split: the route a caller holding a - # bearer token queries, as opposed to the `/` above which is the - # operator's browser. Bare `auth_request` for the ingest route's - # exact reason — `error_page 401 =302` hands an unauthenticated - # machine caller authelia's login page as a **200 with an HTML - # body**, so a client that reads the status code records a - # successful query that returned no rows. 401 is the only answer - # here that a caller cannot mistake for an empty result. - # - # `^~` so it outranks the `/` catch-all and stays ahead of any - # regex location a later change adds. The prefix is the whole - # LogsQL query API and nothing else — `query`, `tail`, `hits`, - # `facets`, the `stats_query*` and the `field_*`/`stream_*` - # routes — while the browser UI sits beside it under - # `/select/vmui`. Both read off the pinned build, not the docs. - # - # The query is forwarded unmodified — no scoping parameter is - # injected, so any authenticated caller reads the whole swarm's - # logs. That is the rule in force, not an omission: read - # permissions are a later thing, and this location is where one - # attaches when it exists. - # - # Three checks: an agent's own `swarm-agent.` bearer goes to - # `swarm-logs-auth`, Grafana's 64-hex bearer to `swarm-metrics-auth`, - # anything else to authelia (`appendHttpConfig` above picks). - "^~ /select/logsql/" = { - # No URI part, so the request path and its query string reach - # the store as the caller sent them. - proxyPass = "http://127.0.0.1:${toString cfg.port}"; - extraConfig = '' - auth_request /__swarm_logs_auth; - ''; - }; + # The read side of that same split: the route a caller holding a + # bearer token queries, as opposed to the `/` above which is the + # operator's browser. Bare `auth_request` for the ingest route's + # exact reason — `error_page 401 =302` hands an unauthenticated + # machine caller authelia's login page as a **200 with an HTML + # body**, so a client that reads the status code records a + # successful query that returned no rows. 401 is the only answer + # here that a caller cannot mistake for an empty result. + # + # `^~` so it outranks the `/` catch-all and stays ahead of any + # regex location a later change adds. The prefix is the whole + # LogsQL query API and nothing else — `query`, `tail`, `hits`, + # `facets`, the `stats_query*` and the `field_*`/`stream_*` + # routes — while the browser UI sits beside it under + # `/select/vmui`. Both read off the pinned build, not the docs. + # + # The query is forwarded unmodified — no scoping parameter is + # injected, so any authenticated caller reads the whole swarm's + # logs. That is the rule in force, not an omission: read + # permissions are a later thing, and this location is where one + # attaches when it exists. + # + # Three checks: an agent's own `swarm-agent.` bearer goes to + # `swarm-logs-auth`, Grafana's 64-hex bearer to `swarm-metrics-auth`, + # anything else to authelia (`appendHttpConfig` above picks). + "^~ /select/logsql/" = { + # No URI part, so the request path and its query string reach + # the store as the caller sent them. + proxyPass = "http://127.0.0.1:${toString cfg.port}"; + extraConfig = '' + auth_request /__swarm_logs_auth; + ''; + }; - # The store's own health, which it publishes as prometheus metrics - # on the listener it serves queries on. The swarm collector requests - # exactly this URL, declared as `publishedScrapeTargets.victorialogs` - # in ./swarm-otel-service.nix; the two must agree, since that string - # is also the token's audience. - # - # Bare `auth_request` to authelia, as on the ingest route above, and - # never `/__swarm_logs_auth`, which admits an agent's bearer: - # authelia's rules for this URL admit the collector's client and - # deny every other subject (see `metricsRules` in ./swarm-authelia.nix). - "= /metrics" = { - proxyPass = "http://127.0.0.1:${toString cfg.port}/metrics"; - extraConfig = '' - auth_request /__hive_authelia; - ''; - }; + # The store's own health, which it publishes as prometheus metrics + # on the listener it serves queries on. The swarm collector requests + # exactly this URL, declared as `publishedScrapeTargets.victorialogs` + # in ./swarm-otel-service.nix; the two must agree, since that string + # is also the token's audience. + # + # Bare `auth_request` to authelia, as on the ingest route above, and + # never `/__swarm_logs_auth`, which admits an agent's bearer: + # authelia's rules for this URL admit the collector's client and + # deny every other subject (see `metricsRules` in ./swarm-authelia.nix). + "= /metrics" = { + proxyPass = "http://127.0.0.1:${toString cfg.port}/metrics"; + extraConfig = '' + auth_request /__hive_authelia; + ''; + }; - # `rewrite … last` re-matches locations, so the subrequest lands in - # one of the two internal locations named by the map. - "= /__swarm_logs_auth" = { - extraConfig = '' - internal; - rewrite ^ $swarm_logs_auth_target last; - ''; - }; + # `rewrite … last` re-matches locations, so the subrequest lands in + # one of the two internal locations named by the map. + "= /__swarm_logs_auth" = { + extraConfig = '' + internal; + rewrite ^ $swarm_logs_auth_target last; + ''; + }; - # The `Authorization` header rides the subrequest unchanged. - # - # Any well-formed agent bearer the responder has not recently granted - # costs a secret store login and read, so the rate per client address - # is capped here, ahead of the responder. 403 when over: the only - # statuses `auth_request` passes through are 401 and 403, and - # `swarm-logs` retries a 401. - "= /__swarm_logs_agent_auth" = { - proxyPass = "http://unix:${logsAuthSocket}:/"; - extraConfig = '' - internal; - limit_req zone=swarm_logs_agent_auth burst=${toString deployCfg.victorialogs.authBurst} nodelay; - limit_req_status 403; - proxy_pass_request_body off; - proxy_set_header Content-Length ""; - ''; - }; + # The `Authorization` header rides the subrequest unchanged. + # + # Any well-formed agent bearer the responder has not recently granted + # costs a secret store login and read, so the rate per client address + # is capped here, ahead of the responder. 403 when over: the only + # statuses `auth_request` passes through are 401 and 403, and + # `swarm-logs` retries a 401. + "= /__swarm_logs_agent_auth" = { + proxyPass = "http://unix:${logsAuthSocket}:/"; + extraConfig = '' + internal; + limit_req zone=swarm_logs_agent_auth burst=${toString deployCfg.victorialogs.authBurst} nodelay; + limit_req_status 403; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + ''; + }; - # Grafana's bearer, 64 hex digits (`openssl rand -hex 32`), checked by - # `swarm-metrics-auth`. The responder reads the store at most once per - # its cache TTL whatever the bearers, so no rate limit sits in front. - "= /__swarm_logs_grafana_auth" = { - proxyPass = "http://unix:${grafanaAuthSocket}:/"; - extraConfig = '' - internal; - proxy_pass_request_body off; - proxy_set_header Content-Length ""; - ''; - }; + # Grafana's bearer, 64 hex digits (`openssl rand -hex 32`), checked by + # `swarm-metrics-auth`. The responder reads the store at most once per + # its cache TTL whatever the bearers, so no rate limit sits in front. + "= /__swarm_logs_grafana_auth" = { + proxyPass = "http://unix:${grafanaAuthSocket}:/"; + extraConfig = '' + internal; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + ''; + }; - # The subrequest itself — same target, same header set, same - # reasoning as `swarm-ui.nix`'s own copy (measured against the - # pinned authelia binary, not copied from an example). - "= /__hive_authelia" = { - proxyPass = "https://${hyperhiveCfg.swarm.authelia.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 hyperhiveCfg.swarm.authelia.domain} - 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; - ''; - }; + # The subrequest itself — same target, same header set, same + # reasoning as `swarm-ui.nix`'s own copy (measured against the + # pinned authelia binary, not copied from an example). + "= /__hive_authelia" = { + proxyPass = "https://${hyperhiveCfg.swarm.authelia.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 hyperhiveCfg.swarm.authelia.domain} + 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; + ''; }; }; + }; # The agents' check on `/select/logsql/`. On the host rather than in the # store's container: it needs nothing of the store, only nginx's reach to diff --git a/nix/host-modules/swarm-victoriametrics.nix b/nix/host-modules/swarm-victoriametrics.nix index 50bf341f..f4a711d9 100644 --- a/nix/host-modules/swarm-victoriametrics.nix +++ b/nix/host-modules/swarm-victoriametrics.nix @@ -252,107 +252,103 @@ in } ''; - # `removeAttrs`/`forceSSL`: authelia answers a plain-http auth subrequest + # `forceSSL`: authelia answers a plain-http auth subrequest # with 400, which `auth_request` cannot read as anything but a broken # check. Same as ./swarm-victorialogs.nix's vhost. - services.nginx.virtualHosts."${cfg.domain}" = - (builtins.removeAttrs (gatewayCfg.lib.tlsFor cfg.domain) [ "addSSL" ]) - // { - forceSSL = true; - listen = gatewayCfg.lib.listen; - extraConfig = gatewayCfg.lib.securityHeaders; - locations = - readLocations - // uiLocations - // { - # Nothing reaches the store unless a location here names it. The - # store serves writes and admin calls (`/api/v1/import`, - # `/api/v1/admin/tsdb/delete_series`, ...) on the same listener as - # its queries, and authenticates none of them. - "/".return = "403"; + services.hyperhive.gateway.vhosts."${cfg.domain}" = { + forceSSL = true; + locations = + readLocations + // uiLocations + // { + # Nothing reaches the store unless a location here names it. The + # store serves writes and admin calls (`/api/v1/import`, + # `/api/v1/admin/tsdb/delete_series`, ...) on the same listener as + # its queries, and authenticates none of them. + "/".return = "403"; - # The swarm UI's quick link opens the bare domain. The store's own - # index page links mostly to paths refused above, so send it to vmui. - "= /".return = "302 /vmui/"; + # The swarm UI's quick link opens the bare domain. The store's own + # index page links mostly to paths refused above, so send it to vmui. + "= /".return = "302 /vmui/"; - # The swarm collector's ingest route. `=` so it outranks the `/` - # prefix above. - # - # ⚠️ No `error_page 401 =302` here, and its absence is the point: a - # redirect is right for a browser and wrong for a pusher, which would - # follow it and POST its batch at a login page that answers 200 — - # ingest reporting healthy while storing nothing. - "= /opentelemetry/api/v1/push" = { - proxyPass = upstream "/opentelemetry/api/v1/push"; - extraConfig = '' - auth_request /__metrics_authelia; - ''; - }; - - # The store's own health, for the swarm collector wherever it runs: - # served on the query listener, so no exporter and no extra port. `=` - # for the same reason as the push route, and no `error_page` for the - # same reason: a scraper would parse a login page as metrics. - # - # The collector requests exactly this URL, declared as - # `publishedScrapeTargets.victoriametrics` in ./swarm-otel-service.nix; - # the two must agree, since that string is also the token's audience. - "= /metrics" = { - proxyPass = upstream "/metrics"; - extraConfig = '' - auth_request /__metrics_authelia; - ''; - }; - - # `rewrite … last` re-matches locations, so the subrequest lands in - # one of the two internal locations named by the map. - "= /__metrics_read_auth" = { - extraConfig = '' - internal; - rewrite ^ $swarm_metrics_read_auth_target last; - ''; - }; - - # Grafana's bearer, checked by `swarm-metrics-auth`. The - # `Authorization` header rides the subrequest unchanged. No rate limit - # in front: the responder reads the store at most once per its cache - # TTL, whatever the bearers. - "= /__metrics_grafana_auth" = { - proxyPass = "http://unix:${metricsAuthSocket}:/"; - extraConfig = '' - internal; - proxy_pass_request_body off; - proxy_set_header Content-Length ""; - ''; - }; - - # The authelia subrequest. For the collector's two locations above it - # admits only the collector's client; for the read and vmui locations - # it admits an operator's session (`metricsStoreRules` in - # ./swarm-authelia.nix). Same target and header set as the sibling log - # store's, which took them from `swarm-ui.nix` — `X-Original-URL` and - # `X-Original-Method` are what authelia's auth-request implementation - # reads, and the address it compares the token's audience against. - "= /__metrics_authelia" = { - proxyPass = "https://${hyperhiveCfg.swarm.authelia.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 hyperhiveCfg.swarm.authelia.domain} - 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; - ''; - }; + # The swarm collector's ingest route. `=` so it outranks the `/` + # prefix above. + # + # ⚠️ No `error_page 401 =302` here, and its absence is the point: a + # redirect is right for a browser and wrong for a pusher, which would + # follow it and POST its batch at a login page that answers 200 — + # ingest reporting healthy while storing nothing. + "= /opentelemetry/api/v1/push" = { + proxyPass = upstream "/opentelemetry/api/v1/push"; + extraConfig = '' + auth_request /__metrics_authelia; + ''; }; - }; + + # The store's own health, for the swarm collector wherever it runs: + # served on the query listener, so no exporter and no extra port. `=` + # for the same reason as the push route, and no `error_page` for the + # same reason: a scraper would parse a login page as metrics. + # + # The collector requests exactly this URL, declared as + # `publishedScrapeTargets.victoriametrics` in ./swarm-otel-service.nix; + # the two must agree, since that string is also the token's audience. + "= /metrics" = { + proxyPass = upstream "/metrics"; + extraConfig = '' + auth_request /__metrics_authelia; + ''; + }; + + # `rewrite … last` re-matches locations, so the subrequest lands in + # one of the two internal locations named by the map. + "= /__metrics_read_auth" = { + extraConfig = '' + internal; + rewrite ^ $swarm_metrics_read_auth_target last; + ''; + }; + + # Grafana's bearer, checked by `swarm-metrics-auth`. The + # `Authorization` header rides the subrequest unchanged. No rate limit + # in front: the responder reads the store at most once per its cache + # TTL, whatever the bearers. + "= /__metrics_grafana_auth" = { + proxyPass = "http://unix:${metricsAuthSocket}:/"; + extraConfig = '' + internal; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + ''; + }; + + # The authelia subrequest. For the collector's two locations above it + # admits only the collector's client; for the read and vmui locations + # it admits an operator's session (`metricsStoreRules` in + # ./swarm-authelia.nix). Same target and header set as the sibling log + # store's, which took them from `swarm-ui.nix` — `X-Original-URL` and + # `X-Original-Method` are what authelia's auth-request implementation + # reads, and the address it compares the token's audience against. + "= /__metrics_authelia" = { + proxyPass = "https://${hyperhiveCfg.swarm.authelia.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 hyperhiveCfg.swarm.authelia.domain} + 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; + ''; + }; + }; + }; # The socket directory, created host-side before the container starts: # nixos-container refuses to start when a bind source is missing. diff --git a/nix/module-eval/grafana.nix b/nix/module-eval/grafana.nix index 5113c324..d2d8bea3 100644 --- a/nix/module-eval/grafana.nix +++ b/nix/module-eval/grafana.nix @@ -88,6 +88,8 @@ let deploy.grafana.plugins = [ ]; deploy.grafana.package = pkgs.emptyDirectory; swarm.authelia.url = "https://auth.example.invalid"; + # Declares a vhost that binds its own `listen`, which the gateway refuses. + gateway.vhosts."own-listen.t.local".listen = [ ]; }; # Did ./host-modules/swarm-grafana.nix refuse this host, and for which of its @@ -479,6 +481,39 @@ let && builtins.length selectors == 12 && lib.all (e: lib.hasInfix "\"service.name\"=\"${stamped.value}\"" e) selectors; } + { + # A module declares its vhost under `gateway.vhosts` and the gateway adds + # what it owns: the listen set, the cert for the name and the security + # headers ahead of the module's own `extraConfig`. + name = "grafana's vhost is rendered with the gateway's listen set, TLS and security headers"; + ok = + let + gw = grafanaOldPath.services.hyperhive.gateway; + v = + grafanaOldPath.services.nginx.virtualHosts.${grafanaOldPath.services.hyperhive.swarm.grafana.domain}; + # nginx's `listen` entries come back with defaults filled in + # (`extraParameters`, `proxyProtocol`), so compare the fields set. + listenOf = map (l: { + inherit (l) addr port; + ssl = l.ssl or false; + }); + in + listenOf v.listen == listenOf gw.lib.listen + && lib.hasPrefix gw.lib.securityHeaders v.extraConfig + && v.addSSL + && !(v ? certificate); + } + { + # A vhost that sets `listen` itself would bind a port the gateway does + # not consider its own, so the eval refuses it and names the vhost. + name = "a gateway vhost that sets its own listen is refused"; + ok = lib.any ( + a: + !a.assertion + && lib.hasInfix "sets attributes the gateway owns" a.message + && lib.hasInfix "own-listen.t.local.listen" a.message + ) grafanaNoIdentity.assertions; + } ]; in runGroup "grafana" cases