Compare commits
17 changed files with 172 additions and 430 deletions
|
|
@ -62,7 +62,7 @@ of the cell says how.
|
|||
| `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl`, inside the `hive-matrix` container, once | `swarm-controller`, under its own certificate | ❌ `swarm-matrix-ctl` mints it once; the container keeps its copy and republishes it when the store's differs | ✅ the controller reads it on every five-minute matrix pass |
|
||||
| `swarm/controller/swarm-controller/oidc/client` | authelia, at its first boot; `swarm-secret-publish` copies it in | `swarm-controller`, under its own certificate, once at start | ❌ authelia mints it once. A re-mint is republished by `swarm-secret-publish`'s path unit | ❌ read once at start; the controller holds the old value until it restarts |
|
||||
| `swarm/controller/swarm-controller/forge/token` | the forge, inside the `hive-forge` container, once; `swarm-bao-forge-publisher` on the forge's host copies it in under its own certificate, with a write-only grant | `swarm-controller`, under its own certificate, at start | ❌ the forge mints it once and keeps its copy in the container; the publisher writes that copy each time the forge container starts and each time the mint writes the file | ❌ the controller reads it at start, and in the background with backoff until it first finds it; a replaced token reaches it with the next restart |
|
||||
| `swarm/agents/<agent>/bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), which generates the key, at `swarm-controller`'s request at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config; then `hive-agent-bao-identity` in the agent container, under the leaf it holds | ✅ `swarm-controller`'s five-minute pass re-issues a live agent's leaf once it's past half its validity (45 of 90 days, read from the certificate itself) | ✅ `hive-agent-bao-identity` re-reads it every 10 minutes and swaps a new leaf into `/run/hive-agent-bao-identity/`, which every store login in the container presents. It reads under the leaf it holds, so it renews only while that leaf is valid |
|
||||
| `swarm/agents/<agent>/bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), which generates the key, at `swarm-controller`'s request at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | ✅ `swarm-controller`'s five-minute pass re-issues a live agent's leaf once it's past half its validity (45 of 90 days, read from the certificate itself) | ❌ `hive-c0re` reads it when it writes the container config, so the agent presents a new leaf from its next start; the old leaf stays valid until it expires |
|
||||
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | `hive-agent` in the agent container, under the agent's own certificate, held in memory — the identity it presents to the swarm queue, naming that one agent rather than its hive | ✅ `swarm-controller`'s five-minute pass re-mints a live agent's secret once it's 45 days old by `minted_at` on the stored object; a secret with no `minted_at` gets one stamped, value unchanged. The pass skips agents declared `Destroyed` — declaring an agent destroyed deletes every version of the path instead, the undo of the mint rather than another one | ✅ `hive-agent` reads the path before its first connect and again on every reconnect attempt, so a reconnect after a re-mint presents the new secret. An open connection keeps the secret it connected with; after a revocation the agent keeps retrying under the queue client's backoff |
|
||||
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | ✅ the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the agent re-fetches on a 10-minute timer |
|
||||
| `swarm/agents/<agent>/forge/<label>` | `swarm-controller`, when an operator links an external forge account in the swarm UI | `hive-agent-forge-accounts` in the agent container, under the agent's own certificate, into `<state>/forge-<label>-token` and `forge-<label>.json` | ❌ an operator's token; replaced only by deleting it and linking the label again | ✅ the agent re-fetches on a 2-minute timer |
|
||||
|
|
@ -170,10 +170,7 @@ as root and re-exposes it under the consuming unit's own user.
|
|||
Inside the container, `hive-agent-bao-identity.service` logs in with that
|
||||
certificate and reads this row back before reporting success, so an agent
|
||||
locked out of its own identity says so at boot rather than at whichever pull
|
||||
needed the store first. It keeps a live copy of the pair in `/run/hive-agent-bao-identity/`,
|
||||
which every unit in the container that logs in to the store presents, and
|
||||
re-runs every 10 minutes: a leaf the controller re-issued replaces that copy
|
||||
once a login with it succeeds. The unit exists whenever
|
||||
needed the store first. The unit exists whenever
|
||||
`services.hyperhive.agent.bao.addr` has a value, which the hive's meta flake
|
||||
sets from its own store address — the same all-or-nothing gate the per-hive
|
||||
queue credential beside it uses, and the reason the delivery above never lands
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
# This agent's own identity at the swarm secret store: the check that proves
|
||||
# it works, and the live copy every store reader in the container presents.
|
||||
# This agent's own identity at the swarm secret store, and the check that
|
||||
# proves it works.
|
||||
#
|
||||
# `swarm-controller` mints the leaf at agent creation and publishes it; this
|
||||
# agent's hive collects it under the hive's own certificate and hands it in as
|
||||
# systemd credentials (`hive_c0re::lifecycle::agent_identity`). That copy is
|
||||
# fixed for the life of the container, and the controller re-issues the leaf
|
||||
# at half its validity, so this unit seeds a live copy from it and, on a timer,
|
||||
# reads the current leaf back from the store under the one it holds. Because
|
||||
# that login needs the held leaf to still be valid, a container whose leaf
|
||||
# expired while every pull failed recovers only at its next start.
|
||||
# systemd credentials (`hive_c0re::lifecycle::agent_identity`). Nothing here
|
||||
# fetches anything from the store — by the time this container boots, its
|
||||
# identity is already inside it.
|
||||
#
|
||||
# ⚠️ The identity arrives as credentials and NOT as a bind mount, and the mode
|
||||
# is why: the host file is `0600` to the hive daemon and this unit runs as the
|
||||
|
|
@ -49,17 +46,6 @@ let
|
|||
|
||||
unitName = "hive-agent-bao-identity";
|
||||
|
||||
runtimeDir = unitName;
|
||||
# A symlink to the directory that holds the live pair. Each pair is written
|
||||
# into a fresh directory and goes live with one rename of this link, so the
|
||||
# link always names one whole pair, old or new.
|
||||
liveLink = "/run/${runtimeDir}/current";
|
||||
certFile = "${liveLink}/cert";
|
||||
keyFile = "${liveLink}/key";
|
||||
# The store's whole answer, key included, in the unit's own `0700` directory.
|
||||
rawFile = "/run/${runtimeDir}/bao-mtls.json";
|
||||
errFile = "/run/${runtimeDir}/bao.err";
|
||||
|
||||
# Where the identity lives in the store, spelled from the same pieces the
|
||||
# publisher uses. `swarm_secret_client::mtls::identity_path` builds
|
||||
# `swarm/agents/<agent>/bao-mtls` and `path::MOUNT` is `secret`; this
|
||||
|
|
@ -101,39 +87,16 @@ in
|
|||
loudest possible way about a deployment that never asked for it.
|
||||
'';
|
||||
};
|
||||
|
||||
clientCertFile = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
readOnly = true;
|
||||
default = certFile;
|
||||
description = ''
|
||||
This agent's current store certificate, kept by `${unitName}.service`.
|
||||
Read-only: it is where that unit writes, not a knob. Every unit that
|
||||
logs in to the store as this agent points `BAO_CLIENT_CERT` here.
|
||||
'';
|
||||
};
|
||||
|
||||
clientKeyFile = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
readOnly = true;
|
||||
default = keyFile;
|
||||
description = ''
|
||||
The private key for `clientCertFile`, `0400` to the agent user.
|
||||
🩸 A PATH and never a value.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf configured {
|
||||
systemd.services.${unitName} = {
|
||||
description = "keep this agent's swarm secret store identity current, and prove it works";
|
||||
description = "prove this agent can authenticate to the swarm secret store as itself";
|
||||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
path = [
|
||||
pkgs.openbao
|
||||
pkgs.coreutils
|
||||
pkgs.diffutils
|
||||
pkgs.jq
|
||||
];
|
||||
# ../host-modules/lib/store-retry.nix. From an agent the store is reached
|
||||
# through the gateway's stream passthrough, so either being down fails
|
||||
|
|
@ -141,16 +104,10 @@ in
|
|||
inherit (storeRetry) startLimitBurst startLimitIntervalSec;
|
||||
serviceConfig = storeRetry.serviceConfig // {
|
||||
Type = "oneshot";
|
||||
# Not `RemainAfterExit`, so the timer below can start it again.
|
||||
# `RuntimeDirectoryPreserve` keeps the live pair between runs.
|
||||
RemainAfterExit = false;
|
||||
RemainAfterExit = true;
|
||||
TimeoutStartSec = 30;
|
||||
User = agentName;
|
||||
Group = agentName;
|
||||
RuntimeDirectory = runtimeDir;
|
||||
RuntimeDirectoryMode = "0700";
|
||||
RuntimeDirectoryPreserve = "yes";
|
||||
UMask = "0377";
|
||||
# Bare ids, no paths: the terse `LoadCredential=` form that inherits a
|
||||
# credential the service *manager* received, which is what the
|
||||
# container manager passed in. ./queue.nix states the same shape.
|
||||
|
|
@ -162,8 +119,9 @@ in
|
|||
};
|
||||
environment = {
|
||||
BAO_ADDR = cfg.addr;
|
||||
BAO_CLIENT_CERT = certFile;
|
||||
BAO_CLIENT_KEY = keyFile;
|
||||
# `%d` is `$CREDENTIALS_DIRECTORY`, per-unit and owned by `User=`.
|
||||
BAO_CLIENT_CERT = "%d/${certCredential}";
|
||||
BAO_CLIENT_KEY = "%d/${keyCredential}";
|
||||
};
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
|
|
@ -192,53 +150,8 @@ in
|
|||
export BAO_CACERT="$CREDENTIALS_DIRECTORY/${serverCaCredential}"
|
||||
fi
|
||||
|
||||
# `UMask=0377` makes every file this script creates `0400`, so each one
|
||||
# is removed before the command that creates it.
|
||||
err=${lib.escapeShellArg errFile}
|
||||
raw=${lib.escapeShellArg rawFile}
|
||||
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
|
||||
# empty at boot and kept across this unit's runs.
|
||||
if [ ! -s "$BAO_CLIENT_CERT" ] || [ ! -s "$BAO_CLIENT_KEY" ]; then
|
||||
seed=$(new_pair)
|
||||
cp "$CREDENTIALS_DIRECTORY/${keyCredential}" "$seed/key"
|
||||
cp "$CREDENTIALS_DIRECTORY/${certCredential}" "$seed/cert"
|
||||
go_live "$seed"
|
||||
fi
|
||||
err="$(mktemp)"
|
||||
trap 'rm -f "$err"' EXIT
|
||||
|
||||
# Cert auth is a login, not a transport setting. The `BAO_CLIENT_*`
|
||||
# variables above only decide which certificate the TLS handshake
|
||||
|
|
@ -250,7 +163,6 @@ in
|
|||
# it accepts, so this agent's leaf matches its own role and no other.
|
||||
# Naming the role here would be a third copy of a string
|
||||
# `swarm_secret_client::policy::agent_object_name` already owns.
|
||||
rm -f "$err"
|
||||
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
|
||||
echo "this agent's certificate was refused by the swarm secret store at $BAO_ADDR." >&2
|
||||
if [ -s "$err" ]; then
|
||||
|
|
@ -263,13 +175,16 @@ in
|
|||
export BAO_TOKEN
|
||||
|
||||
# A token is not yet an answer: the login proves the certificate, this
|
||||
# proves the policy attached to it, and the answer is the current
|
||||
# leaf. One read, so the certificate and key come from one version.
|
||||
# proves the policy attached to it. Reading this agent's own identity
|
||||
# back is the smallest read its document grants, and it is the same
|
||||
# check `swarm-controller` runs against the leaf before it reports the
|
||||
# creation done — so a policy that drifted apart from the path fails
|
||||
# here rather than in whichever pull needed it first.
|
||||
#
|
||||
# ⚠️ Written by redirect, never printed: the object holds a private
|
||||
# key.
|
||||
rm -f "$err" "$raw"
|
||||
if ! bao kv get -format=json ${lib.escapeShellArg identityPath} >"$raw" 2>"$err"; then
|
||||
# ⚠️ Output discarded, not printed: the field is a certificate and the
|
||||
# object beside it is a private key. Nothing about this check needs a
|
||||
# value, only whether the read succeeded.
|
||||
if ! bao kv get -field=cert ${lib.escapeShellArg identityPath} >/dev/null 2>"$err"; then
|
||||
echo "this agent logged in to the swarm secret store but cannot read ${identityPath}, so its policy does not cover its own path." >&2
|
||||
if [ -s "$err" ]; then
|
||||
cat "$err" >&2
|
||||
|
|
@ -279,46 +194,8 @@ in
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# `-j`: the bytes as the store holds them, which is what the hive
|
||||
# delivered, so an unchanged leaf compares equal to the seeded copy.
|
||||
staged=$(new_pair)
|
||||
if ! jq -ej '.data.data.cert | strings' "$raw" >"$staged/cert" \
|
||||
|| ! 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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if cmp -s "$staged/cert" "$BAO_CLIENT_CERT" && cmp -s "$staged/key" "$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."
|
||||
else
|
||||
# This unit's next login uses whatever goes live, so a pair the
|
||||
# store refuses must never replace the one it accepts.
|
||||
rm -f "$err"
|
||||
if ! BAO_CLIENT_CERT="$staged/cert" BAO_CLIENT_KEY="$staged/key" \
|
||||
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
|
||||
if [ -s "$err" ]; then cat "$err" >&2; fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
go_live "$staged"
|
||||
echo "replaced this agent's store certificate with the one at ${identityPath}."
|
||||
fi
|
||||
echo "authenticated to the swarm secret store at $BAO_ADDR as ${agentName} and read ${identityPath}."
|
||||
'';
|
||||
};
|
||||
|
||||
# The controller re-issues a leaf at half its validity, so ten minutes is
|
||||
# small against the time the old one stays valid.
|
||||
systemd.timers.${unitName} = {
|
||||
description = "re-read this agent's store certificate from the secret store";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnUnitInactiveSec = "10min";
|
||||
RandomizedDelaySec = "1min";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ let
|
|||
agentName = config.services.hyperhive.agent.user.name;
|
||||
stateDir = "/agents/${agentName}/state";
|
||||
|
||||
# The server CA id ./bao.nix loads.
|
||||
# The same three ids ./bao.nix and ./forge-token.nix load.
|
||||
certCredential = "hive-agent-bao-cert";
|
||||
keyCredential = "hive-agent-bao-key";
|
||||
serverCaCredential = "hive-agent-bao-server-ca";
|
||||
|
||||
unitName = "hive-agent-forge-accounts";
|
||||
|
|
@ -71,19 +73,23 @@ in
|
|||
RuntimeDirectoryMode = "0700";
|
||||
# `0600`, the mode the files in the state dir have always had.
|
||||
UMask = "0077";
|
||||
LoadCredential = [ serverCaCredential ];
|
||||
LoadCredential = [
|
||||
certCredential
|
||||
keyCredential
|
||||
serverCaCredential
|
||||
];
|
||||
};
|
||||
environment = {
|
||||
BAO_ADDR = cfg.addr;
|
||||
BAO_CLIENT_CERT = cfg.clientCertFile;
|
||||
BAO_CLIENT_KEY = cfg.clientKeyFile;
|
||||
BAO_CLIENT_CERT = "%d/${certCredential}";
|
||||
BAO_CLIENT_KEY = "%d/${keyCredential}";
|
||||
};
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
|
||||
# No identity delivered: ./bao.nix's check reports that.
|
||||
for f in "$BAO_CLIENT_CERT" "$BAO_CLIENT_KEY"; do
|
||||
if [ ! -s "$f" ]; then
|
||||
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 external forge accounts." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@ let
|
|||
# The name `swarm-controller` minted the token under — see ./queue-identity.nix.
|
||||
agentName = config.services.hyperhive.agent.user.name;
|
||||
|
||||
# The server CA id ./bao.nix loads.
|
||||
# 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";
|
||||
|
|
@ -106,25 +108,29 @@ in
|
|||
RuntimeDirectoryMode = "0700";
|
||||
RuntimeDirectoryPreserve = "yes";
|
||||
UMask = "0377";
|
||||
LoadCredential = [ serverCaCredential ];
|
||||
LoadCredential = [
|
||||
certCredential
|
||||
keyCredential
|
||||
serverCaCredential
|
||||
];
|
||||
};
|
||||
environment = {
|
||||
BAO_ADDR = cfg.addr;
|
||||
BAO_CLIENT_CERT = cfg.clientCertFile;
|
||||
BAO_CLIENT_KEY = cfg.clientKeyFile;
|
||||
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 f in "$BAO_CLIENT_CERT" "$BAO_CLIENT_KEY"; do
|
||||
if [ ! -s "$f" ]; then
|
||||
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 "$f" ]; then
|
||||
echo "cannot read $f, so this agent cannot present its store identity." >&2
|
||||
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
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ let
|
|||
agentName = config.services.hyperhive.agent.user.name;
|
||||
stateDir = "/agents/${agentName}/state";
|
||||
|
||||
# The server CA id ./bao.nix loads.
|
||||
# The same three ids ./bao.nix and ./forge-accounts.nix load.
|
||||
certCredential = "hive-agent-bao-cert";
|
||||
keyCredential = "hive-agent-bao-key";
|
||||
serverCaCredential = "hive-agent-bao-server-ca";
|
||||
|
||||
unitName = "hive-agent-github-token";
|
||||
|
|
@ -74,19 +76,23 @@ in
|
|||
RuntimeDirectoryMode = "0700";
|
||||
# `0600`, the mode `github-token` has.
|
||||
UMask = "0077";
|
||||
LoadCredential = [ serverCaCredential ];
|
||||
LoadCredential = [
|
||||
certCredential
|
||||
keyCredential
|
||||
serverCaCredential
|
||||
];
|
||||
};
|
||||
environment = {
|
||||
BAO_ADDR = cfg.addr;
|
||||
BAO_CLIENT_CERT = cfg.clientCertFile;
|
||||
BAO_CLIENT_KEY = cfg.clientKeyFile;
|
||||
BAO_CLIENT_CERT = "%d/${certCredential}";
|
||||
BAO_CLIENT_KEY = "%d/${keyCredential}";
|
||||
};
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
|
||||
# No identity delivered: ./bao.nix's check reports that.
|
||||
for f in "$BAO_CLIENT_CERT" "$BAO_CLIENT_KEY"; do
|
||||
if [ ! -s "$f" ]; then
|
||||
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 GitHub token." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -21,10 +21,12 @@ let
|
|||
accounts = config.services.hyperhive.agent.matrixAccounts;
|
||||
# This agent's own identity at the swarm secret store (./bao.nix). The daemon
|
||||
# reads each account's token from the store itself, `main` included (the
|
||||
# swarm mints that one), so the store's coordinates belong on its unit: the
|
||||
# live certificate pair ./bao.nix keeps, and the server CA id it loads.
|
||||
# swarm mints that one), so the store's coordinates belong on its unit. The
|
||||
# same three credential ids ./bao.nix and ./forge-token.nix load.
|
||||
baoCfg = config.services.hyperhive.agent.bao;
|
||||
storeConfigured = baoCfg.addr != null;
|
||||
certCredential = "hive-agent-bao-cert";
|
||||
keyCredential = "hive-agent-bao-key";
|
||||
serverCaCredential = "hive-agent-bao-server-ca";
|
||||
# **The enable signal.** Matrix is on for this agent exactly when it has at
|
||||
# least one account, because an account is the only thing the daemon has to
|
||||
|
|
@ -306,11 +308,7 @@ in
|
|||
description = "long-running matrix-sdk Client + MCP daemon";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "hive-agent.service" ];
|
||||
# The live certificate pair exists once ./bao.nix's unit has run.
|
||||
after = [
|
||||
"network-online.target"
|
||||
]
|
||||
++ lib.optional storeConfigured "hive-agent-bao-identity.service";
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
environment = {
|
||||
# In-agent todo socket the harness serves (loose-ends v2): the
|
||||
|
|
@ -330,8 +328,8 @@ in
|
|||
// lib.optionalAttrs storeConfigured {
|
||||
HIVE_AGENT_NAME = userName;
|
||||
BAO_ADDR = baoCfg.addr;
|
||||
BAO_CLIENT_CERT = baoCfg.clientCertFile;
|
||||
BAO_CLIENT_KEY = baoCfg.clientKeyFile;
|
||||
BAO_CLIENT_CERT = "%d/${certCredential}";
|
||||
BAO_CLIENT_KEY = "%d/${keyCredential}";
|
||||
# Named even when no CA was delivered: a bare `LoadCredential=` is
|
||||
# non-fatal when absent, and the daemon treats a missing or empty file
|
||||
# as "use the container's own trust store".
|
||||
|
|
@ -402,10 +400,14 @@ in
|
|||
User = userName;
|
||||
Group = userName;
|
||||
}
|
||||
# The store's server CA, imported by id so systemd materialises it under
|
||||
# this unit's `User=` — the same bare form ./forge-token.nix uses.
|
||||
# This agent's own certificate, imported by id so systemd materialises it
|
||||
# under this unit's `User=` — the same bare form ./forge-token.nix uses.
|
||||
// lib.optionalAttrs storeConfigured {
|
||||
LoadCredential = [ serverCaCredential ];
|
||||
LoadCredential = [
|
||||
certCredential
|
||||
keyCredential
|
||||
serverCaCredential
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -381,8 +381,6 @@ in
|
|||
description = "subagent task runner + MCP daemon for hive-subagent";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "hive-agent.service" ];
|
||||
# The live certificate pair exists once ./bao.nix's unit has run.
|
||||
after = lib.optional subagentReadsStore "hive-agent-bao-identity.service";
|
||||
# A subagent task runs its own nested `claude` invocation (via
|
||||
# `hive-claude`), which needs to resolve `claude` itself off PATH —
|
||||
# same reasoning as `hive-bash-daemon`'s `path` above, even though
|
||||
|
|
@ -444,8 +442,8 @@ in
|
|||
# Paths and a name only. `%d` is this unit's own credentials directory.
|
||||
HIVE_AGENT_NAME = userName;
|
||||
BAO_ADDR = config.services.hyperhive.agent.bao.addr;
|
||||
BAO_CLIENT_CERT = config.services.hyperhive.agent.bao.clientCertFile;
|
||||
BAO_CLIENT_KEY = config.services.hyperhive.agent.bao.clientKeyFile;
|
||||
BAO_CLIENT_CERT = "%d/hive-agent-bao-cert";
|
||||
BAO_CLIENT_KEY = "%d/hive-agent-bao-key";
|
||||
BAO_CACERT = "%d/hive-agent-bao-server-ca";
|
||||
};
|
||||
serviceConfig = {
|
||||
|
|
@ -483,9 +481,13 @@ in
|
|||
MemoryHigh = toString subagentMemoryHigh;
|
||||
}
|
||||
// lib.optionalAttrs subagentReadsStore {
|
||||
# Bare id: inherits the credential the container manager passed in,
|
||||
# Bare ids: inherits the credentials the container manager passed in,
|
||||
# the same form ./queue-identity.nix uses.
|
||||
LoadCredential = [ "hive-agent-bao-server-ca" ];
|
||||
LoadCredential = [
|
||||
"hive-agent-bao-cert"
|
||||
"hive-agent-bao-key"
|
||||
"hive-agent-bao-server-ca"
|
||||
];
|
||||
}
|
||||
//
|
||||
lib.optionalAttrs
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
# before its first queue connection and again on every reconnect attempt
|
||||
# (`hive-agent`'s `swarm_queue`), so a re-minted secret is picked up without a
|
||||
# restart. What this module hands the harness is the store's address and the
|
||||
# paths of the live certificate pair ./bao.nix keeps.
|
||||
# paths of its certificate.
|
||||
#
|
||||
# ⛔ The store certificate is for reaching the store and nothing else. It is
|
||||
# never presented to the queue: what goes to the queue is the secret read
|
||||
|
|
@ -37,9 +37,11 @@ let
|
|||
# role from it.
|
||||
agentName = config.services.hyperhive.agent.user.name;
|
||||
|
||||
# The server CA id ./bao.nix loads; the id is
|
||||
# `hive_c0re::lifecycle::agent_identity`'s and a rename is a rename there
|
||||
# too.
|
||||
# The same three ids ./bao.nix loads, because there is one store identity
|
||||
# per agent; the ids are `hive_c0re::lifecycle::agent_identity`'s and a
|
||||
# rename is a rename there too.
|
||||
certCredential = "hive-agent-bao-cert";
|
||||
keyCredential = "hive-agent-bao-key";
|
||||
serverCaCredential = "hive-agent-bao-server-ca";
|
||||
|
||||
# The store's address is the whole switch, exactly as in ./bao.nix — and
|
||||
|
|
@ -52,18 +54,20 @@ in
|
|||
{
|
||||
config = lib.mkIf configured {
|
||||
systemd.services.hive-agent = {
|
||||
# A bare id, no path: the terse `LoadCredential=` form that inherits a
|
||||
# Bare ids, no paths: the terse `LoadCredential=` form that inherits a
|
||||
# credential the service *manager* received, which is what the container
|
||||
# manager passed in. ./bao.nix and ./queue.nix state the same shape.
|
||||
serviceConfig.LoadCredential = [ serverCaCredential ];
|
||||
# The live certificate pair exists once ./bao.nix's unit has run.
|
||||
after = [ "hive-agent-bao-identity.service" ];
|
||||
serviceConfig.LoadCredential = [
|
||||
certCredential
|
||||
keyCredential
|
||||
serverCaCredential
|
||||
];
|
||||
# Paths and a name only. `%d` is this unit's own credentials directory.
|
||||
environment = {
|
||||
HIVE_AGENT_NAME = agentName;
|
||||
BAO_ADDR = cfg.addr;
|
||||
BAO_CLIENT_CERT = cfg.clientCertFile;
|
||||
BAO_CLIENT_KEY = cfg.clientKeyFile;
|
||||
BAO_CLIENT_CERT = "%d/${certCredential}";
|
||||
BAO_CLIENT_KEY = "%d/${keyCredential}";
|
||||
# Named even when no CA was delivered: a bare `LoadCredential=` is
|
||||
# non-fatal when absent, and the harness treats a missing or empty file
|
||||
# as "use the container's own trust store".
|
||||
|
|
|
|||
|
|
@ -75,17 +75,19 @@ let
|
|||
}
|
||||
{
|
||||
# One store identity per agent: the fetch presents the same certificate
|
||||
# the identity check keeps current. Every `BAO_*` value is an address or
|
||||
# a path, never a value.
|
||||
# the identity check proves. Every `BAO_*` value is an address or a
|
||||
# `%d/` path, never a value.
|
||||
name = "the fetch presents the agent's own store identity, by path";
|
||||
ok =
|
||||
let
|
||||
u = fetchUnit agentForgeBao;
|
||||
e = u.environment;
|
||||
in
|
||||
e.BAO_ADDR == baoAddr
|
||||
&& e.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
|
||||
&& e.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/current/key";
|
||||
builtins.elem "hive-agent-bao-cert" u.serviceConfig.LoadCredential
|
||||
&& builtins.elem "hive-agent-bao-key" u.serviceConfig.LoadCredential
|
||||
&& e.BAO_ADDR == baoAddr
|
||||
&& e.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
|
||||
&& e.BAO_CLIENT_KEY == "%d/hive-agent-bao-key";
|
||||
}
|
||||
{
|
||||
# The field is the secret. It goes to a file by redirect and is never
|
||||
|
|
@ -199,7 +201,8 @@ let
|
|||
in
|
||||
u.serviceConfig.User == name
|
||||
&& u.serviceConfig.UMask == "0077"
|
||||
&& u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert";
|
||||
&& builtins.elem "hive-agent-bao-cert" u.serviceConfig.LoadCredential
|
||||
&& u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert";
|
||||
}
|
||||
{
|
||||
# Files a hive wrote keep working until the operator re-links them.
|
||||
|
|
|
|||
|
|
@ -82,9 +82,11 @@ let
|
|||
in
|
||||
u.serviceConfig.User == name
|
||||
&& u.serviceConfig.UMask == "0077"
|
||||
&& builtins.elem "hive-agent-bao-cert" u.serviceConfig.LoadCredential
|
||||
&& builtins.elem "hive-agent-bao-key" u.serviceConfig.LoadCredential
|
||||
&& u.environment.BAO_ADDR == baoAddr
|
||||
&& u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
|
||||
&& u.environment.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/current/key";
|
||||
&& u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
|
||||
&& u.environment.BAO_CLIENT_KEY == "%d/hive-agent-bao-key";
|
||||
}
|
||||
{
|
||||
# A file a hive wrote keeps working until the operator links a token in
|
||||
|
|
|
|||
|
|
@ -116,17 +116,20 @@ let
|
|||
# The daemon reads this agent's tokens from the store ITSELF, as itself,
|
||||
# from inside this container — `main` too, since the swarm mints it and
|
||||
# no hive does. So it needs the same identity ./agent-forge-bao.nix's
|
||||
# fetch carries: the live pair, and the server CA from its credentials.
|
||||
# fetch carries, in its own credentials directory.
|
||||
name = "the matrix daemon carries this agent's own store identity";
|
||||
ok =
|
||||
let
|
||||
u = daemon agentMatrixBao;
|
||||
in
|
||||
u.serviceConfig.LoadCredential == [ "hive-agent-bao-server-ca" ]
|
||||
u.serviceConfig.LoadCredential == [
|
||||
"hive-agent-bao-cert"
|
||||
"hive-agent-bao-key"
|
||||
"hive-agent-bao-server-ca"
|
||||
]
|
||||
&& u.environment.BAO_ADDR == "https://bao.t.local:8200"
|
||||
&& u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
|
||||
&& u.environment.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/current/key"
|
||||
&& builtins.elem "hive-agent-bao-identity.service" u.after;
|
||||
&& u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
|
||||
&& u.environment.BAO_CLIENT_KEY == "%d/hive-agent-bao-key";
|
||||
}
|
||||
{
|
||||
# The agent's name, and nothing derived from it: the daemon builds its
|
||||
|
|
@ -140,24 +143,16 @@ let
|
|||
&& !(lib.any (lib.hasInfix "swarm/agents") (lib.attrValues e));
|
||||
}
|
||||
{
|
||||
# 🩸 A secret is a path: every `BAO_*` entry is the store's address, a
|
||||
# file under this unit's own credentials directory, or the one file of
|
||||
# the live pair ./bao.nix keeps that its name stands for — never bytes
|
||||
# in an environment `/proc/<pid>/environ` publishes. The live pair is
|
||||
# spelled out rather than read from the option, so a default that
|
||||
# turned into a value would fail here too.
|
||||
# 🩸 A secret is a path: every `BAO_*` entry is the store's address or a
|
||||
# file under this unit's own credentials directory, never bytes in an
|
||||
# environment `/proc/<pid>/environ` publishes.
|
||||
name = "the matrix daemon is handed store paths and never store values";
|
||||
ok =
|
||||
let
|
||||
store = lib.filterAttrs (n: _: lib.hasPrefix "BAO_" n) (daemon agentMatrixBao).environment;
|
||||
livePair = {
|
||||
BAO_CLIENT_CERT = "/run/hive-agent-bao-identity/current/cert";
|
||||
BAO_CLIENT_KEY = "/run/hive-agent-bao-identity/current/key";
|
||||
};
|
||||
isPath =
|
||||
n: n == "BAO_ADDR" || lib.hasPrefix "%d/" store.${n} || (livePair.${n} or null) == store.${n};
|
||||
in
|
||||
store != { } && lib.all isPath (lib.attrNames store);
|
||||
store != { }
|
||||
&& lib.all (n: n == "BAO_ADDR" || lib.hasPrefix "%d/" store.${n}) (lib.attrNames store);
|
||||
}
|
||||
{
|
||||
# A token the swarm mints or replaces in the store changes no file, so
|
||||
|
|
|
|||
|
|
@ -87,30 +87,20 @@ let
|
|||
&& builtins.elem "hive-agent-bao-server-ca" c;
|
||||
}
|
||||
{
|
||||
# The live pair sits in the unit's own runtime directory, which outlives
|
||||
# each run so the timer's next run logs in with what the last one
|
||||
# wrote. The address is the option's value rather than a literal that
|
||||
# agrees with it today.
|
||||
name = "the identity check presents the live pair it keeps in its runtime directory";
|
||||
# `%d` and not a path under the agent's state dir: the host file is
|
||||
# `0600` to the hive daemon, so the only copy this unprivileged unit can
|
||||
# open is the one systemd puts in its own credentials directory. The
|
||||
# address is the option's value rather than a literal that agrees with
|
||||
# it today.
|
||||
name = "the identity check presents its certificate out of the credentials directory";
|
||||
ok =
|
||||
let
|
||||
u = agentBaoIdentity agentBao;
|
||||
e = u.environment;
|
||||
e = (agentBaoIdentity agentBao).environment;
|
||||
in
|
||||
e.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
|
||||
&& e.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/current/key"
|
||||
&& u.serviceConfig.RuntimeDirectory == "hive-agent-bao-identity"
|
||||
&& u.serviceConfig.RuntimeDirectoryPreserve == "yes"
|
||||
e.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
|
||||
&& e.BAO_CLIENT_KEY == "%d/hive-agent-bao-key"
|
||||
&& e.BAO_ADDR == agentBao.services.hyperhive.agent.bao.addr;
|
||||
}
|
||||
{
|
||||
# A leaf the controller re-issues reaches a running container only
|
||||
# through this re-run, and an active unit cannot be started again.
|
||||
name = "the identity check re-runs on a timer";
|
||||
ok =
|
||||
(agentBaoIdentity agentBao).serviceConfig.RemainAfterExit == false
|
||||
&& agentBao.systemd.timers.hive-agent-bao-identity.timerConfig.OnUnitInactiveSec == "10min";
|
||||
}
|
||||
{
|
||||
# Same 403-not-a-miss reason as the hive-side readers: the path
|
||||
# `swarm_secret_client::mtls::identity_path` builds is the one this
|
||||
|
|
@ -143,22 +133,22 @@ let
|
|||
{
|
||||
# Nothing about the identity may be printed, and the read-back is where
|
||||
# that could slip: `bao kv get` on this path answers with certificate
|
||||
# material and a private key. The answer goes to a file in the unit's
|
||||
# own `0700` directory.
|
||||
# material, and the object beside it is a private key. The check needs
|
||||
# only whether the read succeeded.
|
||||
#
|
||||
# The path goes through `lib.escapeShellArg` here for the same reason the
|
||||
# module passes it through one — that helper decides whether an argument
|
||||
# needs quotes at all, and this one (only `[a-z0-9/-]`) comes back bare.
|
||||
# Spelling the quotes in by hand asserts a rendering nixpkgs chooses
|
||||
# rather than the redirect this property is about.
|
||||
name = "the identity check writes what it reads back to a file";
|
||||
name = "the identity check discards what it reads back";
|
||||
ok =
|
||||
let
|
||||
m = agentBao;
|
||||
name = m.services.hyperhive.agent.user.name;
|
||||
arg = lib.escapeShellArg "secret/swarm/agents/${name}/bao-mtls";
|
||||
in
|
||||
lib.hasInfix "bao kv get -format=json ${arg} >\"$raw\"" (agentBaoIdentity m).script;
|
||||
lib.hasInfix "bao kv get -field=cert ${arg} >/dev/null" (agentBaoIdentity m).script;
|
||||
}
|
||||
{
|
||||
# The absence arm, and what makes the four above able to fail. An agent
|
||||
|
|
@ -170,16 +160,17 @@ let
|
|||
}
|
||||
{
|
||||
# The harness reads its per-agent queue secret from the store itself,
|
||||
# under the same store identity the check above keeps current, because
|
||||
# there is one identity per agent.
|
||||
# under the same store identity the check above proves, because there is
|
||||
# one identity per agent.
|
||||
name = "the harness presents the agent's own store identity";
|
||||
ok =
|
||||
let
|
||||
u = agentHarness agentBao;
|
||||
in
|
||||
u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
|
||||
&& 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-cert" u.serviceConfig.LoadCredential
|
||||
&& builtins.elem "hive-agent-bao-key" u.serviceConfig.LoadCredential
|
||||
&& u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
|
||||
&& u.environment.BAO_CLIENT_KEY == "%d/hive-agent-bao-key"
|
||||
&& u.environment.BAO_ADDR == agentBao.services.hyperhive.agent.bao.addr;
|
||||
}
|
||||
{
|
||||
|
|
@ -209,7 +200,7 @@ let
|
|||
in
|
||||
!(u.environment ? BAO_ADDR)
|
||||
&& !(u.environment ? HIVE_AGENT_NAME)
|
||||
&& !(builtins.elem "hive-agent-bao-server-ca" (u.serviceConfig.LoadCredential or [ ]));
|
||||
&& !(builtins.elem "hive-agent-bao-cert" (u.serviceConfig.LoadCredential or [ ]));
|
||||
}
|
||||
];
|
||||
in
|
||||
|
|
|
|||
|
|
@ -113,11 +113,15 @@ let
|
|||
lib.all
|
||||
(
|
||||
u:
|
||||
u.serviceConfig.LoadCredential == [ "hive-agent-bao-server-ca" ]
|
||||
u.serviceConfig.LoadCredential == [
|
||||
"hive-agent-bao-cert"
|
||||
"hive-agent-bao-key"
|
||||
"hive-agent-bao-server-ca"
|
||||
]
|
||||
&& u.environment.HIVE_AGENT_NAME == "a1"
|
||||
&& u.environment.BAO_ADDR == "https://bao.t.local:8200"
|
||||
&& u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
|
||||
&& u.environment.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/current/key"
|
||||
&& u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
|
||||
&& u.environment.BAO_CLIENT_KEY == "%d/hive-agent-bao-key"
|
||||
)
|
||||
[
|
||||
(subagent claudeBao)
|
||||
|
|
@ -131,11 +135,15 @@ let
|
|||
let
|
||||
u = subagent opencodeBao;
|
||||
in
|
||||
u.serviceConfig.LoadCredential == [ "hive-agent-bao-server-ca" ]
|
||||
u.serviceConfig.LoadCredential == [
|
||||
"hive-agent-bao-cert"
|
||||
"hive-agent-bao-key"
|
||||
"hive-agent-bao-server-ca"
|
||||
]
|
||||
&& u.environment.HIVE_AGENT_NAME == "a1"
|
||||
&& u.environment.BAO_ADDR == "https://bao.t.local:8200"
|
||||
&& u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
|
||||
&& u.environment.BAO_CLIENT_KEY == "/run/hive-agent-bao-identity/current/key";
|
||||
&& u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
|
||||
&& u.environment.BAO_CLIENT_KEY == "%d/hive-agent-bao-key";
|
||||
}
|
||||
{
|
||||
name = "an opencode agent with no store hands its subagent daemon no store identity";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
# `checks.script-test-agent-bao-fetch` — runs the agent units that log in to
|
||||
# the swarm secret store and fetch a secret, ../agent-modules/forge-token.nix
|
||||
# and ../agent-modules/bao.nix, against a stub `bao`. The cases are in
|
||||
# ./agent-bao-fetch.sh.
|
||||
# `checks.script-test-agent-bao-fetch` — runs the agent unit that logs in to
|
||||
# the swarm secret store and fetches a secret, ../agent-modules/forge-token.nix,
|
||||
# against a stub `bao`. The cases are in ./agent-bao-fetch.sh.
|
||||
#
|
||||
# What runs is the unit's rendered `ExecStart`, on the unit's own `PATH` with
|
||||
# the stub in front. The one edit is the unit's `/run/<unit>/` prefix, moved
|
||||
|
|
@ -43,16 +42,11 @@ let
|
|||
|
||||
# Answers `login` and `kv get` from `FAKE_BAO_*` variables and logs every
|
||||
# call. A `kv` call without the token `login` printed fails, so a script that
|
||||
# drops `BAO_TOKEN` between the two cannot pass. A login presenting the
|
||||
# certificate `FAKE_BAO_REFUSE_CERT` is refused.
|
||||
# drops `BAO_TOKEN` between the two cannot pass.
|
||||
fakeBao = pkgs.writeShellScriptBin "bao" ''
|
||||
echo "$*" >> "$FAKE_BAO_LOG"
|
||||
case "$1" in
|
||||
login)
|
||||
if [ -n "''${FAKE_BAO_REFUSE_CERT-}" ] && [ "$(<"$BAO_CLIENT_CERT")" = "$FAKE_BAO_REFUSE_CERT" ]; then
|
||||
echo "fake bao: certificate refused" >&2
|
||||
exit 2
|
||||
fi
|
||||
printf '%s' "$FAKE_BAO_LOGIN_ERR" >&2
|
||||
printf '%s' "$FAKE_BAO_LOGIN_OUT"
|
||||
exit "$FAKE_BAO_LOGIN_RC"
|
||||
|
|
@ -76,7 +70,6 @@ in
|
|||
pkgs.runCommand "hyperhive-script-test-agent-bao-fetch"
|
||||
(
|
||||
unitEnv "FORGE" "hive-agent-forge-token"
|
||||
// unitEnv "IDENT" "hive-agent-bao-identity"
|
||||
// {
|
||||
FAKE_BAO_BIN = "${fakeBao}/bin";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,17 +36,8 @@ setup() {
|
|||
log=$dir/bao.log
|
||||
mkdir -m 0700 "$rt"
|
||||
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 key >"$creds/hive-agent-bao-key"
|
||||
# The identity pair the unit presents: the delivered one for the fetch, the
|
||||
# live copy it keeps for the identity unit.
|
||||
if [ "$prefix" = IDENT ]; then
|
||||
client_cert=$rt/current/cert client_key=$rt/current/key
|
||||
else
|
||||
client_cert=$creds/hive-agent-bao-cert client_key=$creds/hive-agent-bao-key
|
||||
fi
|
||||
: >"$log"
|
||||
if ! sed "s|/run/$unit/|$rt/|g" "${!script_var}" >"$dir/script"; then
|
||||
echo "cannot read the rendered script for $unit" >&2
|
||||
|
|
@ -62,7 +53,6 @@ setup() {
|
|||
kv_rc=0
|
||||
kv_out=the-secret
|
||||
kv_err=
|
||||
refuse_cert=
|
||||
}
|
||||
|
||||
go() {
|
||||
|
|
@ -71,10 +61,8 @@ go() {
|
|||
umask 0377
|
||||
cd "$dir" || exit 99
|
||||
exec env -i \
|
||||
PATH="$dir/bin:$FAKE_BAO_BIN:$unit_path" \
|
||||
PATH="$FAKE_BAO_BIN:$unit_path" \
|
||||
BAO_ADDR="$bao_addr" \
|
||||
BAO_CLIENT_CERT="$client_cert" \
|
||||
BAO_CLIENT_KEY="$client_key" \
|
||||
CREDENTIALS_DIRECTORY="$creds" \
|
||||
FAKE_BAO_LOG="$log" \
|
||||
FAKE_BAO_LOGIN_RC="$login_rc" \
|
||||
|
|
@ -83,7 +71,6 @@ go() {
|
|||
FAKE_BAO_KV_RC="$kv_rc" \
|
||||
FAKE_BAO_KV_OUT="$kv_out" \
|
||||
FAKE_BAO_KV_ERR="$kv_err" \
|
||||
FAKE_BAO_REFUSE_CERT="$refuse_cert" \
|
||||
"$dir/script"
|
||||
) >"$dir/out" 2>"$dir/err"
|
||||
rc=$?
|
||||
|
|
@ -247,141 +234,6 @@ expect_out "fetched this agent's forge token"
|
|||
expect_file "$rt/token" the-secret
|
||||
expect_no_file "$rt/token.new"
|
||||
|
||||
ident_path=secret/swarm/agents/a1/bao-mtls
|
||||
ident_kv_call="kv get -format=json $ident_path"
|
||||
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"
|
||||
: >"$creds/hive-agent-bao-cert"
|
||||
go
|
||||
expect_rc 1
|
||||
expect_err "has no store identity"
|
||||
expect_calls
|
||||
if [ -e "$rt/current" ]; then fail "a live pair was made from nothing"; fi
|
||||
|
||||
setup IDENT "a first run makes the delivered pair live"
|
||||
kv_out=$(leaf cert key)
|
||||
go
|
||||
expect_rc 0
|
||||
expect_out "is unchanged"
|
||||
expect_calls "$login_call" "$ident_kv_call"
|
||||
if [ ! -L "$rt/current" ]; then fail "$rt/current is not a symlink"; fi
|
||||
expect_pair cert key
|
||||
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"
|
||||
|
||||
setup IDENT "the live pair outlives the delivered one"
|
||||
live_pair old cert2 key2
|
||||
kv_out=$(leaf cert2 key2)
|
||||
go
|
||||
expect_rc 0
|
||||
expect_out "is unchanged"
|
||||
expect_pair cert2 key2
|
||||
expect_pairs 1
|
||||
|
||||
setup IDENT "a re-issued leaf goes live once a login with it succeeds"
|
||||
kv_out=$(leaf cert2 key2)
|
||||
go
|
||||
expect_rc 0
|
||||
expect_out "replaced this agent's store certificate"
|
||||
expect_calls "$login_call" "$ident_kv_call" "$login_call"
|
||||
expect_pair cert2 key2
|
||||
# The replaced pair stays for a reader that resolved the link before the swap.
|
||||
expect_pairs 2
|
||||
go
|
||||
expect_rc 0
|
||||
expect_out "is unchanged"
|
||||
expect_pairs 1
|
||||
|
||||
setup IDENT "a re-issued leaf the store refuses does not go live"
|
||||
kv_out=$(leaf cert2 key2)
|
||||
refuse_cert=cert2
|
||||
go
|
||||
expect_rc 1
|
||||
expect_err "refused the certificate at $ident_path"
|
||||
expect_pair cert key
|
||||
expect_pairs 1
|
||||
|
||||
setup IDENT "an object without a key keeps the live pair"
|
||||
kv_out='{"data":{"data":{"cert":"cert2"}}}'
|
||||
go
|
||||
expect_rc 1
|
||||
expect_err "holds no certificate and key"
|
||||
expect_pair cert 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
|
||||
echo "script-test-agent-bao-fetch: $failures failed assertions over $count runs" >&2
|
||||
exit 1
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@
|
|||
//! the controller's own record, [`queue::AgentCredential::minted_at`]; a secret
|
||||
//! without one is stamped, value unchanged ([`SecretStep::Backfill`]).
|
||||
//!
|
||||
//! A running agent re-reads its certificate every ten minutes under the one it
|
||||
//! holds (`nix/agent-modules/bao.nix`); the old one stays valid until it
|
||||
//! expires. A new secret reaches the agent at its next start, when the
|
||||
//! container fetches it. The old secret stops working at once: the queue checks
|
||||
//! it only at `CONNECT`, so an open connection is unaffected, but a reconnect
|
||||
//! before that restart presents the old secret and is denied.
|
||||
//! Either replacement reaches the agent at its next start, when the container
|
||||
//! is handed the certificate and fetches the secret; nothing pulls either into
|
||||
//! a running container. The old certificate stays valid until it expires. The
|
||||
//! old secret stops working at once: the queue checks it only at `CONNECT`, so
|
||||
//! an open connection is unaffected, but a reconnect before that restart
|
||||
//! presents the old secret and is denied.
|
||||
//!
|
||||
//! [`spawn`]'s pass, once the queue is up and every [`RECONCILE_INTERVAL`],
|
||||
//! inserts a job per agent with anything due: `MintAgentIdentity` for the
|
||||
|
|
|
|||
|
|
@ -9,11 +9,9 @@
|
|||
//! container.
|
||||
//!
|
||||
//! 🔑 The recursion this shape looks like it has — *a credential fetched from
|
||||
//! the store that is what opens the store* — is broken by who reads it first.
|
||||
//! The first reader is the hive, which already holds its own leaf, so nothing
|
||||
//! has to already be an agent in order to obtain an agent's identity. After
|
||||
//! that the agent re-reads its own path under the leaf it holds, which picks
|
||||
//! up a re-issued leaf only while the held one is still valid.
|
||||
//! the store that is what opens the store* — is broken by who reads it. The
|
||||
//! reader is the hive, never the agent, and a hive already holds its own leaf.
|
||||
//! Nothing has to already be an agent in order to obtain an agent's identity.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue