Watch
0
0
Fork
You've already forked hyperhive
0

agents: swap the agent's bao leaf in with one rename

The live pair was replaced with two renames, key then cert. A run
stopped between them (timeout, container stop, power loss) left a new
key beside the old cert, and every later run logged in with that pair,
so the agent could not re-pull until its container restarted.

Each pair now goes into a fresh directory under
/run/hive-agent-bao-identity, and `current`, a symlink to it, is
replaced by one rename. Wherever a run stops, `current` names one whole
pair; the next run removes whatever a stopped run left. Consumers read
`current/cert` and `current/key` through the same read-only options and
still re-read them on every login.

The script test covers a swap stopped at each of its link and rename
steps: the live pair is always one whole pair, and the next run puts
the new one live.

Refs #5017
This commit is contained in:
atlas 2026-10-11 03:24:49 +02:00 • committed by mara
commit 4c4ecb8ed9
7 changed files with 166 additions and 58 deletions

View file

@ -50,12 +50,12 @@ let
unitName = "hive-agent-bao-identity"; unitName = "hive-agent-bao-identity";
runtimeDir = unitName; runtimeDir = unitName;
certFile = "/run/${runtimeDir}/cert"; # A symlink to the directory that holds the live pair. Each pair is written
keyFile = "/run/${runtimeDir}/key"; # into a fresh directory and goes live with one rename of this link, so the
# Beside the live pair, so each rename that replaces it stays in one # link always names one whole pair, old or new.
# directory. liveLink = "/run/${runtimeDir}/current";
stagedCert = "/run/${runtimeDir}/cert.new"; certFile = "${liveLink}/cert";
stagedKey = "/run/${runtimeDir}/key.new"; keyFile = "${liveLink}/key";
# The store's whole answer, key included, in the unit's own `0700` directory. # The store's whole answer, key included, in the unit's own `0700` directory.
rawFile = "/run/${runtimeDir}/bao-mtls.json"; rawFile = "/run/${runtimeDir}/bao-mtls.json";
errFile = "/run/${runtimeDir}/bao.err"; errFile = "/run/${runtimeDir}/bao.err";
@ -196,14 +196,48 @@ in
# is removed before the command that creates it. # is removed before the command that creates it.
err=${lib.escapeShellArg errFile} err=${lib.escapeShellArg errFile}
raw=${lib.escapeShellArg rawFile} raw=${lib.escapeShellArg rawFile}
trap 'rm -f "$err" "$raw" ${lib.escapeShellArg stagedCert} ${lib.escapeShellArg stagedKey}' EXIT trap 'rm -f "$err" "$raw"' EXIT
live=${lib.escapeShellArg liveLink}
dir=''${live%/*}
# A fresh directory for one pair. `chmod`, because the umask cuts
# mktemp's `0700` down to a `0400` no file can be created in.
new_pair() {
local d
d=$(mktemp -d -p "$dir" pair.XXXXXX)
chmod 0700 "$d"
echo "$d"
}
# The only step that changes the live pair: one rename of the link. A
# reader whose two opens straddle it gets one file of each pair for
# that one login; the next login reads a whole pair.
go_live() {
rm -f "$live.new"
ln -s "''${1##*/}" "$live.new"
mv -Tf "$live.new" "$live"
}
# Every pair directory but the live one is left over: the pair the
# last swap replaced, kept until now for a reader that resolved the
# link before the swap, or a pair a stopped run staged.
current=$(readlink "$live" || true)
for d in "$dir"/pair.*; do
if [ -d "$d" ] && [ "''${d##*/}" != "$current" ]; then
chmod 0700 "$d"
rm -rf "$d"
fi
done
rm -f "$live.new"
# The delivered pair is the start of every container's life: /run is # The delivered pair is the start of every container's life: /run is
# empty at boot and kept across this unit's runs. # empty at boot and kept across this unit's runs.
if [ ! -s "$BAO_CLIENT_CERT" ] || [ ! -s "$BAO_CLIENT_KEY" ]; then if [ ! -s "$BAO_CLIENT_CERT" ] || [ ! -s "$BAO_CLIENT_KEY" ]; then
rm -f "$BAO_CLIENT_CERT" "$BAO_CLIENT_KEY" seed=$(new_pair)
cp "$CREDENTIALS_DIRECTORY/${keyCredential}" "$BAO_CLIENT_KEY" cp "$CREDENTIALS_DIRECTORY/${keyCredential}" "$seed/key"
cp "$CREDENTIALS_DIRECTORY/${certCredential}" "$BAO_CLIENT_CERT" cp "$CREDENTIALS_DIRECTORY/${certCredential}" "$seed/cert"
go_live "$seed"
fi fi
# Cert auth is a login, not a transport setting. The `BAO_CLIENT_*` # Cert auth is a login, not a transport setting. The `BAO_CLIENT_*`
@ -247,31 +281,30 @@ in
# `-j`: the bytes as the store holds them, which is what the hive # `-j`: the bytes as the store holds them, which is what the hive
# delivered, so an unchanged leaf compares equal to the seeded copy. # delivered, so an unchanged leaf compares equal to the seeded copy.
rm -f ${lib.escapeShellArg stagedCert} ${lib.escapeShellArg stagedKey} staged=$(new_pair)
if ! jq -ej '.data.data.cert | strings' "$raw" >${lib.escapeShellArg stagedCert} \ if ! jq -ej '.data.data.cert | strings' "$raw" >"$staged/cert" \
|| ! jq -ej '.data.data.key | strings' "$raw" >${lib.escapeShellArg stagedKey}; then || ! jq -ej '.data.data.key | strings' "$raw" >"$staged/key"; then
rm -rf "$staged"
echo "${identityPath} holds no certificate and key; this agent keeps the one it has." >&2 echo "${identityPath} holds no certificate and key; this agent keeps the one it has." >&2
exit 1 exit 1
fi fi
if cmp -s ${lib.escapeShellArg stagedCert} "$BAO_CLIENT_CERT" \ if cmp -s "$staged/cert" "$BAO_CLIENT_CERT" && cmp -s "$staged/key" "$BAO_CLIENT_KEY"; then
&& cmp -s ${lib.escapeShellArg stagedKey} "$BAO_CLIENT_KEY"; then rm -rf "$staged"
echo "authenticated to the swarm secret store at $BAO_ADDR as ${agentName}; its certificate at ${identityPath} is unchanged." echo "authenticated to the swarm secret store at $BAO_ADDR as ${agentName}; its certificate at ${identityPath} is unchanged."
else else
# This unit's next login uses whatever is swapped in, so a pair the # This unit's next login uses whatever goes live, so a pair the
# store refuses must never replace the one it accepts. # store refuses must never replace the one it accepts.
rm -f "$err" rm -f "$err"
if ! BAO_CLIENT_CERT=${lib.escapeShellArg stagedCert} BAO_CLIENT_KEY=${lib.escapeShellArg stagedKey} \ if ! BAO_CLIENT_CERT="$staged/cert" BAO_CLIENT_KEY="$staged/key" \
bao login -method=cert -token-only >/dev/null 2>"$err"; then bao login -method=cert -token-only >/dev/null 2>"$err"; then
rm -rf "$staged"
echo "the swarm secret store refused the certificate at ${identityPath}; this agent keeps the one it has." >&2 echo "the swarm secret store refused the certificate at ${identityPath}; this agent keeps the one it has." >&2
if [ -s "$err" ]; then cat "$err" >&2; fi if [ -s "$err" ]; then cat "$err" >&2; fi
exit 1 exit 1
fi fi
# Two renames: a reader that opens the pair between them gets a go_live "$staged"
# mismatched cert and key and fails that one login.
mv -f ${lib.escapeShellArg stagedKey} "$BAO_CLIENT_KEY"
mv -f ${lib.escapeShellArg stagedCert} "$BAO_CLIENT_CERT"
echo "replaced this agent's store certificate with the one at ${identityPath}." echo "replaced this agent's store certificate with the one at ${identityPath}."
fi fi
''; '';

