Watch
0
0
Fork
You've already forked hyperhive
0

attic-init: replace the stored public key with the one the cache reports

a re-created cache signs with a new key; the fact was only created once, so
every hive then rejected the swarm's closures. the cache's grant gains update
on that one leaf, and a store that refuses it leaves the key as it was.

Closes #6181
This commit is contained in:
atlas 2026-11-07 07:32:28 +01:00 • committed by mara
commit b0f6ccaa64
5 changed files with 159 additions and 75 deletions

View file

@ -1,7 +1,8 @@
# The shell for the in-swarm cache's first start: what its two host units run
# after they log in to the secret store under the cache's own certificate.
# Each secret is created only while the store holds none, so a second run reads
# what the first one stored and mints nothing.
# what the first one stored and mints nothing. The cache's public key is the
# exception: the store is brought to the key the cache reports.
#
# Pure function — NOT a NixOS module. Call it from a module's `let`:
#
@ -21,9 +22,10 @@
jwtPath,
pullPath,
pushPath,
# The cache's public facts, created only while absent: the server's address
# (scheme included), the cache's name, and the key its narinfo are signed
# with. Hives pull with them.
# The cache's public facts: the server's address (scheme included), the
# cache's name, and the key its narinfo are signed with. Hives pull with
# them. The address and the name are created only while absent; the key is
# replaced whenever the cache reports another.
urlPath,
cachePath,
publicKeyPath,
@ -187,6 +189,31 @@ in
}
ensure_secret ${q urlPath} mint_url >/dev/null || exit 1
ensure_secret ${q cachePath} mint_cache >/dev/null || exit 1
ensure_secret ${q publicKeyPath} mint_public_key >/dev/null || exit 1
stored_key="$(ensure_secret ${q publicKeyPath} mint_public_key)" || exit 1
# A cache created again has a new signing key, so the stored key is
# replaced by the one the cache reports. Only the cache's own grant on this
# path allows an update; a store whose policy lacks it refuses, and the
# stored key stays.
live_key="$(mint_public_key)"
if [ -z "$live_key" ]; then
echo "the cache ${cache} reported no public key." >&2
exit 1
fi
if [ "$stored_key" != "$live_key" ]; then
if printf '%s' "$live_key" | bao kv put ${q publicKeyPath} value=- >/dev/null 2>"$err"; then
echo "replaced the stored public key with the one the cache ${cache} reports."
else
case "$(cat "$err")" in
*"permission denied"*)
echo "the store refuses to update ${publicKeyPath}; the stored key is not the cache's." >&2
;;
*)
cat "$err" >&2
exit 1
;;
esac
fi
fi
'';
}

View file

@ -7,8 +7,9 @@
# ./swarm-grafana.nix gives its admin password. One creates the key atticd signs
# tokens with, if the store holds none, and hands it to the container. The other
# creates the cache, the push and pull tokens, and the facts hives pull with
# (its address, name and public key), each only while absent
# (./lib/attic-init.nix). Neither needs a manual step.
# (its address and name, created only while absent, and its public key, which
# follows what the cache reports; ./lib/attic-init.nix). Neither needs a manual
# step.
#
# The container has a network namespace of its own on the hive bridge. atticd
# listens on that namespace's loopback only, because it cannot listen on a unix

View file

@ -1307,11 +1307,11 @@ let
}
];
# The in-swarm cache's six secrets, create and read and nothing more: its
# host units create each one only while it is absent, so none needs `update`,
# and a rewrite of a signing key or a token an operator holds is refused. The
# cache container's journal forwarder reads its client secret under the same
# leaf.
# The in-swarm cache's six secrets: create and read, so a rewrite of a signing
# key or a token an operator holds is refused. The public key also gets
# `update`: the cache's host unit replaces it when a re-created cache signs
# with another. The cache container's journal forwarder reads its client
# secret under the same leaf.
atticWriters = [
{
name = "swarm-attic";
@ -1328,8 +1328,13 @@ let
atticJwtLeaf
atticPushTokenLeaf
]
++ atticFactLeaves
++ lib.remove atticPublicKeyLeaf atticFactLeaves
)
+ ''
path "${credentialMountPath}/data/${atticPublicKeyLeaf}" {
capabilities = ["create", "read", "update"]
}
''
+ readStanza "${credentialMountPath}/data/swarm/services/${hyperhiveCfg.swarm.attic.otel.clientId}/oidc/client";
}
];
@ -2592,7 +2597,8 @@ in
Subject the store's `swarm-attic` cert-auth role accepts: the identity
the in-swarm cache's host units present. Its grant is create and read on
six paths under `swarm/services/attic/`, `jwt-secret`, `push-token`,
`pull-token`, `url`, `cache` and `public-key`, and nothing else.
`pull-token`, `url`, `cache` and `public-key`, plus `update` on
`public-key`, and nothing else.
⚠️ Reserved as a hive name by ./swarm.nix, like its siblings.
'';

