diff --git a/docs/swarm/ca.md b/docs/swarm/ca.md index 7920def6..2e9683aa 100644 --- a/docs/swarm/ca.md +++ b/docs/swarm/ca.md @@ -63,6 +63,12 @@ lands, and then the unit starts nginx. nginx serves the leaf alone, without its issuer: the issuer is the self-signed root a consumer already holds in its trust bundle. +**A gateway host that runs no hive** (`deploy.hive-controller.enable` off, +such as a dedicated swarm-services host) has no hive leaf, no +`hive-tls-resign.service` and needs no hive identity. Its gateway serves the +swarm-services leaf as its default certificate too, and waits for +`swarm-services-cert.service` at start. + ## The trust bundle `hive-tls-ca.service` writes `/trust-bundle.pem`, diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index 9b23394d..7ad01445 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -25,6 +25,10 @@ let swarmServiceDomains = config.services.hyperhive.swarm.serviceDomains; matrixCfg = config.services.hyperhive.swarm.matrix; matrixDeployCfg = config.services.hyperhive.deploy.matrix; + tlsStateDir = config.services.hyperhive.deploy.hive-controller.tls.stateDir; + # The hive leaf exists only where a hive runs; ../hive-tls.nix issues it + # under that same condition. + runsHive = config.services.hyperhive.deploy.hive-controller.enable; # Every vhost claiming `default_server`, ours and the operator's # alike. Computed once so the assertion below and the message it @@ -45,7 +49,7 @@ let # Self-signed TLS is the implicit floor: when neither an operator cert # (`tls.certDir`) nor ACME (`tls.acme.enable`) is configured, the gateway - # generates + serves a hive-CA-signed leaf (see hive-tls.nix). There is no + # serves a leaf the secret store issues (see hive-tls.nix). There is no # explicit toggle and no http-only mode — matrix discovery requires https, # so the gateway always terminates TLS. # `cfg.useSelfSigned` (options.nix) is the derived single source of truth. @@ -57,15 +61,16 @@ let # certs for no gain. tlsDir = "/var/lib/hive-gateway/tls"; # TLS cert + key paths. - # - self-signed (default): the hive-CA-signed leaf, imported into the - # state dir by `hive-gateway-self-signed-cert` below. + # - self-signed (default): the hive leaf the store issues (the + # swarm-services leaf on a host with no hive), imported into the state + # dir by `hive-gateway-self-signed-cert` below. # - tls.certDir set: the operator's own cert dir, read directly. tlsCert = if cfg.tls.certDir != null then "${cfg.tls.certDir}/${cfg.tls.certName}" else "${tlsDir}/cert.pem"; tlsKey = if cfg.tls.certDir != null then "${cfg.tls.certDir}/${cfg.tls.keyName}" else "${tlsDir}/key.pem"; - # The swarm-services pair, used only by the vhosts whose names this - # hive's CA cannot sign. Self-signed mode only: with an operator cert + # The swarm-services pair, used only by the vhosts whose names the + # hive leaf cannot carry. Self-signed mode only: with an operator cert # or ACME the operator owns every name and there is no second issuer. svcCert = "${tlsDir}/swarm-services.pem"; svcKey = "${tlsDir}/swarm-services-key.pem"; @@ -244,7 +249,7 @@ in defaults.email = cfg.tls.acme.email; }; - # Import the hive-CA leaf into nginx's state dir before nginx starts. + # Import the gateway leaves into nginx's state dir before nginx starts. # # 🚨 DO NOT "simplify" this into pointing nginx at the CA dir. It does # TWO jobs: it re-modes the leaf to the 0640 root:nginx copy below (a @@ -292,18 +297,27 @@ in # read it — a 0600 root:root key passes the master load but # fails the pre-start config test with `BIO_new_file() … # Permission denied`, blocking the unit. Cert is world-read. - install -m 0644 ${config.services.hyperhive.deploy.hive-controller.tls.stateDir}/gateway.pem ${tlsCert} - install -m 0640 -g nginx ${config.services.hyperhive.deploy.hive-controller.tls.stateDir}/gateway-key.pem ${tlsKey} + ${ + if runsHive then + '' + install -m 0644 ${tlsStateDir}/gateway.pem ${tlsCert} + install -m 0640 -g nginx ${tlsStateDir}/gateway-key.pem ${tlsKey}'' + else + '' + # No hive here, so no hive leaf: the catch-all and any other + # vhost on the hive cert path serve the swarm-services leaf. + install -m 0644 ${tlsStateDir}/swarm-services.pem ${tlsCert} + install -m 0640 -g nginx ${tlsStateDir}/swarm-services-key.pem ${tlsKey}'' + } # The swarm-services leaf, when this host issues one. It is # a separate pair rather than more SANs on the one above - # because no hive CA can sign these names — each is - # constrained to its own hive's domain and the service - # names are siblings of it. + # because the hive leaf's role allows only its own hive's + # domain, and the service names are siblings of it. # - # Absent is a normal state, not a failure: the leaf exists - # only where the swarm CA is autoconfigured, and issuance - # can also fail on a host that wants one. + # Absent is a normal state, not a failure: a host fronting + # no swarm name has none, and issuance can also fail on a + # host that wants one. # # ⚠️ When it is absent the HIVE leaf goes to this path # anyway, and that fallback is load-bearing rather than diff --git a/nix/host-modules/hive-gateway/options.nix b/nix/host-modules/hive-gateway/options.nix index 5682144d..133e2598 100644 --- a/nix/host-modules/hive-gateway/options.nix +++ b/nix/host-modules/hive-gateway/options.nix @@ -159,7 +159,7 @@ in 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 + 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 @@ -231,7 +231,7 @@ in defaultText = lib.literalExpression "tls.certDir == null && !tls.acme.enable"; description = '' Read-only derived flag: `true` when the gateway serves the - self-signed (hive-CA-signed) leaf — i.e. neither `tls.certDir` nor + self-signed leaf the secret store issues — i.e. neither `tls.certDir` nor `tls.acme.enable` is configured. Single source of truth for the self-signed condition; consumed by the `hive-tls` and `hive-ci` modules so the derivation isn't duplicated. Internal — not meant to @@ -261,7 +261,7 @@ in Path to a host directory containing a TLS certificate and private key for nginx. When set, nginx listens on `httpsPort` and uses this cert, overriding the self-signed default — the - auto-generated hive-CA-signed leaf is skipped entirely. + store-issued self-signed leaf is skipped entirely. nginx reads `/` and `/` directly — it runs on the host, so diff --git a/nix/host-modules/hive-gateway/vhost-lib.nix b/nix/host-modules/hive-gateway/vhost-lib.nix index 0b1fb91f..4971b786 100644 --- a/nix/host-modules/hive-gateway/vhost-lib.nix +++ b/nix/host-modules/hive-gateway/vhost-lib.nix @@ -19,11 +19,11 @@ cfg, # services.hyperhive.gateway tlsCert, tlsKey, - svcCert, # swarm-services leaf, for names the hive CA cannot sign + svcCert, # swarm-services leaf, for names the hive leaf cannot carry svcKey, swarmServiceDomains, # which names those are (../swarm.nix derives it) errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized, ssoUnavailable } - caBundle, # hive CA trust bundle on the host (root + intermediate) + caBundle, # hive TLS trust bundle on the host }: let # nixos `services.nginx.virtualHosts.` ssl attrs for a vhost @@ -68,8 +68,8 @@ in ]; # 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 + # outside this hive's domain — and then the hive leaf's role + # refuses 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 diff --git a/nix/host-modules/hive-tls.nix b/nix/host-modules/hive-tls.nix index 493d74c3..79a709af 100644 --- a/nix/host-modules/hive-tls.nix +++ b/nix/host-modules/hive-tls.nix @@ -46,8 +46,16 @@ let # Nor on a host fronting no swarm name: it asks the store for the root # alone, which nginx never reads, and a sealed store would otherwise hold # its gateway down. + # + # On a host with no hive there is no hive leaf to start on: the services + # leaf is the only certificate its gateway has, so it always waits. gatewayWaitsForServicesLeaf = - localServiceDomains != [ ] && localServiceDomains != [ baoCfg.ui.domain ]; + localServiceDomains != [ ] && (!runsHive || localServiceDomains != [ baoCfg.ui.domain ]); + + # The hive leaf, `hive-tls-resign` and the hive identity it needs exist only + # where a hive runs. A gateway host without one serves the swarm-services + # leaf in their place (../hive-gateway/default.nix). + runsHive = hyperhiveCfg.deploy.hive-controller.enable; # The gateway's own leaf is only issued when the gateway actually serves a # self-signed cert: the gateway must run here and be in self-signed mode. @@ -458,7 +466,7 @@ in # propagation failure means the leaf rotates on disk while nginx keeps # serving the old copy until it expires, with this unit reporting # success the whole time. - systemd.services.hive-tls-resign = { + systemd.services.hive-tls-resign = lib.mkIf runsHive { description = "Issue or renew the gateway TLS leaf from the secret store's PKI"; wantedBy = [ "multi-user.target" ]; # The store's container and its granting unit where they run here; @@ -941,7 +949,7 @@ in # Daily, for the reason `swarm-services-cert-renew` below is: the leaf # needs the store, which can be sealed or unreachable. - systemd.timers.hive-tls-resign = { + systemd.timers.hive-tls-resign = lib.mkIf runsHive { description = "Daily renewal check for the gateway TLS leaf"; wantedBy = [ "timers.target" ]; timerConfig = { diff --git a/nix/module-eval/swarm-services-switch.nix b/nix/module-eval/swarm-services-switch.nix index c296057f..242295b9 100644 --- a/nix/module-eval/swarm-services-switch.nix +++ b/nix/module-eval/swarm-services-switch.nix @@ -30,6 +30,14 @@ let deploy.allSwarmServices = true; }; + # A services host with hives elsewhere and the store elsewhere too: no hive + # and no store identity minted here. + swarmServicesOnlyNoStore = hive { + deploy.hive-controller.enable = false; + deploy.allSwarmServices = true; + deploy.bao.enable = false; + }; + # The same switch with every service it owns placed elsewhere, so the only # thing that can enable the gateway/resolver/bridge here is that toggle's # own module — each service module asserts them from its own `enable`. @@ -187,6 +195,36 @@ let && swarmServicesSwitchAlone.services.hyperhive.gateway.dns.enable && swarmServicesSwitchAlone.services.hyperhive.network.enable; } + { + # No hive, so no hive leaf and no hive identity: the gateway's default + # certificate is the swarm-services leaf, and its start waits for that + # leaf. The control is a hive host with the same services, which keeps + # the hive leaf and the unit that issues it. + name = "a gateway host without a hive serves the swarm-services leaf as its default cert and has no hive-leaf unit"; + ok = + let + noHive = + m: + let + s = m.systemd.services; + imp = s.hive-gateway-self-signed-cert.script; + in + !(s ? hive-tls-resign) + && !(m.systemd.timers ? hive-tls-resign) + && lib.hasInfix "install -m 0644 /var/lib/hive-tls/swarm-services.pem /var/lib/hive-gateway/tls/cert.pem" imp + && !(lib.hasInfix "/var/lib/hive-tls/gateway.pem /var/lib/hive-gateway/tls/cert.pem" imp) + && lib.elem "hive-gateway-self-signed-cert.service" s.swarm-services-cert.requiredBy + && lib.elem "hive-gateway-self-signed-cert.service" s.swarm-services-cert.before + # Fronting swarm names, so the root-only branch that presents the + # hive identity is not the one that runs. + && lib.hasInfix "want_svc=1" s.swarm-services-cert.script; + hs = swarmServicesHere.systemd.services; + in + noHive swarmServicesOnly + && noHive swarmServicesOnlyNoStore + && hs ? hive-tls-resign + && lib.hasInfix "install -m 0644 /var/lib/hive-tls/gateway.pem /var/lib/hive-gateway/tls/cert.pem" hs.hive-gateway-self-signed-cert.script; + } { # Each service is gated on its own deployment toggle alone, so a host # that runs no hive still runs every shared service the switch turns