From d5782965dbde19d323e38ceb51371b26ff34917b Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 13 Aug 2026 11:17:41 +0200 Subject: [PATCH] refactor(3202): the gateway publishes its vhost construction kit Slice 1 of #3202. The listen set, the per-name TLS attrs and the security headers move out of vhosts.nix into ./vhost-lib.nix and are published as `services.hyperhive.gateway.lib` (internal, readOnly). No behaviour change: vhosts.nix consumes the published value, so the rendered vhost tree is identical. The point is the next slice. Today a swarm service's vhost lives in the gateway because only the gateway knows the port pair, the issuer for a name, and the header block. Publishing those three is what lets a service module declare its own vhost without the gateway having to know that service by name. --- nix/host-modules/hive-gateway/default.nix | 29 +++++- nix/host-modules/hive-gateway/options.nix | 50 ++++++++++ nix/host-modules/hive-gateway/vhost-lib.nix | 101 ++++++++++++++++++++ nix/host-modules/hive-gateway/vhosts.nix | 91 +++--------------- 4 files changed, 189 insertions(+), 82 deletions(-) create mode 100644 nix/host-modules/hive-gateway/vhost-lib.nix diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index 86ed16d3..469ca341 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -65,7 +65,25 @@ let svcCert = "${tlsDir}/swarm-services.pem"; svcKey = "${tlsDir}/swarm-services-key.pem"; + # The vhost construction kit (listen set / per-name TLS attrs / + # security headers). Computed here, published as `cfg.lib` below, and + # handed to ./vhosts.nix **as the published value** — so the tree the + # gateway renders and the kit a service module gets are the same + # object by construction, not by two call sites agreeing. + vhostLib = import ./vhost-lib.nix { + inherit + lib + cfg + tlsCert + tlsKey + svcCert + svcKey + swarmServiceDomains + ; + }; + nginxTree = import ./vhosts.nix { + gwLib = cfg.lib; inherit lib cfg @@ -77,11 +95,6 @@ let hyperhiveDomain dashboardDist swaggerUiTheme - tlsCert - tlsKey - svcCert - svcKey - swarmServiceDomains ; errorPages = import ./error-pages.nix { inherit pkgs; }; }; @@ -90,6 +103,12 @@ in imports = [ ./options.nix ]; config = lib.mkIf config.services.hyperhive.enable { + # Publish the kit. Defined here rather than as an option `default` + # so it stays a plain value computed once from resolved cert paths — + # `tlsFor` closes over `svcCert`/`svcKey`, which are derived in this + # file's `let` and are not option surface. + services.hyperhive.gateway.lib = vhostLib; + assertions = [ { assertion = !(cfg.tls.acme.enable && cfg.tls.certDir != null); diff --git a/nix/host-modules/hive-gateway/options.nix b/nix/host-modules/hive-gateway/options.nix index ecdcc5c3..f0945a6b 100644 --- a/nix/host-modules/hive-gateway/options.nix +++ b/nix/host-modules/hive-gateway/options.nix @@ -94,6 +94,56 @@ in ''; }; + lib = { + listen = lib.mkOption { + type = lib.types.listOf (lib.types.attrsOf lib.types.raw); + internal = true; + readOnly = true; + 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. + ''; + }; + + tlsFor = lib.mkOption { + type = lib.types.functionTo (lib.types.attrsOf lib.types.raw); + internal = true; + readOnly = true; + description = '' + Read-only: `host -> ssl attrs` for a vhost of that name. + + Which issuer covers a name is **gateway** knowledge, not the + service's: a swarm service's name can sit outside this hive's + domain, and the hive CA is name-constrained out of 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 + agreeing. + ''; + }; + + securityHeaders = lib.mkOption { + type = lib.types.lines; + internal = true; + readOnly = true; + description = '' + Read-only: the server-scope security headers every vhost in + front of this gateway sets. + + ⚠️ nginx does not merge `add_header`: a location that sets one + of its own inherits **none** of these, so such a location must + repeat them. That rule is why this is published rather than + left implicit — a service module writing its own `locations` + needs the text, not a description of it. + ''; + }; + }; + useSelfSigned = lib.mkOption { type = lib.types.bool; internal = true; diff --git a/nix/host-modules/hive-gateway/vhost-lib.nix b/nix/host-modules/hive-gateway/vhost-lib.nix new file mode 100644 index 00000000..d60e52d2 --- /dev/null +++ b/nix/host-modules/hive-gateway/vhost-lib.nix @@ -0,0 +1,101 @@ +# The gateway's vhost construction kit: the listen set, the per-name TLS +# 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. +# +# 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. +{ + lib, + cfg, # services.hyperhive.gateway + tlsCert, + tlsKey, + svcCert, # swarm-services leaf, for names the hive CA cannot sign + svcKey, + swarmServiceDomains, # which names those are (../swarm.nix derives it) +}: +let + # nixos `services.nginx.virtualHosts.` ssl attrs for a vhost + # covered by the hive's own cert. For ACME mode: `enableACME` + + # `addSSL` — NixOS's ACME integration manages the cert lifecycle and + # sets ssl_certificate automatically. For self-signed / certDir: + # explicit cert paths. + vhostTls = + if cfg.tls.acme.enable then + { + addSSL = true; + enableACME = true; + } + else + { + addSSL = true; + sslCertificate = tlsCert; + sslCertificateKey = tlsKey; + }; + + hstsDirectives = lib.concatStringsSep "; " ( + [ "max-age=${toString cfg.hsts.maxAge}" ] + ++ lib.optional cfg.hsts.includeSubDomains "includeSubDomains" + ); +in +{ + # The gateway always terminates TLS: self-signed is the implicit + # floor when neither `tls.certDir` nor ACME is set, so there is no + # http-only mode. Listen addresses every vhost shares — plain http + # on `cfg.port` plus TLS on `cfg.httpsPort`. See `docs/gateway.md` + # ("TLS modes"). + listen = [ + { + addr = "0.0.0.0"; + port = cfg.port; + } + { + addr = "0.0.0.0"; + port = cfg.httpsPort; + ssl = true; + } + ]; + + # TLS attrs for one vhost, by name. A swarm service's name may sit + # outside this hive's domain — and then the hive CA is + # name-constrained out of it, so its vhost must serve the + # swarm-services leaf instead. Everything else keeps the hive leaf. + # + # Only in self-signed mode: with ACME or an operator cert there is a + # single issuer that already covers every name, and a second pair + # would be a cert nobody asked for. + tlsFor = + host: + if !cfg.tls.acme.enable && cfg.tls.certDir == null && builtins.elem host swarmServiceDomains then + { + addSSL = true; + sslCertificate = svcCert; + sslCertificateKey = svcKey; + } + else + vhostTls; + + # Security headers added at the server scope on every vhost. + # nginx's add_header inheritance rule: a location that defines its + # own add_header does NOT inherit the server-level ones. Any + # location with its own add_header (e.g. CORS on /.well-known or + # /_matrix/) must repeat the security headers explicitly — see those + # locations in ./vhosts.nix. HTML-serving and proxy locations that + # carry no add_header of their own pick these up from the server + # scope automatically. + securityHeaders = '' + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + ${lib.optionalString cfg.hsts.enable ''add_header Strict-Transport-Security "${hstsDirectives}" always;''} + ''; +} diff --git a/nix/host-modules/hive-gateway/vhosts.nix b/nix/host-modules/hive-gateway/vhosts.nix index 4678a025..b35e75f7 100644 --- a/nix/host-modules/hive-gateway/vhosts.nix +++ b/nix/host-modules/hive-gateway/vhosts.nix @@ -16,65 +16,17 @@ dashboardDist, swaggerUiTheme, # nix/packages/swagger-ui-theme.nix: has index.html + hyperhive-theme.css errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized, ssoUnavailable } - tlsCert, - tlsKey, - svcCert, # swarm-services leaf, for names the hive CA cannot sign - svcKey, - swarmServiceDomains, # which vhosts those are (../swarm.nix derives it) + gwLib, # `services.hyperhive.gateway.lib` — ./vhost-lib.nix's kit, via the option }: let - # The gateway always terminates TLS: self-signed is the implicit - # floor when neither `tls.certDir` nor ACME is set, so there is no - # http-only mode. Listen addresses every vhost shares — plain http - # on `cfg.port` plus TLS on `cfg.httpsPort`. See `docs/gateway.md` - # ("TLS modes"). - vhostListen = [ - { - addr = "0.0.0.0"; - port = cfg.port; - } - { - addr = "0.0.0.0"; - port = cfg.httpsPort; - ssl = true; - } - ]; - # nixos `services.nginx.virtualHosts.` ssl attrs merged - # into each vhost. For ACME mode: `enableACME` + `addSSL` — - # NixOS's ACME integration manages the cert lifecycle and sets - # ssl_certificate automatically. For self-signed / certDir: - # explicit cert paths. - vhostTls = - if cfg.tls.acme.enable then - { - addSSL = true; - enableACME = true; - } - else - { - addSSL = true; - sslCertificate = tlsCert; - sslCertificateKey = tlsKey; - }; - - # TLS attrs for one vhost, by name. A swarm service's name may sit - # outside this hive's domain — and then the hive CA is - # name-constrained out of it, so its vhost must serve the - # swarm-services leaf instead. Everything else keeps the hive leaf. - # - # Only in self-signed mode: with ACME or an operator cert there is a - # single issuer that already covers every name, and a second pair - # would be a cert nobody asked for. - vhostTlsFor = - host: - if !cfg.tls.acme.enable && cfg.tls.certDir == null && builtins.elem host swarmServiceDomains then - { - addSSL = true; - sslCertificate = svcCert; - sslCertificateKey = svcKey; - } - else - vhostTls; + # 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. + 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 @@ -85,25 +37,6 @@ let publicPort = cfg.httpsPort; publicPortSuffix = if publicPort == 443 then "" else ":${toString publicPort}"; - # Security headers added at the server scope on every vhost. - # nginx's add_header inheritance rule: a location that defines its - # own add_header does NOT inherit the server-level ones. Any - # location with its own add_header (e.g. CORS on /.well-known or - # /_matrix/) must repeat the security headers explicitly — see those - # locations below. HTML-serving and proxy locations that carry no - # add_header of their own pick these up from the server scope - # automatically. - hstsDirectives = lib.concatStringsSep "; " ( - [ "max-age=${toString cfg.hsts.maxAge}" ] - ++ lib.optional cfg.hsts.includeSubDomains "includeSubDomains" - ); - securityHeaders = '' - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - ${lib.optionalString cfg.hsts.enable ''add_header Strict-Transport-Security "${hstsDirectives}" always;''} - ''; - # Forge sub-domain vhost. `server_name = forge.domain`, proxies # all `/` → forgejo. Tuned for git: `client_max_body_size 1G`, # `proxy_read_timeout 1h` (multi-GB clones). SSH stays direct on @@ -534,7 +467,11 @@ in ''; virtualHosts = { - "_" = vhostTls // { + # `tlsFor "_"`, not a separate binding: the default server is a + # vhost named `_`, and a name that is not a swarm service domain + # (`_` never is) resolves to the hive's own leaf — which is what + # this vhost has always served. + "_" = (vhostTlsFor "_") // { listen = vhostListen; locations = matrixRedirectLocations