View file

@ -2129,10 +2129,10 @@ let
}
{
# The cache's host units create each of its six secrets and read them
# back, and may not replace one: no `update`. Its container's journal
# forwarder reads its own client secret, read only. The notification
# router's grant is the control that `grantsIn` reads a one-path grant too.
name = "the cache's grant is create and read on its signing key, its two tokens and its three public facts, read on its forwarder client, and nothing else";
# back, and replace only the public key: `update` there and nowhere else.
# Its container's journal forwarder reads its own client secret, read
# only. The notification router's grant is the control that `grantsIn` reads a one-path grant too.
name = "the cache's grant is create and read on its signing key, its two tokens and its three public facts, update on its public key only, read on its forwarder client, and nothing else";
ok =
let
s = baoGrantHere.systemd.services.swarm-bao-attic-policy.script;
@ -2147,7 +2147,7 @@ let
&& (grantFor grants "secret/data/swarm/services/attic/pull-token").caps == caps
&& (grantFor grants "secret/data/swarm/services/attic/url").caps == caps
&& (grantFor grants "secret/data/swarm/services/attic/cache").caps == caps
&& (grantFor grants "secret/data/swarm/services/attic/public-key").caps == caps
&& (grantFor grants "secret/data/swarm/services/attic/public-key").caps == caps ++ [ "update" ]
&&
(grantFor grants "secret/data/swarm/services/swarm-attic-collector/oidc/client").caps == [ "read" ]
&& lib.length grants == 7

View file

@ -48,6 +48,8 @@ case "\$1 \$2" in
f=\$(file "\$path")
echo "bao put cas=\$cas \$path" >>"$calls"
value=\$(cat)
# UPDATE_FAIL is what the store says to a put that replaces a value.
if [ "\$cas" = 0 ] && [ -n "\${UPDATE_FAIL:-}" ]; then echo "\$UPDATE_FAIL" >&2; exit 1; fi
if [ "\${RACE_PATH:-}" = "\$path" ] && [ ! -f "\$f" ]; then
printf '%s' raced >"\$f"
fi
@ -85,7 +87,7 @@ token=\$(cat "\$tokenfile")
echo "attic \$* | token=\$token" >>"$calls"
case "\$1 \$2" in
"cache info")
[ -f "\$DIR/cache-exists" ] && echo " Public Key: grid:FAKEKEY" >&2
[ -f "\$DIR/cache-exists" ] && echo " Public Key: \$(cat "\$DIR/live-key" 2>/dev/null || echo grid:FAKEKEY)" >&2
;;
"cache create") touch "\$DIR/cache-exists" ;;
*) exit 1 ;;
@ -122,7 +124,7 @@ run() {
}
reset() {
rm -rf "$dir/store" "$dir/secrets" "$dir/cache-exists" "$calls"
rm -rf "$dir/store" "$dir/secrets" "$dir/cache-exists" "$dir/live-key" "$calls"
mkdir -p "$dir/store" "$dir/secrets"
: >"$calls"
}
@ -134,6 +136,7 @@ PUSH=secret_swarm_services_attic_push-token
URL=secret_swarm_services_attic_url
CACHE=secret_swarm_services_attic_cache
KEY=secret_swarm_services_attic_public-key
KEY_PATH=secret/swarm/services/attic/public-key
ENVFILE=$dir/secrets/server.env
expect() { # <description> <got> <want>
@ -208,12 +211,12 @@ expect "exit status" $? 0
expect "no token minted" "$(n '^atticadm')" 0
expect "no create call" "$(n '^bao put')" 0
expect "no cache create" "$(n '^attic cache create')" 0
expect "the pull token looked the cache up" "$(n "^attic cache info local:grid | token=$pull")" 1
expect "the pull token looked the cache up, and read its key" "$(n "^attic cache info local:grid | token=$pull")" 2
expect "pull token unchanged" "$(stored $PULL)" "$pull"
expect "push token unchanged" "$(stored $PUSH)" "$push"
expect "address, name and key unchanged" "$(stored $URL)$(stored $CACHE)$(stored $KEY)" "http://cache.examplegridgrid:FAKEKEY"
case="values the operator stored over the minted ones are kept"
case="values the operator stored over the minted ones are kept, except the public key"
for leaf in $PULL $PUSH $URL $CACHE $KEY; do
printf '%s' "operator-$leaf" >"$dir/store/$leaf"
done
@ -221,12 +224,14 @@ done
run "$PROVISION"
expect "exit status" $? 0
expect "no token minted" "$(n '^atticadm')" 0
expect "nothing written" "$(n '^bao put')" 0
expect "only the public key is written" "$(n '^bao put')" 1
expect "the put replaces, it does not create" "$(n "^bao put cas=0 $KEY_PATH")" 1
expect "the operator's push token is still stored" "$(stored $PUSH)" "operator-$PUSH"
expect "the operator's pull token is still stored" "$(stored $PULL)" "operator-$PULL"
expect "the operator's address, name and key are still stored" \
"$(stored $URL)$(stored $CACHE)$(stored $KEY)" "operator-${URL}operator-${CACHE}operator-${KEY}"
expect "the cache lookup used the operator's pull token" "$(n "^attic cache info local:grid | token=operator-$PULL")" 1
expect "the operator's address and name are still stored" \
"$(stored $URL)$(stored $CACHE)" "operator-${URL}operator-${CACHE}"
expect "the key is the cache's, not the operator's" "$(stored $KEY)" "grid:FAKEKEY"
expect "the cache lookup used the operator's pull token, twice" "$(n "^attic cache info local:grid | token=operator-$PULL")" 2
printf '%s' "$pull" >"$dir/store/$PULL"
printf '%s' "$push" >"$dir/store/$PUSH"
printf '%s' http://cache.example >"$dir/store/$URL"
@ -243,6 +248,48 @@ expect "no secret rewritten" "$(n '^bao put')" 0
expect "cache created" "$(n '^attic cache create')" 1
expect "pull token unchanged" "$(stored $PULL)" "$pull"
case="a cache created again with another key replaces the stored key"
rm -f "$dir/cache-exists"
printf '%s' grid:NEWKEY >"$dir/live-key"
: >"$calls"
run "$PROVISION"
expect "exit status" $? 0
expect "cache created" "$(n '^attic cache create')" 1
expect "the stored key is the new cache's" "$(stored $KEY)" "grid:NEWKEY"
expect "one key written, replacing" "$(n "^bao put cas=0 $KEY_PATH")" 1
expect "nothing else written" "$(n '^bao put')" 1
expect "pull token unchanged" "$(stored $PULL)" "$pull"
case="a stored key equal to the cache's is not written again"
: >"$calls"
run "$PROVISION"
expect "exit status" $? 0
expect "nothing written" "$(n '^bao put')" 0
expect "the key is still the new one" "$(stored $KEY)" "grid:NEWKEY"
case="a store that refuses the update keeps the key and the unit succeeds"
printf '%s' grid:FAKEKEY >"$dir/store/$KEY"
: >"$calls"
run "$PROVISION" 'UPDATE_FAIL=Error making API request. Code: 403. Errors: * 1 error occurred: * permission denied'
expect "exit status" $? 0
expect "the update was tried" "$(n "^bao put cas=0 $KEY_PATH")" 1
expect "the old key stays" "$(stored $KEY)" "grid:FAKEKEY"
expect "the refusal is said" "$(grep -c 'refuses to update' "$dir/err" || true)" 1
case="any other failure of the update fails the unit"
: >"$calls"
run "$PROVISION" UPDATE_FAIL="connection refused"
expect "exit status" $? 1
expect "the old key stays" "$(stored $KEY)" "grid:FAKEKEY"
case="a cache that reports no key fails the unit and writes nothing"
printf '' >"$dir/live-key"
: >"$calls"
run "$PROVISION"
expect "exit status" $? 1
expect "nothing written" "$(n '^bao put')" 0
rm -f "$dir/live-key"
case="no tokens are minted while the server is down"
reset
run "$ENSURE_JWT"
@ -277,7 +324,7 @@ run "$ENSURE_JWT"
run "$PROVISION"
expect "no token among the client's arguments" "$(grep -c -- '^attic [^|]*tok-' "$calls" || true)" 0
# The control: the client did see tokens, through its config file.
expect "tokens seen through the config file" "$(grep -c -- '| token=tok-' "$calls" || true)" 4
expect "tokens seen through the config file" "$(grep -c -- '| token=tok-' "$calls" || true)" 5
if [ "$failures" -ne 0 ]; then
echo "$failures of $count checks failed" >&2