Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/agent-modules/forge-token.nix
atlas 97cf8a1b2b agent-modules: write bao's stderr where UMask=0377 lets it, name what failed
hive-agent-forge-token and hive-agent-queue-credential both run with
UMask=0377. Their scripts captured bao's stderr in `err="$(mktemp)"`,
which under that umask is created 0400; the very next `2>"$err"` on the
`bao login` line cannot reopen it for writing, so bash fails the
redirect with "Permission denied" before bao ever runs. The `if !`
around the login then took the only error branch it had and printed
"this agent's certificate was refused by the swarm secret store" — the
store was never contacted. No agent has fetched either credential.

The stderr file now lives in each unit's own 0700 RuntimeDirectory and
is removed before every redirect into it, so the redirect creates it —
the idiom forge-token.nix already used for its staging file.

The login's error branch now says which of these happened, then quotes
bao's output:
- `$err` could not be created, so bao never ran;
- the store answered with HTTP 4xx (refusal) or another status;
- the store sent a TLS alert rejecting the certificate;
- no answer at all (network, DNS, or local TLS).
Unreadable cert/key credentials are reported before bao runs.

bao.nix has the same fetch shape but no UMask=, so its mktemp file is
0600 and writable; it is untouched.

Closes #4735
2026-09-26 21:50:39 +02:00

