diff --git a/docs/networking/gateway.md b/docs/networking/gateway.md index cc9839cd..ac47d455 100644 --- a/docs/networking/gateway.md +++ b/docs/networking/gateway.md @@ -21,14 +21,14 @@ You rarely switch it on yourself. `gateway.enable` defaults to off, and every mo | `chat./config.json` | inline JSON (FluffyChat boot config) | `hive-matrix.nix`, `swarm.matrix.gatewayHost != null` | | `grafana.`, `metrics.`, `logs.`, `otel.`, `bao.` | the matching swarm service | that service's module → [`swarm/services.md`](../swarm/services.md) | -**The hive domain's own vhost**, named for the hive domain. It renders where a hive runs (`deploy.hive-controller.enable`) or the homeserver does (`deploy.matrix.enable`); on a host with neither there is no such vhost. On a host without a hive only the two matrix rows exist: +**The hive's own vhost**, named for the hive domain. It exists 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 | | --- | --- | --- | -| `/` | dashboard dist (static, from `servedFrontend`) | `deploy.hive-controller.enable` | -| `/api/`, `/health/` | hive-c0re (`7000`) | `deploy.hive-controller.enable` | -| `/api/docs/` | themed Swagger UI dist (static) | `deploy.hive-controller.enable` | -| `/agent//` | per-agent harness over its unix socket | `deploy.hive-controller.enable`; `agents.conf` (runtime-generated) | +| `/` | dashboard dist (static, from `servedFrontend`) | always | +| `/api/`, `/health/` | hive-c0re (`7000`) | always | +| `/api/docs/` | themed Swagger UI dist (static) | always | +| `/agent//` | per-agent harness over its unix socket | `agents.conf` (runtime-generated) | | `/.well-known/matrix/{client,server}` | inline JSON | `deploy.matrix.enable` | | `/matrix/` | 301 → `chat./` | `deploy.matrix.enable` and `gatewayHost` set | @@ -86,7 +86,7 @@ The issuer is the **secret store's `pki` mount**, not a bare self-signed leaf. ` **Cert shape**: the leaf's CN is the bare hive domain; its SANs are `` and `*.`. The hive's role allows nothing outside ``, so a swarm-service name can't go on this leaf. Those names get the swarm-services leaf instead ([`swarm/ca.md`](../swarm/ca.md)). -**Rotation**: `hive-tls-resign.service` runs at boot, on every deploy and once a day, and asks the store for a new leaf when the current one is missing, past half its 720-hour lifetime, no longer covers the configured names, doesn't match its key, or doesn't chain to the store's root. A leaf already on disk lets the gateway start while the store can't answer; a failed renewal leaves it in place. To force a rotation, delete `gateway.pem` under the state dir and restart the unit; it re-imports the leaf and reloads `nginx` itself. +**Rotation**: `hive-tls-resign.service` runs at boot, on every deploy and once a day, and asks the store for a new leaf when the current one is missing, past half its 720-hour lifetime, doesn't cover the configured names, doesn't match its key, or doesn't chain to the store's root. A leaf already on disk lets the gateway start while the store can't answer; a failed renewal leaves it in place. To force a rotation, delete `gateway.pem` under the state dir and restart the unit; it re-imports the leaf and reloads `nginx` itself. **Cert prompts**: browsers warn once per host until you add the hive's `trust-bundle.pem` (the anchor, not the leaf) to the browser or OS trust store. A container that needs to trust it for its own outbound TLS gets the bundle a different way, bind-mounted read-only at `/run/hive-ca/trust-bundle.pem` (`nix/host-modules/lib/hive-ca-trust.nix`). diff --git a/docs/swarm/ca.md b/docs/swarm/ca.md index ee08dde1..21b62274 100644 --- a/docs/swarm/ca.md +++ b/docs/swarm/ca.md @@ -65,9 +65,8 @@ 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 or secret-store host) has no hive leaf, no -`hive-tls-resign.service`, no hive identity and no dashboard or agent routes; -its hive-domain vhost exists only to serve matrix discovery where the -homeserver runs there. Its gateway serves the swarm-services leaf as its default certificate too. Where it +`hive-tls-resign.service`, no hive identity and no hive-domain vhost. Its +gateway serves the swarm-services leaf as its default certificate too. Where it fronts other swarm names it waits for `swarm-services-cert.service` at start. Where the store's UI is its only swarm name it starts on a throwaway self-signed certificate and switches to the services leaf once the store diff --git a/nix/host-modules/hive-gateway/vhosts.nix b/nix/host-modules/hive-gateway/vhosts.nix index 512f726b..a95b53b6 100644 --- a/nix/host-modules/hive-gateway/vhosts.nix +++ b/nix/host-modules/hive-gateway/vhosts.nix @@ -248,34 +248,32 @@ in ''; }; } - // lib.optionalAttrs (runsHive || matrixDeployCfg.enable) { + // lib.optionalAttrs runsHive { # The hive domain's own vhost, reachable by NAME, so the catch-all can - # reject everything else. It exists where it has something to serve: - # matrix discovery and the `/matrix/` redirect where the homeserver runs - # here, and the dashboard, agent routes and swagger UI where a hive runs - # (they proxy to hive-c0re, which runs only with the hive controller). + # reject everything else. Only where a hive runs: the dashboard, agent + # routes and swagger UI proxy to hive-c0re, which runs only with the hive + # controller, and the certificate naming the hive domain is the hive + # leaf, which exists only there. ${hyperhiveDomain} = (vhostTlsFor hyperhiveDomain) // { listen = vhostListen; locations = matrixRedirectLocations // wellKnownLocations - // lib.optionalAttrs runsHive ( - agentLocations - // dashboardProxyLocation - // swaggerUiLocations - // lib.optionalAttrs cfg.auth.enable { - # Internal-only target for the dashboard's 401 error_page. - # `internal` prevents direct client access; `alias` serves - # the pre-built HTML from the Nix store. - "= /__hive_auth_unauthorized" = { - extraConfig = '' - internal; - alias ${errorPages.unauthorized}; - default_type text/html; - ''; - }; - } - ); + // agentLocations + // dashboardProxyLocation + // swaggerUiLocations + // lib.optionalAttrs cfg.auth.enable { + # Internal-only target for the dashboard's 401 error_page. + # `internal` prevents direct client access; `alias` serves + # the pre-built HTML from the Nix store. + "= /__hive_auth_unauthorized" = { + extraConfig = '' + internal; + alias ${errorPages.unauthorized}; + default_type text/html; + ''; + }; + }; # Per-agent location blocks, generated at runtime by # hive-c0re and written to /var/lib/hive-gateway/conf/agents.conf # on the host — the same machine nginx runs on. nginx parses @@ -284,11 +282,9 @@ 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 - + lib.optionalString runsHive '' - include /var/lib/hive-gateway/conf/agents.conf; - ''; + extraConfig = securityHeaders + '' + include /var/lib/hive-gateway/conf/agents.conf; + ''; }; }; } diff --git a/nix/host-modules/hive-tls.nix b/nix/host-modules/hive-tls.nix index 987485ab..3b8ccc4c 100644 --- a/nix/host-modules/hive-tls.nix +++ b/nix/host-modules/hive-tls.nix @@ -594,15 +594,10 @@ in # The swarm-services leaf, issued by the secret store's `pki` mount. # - # ⚠️ This unit replaces a chain, not a command. What used to happen - # was two openssl hops on whichever host held the swarm root key: a - # name-constrained `swarm-services` sub-CA (../swarm-ca.nix), then a - # leaf signed under it here. Both are gone. The store's - # `pki/roles/swarm-services` carries the same narrowing as an - # `allowed_domains` list enforced at issue time, so the intermediate - # that used to encode it in x509 `nameConstraints` has nothing left - # to express — and with the root inside the mount there is nothing - # for it to be an intermediate OF. + # The leaf chains straight to the mount's root, with no intermediate: + # the store's `pki/roles/swarm-services` narrows the names with an + # `allowed_domains` list enforced at issue time, so there is no x509 + # `nameConstraints` for an intermediate to carry. # # The names it may carry are not this unit's to assert: the role # refuses anything outside `allowed_domains`, read from the @@ -613,14 +608,10 @@ in systemd.services.swarm-services-cert = { description = "Issue the swarm-services TLS leaf from the secret store's PKI"; wantedBy = [ "multi-user.target" ]; - # ⚠️ The ordering the old shape got by ACCIDENT, written down. - # Nothing used to declare "no TLS until the store is up" — it held - # because `swarm-services-ca.service` happened to block the gateway. - # Now it is a real edge: the gateway's certificate comes out of the - # store, so the store being up is a precondition rather than a - # coincidence, and `requiredBy` is what makes a store that never - # comes up an outage that says so instead of a gateway serving the - # wrong name. + # ⚠️ Where the gateway waits for this leaf, the store being up is a + # precondition of the gateway's certificate, and `requiredBy` is + # what makes a store that never comes up an outage that says so + # instead of a gateway serving the wrong name. # # 🩸 NOT `Before=hive-tls-ca.service`, however much the trust bundle # wants it. That edge closed a cycle — the store's container is diff --git a/nix/module-eval/hive-tls.nix b/nix/module-eval/hive-tls.nix index 8c63ec37..edbc01c2 100644 --- a/nix/module-eval/hive-tls.nix +++ b/nix/module-eval/hive-tls.nix @@ -231,10 +231,10 @@ let && lib.hasInfix "root-key.pem" rootScript; } { - # Left for PR 3, beside dropping `ca.pem`, so a rollback finds the old - # key and does not mint a new anchor. The control is the bundle reading - # `ca.pem` in the same script. - name = "a retired hive CA key is left in place, and its public certificate stays in the trust bundle"; + # Nothing reads or removes a `ca-key.pem`, so a rollback finds the + # key and does not mint a new anchor. The control is the bundle + # reading `ca.pem` in the same script. + name = "a hive CA key is neither read nor removed, and its public certificate stays in the trust bundle"; ok = !(lib.hasInfix "ca-key.pem" caUnit.script) && !(lib.hasInfix "ca-key.pem" leafUnit.script) @@ -281,8 +281,6 @@ let && !(lib.hasInfix "(unset)" ok'); } { - # A leaf the retired hive CA signed is re-issued from the store on the - # first run that reaches it; one that renews in time never is. # A cert from one issuance beside the key of another, left by an # interrupted pair of renames, is re-issued rather than left for # `nginx -t` to refuse. Checked on the decision side of the exit. diff --git a/nix/module-eval/swarm-services-switch.nix b/nix/module-eval/swarm-services-switch.nix index 64516fdb..b1e00b3c 100644 --- a/nix/module-eval/swarm-services-switch.nix +++ b/nix/module-eval/swarm-services-switch.nix @@ -45,8 +45,7 @@ let deploy.bao.enable = true; }; - # The homeserver on a host without a hive: its hive-domain vhost serves - # matrix discovery and nothing of the hive's. + # The homeserver on a host without a hive. matrixNoHive = hive { deploy.hive-controller.enable = false; deploy.matrix.enable = true; @@ -240,24 +239,27 @@ let !(lib.any (t: lib.any (r: lib.hasInfix r t) hiveLeafRefs) texts) && m.services.hyperhive.deploy.hive-controller.tls.baoHiveIdentityCertFile == null && noHiveSurface m; - # The hive-domain vhost, where it renders at all, carries nothing - # that proxies to hive-c0re. + # No hive-domain vhost, and no vhost on the host carrying the + # hive's locations: c0re's `/health/`, the agent routes, swagger, + # or the hive domain's matrix discovery and `/matrix/` redirect. noHiveSurface = m: let vh = m.services.nginx.virtualHosts; - v = vh."h1.t.local"; in !(vh ? "h1.t.local") - || ( - !(lib.any (l: v.locations ? ${l}) [ - "/" - "/api/" + && !(lib.any ( + v: + lib.any (l: (v.locations or { }) ? ${l}) [ + "/health/" "/agent/" "/api/docs/" - ]) - && !(lib.hasInfix "agents.conf" (v.extraConfig or "")) - ); + "/matrix/" + "= /.well-known/matrix/client" + "= /.well-known/matrix/server" + ] + || lib.hasInfix "agents.conf" (v.extraConfig or "") + ) (lib.attrValues vh)); noHive = m: let @@ -302,14 +304,15 @@ let store.services.hyperhive.swarm.localServiceDomains == [ store.services.hyperhive.swarm.bao.ui.domain ] && !(waitsForLeaf store) - # The homeserver without a hive: the hive-domain vhost serves matrix - # discovery and the redirect, and no hive surface. + # The homeserver without a hive: no hive-domain vhost at all, and the + # homeserver's own vhost still proxies `/_matrix/`. && noHive matrixNoHive && ( let - l = matrixNoHive.services.nginx.virtualHosts."h1.t.local".locations; + gh = matrixNoHive.services.hyperhive.swarm.matrix.gatewayHost; + vh = matrixNoHive.services.nginx.virtualHosts; in - l ? "= /.well-known/matrix/client" && l ? "= /.well-known/matrix/server" + gh != null && vh ? ${gh} && lib.any (lib.hasPrefix "/_matrix") (lib.attrNames vh.${gh}.locations) ) && !(store.services.nginx.virtualHosts ? "h1.t.local") && lib.hasInfix "want_svc=1" store.systemd.services.swarm-services-cert.script @@ -318,7 +321,12 @@ let && 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 && swarmServicesHere.services.hyperhive.deploy.hive-controller.tls.baoHiveIdentityCertFile != null - && swarmServicesHere.services.nginx.virtualHosts."h1.t.local".locations ? "/api/" + && ( + let + l = swarmServicesHere.services.nginx.virtualHosts."h1.t.local".locations; + in + l ? "/api/" && l ? "/health/" && l ? "= /.well-known/matrix/client" + ) && !(noHiveLeafAnywhere swarmServicesHere); } {