diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index f888bd6c..e96f2c3f 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -118,8 +118,8 @@ which accepts the leaf `/var/lib/swarm-bao-pki/granter.pem`. Every `swarm-bao-*-policy` unit then logs in with that leaf. The controller's unit mounts the KV and pki engines and writes the `swarm-controller` role, and each sibling unit writes its own principal's policy and role. Every one runs on the -host, because every API listener but the loopback UI one demands a client -certificate and the host is the side that has one. +host, because every API listener demands a client certificate and the host is +the side that has one. **Confirm with `systemctl status swarm-bao-granter-role`**, which should log `Uploaded policy: bao-granter` and `Data written to: auth/cert/certs/bao-granter`. diff --git a/docs/swarm/ca.md b/docs/swarm/ca.md index 0a8f7094..859a7837 100644 --- a/docs/swarm/ca.md +++ b/docs/swarm/ca.md @@ -67,9 +67,7 @@ leaf it signed and folded into `trust-bundle.pem`. On the host running the store it's also at `/var/lib/swarm-bao-services-pki/services-root.pem`. That's the file to hand a browser, and reading it needs no store login — which matters, -because every store listener but the loopback UI one demands a client -certificate, and authelia gates that one to the `admins` group, not -open to an anonymous browser fetching a trust anchor. +because every store listener demands a client certificate. **The granting unit generates the root once, and never again.** It asks the mount whether it already has an issuer (`bao list pki/issuers`) diff --git a/docs/swarm/secrets.md b/docs/swarm/secrets.md index 0bf5c896..edb47924 100644 --- a/docs/swarm/secrets.md +++ b/docs/swarm/secrets.md @@ -428,25 +428,6 @@ agent CA. The listener reads both from `listener-client-ca.pem`, which no cert-auth role pins. The passthrough carries whichever certificate the reader presents, unchanged. -## The browser UI - -OpenBao's built-in web UI is at `https://bao-ui./` -(`swarm.bao.ui.domain`), for members of authelia's `admins` group only. Log in -with a bao token. The gateway vhost checks the session with authelia and -proxies to an nginx inside the store's container on -`127.0.0.1:`. That nginx forwards `/ui/` and `/v1/` to -a second openbao listener on `127.0.0.1:`, answers 403 on -the unseal, seal, step-down, rekey and generate-root endpoints, and 404 on -everything else. ⚠️ That listener asks for **no client certificate**, because -a browser has none to present, so on this one door a bao token is the whole -credential. Anything on the store's host that can dial loopback, and any -`admins` session through the vhost, needs only a token to use the API. Unseal -from the host's `bao` CLI; the UI's unseal form gets a 403. On a self-signed -gateway where the UI is the only swarm name its host fronts, that host's nginx -doesn't wait for the store: it serves the hive certificate on the UI's name (a -browser warning) until the unsealed store issues the services leaf, so the -stream passthrough readers use on that host comes up with the store sealed. - ## The constraint that decides where the root lives A hive CA carries `nameConstraints=permitted;DNS:`, and **a swarm diff --git a/nix/host-modules/hive-tls.nix b/nix/host-modules/hive-tls.nix index cf3a3d38..3a9671ec 100644 --- a/nix/host-modules/hive-tls.nix +++ b/nix/host-modules/hive-tls.nix @@ -35,15 +35,6 @@ let builtins.head localServiceDomains ); - # Whether the gateway's start waits for the services leaf. Not when the - # store's own browser UI is the only swarm name this host fronts: that - # host's nginx also carries the stream passthrough every reader dials to - # reach the store, and waiting on a login to that store would take that - # path down whenever it is sealed. There the gateway starts on the hive - # leaf fallback, and the issuing script re-imports the services leaf into - # the running nginx once it lands. - gatewayWaitsForServicesLeaf = localServiceDomains != [ baoCfg.ui.domain ]; - # The host-managed hive CA is the trust anchor for self-signed mode. # It is only stood up when the gateway actually serves a self-signed # cert: the gateway must run here and be in self-signed mode. The @@ -732,8 +723,8 @@ in # bundle reaches the root the other way round, at the end of the script # below: it restarts `hive-tls-ca` when the root CHANGED, which is the # same path that already covered a store coming up hours late. - before = lib.optional gatewayWaitsForServicesLeaf "hive-gateway-self-signed-cert.service"; - requiredBy = lib.optional gatewayWaitsForServicesLeaf "hive-gateway-self-signed-cert.service"; + before = [ "hive-gateway-self-signed-cert.service" ]; + requiredBy = [ "hive-gateway-self-signed-cert.service" ]; # The store's container, where it runs here. On a hive that reads a # store hosted elsewhere no such unit exists and systemd ignores # the name, which is the correct behaviour rather than a gap: what diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 4a256073..1da10dee 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -1508,14 +1508,6 @@ in domain = swarmDomain; subject = [ "group:${operatorGroup}" ]; policy = "one_factor"; - } - # The store's browser UI. Not keyed to a deploy flag: its - # vhost is on the store's host, which need not be this - # one, and without this rule any session passes it. - ++ lib.optional (swarmDomain != null) { - domain = hyperhiveCfg.swarm.bao.ui.domain; - subject = [ "group:${operatorGroup}" ]; - policy = "one_factor"; }; }; diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index 032bf922..3033a2df 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -19,7 +19,6 @@ # `deploy.bao.extraListenAddresses` names, and — for readers inside an agent # container — the nginx *stream* passthrough below, which routes on the SNI # without decrypting and so leaves the client certificate intact. -# The browser UI's vhost fronts a different listener; see `uiListener`. # # ⚠️ THIS MODULE HAS NO OPINION ABOUT WHERE THE STORE'S IDENTITY COMES FROM. # A store must not take its certificates from an authority it will itself @@ -943,8 +942,7 @@ let // listenerTls; } // extraListeners - // metricsListener - // uiListener; + // metricsListener; # Metrics get their own listener rather than a flag on the one above, and # that follows from what a scraper can express: a prometheus scrape config @@ -981,63 +979,6 @@ let # above. metricsScrapeTarget = "127.0.0.1:${toString baoDeploy.metricsPort}"; - # The browser UI's listener. No client certificate, because a browser has - # none to present, so anything that can dial this address is one bao token - # away from the API. That is why it is loopback only, and why the only - # thing proxying to it is the nginx below, which browsers reach only - # through the gateway, past authelia's `admins` rule. Plain HTTP for the - # metrics listener's reason: the hop never leaves this netns. - uiListener = { - ui = { - type = "tcp"; - address = "127.0.0.1:${toString baoDeploy.uiPort}"; - tls_disable = true; - }; - }; - - # Every port this container binds in the host's netns, each listener's - # derived cluster port included. - containerPorts = [ - cfg.port - (cfg.port + 1) - baoDeploy.metricsPort - (baoDeploy.metricsPort + 1) - baoDeploy.uiPort - (baoDeploy.uiPort + 1) - baoDeploy.uiProxyPort - cfg.otel.telemetryPort - ]; - - # Paths a browser session has no business sending to the store: they unseal, - # seal, rekey or mint a root token, and the unauthenticated ones among them - # can start or cancel an attempt with no token at all. `seal-status` must - # stay reachable, since the UI polls it, which is why the first pattern is - # anchored at the end. - uiDeniedPaths = [ - "~* ^/v1/sys/(unseal|seal|step-down)/?$" - "~* ^/v1/sys/(rekey|generate-root)" - ]; - - # Passes on what the gateway already set for the browser's request. bao - # reads none of these unless `x_forwarded_for_authorized_addrs` is set on - # the listener, which it is not, so its audit log names 127.0.0.1. - uiProxyHeaders = '' - proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; - proxy_set_header X-Forwarded-Host $http_x_forwarded_host; - ''; - - # The gateway vhost's gate, copied from ./swarm-victorialogs.nix for that - # file's reason: one string does not justify importing ./swarm-ui.nix. - # `auth_request` does not inherit across sibling locations, so every - # location but the subrequest itself repeats it. - swarmAuthRequest = '' - auth_request /__hive_authelia; - auth_request_set $target_url $scheme://$http_host$request_uri; - error_page 401 =302 https://${autheliaCfg.domain}/?rd=$target_url; - ''; - # ⚠️ Neither the listener above nor the forwarder below has a condition of # its own, and they lost theirs for the same reason at different times. # @@ -1871,33 +1812,6 @@ in ''; }; - uiPort = lib.mkOption { - type = lib.types.port; - default = 8204; - description = '' - Loopback port of the listener that serves the store's browser UI. - It requires **no client certificate**, so only the nginx in the - store's container proxies to it; see - {option}`services.hyperhive.deploy.bao.uiProxyPort`. - - ⚠️ openbao also binds this port **+ 1** as the listener's cluster - address, so neither may coincide with another port the store or its - container binds. An assertion checks this. - ''; - }; - - uiProxyPort = lib.mkOption { - type = lib.types.port; - default = 8206; - description = '' - Loopback port of the nginx inside the store's container that fronts - {option}`services.hyperhive.deploy.bao.uiPort`. The gateway's - {option}`services.hyperhive.swarm.bao.ui.domain` vhost proxies here. - This nginx forwards only `/ui/` and `/v1/`, and refuses the - unseal, seal, step-down, rekey and generate-root endpoints. - ''; - }; - extraListenAddresses = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; @@ -1944,21 +1858,6 @@ in ''; }; - ui.domain = lib.mkOption { - type = lib.types.str; - default = "bao-ui.${domainBase}"; - defaultText = lib.literalExpression ''"bao-ui.''${services.hyperhive.swarm.domain}"''; - description = '' - Name the gateway serves the store's browser UI on, to members of - authelia's `admins` group only. Swarm-wide because authelia's host - writes the access rule for it and the store's host serves it. - - Must differ from {option}`services.hyperhive.swarm.bao.domain`: that - name is the mutual-TLS endpoint every reader dials, and it has no - vhost. - ''; - }; - port = lib.mkOption { type = lib.types.port; default = 8200; @@ -2054,26 +1953,6 @@ in about it. Pick any other free port. ''; } - { - assertion = lib.length (lib.unique containerPorts) == lib.length containerPorts; - message = '' - Two ports the store's container binds in the host's netns coincide: - ${lib.concatMapStringsSep ", " toString containerPorts}. They are - services.hyperhive.swarm.bao.port and +1, deploy.bao.metricsPort and - +1, deploy.bao.uiPort and +1 (openbao binds each listener's port + 1 - as its cluster address), deploy.bao.uiProxyPort, and - swarm.bao.otel.telemetryPort. Pick free ports. - ''; - } - { - assertion = cfg.ui.domain != cfg.domain; - message = '' - services.hyperhive.swarm.bao.ui.domain equals - services.hyperhive.swarm.bao.domain (${cfg.domain}). That name is the - store's mutual-TLS endpoint; a vhost on it would terminate the TLS - every reader authenticates with. Give the UI a name of its own. - ''; - } { assertion = haveServerTls; message = '' @@ -2145,18 +2024,16 @@ in # to varies, and a multi-host swarm is the operator's upstream DNS. This # covers the case that has no upstream record to configure. # - # ⚠️ DNS only for `cfg.domain`. It is deliberately absent from - # `swarm.serviceDomains` and gets no vhost: nginx terminating TLS would - # strip the client certificate, which is how the store authenticates - # every hive — see this file's header. The UI's name is an ordinary - # sibling service name and gets the whole pattern. - services.hyperhive.gateway.localNames = [ - cfg.domain - cfg.ui.domain - ]; + # ⚠️ DNS only. Bao is deliberately absent from `swarm.serviceDomains` + # and gets no vhost: nginx terminating TLS would strip the client + # certificate, which is how the store authenticates every hive — see + # this file's header. `localNames` is the one half of the sibling + # pattern that applies. + services.hyperhive.gateway.localNames = [ cfg.domain ]; - # The `ssl_preread` stream server below needs the nginx process and - # listens on the bridge address; the UI's vhost is the one vhost. + # All three, and no vhost among them: the `ssl_preread` stream + # server below needs the nginx process without asking it to serve + # anything, and it listens on the bridge address. services.hyperhive.gateway.enable = lib.mkDefault true; services.hyperhive.gateway.dns.enable = lib.mkDefault true; services.hyperhive.network.enable = lib.mkDefault true; @@ -2248,46 +2125,6 @@ in # certificate its CA signed. services.hyperhive.network.exposeHostPorts = [ cfg.port ]; - # The operators' door to the UI, shaped like ./swarm-victorialogs.nix's - # vhost. It proxies to the container's nginx over loopback (shared - # 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; - }; - - "= /__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 # that fails LOUDLY, since every state it fails on is one a retry fixes, @@ -2613,10 +2450,9 @@ in ''; }; - # The swarm's first grant, written from the HOST. Every API listener but - # the loopback UI one sets `tls_require_and_verify_client_cert`, so a - # client needs an identity wherever it runs — and only the host has one: - # the granter's leaf. + # The swarm's first grant, written from the HOST. Every API listener sets + # `tls_require_and_verify_client_cert`, so a client needs an identity + # wherever it runs — and only the host has one: the granter's leaf. systemd.services.swarm-bao-controller-policy = lib.mkIf haveGranter { description = "write the swarm controller's bao policy and cert-auth role"; after = [ "container@${cfg.machine}.service" ] ++ granterAfter; @@ -3445,52 +3281,12 @@ in settings = { listener = listeners; storage.raft.path = stateDir; - # Served on every listener, but a browser can only use the - # one that asks for no client certificate: `ui`, via the - # nginx below. - ui = true; } // advertise // telemetry // sealSettings; }; - # The only client of the `ui` listener. Loopback in the host's - # netns, where the gateway's vhost for `swarm.bao.ui.domain` dials - # it. Everything but the UI and its API is 404, and the key-share - # and seal endpoints are 403 whatever token is presented. - # `absolute_redirect off` keeps the `/` redirect relative; - # otherwise its Location names this listener, not the gateway. - services.nginx = { - enable = true; - virtualHosts.bao-ui = { - listen = [ - { - addr = "127.0.0.1"; - port = baoDeploy.uiProxyPort; - } - ]; - extraConfig = '' - absolute_redirect off; - ''; - locations = { - "= /".return = "302 /ui/"; - "/".return = "404"; - "/ui/" = { - proxyPass = "http://127.0.0.1:${toString baoDeploy.uiPort}"; - extraConfig = uiProxyHeaders; - }; - "/v1/" = { - proxyPass = "http://127.0.0.1:${toString baoDeploy.uiPort}"; - extraConfig = uiProxyHeaders; - }; - } - // lib.genAttrs uiDeniedPaths (_: { - return = "403"; - }); - }; - }; - # The private key crosses the user boundary here, not on the mount. # `swarm-bao-certs` installs it 0600 root-owned, and the unit runs # as a `DynamicUser`, so the bind-mounted file is unreadable to it — @@ -3552,9 +3348,9 @@ in # ⚠️ NO `units` allowlist here, and that is the point rather than a # simplification. A shared collector needs one because the journal # it reads holds six containers' units plus the host's own; this - # one reads only openbao, the UI's nginx and the two oneshots, so - # there is nothing foreign to separate out — and a list of unit names - # is a thing to get wrong, which ships nothing while looking healthy. + # one reads only openbao and the two oneshots beside it, so there + # is nothing foreign to separate out — and a list of unit names is + # a thing to get wrong, which ships nothing while looking healthy. assertions = [ { # Sibling of the agent forwarder's identical assertion, and it diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index 891a769b..86d90dd5 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -109,10 +109,7 @@ let # this list, `gateway.lib.tlsFor` falls back to the hive leaf, which # cannot cover a name under a different apex — see the ⚠️ above this # list for what that looked like the last time a name was missed here. - ++ lib.optional deployCfg.victorialogs.enable swarmCfg.victorialogs.domain - # The store's browser UI, not the store: `swarm.bao.domain` is mTLS and - # never gets a gateway certificate. - ++ lib.optional deployCfg.bao.enable swarmCfg.bao.ui.domain; + ++ lib.optional deployCfg.victorialogs.enable swarmCfg.victorialogs.domain; # Hives whose entry still carries the removed `certFingerprint`. Scanned # here, at top level, because that is the only place an assertion about a diff --git a/nix/module-eval/bao-basics.nix b/nix/module-eval/bao-basics.nix index fb75d944..6f747faf 100644 --- a/nix/module-eval/bao-basics.nix +++ b/nix/module-eval/bao-basics.nix @@ -41,13 +41,6 @@ let deploy.bao.serverKeyFile = "/etc/pki/bao-key.pem"; }; - autheliaOnly = hive { deploy.authelia.enable = true; }; - - baoWithForge = hive { - deploy.bao.enable = true; - deploy.forgejo.enable = true; - }; - # The store's units live inside its container, so the gates below have to # look there rather than at the host's service set. baoUnits = machine: machine.containers.swarm-bao.config.systemd.services; @@ -288,95 +281,6 @@ let && !(lib.hasInfix restart sh) && lib.hasInfix "# then unseal" sh; } - { - # The browser UI's listener must not loosen the one every reader dials. - # `loopback` is named so an empty filter cannot pass. - name = "every listener but metrics and the UI still requires a client certificate"; - ok = - let - l = (baoSettings baoPkcs11).listener; - api = builtins.removeAttrs l [ - "metrics" - "ui" - ]; - in - api ? loopback - && lib.all (x: (x.tls_require_and_verify_client_cert or false) == true) (lib.attrValues api); - } - { - # No client certificate on this one, so loopback is all that keeps it - # away from everything but this netns. - name = "the UI listener is loopback-only and the UI is served"; - ok = - let - s = baoSettings baoPkcs11; - in - (s.ui or false) == true - && lib.hasPrefix "127.0.0.1:" (s.listener.ui.address or "") - && !(s.listener.ui ? tls_require_and_verify_client_cert); - } - { - # The nginx in front of it is the listener's only client and must bind - # loopback too, and it refuses the endpoints a browser never needs. - name = "the UI's nginx binds loopback and refuses the unseal and root-generation paths"; - ok = - let - v = baoPkcs11.containers.swarm-bao.config.services.nginx.virtualHosts.bao-ui; - in - lib.all (x: x.addr == "127.0.0.1") v.listen - && (v.locations."~* ^/v1/sys/(unseal|seal|step-down)/?$".return or null) == "403" - && (v.locations."~* ^/v1/sys/(rekey|generate-root)".return or null) == "403"; - } - { - # A new vhost gets no group gate from `auth_request`; authelia's - # default policy admits any session. Evaluated on a host that runs - # authelia and no store, because that is where the rule has to render. - name = "authelia restricts the store's UI name to admins wherever authelia runs"; - ok = - let - rules = - autheliaOnly.containers.swarm-authelia.config.services.authelia.instances.swarm.settings.access_control.rules; - in - !autheliaOnly.services.hyperhive.deploy.bao.enable - && builtins.elem { - domain = "bao-ui.t.local"; - subject = [ "group:admins" ]; - policy = "one_factor"; - } rules; - } - { - # The other half of that rule: the vhost exists, and asks authelia. - name = "the store's host serves the UI name behind the authelia subrequest"; - ok = - let - v = baoPkcs11.services.nginx.virtualHosts."bao-ui.t.local"; - in - lib.hasInfix "auth_request /__hive_authelia;" (v.locations."/".extraConfig or "") - && (v.locations."/".proxyPass or "") == "http://127.0.0.1:8206"; - } - { - # The stream passthrough every reader dials rides the same nginx as the - # UI's vhost. On a host whose only swarm name is the UI, that nginx must - # not wait on the services leaf, which needs a login to this very store. - # The control is a store host that also fronts forge: its gateway waits, - # exactly as it did before the UI existed. - name = "a store host fronting only the UI does not hold nginx on a store login"; - ok = - let - waits = - m: - builtins.elem "hive-gateway-self-signed-cert.service" m.systemd.services.swarm-services-cert.requiredBy; - orders = - m: - builtins.elem "hive-gateway-self-signed-cert.service" m.systemd.services.swarm-services-cert.before; - in - baoPkcs11.services.hyperhive.swarm.localServiceDomains == [ "bao-ui.t.local" ] - && baoPkcs11.systemd.services ? hive-gateway-self-signed-cert - && !(waits baoPkcs11) - && !(orders baoPkcs11) - && waits baoWithForge - && orders baoWithForge; - } ]; in runGroup "bao-basics" cases diff --git a/nix/module-eval/bao-grants.nix b/nix/module-eval/bao-grants.nix index 974f5934..9f19273e 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -297,8 +297,8 @@ let cases = [ { # Reads the rendered unit on the HOST, which is where the write happens: - # every API listener but the loopback UI one demands a client - # certificate, and the host is the side that has one. + # every API listener demands a client certificate, and the host is the + # side that has one. name = "a store host renders the granting unit on the host, logging in as the granter"; ok = let diff --git a/nix/module-eval/bao-otel-collector.nix b/nix/module-eval/bao-otel-collector.nix index 7d0bfb87..af38f960 100644 --- a/nix/module-eval/bao-otel-collector.nix +++ b/nix/module-eval/bao-otel-collector.nix @@ -115,19 +115,13 @@ let } { # Control for the case above: these settings are rendered per deployment, - # not constants a passing case could be indifferent to. The metrics and - # UI listeners are excluded by name — each answers on a loopback port of - # its own and is not one of the declared addresses this case is counting. + # not constants a passing case could be indifferent to. The metrics + # listener is excluded by name — it answers on a port of its own and is + # not one of the API addresses this case is counting. name = "a declared extra address renders a second listener beside loopback"; ok = builtins.length ( - builtins.filter ( - n: - !(builtins.elem n [ - "metrics" - "ui" - ]) - ) (builtins.attrNames (baoSettings baoTwoAddresses).listener) + builtins.filter (n: n != "metrics") (builtins.attrNames (baoSettings baoTwoAddresses).listener) ) == 2; } {