207 lines
8.5 KiB
Nix
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# This agent's own forge access token, fetched from the swarm secret store by
# the agent itself.
#
# `swarm-controller` mints the token with the forge's admin API and writes it
# to `swarm/agents/<agent>/forge-token`
# (`swarm_secret_client::forge::agent_token_path`); no hive is in that chain.
# This unit logs in to the store with the certificate ./bao.nix already proves
# it can log in with, and reads its own path. The shape is ./queue-identity.nix's,
# for the same reason: a hive handing the token over would be the hive reading
# a secret on the agent's behalf.
#
# Unlike the queue credential this one rotates: the controller replaces the
# token when the forge's copy stops matching the stored one. So the unit is
# re-run by a timer, and it swaps the file in by rename, only when the value
# changed, so a reader never sees half a token and a watcher on the file
# (forge-avatar-sync.path) fires only on a real change.
#
# Consumers read `services.hyperhive.agent.forge.tokenFile` first and fall back
# to `<state>/forge-token`, the file the hive wrote before this existed.
{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.hyperhive.agent.bao;
# The name `swarm-controller` minted the token under — see ./queue-identity.nix.
agentName = config.services.hyperhive.agent.user.name;
# The same three ids ./bao.nix and ./queue-identity.nix load.
certCredential = "hive-agent-bao-cert";
keyCredential = "hive-agent-bao-key";
serverCaCredential = "hive-agent-bao-server-ca";
unitName = "hive-agent-forge-token";
# The nix half of `swarm_secret_client::forge::agent_token_path` plus
# `path::MOUNT`.
tokenPath = "secret/swarm/agents/${agentName}/forge-token";
runtimeDir = unitName;
tokenFile = "/run/${runtimeDir}/token";
# Beside the token, so the rename that replaces it stays in one directory.
stagingFile = "/run/${runtimeDir}/token.new";
# bao's stderr, in the unit's own `0700` directory rather than `/tmp`.
errFile = "/run/${runtimeDir}/bao.err";
# The store's address is the whole switch, as in ./bao.nix and
# ./queue-identity.nix.
configured = cfg.addr != null;
in
{
options.services.hyperhive.agent.forge.tokenFile = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = tokenFile;
description = ''
Path this agent's own forge token is fetched to. Read-only: it is a
fact about where `${unitName}.service` writes, not a knob.
🩸 A PATH and never a value. The file is `0400` to the agent user.
The file exists only once the swarm has minted a token for this agent
and the agent has a store identity to fetch it with. Until then every
consumer falls back to `$HYPERHIVE_STATE_DIR/forge-token`.
'';
};
config = lib.mkIf configured {
# Every unit and the bash-task runner (where `hive-forge` and `git` run)
# resolve the token through this, the same way they find
# `$HYPERHIVE_STATE_DIR`. A path, never the value.
systemd.globalEnvironment.HIVE_FORGE_TOKEN_FILE = tokenFile;
environment.variables.HIVE_FORGE_TOKEN_FILE = tokenFile;
systemd.services.${unitName} = {
description = "fetch this agent's own forge token from the secret store";
after = [
"network.target"
# Ordering only, for the reason ./queue-identity.nix gives.
"hive-agent-bao-identity.service"
];
before = [ "hive-forge-notify.service" ];
wantedBy = [ "multi-user.target" ];
path = [
pkgs.openbao
pkgs.coreutils
pkgs.diffutils
];
startLimitBurst = 4;
startLimitIntervalSec = 300;
serviceConfig = {
Type = "oneshot";
# Not `RemainAfterExit`, unlike the queue fetch: the timer below has to
# be able to start this unit again, and an active unit cannot be
# started. `RuntimeDirectoryPreserve` is what keeps the directory, and
# the token in it, alive between runs instead.
RemainAfterExit = false;
TimeoutStartSec = 30;
Restart = "on-failure";
RestartSec = 15;
User = agentName;
Group = agentName;
RuntimeDirectory = runtimeDir;
RuntimeDirectoryMode = "0700";
RuntimeDirectoryPreserve = "yes";
UMask = "0377";
LoadCredential = [
certCredential
keyCredential
serverCaCredential
];
};
environment = {
BAO_ADDR = cfg.addr;
BAO_CLIENT_CERT = "%d/${certCredential}";
BAO_CLIENT_KEY = "%d/${keyCredential}";
};
script = ''
set -euo pipefail
# No identity delivered: ./bao.nix's check reports that; saying it
# twice adds nothing.
for id in ${lib.escapeShellArg certCredential} ${lib.escapeShellArg keyCredential}; do
if [ ! -s "$CREDENTIALS_DIRECTORY/$id" ]; then
echo "this agent has no store identity, so it cannot fetch its own forge token." >&2
exit 0
fi
if [ ! -r "$CREDENTIALS_DIRECTORY/$id" ]; then
echo "cannot read $CREDENTIALS_DIRECTORY/$id, so this agent cannot present its store identity." >&2
exit 1
fi
done
if [ -s "$CREDENTIALS_DIRECTORY/${serverCaCredential}" ]; then
export BAO_CACERT="$CREDENTIALS_DIRECTORY/${serverCaCredential}"
fi
# `UMask=0377` makes every file this script creates `0400`, so only
# the redirect that creates a file can write to it: `$err` is removed
# before each redirect into it.
err=${lib.escapeShellArg errFile}
trap 'rm -f "$err" ${lib.escapeShellArg stagingFile}' EXIT
rm -f "$err"
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
# The redirect creates `$err` before bao starts, so no file means
# bao never ran. bao prints `Code: <status>` only for an HTTP
# answer, and `remote error: tls:` only for an alert the store sent.
re='Code: ([0-9]{3})'
if [ ! -e "$err" ]; then
echo "could not create $err, so bao never ran and the store at $BAO_ADDR was not asked." >&2
elif [[ "$(<"$err")" =~ $re ]]; then
case "''${BASH_REMATCH[1]}" in
4*) echo "the swarm secret store at $BAO_ADDR refused this agent's certificate login with HTTP ''${BASH_REMATCH[1]}:" >&2 ;;
*) echo "the swarm secret store at $BAO_ADDR failed this agent's certificate login with HTTP ''${BASH_REMATCH[1]}:" >&2 ;;
esac
elif [[ "$(<"$err")" == *"remote error: tls:"* ]]; then
echo "the swarm secret store at $BAO_ADDR refused this agent's certificate in the TLS handshake:" >&2
else
echo "bao got no answer from the swarm secret store at $BAO_ADDR (network, DNS, or TLS on this side):" >&2
fi
if [ -s "$err" ]; then cat "$err" >&2; fi
exit 1
fi
export BAO_TOKEN
# 🩸 Degrades rather than fails, for the reason ./queue-identity.nix
# gives: the policy stanza that let the login read `bao-mtls` covers
# this path too, so a refusal here is a token not minted yet. The
# file already in place, if any, is kept: a store that is briefly
# unreachable must not take a working token away.
#
# ⚠️ Written by redirect, never echoed: the field is the secret. The
# staging file is removed first so the redirect creates it — a file
# `UMask=0377` left behind is `0400` and could not be reopened for
# writing.
rm -f "$err" ${lib.escapeShellArg stagingFile}
if ! bao kv get -field=value ${lib.escapeShellArg tokenPath} > ${lib.escapeShellArg stagingFile} 2>"$err"; then
echo "no forge token at ${tokenPath} yet; consumers keep using the state-dir token if there is one." >&2
if [ -s "$err" ]; then cat "$err" >&2; fi
exit 0
fi
if cmp -s ${lib.escapeShellArg stagingFile} ${lib.escapeShellArg tokenFile}; then
echo "this agent's forge token at ${tokenPath} is unchanged."
exit 0
fi
mv -f ${lib.escapeShellArg stagingFile} ${lib.escapeShellArg tokenFile}
echo "fetched this agent's forge token from ${tokenPath}."
'';
};
# The controller re-checks every agent's token every five minutes; this
# picks up a rotation within about ten more.
systemd.timers.${unitName} = {
description = "re-fetch this agent's forge token from the secret store";
wantedBy = [ "timers.target" ];
timerConfig = {
OnUnitInactiveSec = "10min";
RandomizedDelaySec = "1min";
};
};
};
}