View file

@ -84,8 +84,8 @@ let
e = u.environment; e = u.environment;
in in
e.BAO_ADDR == baoAddr e.BAO_ADDR == baoAddr
&& e.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/cert" && e.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
&& e.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/key"; && e.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/current/key";
} }
{ {
# The field is the secret. It goes to a file by redirect and is never # The field is the secret. It goes to a file by redirect and is never
@ -199,7 +199,7 @@ let
in in
u.serviceConfig.User == name u.serviceConfig.User == name
&& u.serviceConfig.UMask == "0077" && u.serviceConfig.UMask == "0077"
&& u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/cert"; && u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert";
} }
{ {
# Files a hive wrote keep working until the operator re-links them. # Files a hive wrote keep working until the operator re-links them.

View file

@ -83,8 +83,8 @@ let
u.serviceConfig.User == name u.serviceConfig.User == name
&& u.serviceConfig.UMask == "0077" && u.serviceConfig.UMask == "0077"
&& u.environment.BAO_ADDR == baoAddr && u.environment.BAO_ADDR == baoAddr
&& u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/cert" && u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
&& u.environment.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/key"; && u.environment.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/current/key";
} }
{ {
# A file a hive wrote keeps working until the operator links a token in # A file a hive wrote keeps working until the operator links a token in

View file

@ -124,8 +124,8 @@ let
in in
u.serviceConfig.LoadCredential == [ "hive-agent-bao-server-ca" ] u.serviceConfig.LoadCredential == [ "hive-agent-bao-server-ca" ]
&& u.environment.BAO_ADDR == "https://bao.t.local:8200" && u.environment.BAO_ADDR == "https://bao.t.local:8200"
&& u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/cert" && u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
&& u.environment.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/key" && u.environment.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/current/key"
&& builtins.elem "hive-agent-bao-identity.service" u.after; && builtins.elem "hive-agent-bao-identity.service" u.after;
} }
{ {

View file

@ -97,8 +97,8 @@ let
u = agentBaoIdentity agentBao; u = agentBaoIdentity agentBao;
e = u.environment; e = u.environment;
in in
e.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/cert" e.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
&& e.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/key" && e.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/current/key"
&& u.serviceConfig.RuntimeDirectory == "hive-agent-bao-identity" && u.serviceConfig.RuntimeDirectory == "hive-agent-bao-identity"
&& u.serviceConfig.RuntimeDirectoryPreserve == "yes" && u.serviceConfig.RuntimeDirectoryPreserve == "yes"
&& e.BAO_ADDR == agentBao.services.hyperhive.agent.bao.addr; && e.BAO_ADDR == agentBao.services.hyperhive.agent.bao.addr;
@ -177,8 +177,8 @@ let
let let
u = agentHarness agentBao; u = agentHarness agentBao;
in in
u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/cert" u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
&& u.environment.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/key" && u.environment.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/current/key"
&& builtins.elem "hive-agent-bao-identity.service" u.after && builtins.elem "hive-agent-bao-identity.service" u.after
&& u.environment.BAO_ADDR == agentBao.services.hyperhive.agent.bao.addr; && u.environment.BAO_ADDR == agentBao.services.hyperhive.agent.bao.addr;
} }

