Watch
0
0
Fork
You've already forked hyperhive
0

keep the cache's key and pull token for hive-priv in a root-only directory

This commit is contained in:
atlas 2026-10-28 02:21:01 +01:00
commit b845630023
13 changed files with 519 additions and 22 deletions

View file

@ -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

View file

@ -22,7 +22,9 @@ hive's domain and `*.<hive domain>`, read from
`services.hyperhive.swarm.hives.<name>.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`.

View file

@ -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 | `<deploy.hive-controller.tls.stateDir>/<name>.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/<id>.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/<id>.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/<hive>/forge/token`, for the `hive-<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 | `<deploy.hive-controller.tls.stateDir>/<name>.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/<hive>/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/<id>.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/<id>.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/<hive>/forge/token`, for the `hive-<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

View file

@ -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; };

View file

@ -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

View file

@ -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";
};
}

View file

@ -127,8 +127,9 @@ let
# The hive's own store identity: its subject is
# `<hiveLeafCommonNamePrefix>-<hiveName>`, 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
`<services.hyperhive.deploy.bao.hiveLeafCommonNamePrefix>-<hiveName>`;
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

View file

@ -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/<hive>/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
# <leaf> <variable>. 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 <name>:<key> 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
''

View file

@ -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 `<prefix>-h` and may only issue from
The role for hive `h` accepts `<prefix>-h`. Its policy may issue from
`<servicesPkiMountPath>/issue/<hiveLeafPkiRolePrefix>-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} \

View file

@ -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

View file

@ -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

View file

@ -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"
''

View file

@ -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() { # <description> <got> <want>
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 <path>` 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" <<STUB
#!$BASH
echo "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() { # <key> <token>
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"