From b845630023e4f42d2e2bccd53754e96424a72a06 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 28 Oct 2026 02:21:01 +0100 Subject: [PATCH] keep the cache's key and pull token for hive-priv in a root-only directory --- docs/swarm/builder.md | 11 +- docs/swarm/ca.md | 4 +- docs/swarm/credentials-reference.md | 15 +- nix/checks.nix | 4 + nix/host-modules/default.nix | 1 + nix/host-modules/hive-priv-cache.nix | 98 ++++++++++++ nix/host-modules/hive-tls.nix | 8 +- nix/host-modules/lib/attic-root-facts.nix | 87 +++++++++++ nix/host-modules/swarm-bao.nix | 34 ++++- nix/module-eval/bao-grants.nix | 22 ++- nix/module-eval/hive-tls.nix | 48 ++++++ nix/script-tests/attic-root-facts.nix | 32 ++++ nix/script-tests/attic-root-facts.sh | 177 ++++++++++++++++++++++ 13 files changed, 519 insertions(+), 22 deletions(-) create mode 100644 nix/host-modules/hive-priv-cache.nix create mode 100644 nix/host-modules/lib/attic-root-facts.nix create mode 100644 nix/script-tests/attic-root-facts.nix create mode 100644 nix/script-tests/attic-root-facts.sh diff --git a/docs/swarm/builder.md b/docs/swarm/builder.md index f1a0f542..6d76b5a7 100644 --- a/docs/swarm/builder.md +++ b/docs/swarm/builder.md @@ -388,7 +388,16 @@ store doesn't hold, and leaves a copy whose source disappeared. identity and writes `/run/hyperhive/attic/netrc`, `substituter` and `public-key` (tmpfs, each `0600`). It accepts only an `https` URL, since the token goes out in a header and in the netrc; a hive that finds another scheme -writes nothing. No deploy reads the files yet: deploys still build locally. +writes nothing. + +`hive-priv` doesn't read those files. The key that must have signed a download and +the pull token come from `hive-priv-cache-facts`, a root unit that reads the +hive's own `attic/public-key` and `attic/pull-token` from the store under the +hive's own store identity every ten minutes and writes them to +`/run/hyperhive-priv/attic/` (`0700`, root; each file `0600`). The cache's +address is the host's `services.hyperhive.swarm.attic.domain` and `cache`, +handed to `hive-priv` in its unit's environment. A file `hive-c0re` writes +decides nothing `hive-priv` trusts. The same pass checks each of the hive's agents. It reads the agent's record in `agent-closures`, then fetches the closure's narinfo from diff --git a/docs/swarm/ca.md b/docs/swarm/ca.md index e83ddc0e..0b55be95 100644 --- a/docs/swarm/ca.md +++ b/docs/swarm/ca.md @@ -22,7 +22,9 @@ hive's domain and `*.`, read from `services.hyperhive.swarm.hives..domain` on the store's host, with no IP SANs and no `localhost`. A request for any other name is a refusal from the store naming that name. The hive's policy grants `update` on that one -issue path and nothing else. `swarm-bao-hive-leaf-policy.service`, on the +issue path and `read` on its own `attic/public-key` and `attic/pull-token` +(see [the cache facts for hive-priv](credentials-reference.md)), and nothing +else. `swarm-bao-hive-leaf-policy.service`, on the store's host, writes the role, the policy and the cert-auth role for every hive in `swarm.hives`. diff --git a/docs/swarm/credentials-reference.md b/docs/swarm/credentials-reference.md index cbf14225..650fad93 100644 --- a/docs/swarm/credentials-reference.md +++ b/docs/swarm/credentials-reference.md @@ -146,13 +146,14 @@ pieces fit, and the reasoning behind them, is in [`credentials.md`](credentials. ### Hive-level — one of each per hive -| secret | generated by | lives at | -| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| hive leaf certs | `hive-tls.nix`, issued by the secret store's `pki` mount | `/.pem` | -| the hive's own store identity | `glue-bao-tls.nix` on the store's host; placed by hand on every other host ([`ca.md`](ca.md#the-gateway-leaf-comes-from-the-secret-store)) | `deploy.hive-controller.tls.baoHiveIdentityCertFile` / `baoHiveIdentityKeyFile` | -| the forge's copy of its OIDC secret | `swarm-bao-forge-oidc.service` reads it out of the swarm secret store under the forge publisher's leaf, wherever authelia runs | `/run/hive-forge-oidc/.secret` on the host (tmpfs), bound into the forge container, handed to `forgejo-sso-source` through `LoadCredential` | -| the homeserver's copy of its OIDC secret | `swarm-bao-matrix-oidc.service` reads it out of the swarm secret store under matrix-ctl's leaf, wherever authelia runs | `/run/hive-matrix-oidc/.secret` on the host (tmpfs), bound into the homeserver container, handed to tuwunel through `LoadCredential` | -| this hive's forge read token | `swarm-controller`, into the secret store at `swarm/hives//forge/token`, for the `hive-` account; a pass at start and every five minutes re-mints a missing or stale token | `hive-c0re` reads it under the hive's own store identity, and copies it (account on the first line, token on the second) to `/run/hyperhive/forge-hive-token` (tmpfs, `0600` `hive-core`) for nix's `git`, which `hive-priv` also runs. Each `internal/meta` read and each boot rewrites the copy when it differs; where they differ after a rotation, a build that starts before the next read fails to authenticate, and a retry reads the new token | +| secret | generated by | lives at | +| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| hive leaf certs | `hive-tls.nix`, issued by the secret store's `pki` mount | `/.pem` | +| the hive's own store identity | `glue-bao-tls.nix` on the store's host; placed by hand on every other host ([`ca.md`](ca.md#the-gateway-leaf-comes-from-the-secret-store)) | `deploy.hive-controller.tls.baoHiveIdentityCertFile` / `baoHiveIdentityKeyFile` | +| the cache's public key and pull token, for hive-priv | `swarm-controller` copies them to `swarm/hives//attic/{public-key,pull-token}` (see the cache-facts row above) | `hive-priv-cache-facts` (root) reads those two under the hive's own store identity every ten minutes and writes `/run/hyperhive-priv/attic/public-key` and `netrc` (tmpfs, root, each `0600`, directory `0700`); the cache's address comes from `services.hyperhive.swarm.attic.domain` and `cache`, not from the store. A store that holds no facts, or that doesn't answer, leaves the files as they stand, and a hive that never got them has `hive-priv` answer `not available`. | +| the forge's copy of its OIDC secret | `swarm-bao-forge-oidc.service` reads it out of the swarm secret store under the forge publisher's leaf, wherever authelia runs | `/run/hive-forge-oidc/.secret` on the host (tmpfs), bound into the forge container, handed to `forgejo-sso-source` through `LoadCredential` | +| the homeserver's copy of its OIDC secret | `swarm-bao-matrix-oidc.service` reads it out of the swarm secret store under matrix-ctl's leaf, wherever authelia runs | `/run/hive-matrix-oidc/.secret` on the host (tmpfs), bound into the homeserver container, handed to tuwunel through `LoadCredential` | +| this hive's forge read token | `swarm-controller`, into the secret store at `swarm/hives//forge/token`, for the `hive-` account; a pass at start and every five minutes re-mints a missing or stale token | `hive-c0re` reads it under the hive's own store identity, and copies it (account on the first line, token on the second) to `/run/hyperhive/forge-hive-token` (tmpfs, `0600` `hive-core`) for nix's `git`, which `hive-priv` also runs. Each `internal/meta` read and each boot rewrites the copy when it differs; where they differ after a rotation, a build that starts before the next read fails to authenticate, and a retry reads the new token | ### Store identities, one per principal diff --git a/nix/checks.nix b/nix/checks.nix index 66dc6d25..1d194d25 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -72,6 +72,10 @@ in # cache's key, cache and tokens, twice over against stub tools: the second # run must mint nothing. script-test-attic-init = import ./script-tests/attic-init.nix { inherit pkgs; }; + # Runs ./host-modules/lib/attic-root-facts.nix, the root unit that keeps the + # cache's key and pull token for hive-priv, against a stub `bao`: file modes, + # no rewrite when nothing changed, and no file removed when the store fails. + script-test-attic-root-facts = import ./script-tests/attic-root-facts.nix { inherit pkgs; }; # Runs ./host-modules/lib/store-seeded-secret.nix's seed, store-wins and mint # decisions against a stub `bao` that keeps its leaves in files. script-test-store-seeded-secret = import ./script-tests/store-seeded-secret.nix { inherit pkgs; }; diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index 0a73c600..e720cfe8 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -23,6 +23,7 @@ ./hive-matrix.nix ./hive-network.nix ./hive-priv.nix + ./hive-priv-cache.nix ./hive-squid.nix ./hive-tls.nix ./otel.nix diff --git a/nix/host-modules/hive-priv-cache.nix b/nix/host-modules/hive-priv-cache.nix new file mode 100644 index 00000000..3e7b701b --- /dev/null +++ b/nix/host-modules/hive-priv-cache.nix @@ -0,0 +1,98 @@ +# The binary-cache facts hive-priv (root) trusts, kept where only root can +# change them. +# +# hive-priv downloads an agent's closure from the swarm cache when a rebuild +# asks it to. What decides whether a download is accepted — which cache, and +# which key must have signed it — must not come from a file the unprivileged +# hive-c0re writes, or a compromised hive-c0re could have root accept a +# closure from a cache of its own. So: +# +# - the cache's address is this host's configuration +# ({option}`services.hyperhive.swarm.attic.domain` and `cache`), handed to +# hive-priv in its unit's environment; +# - the cache's public key and the pull token are read from the store by a root +# oneshot, `hive-priv-cache-facts`, under this hive's own store identity, and +# land in `/run/hyperhive-priv/attic/` (root, `0700`; files `0600`). +# +# hive-c0re keeps its own copies under `/run/hyperhive/attic/` for the probe, +# which only reports. +{ + pkgs, + lib, + config, + ... +}: +let + hyperhiveCfg = config.services.hyperhive; + hiveController = hyperhiveCfg.deploy.hive-controller; + tls = hiveController.tls; + baoCfg = hyperhiveCfg.swarm.bao; + baoDeploy = hyperhiveCfg.deploy.bao; + atticCfg = hyperhiveCfg.swarm.attic; + + # Without a cache to name, a hive name to read under or an identity to log + # in with there is nothing to fetch, and hive-priv has no cache to download + # from. + enabled = + hiveController.enable + && atticCfg.domain != null + && atticCfg.cache != null + && hiveController.hiveName != null + && tls.baoHiveIdentityCertFile != null + && tls.baoHiveIdentityKeyFile != null; + + dir = "/run/hyperhive-priv/attic"; + + storeRetry = import ./lib/store-retry.nix { }; + storeRefreshTimer = import ./lib/store-refresh-timer.nix { }; +in +{ + config = lib.mkIf enabled { + systemd.tmpfiles.rules = [ "d /run/hyperhive-priv 0700 root root - -" ]; + + # The URL nix is told is the only substituter for the download; no request + # chooses it. + systemd.services.hive-priv.environment.HIVE_PRIV_CACHE_URL = + "https://${atticCfg.domain}/${atticCfg.cache}"; + + systemd.services.hive-priv-cache-facts = { + description = "Read the swarm cache's public key and pull token for hive-priv"; + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + after = [ + "network-online.target" + "systemd-tmpfiles-setup.service" + ]; + path = [ + baoDeploy.package + pkgs.coreutils + pkgs.gnugrep + pkgs.systemd + ]; + environment = { + BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}"; + BAO_CLIENT_CERT = tls.baoHiveIdentityCertFile; + BAO_CLIENT_KEY = tls.baoHiveIdentityKeyFile; + } + // lib.optionalAttrs (baoDeploy.serverCaFile != null) { + BAO_CACERT = baoDeploy.serverCaFile; + }; + # ./lib/store-retry.nix: the login fails for as long as the store is + # sealed or down. + inherit (storeRetry) startLimitBurst startLimitIntervalSec; + serviceConfig = storeRetry.serviceConfig // { + Type = "oneshot"; + UMask = "0077"; + SyslogIdentifier = "hive-priv-cache-facts"; + TimeoutStartSec = "5min"; + }; + script = import ./lib/attic-root-facts.nix { inherit lib; } { + hive = hiveController.hiveName; + host = atticCfg.domain; + inherit dir; + }; + }; + + systemd.timers.hive-priv-cache-facts = storeRefreshTimer "re-read the swarm cache's facts for hive-priv"; + }; +} diff --git a/nix/host-modules/hive-tls.nix b/nix/host-modules/hive-tls.nix index e0eb2518..af763e5c 100644 --- a/nix/host-modules/hive-tls.nix +++ b/nix/host-modules/hive-tls.nix @@ -127,8 +127,9 @@ let # The hive's own store identity: its subject is # `-`, and its policy may issue from - # `hiveLeafRole` alone. It also carries the root fetch's handshake. Every - # hive must hold one. + # `hiveLeafRole` and read this hive's own cache public key and pull token + # (`hive-priv-cache-facts`, ./hive-priv-cache.nix), and nothing else. It also + # carries the root fetch's handshake. Every hive must hold one. haveHiveIdentity = cfg.baoHiveIdentityCertFile != null && cfg.baoHiveIdentityKeyFile != null; requireHiveIdentity = requireIdentity { holder = "this hive"; @@ -327,7 +328,8 @@ in Its subject has to be `-`; that cert-auth role's policy may issue from this hive's gateway-leaf - role and nothing else. + role and read this hive's own cache public key and pull token, and + nothing else. `hive-priv-cache-facts` reads those two under this identity. Every hive with a self-signed gateway needs it: without it both units fail, naming this option and diff --git a/nix/host-modules/lib/attic-root-facts.nix b/nix/host-modules/lib/attic-root-facts.nix new file mode 100644 index 00000000..8e0a8652 --- /dev/null +++ b/nix/host-modules/lib/attic-root-facts.nix @@ -0,0 +1,87 @@ +# The root-side copy of the two cache facts hive-priv trusts: the key a +# downloaded closure must be signed by and the netrc the pull token goes out +# in. The script runs as root on the hive host under the hive's own store +# identity and writes both into `dir`, which only root can read. +# +# Everything hive-priv decides on is here or in the host's configuration, and +# nothing is read from a file hive-c0re (unprivileged) writes: the key and the +# token come from the store, the cache's address from +# `services.hyperhive.swarm.attic.{domain,cache}`. +# +# A store that holds no facts yet, or cannot be reached, leaves what is in `dir` +# as it is. Nothing here removes a file. +# +# Pure function — NOT a NixOS module. Call it from a unit's `script`, with +# `bao`, `coreutils` and `gnugrep` on its `path` and the `BAO_*` client +# environment set: +# +# script = import ./lib/attic-root-facts.nix { inherit lib; } { +# hive = "alpha"; host = "attic.example.org"; dir = "/run/hyperhive-priv/attic"; +# }; +{ lib }: +{ + # The hive's name in the store: facts are read from + # `secret/swarm/hives//attic/`. + hive, + # The cache server's host name, which the netrc line names. + host, + # Where the two files go. + dir, +}: +'' + set -euo pipefail + ${import ./store-seeded-secret.nix { }} + ${import ./atomic-write-secret.nix { }} + ${import ./refresh-consumer.nix { }} + + dir=${lib.escapeShellArg dir} + install -d -m 0700 "$dir" + + store_init + store_login + if [ "$STORE_UP" != 1 ]; then + echo "could not read the cache facts; $dir stays as it is." >&2 + exit 1 + fi + + # . Sets the variable to the leaf's value. A leaf the store + # does not hold sets `missing`; any other failure ends the run. + missing=0 + fetch() { + local value + if value="$(store_bao kv get -field=value ${lib.escapeShellArg "secret/swarm/hives/${hive}/attic"}/"$1" 2>"$STORE_ERR")"; then + printf -v "$2" '%s' "$value" + elif grep -qi 'no value found' "$STORE_ERR"; then + missing=1 + else + cat "$STORE_ERR" >&2 + echo "could not read the cache fact $1; $dir stays as it is." >&2 + exit 1 + fi + } + + key="" + token="" + fetch public-key key + fetch pull-token token + if [ "$missing" = 1 ]; then + echo "the swarm has not given this hive its cache facts yet; $dir stays as it is." + exit 0 + fi + + # One token each: both land in a file nix and hive-priv parse by whitespace. + if [[ ! "$key" =~ ^[^[:space:]:]+:[^[:space:]]+$ ]] || [[ ! "$token" =~ ^[^[:space:]]+$ ]]; then + echo "the stored cache facts are not a : and a single token; $dir stays as it is." >&2 + exit 1 + fi + + # Written only when the value changed, so the files keep their mtime between + # runs. The owner is the running user, root, and the mode is 0600. + if secret_differs "$dir/public-key" "$key"; then + atomic_write_secret 0600 "" "$dir/public-key" "$key" + fi + netrc="machine ${host} password $token" + if secret_differs "$dir/netrc" "$netrc"; then + atomic_write_secret 0600 "" "$dir/netrc" "$netrc" + fi +'' diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index f328dd15..ecd6b395 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -935,8 +935,30 @@ let cn = "${baoDeploy.hiveLeafCommonNamePrefix}-${hiveName}"; inherit (hive) domain; dashboard = hyperhiveCfg.swarm.dashboardDomains.${hiveName}; + inherit hiveName; }) hyperhiveCfg.swarm.hives; + # What a hive's gateway-leaf identity may do: issue from its own role, and + # read two of its own cache facts. The root unit on the hive host + # (`hive-priv-cache-facts`, ./hive-priv-cache.nix) reads them under this + # identity: the key hive-priv's downloads must be signed by, and the token it + # pulls with. The cache's URL and name come from the host's configuration, + # not from the store. + hiveLeafPolicyText = + obj: + '' + path "${servicesPkiMountPath}/issue/${obj.name}" { + capabilities = ["update"] + } + '' + + + lib.concatMapStrings + (fact: readStanza "${credentialMountPath}/data/swarm/hives/${obj.hiveName}/attic/${fact}") + [ + "public-key" + "pull-token" + ]; + # The PKI mount agent client certificates are issued from. Its root is # generated inside the store, so the agent CA's key never exists outside it. # A mount of its own because the services mount holds exactly one issuer; a @@ -2241,9 +2263,11 @@ in example = "swarm-bao-hive-leaf.svc"; description = '' Prefix of the subject the store's per-hive gateway-leaf roles accept. - The role for hive `h` accepts `-h` and may only issue from + The role for hive `h` accepts `-h`. Its policy may issue from `/issue/-h`, which issues - for `h`'s domain and `*.` that domain and nothing else. + for `h`'s domain and `*.` that domain and nothing else, and read + `secret/swarm/hives/h/attic/public-key` and `…/pull-token`, which the + root unit `hive-priv-cache-facts` fetches for hive-priv. Per hive because one subject for every hive could ask for any hive's certificate. @@ -3792,11 +3816,7 @@ in ttl=${servicesPkiLeafTtl} \ max_ttl=${servicesPkiLeafTtl} - printf '%s' ${lib.escapeShellArg '' - path "${servicesPkiMountPath}/issue/${obj.name}" { - capabilities = ["update"] - } - ''} | + printf '%s' ${lib.escapeShellArg (hiveLeafPolicyText obj)} | bao policy write ${lib.escapeShellArg obj.name} - bao write auth/cert/certs/${lib.escapeShellArg obj.name} \ diff --git a/nix/module-eval/bao-grants.nix b/nix/module-eval/bao-grants.nix index e5c6f89f..2f7951d6 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -1496,9 +1496,9 @@ let { # One role, policy and login role per hive in the directory, each # naming only its own hive: the policy text in the unit is exactly the - # two `update` grants, and each hive's block names neither the other's - # role nor its subject. - name = "each hive's gateway-leaf identity may issue from its own role alone, for its own domain and dashboard"; + # `update` on its own role and `read` on its own two cache facts, and each + # hive's block names neither the other's role nor its subject. + name = "each hive's gateway-leaf identity may issue from its own role and read its own two cache facts, and nothing else"; ok = let s = baoGrantTwoHives.systemd.services.swarm-bao-hive-leaf-policy.script; @@ -1528,10 +1528,26 @@ let path = "pki/issue/swarm-hive-leaf-h1"; caps = [ "update" ]; } + { + path = "secret/data/swarm/hives/h1/attic/public-key"; + caps = [ "read" ]; + } + { + path = "secret/data/swarm/hives/h1/attic/pull-token"; + caps = [ "read" ]; + } { path = "pki/issue/swarm-hive-leaf-h2"; caps = [ "update" ]; } + { + path = "secret/data/swarm/hives/h2/attic/public-key"; + caps = [ "read" ]; + } + { + path = "secret/data/swarm/hives/h2/attic/pull-token"; + caps = [ "read" ]; + } ] && issues "h1" "h1.t.local" h1 && issues "h2" "h2.example.org" h2 diff --git a/nix/module-eval/hive-tls.nix b/nix/module-eval/hive-tls.nix index ed77a93a..45f265c7 100644 --- a/nix/module-eval/hive-tls.nix +++ b/nix/module-eval/hive-tls.nix @@ -758,6 +758,54 @@ let ] && lib.hasInfix "--connect-timeout 10 --max-time 30" (noSvcBranch noSvcFetch); } + { + # What hive-priv trusts comes from root-owned places: the store, under + # this hive's own identity, and nothing under `swarm/services/`, which + # no hive may read. + name = "the root cache-facts unit reads this hive's own facts under its store identity and writes a root-only directory"; + ok = + let + u = units.hive-priv-cache-facts; + in + lib.hasInfix "secret/swarm/hives/h1/attic" u.script + && !(lib.hasInfix "swarm/services" u.script) + && !(lib.hasInfix "swarm/hives/h2" u.script) + && lib.hasInfix "dir=/run/hyperhive-priv/attic" u.script + && lib.hasInfix "install -d -m 0700 \"$dir\"" u.script + && lib.hasInfix ''atomic_write_secret 0600 "" "$dir/public-key"'' u.script + && lib.hasInfix ''atomic_write_secret 0600 "" "$dir/netrc"'' u.script + && lib.hasInfix "machine attic.t.local password" u.script + && u.environment.BAO_CLIENT_CERT == leafUnit.environment.BAO_CLIENT_CERT + && u.environment.BAO_CLIENT_KEY == leafUnit.environment.BAO_CLIENT_KEY + && u.environment.BAO_CLIENT_CERT != allLocal.services.hyperhive.deploy.bao.clientCertFile + && u.serviceConfig.Type == "oneshot" + && !(remainsAfterExit u) + && lib.elem "d /run/hyperhive-priv 0700 root root - -" allLocal.systemd.tmpfiles.rules; + } + { + # The control for the case above, and the one below: a hive with no + # store identity has neither unit and hive-priv gets no cache URL, so a + # lookup that always came back empty would fail the case before it. + name = "the cache-facts unit has a timer, and a hive with no store identity has neither unit"; + ok = + let + t = allLocal.systemd.timers.hive-priv-cache-facts; + in + lib.elem "timers.target" t.wantedBy + && t.timerConfig.OnUnitInactiveSec == "10min" + && !(noIdentity.systemd.services ? hive-priv-cache-facts) + && !(noIdentity.systemd.timers ? hive-priv-cache-facts) + && noService.systemd.services ? hive-priv-cache-facts + && + noService.systemd.services.hive-priv-cache-facts.environment.BAO_CLIENT_CERT + == "/etc/pki/bao-hive.pem"; + } + { + name = "hive-priv is told the cache's address by the host's configuration"; + ok = + allLocal.systemd.services.hive-priv.environment.HIVE_PRIV_CACHE_URL == "https://attic.t.local/swarm" + && !(noIdentity.systemd.services.hive-priv.environment ? HIVE_PRIV_CACHE_URL); + } ]; in runGroup "hive-tls" cases diff --git a/nix/script-tests/attic-root-facts.nix b/nix/script-tests/attic-root-facts.nix new file mode 100644 index 00000000..405aea8a --- /dev/null +++ b/nix/script-tests/attic-root-facts.nix @@ -0,0 +1,32 @@ +# `checks.script-test-attic-root-facts` — runs +# ../host-modules/lib/attic-root-facts.nix's script, as `hive-priv-cache-facts` +# runs it, against a stub `bao` that keeps its leaves in files. The cases are in +# ./attic-root-facts.sh. +# +# What runs is the rendered script, with the directory it writes moved into the +# test's scratch space. +{ + pkgs, + ... +}: +let + script = pkgs.writeText "attic-root-facts.sh" ( + import ../host-modules/lib/attic-root-facts.nix { inherit (pkgs) lib; } { + hive = "alpha"; + host = "cache.example"; + dir = "@DIR@/attic"; + } + ); +in +pkgs.runCommand "hyperhive-script-test-attic-root-facts" + { + SCRIPT = script; + nativeBuildInputs = [ + pkgs.coreutils + pkgs.gnugrep + ]; + } + '' + ${pkgs.bash}/bin/bash ${./attic-root-facts.sh} + touch "$out" + '' diff --git a/nix/script-tests/attic-root-facts.sh b/nix/script-tests/attic-root-facts.sh new file mode 100644 index 00000000..74faf2bf --- /dev/null +++ b/nix/script-tests/attic-root-facts.sh @@ -0,0 +1,177 @@ +# Cases for ../host-modules/lib/attic-root-facts.nix. Each case runs the script +# against a stub `bao` over a store kept in files, and compares what the script +# left in its directory and which leaves it read. +set -uo pipefail + +failures=0 +count=0 +case="setup" + +fail() { + echo "FAILED: $case: $*" >&2 + failures=$((failures + 1)) +} + +expect() { # + count=$((count + 1)) + if [ "$2" != "$3" ]; then fail "$1: got '$2', wanted '$3'"; fi +} + +dir=$(mktemp -d) +mkdir -p "$dir/bin" +sed "s|@DIR@|$dir|g" "$SCRIPT" >"$dir/script.sh" +calls=$dir/calls +export DIR=$dir + +# `login` answers from LOGIN_FAIL. `kv get -field=value ` prints the file +# kept for the path; a missing one is the store's "no value found" and DENY_READ +# is a policy that grants nothing. Every call is logged. +cat >"$dir/bin/bao" <>"$calls" +case "\$1 \$2" in + "login -method=cert") + if [ "\${LOGIN_FAIL:-0}" = 1 ]; then echo "certificate refused" >&2; exit 2; fi + echo store-token + ;; + "kv get") + [ "\${BAO_TOKEN-}" = store-token ] || { echo "kv without the login's token" >&2; exit 97; } + f="\$DIR/store/\$(printf '%s' "\$4" | tr / _)" + if [ "\${DENY_READ:-0}" = 1 ]; then echo "permission denied" >&2; exit 2; fi + [ -f "\$f" ] || { echo "No value found at \$4" >&2; exit 2; } + cat "\$f" + ;; + *) echo "unexpected call: \$*" >&2; exit 98 ;; +esac +STUB +chmod +x "$dir/bin/bao" +export PATH="$dir/bin:$PATH" + +KEYLEAF=secret_swarm_hives_alpha_attic_public-key +TOKENLEAF=secret_swarm_hives_alpha_attic_pull-token + +reset() { + rm -rf "$dir/store" "$dir/attic" "$calls" + mkdir -p "$dir/store" + : >"$calls" +} + +seed() { # + printf '%s' "$1" >"$dir/store/$KEYLEAF" + printf '%s' "$2" >"$dir/store/$TOKENLEAF" +} + +# run [VAR=value…]: the script's exit status. +run() { + env "$@" bash "$dir/script.sh" >"$dir/out" 2>"$dir/err" +} + +mode() { stat -c %a "$1" 2>/dev/null || echo missing; } +ino() { stat -c %i "$1" 2>/dev/null || echo missing; } +body() { cat "$1" 2>/dev/null || echo missing; } + +case="first run writes the key and the netrc, each 0600, in a 0700 directory" +reset +seed "swarm:S0VZ" "tok-1" +run +expect "exit" $? 0 +expect "key" "$(body "$dir/attic/public-key")" "swarm:S0VZ" +expect "netrc" "$(body "$dir/attic/netrc")" "machine cache.example password tok-1" +expect "key mode" "$(mode "$dir/attic/public-key")" 600 +expect "netrc mode" "$(mode "$dir/attic/netrc")" 600 +expect "dir mode" "$(mode "$dir/attic")" 700 + +case="a second run with the same facts rewrites nothing" +k=$(ino "$dir/attic/public-key") +n=$(ino "$dir/attic/netrc") +run +expect "exit" $? 0 +expect "key untouched" "$(ino "$dir/attic/public-key")" "$k" +expect "netrc untouched" "$(ino "$dir/attic/netrc")" "$n" + +case="a rotated token replaces the netrc and leaves the key" +seed "swarm:S0VZ" "tok-2" +run +expect "exit" $? 0 +expect "netrc" "$(body "$dir/attic/netrc")" "machine cache.example password tok-2" +expect "netrc mode" "$(mode "$dir/attic/netrc")" 600 +expect "key untouched" "$(ino "$dir/attic/public-key")" "$k" + +case="a rotated key replaces the key file" +seed "swarm:Tk9XCg" "tok-2" +run +expect "exit" $? 0 +expect "key" "$(body "$dir/attic/public-key")" "swarm:Tk9XCg" + +case="only the hive's own leaves are read, after a login" +expect "reads" "$(grep -c '^bao kv get' "$calls")" "$(grep -c '^bao kv get -field=value secret/swarm/hives/alpha/attic/' "$calls")" +expect "login first" "$(head -1 "$calls")" "bao login -method=cert -token-only" +expect "something was read" "$([ "$(grep -c '^bao kv get' "$calls")" -ge 2 ] && echo yes || echo no)" yes + +case="a store with no facts exits cleanly and creates nothing" +reset +run +expect "exit" $? 0 +expect "no key" "$(body "$dir/attic/public-key")" missing +expect "no netrc" "$(body "$dir/attic/netrc")" missing + +case="one fact without the other creates nothing" +reset +printf '%s' "swarm:S0VZ" >"$dir/store/$KEYLEAF" +run +expect "exit" $? 0 +expect "no key" "$(body "$dir/attic/public-key")" missing +expect "no netrc" "$(body "$dir/attic/netrc")" missing + +# The failure cases start from a good state and an extra file in the directory. +good() { + reset + seed "swarm:S0VZ" "tok-1" + run + touch "$dir/attic/other" +} +intact() { + expect "key kept" "$(body "$dir/attic/public-key")" "swarm:S0VZ" + expect "netrc kept" "$(body "$dir/attic/netrc")" "machine cache.example password tok-1" + expect "other file kept" "$(body "$dir/attic/other")" "" +} + +case="a store that refuses the login fails the run and removes nothing" +good +seed "swarm:NEW" "tok-new" +run LOGIN_FAIL=1 +expect "exit" $? 1 +intact + +case="a store that refuses the read fails the run and removes nothing" +good +run DENY_READ=1 +expect "exit" $? 1 +intact + +case="a key with whitespace is refused and nothing changes" +good +seed "swarm:S0 VZ" "tok-new" +run +expect "exit" $? 1 +intact + +case="a token with whitespace is refused and nothing changes" +good +seed "swarm:S0VZ" "tok new" +run +expect "exit" $? 1 +intact + +case="a key without a name is refused and nothing changes" +good +seed "S0VZ" "tok-new" +run +expect "exit" $? 1 +intact + +if [ "$failures" -ne 0 ]; then + echo "$failures of $count checks failed" >&2 + exit 1 +fi +echo "$count checks passed"