View file

@ -116,8 +116,8 @@ let
u.serviceConfig.LoadCredential == [ "hive-agent-bao-server-ca" ] u.serviceConfig.LoadCredential == [ "hive-agent-bao-server-ca" ]
&& u.environment.HIVE_AGENT_NAME == "a1" && u.environment.HIVE_AGENT_NAME == "a1"
&& u.environment.BAO_ADDR == "https://bao.t.local:8200" && u.environment.BAO_ADDR == "https://bao.t.local:8200"
&& u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/cert" && u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
&& u.environment.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/key" && u.environment.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/current/key"
) )
[ [
(subagent claudeBao) (subagent claudeBao)
@ -134,8 +134,8 @@ let
u.serviceConfig.LoadCredential == [ "hive-agent-bao-server-ca" ] u.serviceConfig.LoadCredential == [ "hive-agent-bao-server-ca" ]
&& u.environment.HIVE_AGENT_NAME == "a1" && u.environment.HIVE_AGENT_NAME == "a1"
&& u.environment.BAO_ADDR == "https://bao.t.local:8200" && u.environment.BAO_ADDR == "https://bao.t.local:8200"
&& u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/cert" && u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
&& u.environment.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/key"; && u.environment.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/current/key";
} }
{ {
name = "an opencode agent with no store hands its subagent daemon no store identity"; name = "an opencode agent with no store hands its subagent daemon no store identity";

View file

@ -36,12 +36,14 @@ setup() {
log=$dir/bao.log log=$dir/bao.log
mkdir -m 0700 "$rt" mkdir -m 0700 "$rt"
mkdir "$creds" mkdir "$creds"
# Per-case stubs that shadow the unit's own tools; empty unless a case adds one.
mkdir "$dir/bin"
printf cert >"$creds/hive-agent-bao-cert" printf cert >"$creds/hive-agent-bao-cert"
printf key >"$creds/hive-agent-bao-key" printf key >"$creds/hive-agent-bao-key"
# The identity pair the unit presents: the delivered one for the fetch, the # The identity pair the unit presents: the delivered one for the fetch, the
# live copy it keeps for the identity unit. # live copy it keeps for the identity unit.
if [ "$prefix" = IDENT ]; then if [ "$prefix" = IDENT ]; then
client_cert=$rt/cert client_key=$rt/key client_cert=$rt/current/cert client_key=$rt/current/key
else else
client_cert=$creds/hive-agent-bao-cert client_key=$creds/hive-agent-bao-key client_cert=$creds/hive-agent-bao-cert client_key=$creds/hive-agent-bao-key
fi fi
@ -69,7 +71,7 @@ go() {
umask 0377 umask 0377
cd "$dir" || exit 99 cd "$dir" || exit 99
exec env -i \ exec env -i \
PATH="$FAKE_BAO_BIN:$unit_path" \ PATH="$dir/bin:$FAKE_BAO_BIN:$unit_path" \
BAO_ADDR="$bao_addr" \ BAO_ADDR="$bao_addr" \
BAO_CLIENT_CERT="$client_cert" \ BAO_CLIENT_CERT="$client_cert" \
BAO_CLIENT_KEY="$client_key" \ BAO_CLIENT_KEY="$client_key" \
@ -249,63 +251,136 @@ ident_path=secret/swarm/agents/a1/bao-mtls
ident_kv_call="kv get -format=json $ident_path" ident_kv_call="kv get -format=json $ident_path"
leaf() { printf '{"data":{"data":{"cert":"%s","key":"%s"}}}' "$1" "$2"; } leaf() { printf '{"data":{"data":{"cert":"%s","key":"%s"}}}' "$1" "$2"; }
expect_pair() {
expect_file "$rt/current/cert" "$1"
expect_file "$rt/current/key" "$2"
}
expect_pairs() {
local d n=0
for d in "$rt"/pair.*; do
if [ -d "$d" ]; then n=$((n + 1)); fi
done
if [ "$n" != "$1" ]; then fail "$n pair directories left, expected $1"; fi
}
# live_pair <name> <cert> <key> — a pair an earlier run made live.
live_pair() {
mkdir -m 0700 "$rt/pair.$1"
printf '%s' "$2" >"$rt/pair.$1/cert"
printf '%s' "$3" >"$rt/pair.$1/key"
chmod 0400 "$rt/pair.$1/cert" "$rt/pair.$1/key"
ln -sfn "pair.$1" "$rt/current"
}
# stop_at <n> — the n-th `ln` or `mv` the script runs fails, which leaves
# things as a run killed just before that step would.
stop_at() {
local tool real
mkdir "$dir/steps"
for tool in ln mv; do
real=$(PATH=$unit_path command -v "$tool")
cat >"$dir/bin/$tool" <<STUB
#!$BASH
mktemp -p "$dir/steps" >/dev/null
steps=("$dir/steps"/*)
if [ "\${#steps[@]}" -ge "$1" ]; then exit 1; fi
exec "$real" "\$@"
STUB
chmod +x "$dir/bin/$tool"
done
}
setup IDENT "no store identity delivered" setup IDENT "no store identity delivered"
: >"$creds/hive-agent-bao-cert" : >"$creds/hive-agent-bao-cert"
go go
expect_rc 1 expect_rc 1
expect_err "has no store identity" expect_err "has no store identity"
expect_calls expect_calls
expect_no_file "$rt/cert" if [ -e "$rt/current" ]; then fail "a live pair was made from nothing"; fi
setup IDENT "a first run seeds the live pair from the delivered one" setup IDENT "a first run makes the delivered pair live"
kv_out=$(leaf cert key) kv_out=$(leaf cert key)
go go
expect_rc 0 expect_rc 0
expect_out "is unchanged" expect_out "is unchanged"
expect_calls "$login_call" "$ident_kv_call" expect_calls "$login_call" "$ident_kv_call"
expect_file "$rt/cert" cert if [ ! -L "$rt/current" ]; then fail "$rt/current is not a symlink"; fi
expect_file "$rt/key" key expect_pair cert key
if [ "$(stat -c %a "$rt/key")" != 400 ]; then fail "key is mode $(stat -c %a "$rt/key"), expected 400"; fi if [ "$(stat -L -c %a "$rt/current/key")" != 400 ]; then fail "key is mode $(stat -L -c %a "$rt/current/key"), expected 400"; fi
expect_pairs 1
expect_no_file "$rt/bao-mtls.json" expect_no_file "$rt/bao-mtls.json"
setup IDENT "the live pair outlives the delivered one" setup IDENT "the live pair outlives the delivered one"
printf cert2 >"$rt/cert" live_pair old cert2 key2
printf key2 >"$rt/key"
chmod 0400 "$rt/cert" "$rt/key"
kv_out=$(leaf cert2 key2) kv_out=$(leaf cert2 key2)
go go
expect_rc 0 expect_rc 0
expect_out "is unchanged" expect_out "is unchanged"
expect_file "$rt/cert" cert2 expect_pair cert2 key2
expect_pairs 1
setup IDENT "a re-issued leaf replaces the live pair once a login with it succeeds" setup IDENT "a re-issued leaf goes live once a login with it succeeds"
kv_out=$(leaf cert2 key2) kv_out=$(leaf cert2 key2)
go go
expect_rc 0 expect_rc 0
expect_out "replaced this agent's store certificate" expect_out "replaced this agent's store certificate"
expect_calls "$login_call" "$ident_kv_call" "$login_call" expect_calls "$login_call" "$ident_kv_call" "$login_call"
expect_file "$rt/cert" cert2 expect_pair cert2 key2
expect_file "$rt/key" key2 # The replaced pair stays for a reader that resolved the link before the swap.
expect_no_file "$rt/cert.new" expect_pairs 2
expect_no_file "$rt/key.new" go
expect_rc 0
expect_out "is unchanged"
expect_pairs 1
setup IDENT "a re-issued leaf the store refuses is not swapped in" setup IDENT "a re-issued leaf the store refuses does not go live"
kv_out=$(leaf cert2 key2) kv_out=$(leaf cert2 key2)
refuse_cert=cert2 refuse_cert=cert2
go go
expect_rc 1 expect_rc 1
expect_err "refused the certificate at $ident_path" expect_err "refused the certificate at $ident_path"
expect_file "$rt/cert" cert expect_pair cert key
expect_file "$rt/key" key expect_pairs 1
expect_no_file "$rt/cert.new"
setup IDENT "an object without a key keeps the live pair" setup IDENT "an object without a key keeps the live pair"
kv_out='{"data":{"data":{"cert":"cert2"}}}' kv_out='{"data":{"data":{"cert":"cert2"}}}'
go go
expect_rc 1 expect_rc 1
expect_err "holds no certificate and key" expect_err "holds no certificate and key"
expect_file "$rt/cert" cert expect_pair cert key
expect_file "$rt/key" key expect_pairs 1
setup IDENT "what a stopped run left behind is removed"
live_pair old cert key
mkdir -m 0700 "$rt/pair.stale"
printf cert2 >"$rt/pair.stale/cert"
ln -s pair.stale "$rt/current.new"
kv_out=$(leaf cert key)
go
expect_rc 0
expect_pair cert key
expect_pairs 1
if [ -L "$rt/current.new" ]; then fail "current.new left behind"; fi
# The swap is atomic: wherever a run stops, the link names one whole pair,
# and the next run finishes the job.
for n in 1 2 3; do
setup IDENT "a swap stopped at its step $n leaves one whole pair live"
live_pair old cert key
kv_out=$(leaf cert2 key2)
stop_at "$n"
go
got="$(<"$rt/current/cert") $(<"$rt/current/key")"
case "$got" in
"cert key" | "cert2 key2") ;;
*) fail "the live pair is '$got', one file of each" ;;
esac
rm -f "$dir/bin/ln" "$dir/bin/mv"
go
expect_rc 0
expect_pair cert2 key2
done
if [ "$failures" -ne 0 ]; then if [ "$failures" -ne 0 ]; then
echo "script-test-agent-bao-fetch: $failures failed assertions over $count runs" >&2 echo "script-test-agent-bao-fetch: $failures failed assertions over $count runs" >&2