Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/agent-modules/hive-ca.nix

196 lines
7 KiB
Nix

# The swarm's root certificate in this agent's trust, fetched from the store
# at boot by the agent itself.
#
# The root is the store's services `pki` mount's CA. It does not exist when the
# agent is built, so the meta flake names no CA file: this unit reads it from
# the store with the agent's own certificate (./bao.nix keeps it current) and
# writes two files, both public:
#
# - `bundle`: the system CAs plus the swarm root. `SSL_CERT_FILE` replaces the
# trust store of Rust (rustls platform verifier), Go and OpenSSL clients, so
# it carries both halves.
# - `root`: the swarm root alone, for `NODE_EXTRA_CA_CERTS`, which adds to
# Node's store (Claude Code).
#
# A process reads its trust when it starts, so a rotated root reaches the
# consumers below at their next start. They `Requires=` this unit, which keeps
# one from running with a store that lacks the root.
{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.hyperhive.agent.bao;
agentCfg = config.services.hyperhive.agent;
agentName = agentCfg.user.name;
# The server CA id ./bao.nix loads.
serverCaCredential = "hive-agent-bao-server-ca";
unitName = "hive-agent-trust-bundle";
runtimeDir = unitName;
bundleFile = "/run/${runtimeDir}/bundle.pem";
rootFile = "/run/${runtimeDir}/root.pem";
systemBundle = "/etc/ssl/certs/ca-certificates.crt";
configured = cfg.addr != null;
storeRetry = import ../host-modules/lib/store-retry.nix { };
# Units that dial a swarm service over TLS. The first group is defined
# whenever the agent module is; the rest exist under the same condition as
# their definitions, because ordering on a unit that is not defined would
# generate an inert one.
consumers = [
"hive-agent"
"hive-bash-daemon"
"hive-subagent-daemon"
"hive-mcp-http"
"hive-forge-notify"
]
++ lib.optional (agentCfg.matrixAccounts != { }) "hive-matrix-daemon"
++ lib.optional (agentCfg.github.enable) "hive-github-notify"
++ lib.optional (agentCfg.icon != null && agentCfg.forge.url != null) "forge-avatar-sync";
trustEnv = {
SSL_CERT_FILE = bundleFile;
NODE_EXTRA_CA_CERTS = rootFile;
};
in
{
options.services.hyperhive.agent.bao.servicesPkiMount = lib.mkOption {
type = lib.types.str;
default = "pki";
example = "pki";
description = ''
Mount path of the store's services `pki` engine, whose root is the one
anchor every swarm service certificate chains to. Set by the generated
meta flake from the host's
`services.hyperhive.deploy.bao.servicesPkiMountPath`.
`${unitName}.service` reads `<mount>/ca/pem` from it.
'';
};
config = lib.mkIf configured {
# Every process, including a bash task's children, resolves trust through
# these, as it resolves `HIVE_FORGE_TOKEN_FILE`.
systemd.globalEnvironment = trustEnv;
environment.variables = trustEnv;
systemd.services = {
${unitName} = {
description = "fetch the swarm root certificate from the secret store";
after = [
"network.target"
"hive-agent-bao-identity.service"
];
wantedBy = [ "multi-user.target" ];
path = [
pkgs.coreutils
pkgs.curl
pkgs.openssl
];
inherit (storeRetry) startLimitBurst startLimitIntervalSec;
serviceConfig = storeRetry.serviceConfig // {
Type = "oneshot";
RemainAfterExit = false;
TimeoutStartSec = 60;
User = agentName;
Group = agentName;
RuntimeDirectory = runtimeDir;
# Public certificates, read by every consumer's user.
RuntimeDirectoryMode = "0755";
RuntimeDirectoryPreserve = "yes";
UMask = "0022";
LoadCredential = [ serverCaCredential ];
};
# Overrides the global value: this unit runs before the bundle exists,
# and its own TLS is checked against the credential below or the
# system bundle.
environment = {
SSL_CERT_FILE = lib.mkForce systemBundle;
BAO_ADDR = cfg.addr;
BAO_CLIENT_CERT = cfg.clientCertFile;
BAO_CLIENT_KEY = cfg.clientKeyFile;
};
script = ''
set -euo pipefail
for f in "$BAO_CLIENT_CERT" "$BAO_CLIENT_KEY"; do
if [ ! -s "$f" ]; then
echo "this agent has no store identity at $f, so it cannot fetch the swarm root certificate." >&2
exit 1
fi
done
cacert=
if [ -s "$CREDENTIALS_DIRECTORY/${serverCaCredential}" ]; then
cacert="$CREDENTIALS_DIRECTORY/${serverCaCredential}"
echo "trusting bao via the delivered server CA credential" >&2
else
echo "no bao server CA credential delivered; trusting the system bundle" >&2
fi
dir=/run/${runtimeDir}
err=$(mktemp -p "$dir" err.XXXXXX)
raw=$(mktemp -p "$dir" raw.XXXXXX)
root=$(mktemp -p "$dir" root.XXXXXX)
bundle=$(mktemp -p "$dir" bundle.XXXXXX)
trap 'rm -f "$err" "$raw" "$root" "$bundle"' EXIT
# The listener demands a client certificate and the endpoint takes no
# token, so no login precedes this.
if ! curl --fail --silent --show-error \
--connect-timeout 10 --max-time 30 \
''${cacert:+--cacert "$cacert"} \
--cert "$BAO_CLIENT_CERT" --key "$BAO_CLIENT_KEY" \
--output "$raw" \
${lib.escapeShellArg "${cfg.addr}/v1/${cfg.servicesPkiMount}/ca/pem"} 2>"$err"; then
echo "could not read the store's ${cfg.servicesPkiMount} root at $BAO_ADDR." >&2
cat "$err" >&2
exit 1
fi
# Re-encoded rather than copied: it fails on a body that is not a
# certificate and ends the file in the newline the concatenation
# below relies on.
if ! openssl x509 -in "$raw" -out "$root" 2>"$err"; then
echo "the store's ${cfg.servicesPkiMount} root does not parse as a certificate." >&2
cat "$err" >&2
exit 1
fi
cat ${systemBundle} "$root" >"$bundle"
# Neither file moves unless it changed, so a reader never sees a half
# write and the timer's rerun is silent.
if cmp -s "$bundle" ${bundleFile} && cmp -s "$root" ${rootFile}; then
echo "the store's ${cfg.servicesPkiMount} root is unchanged"
exit 0
fi
chmod 0644 "$root" "$bundle"
mv -f "$root" ${rootFile}
mv -f "$bundle" ${bundleFile}
echo "wrote the swarm root certificate from the store's ${cfg.servicesPkiMount} mount"
'';
};
}
// lib.genAttrs consumers (_: {
after = [ "${unitName}.service" ];
requires = [ "${unitName}.service" ];
});
systemd.timers.${unitName} = {
description = "re-read the swarm root certificate from the secret store";
wantedBy = [ "timers.target" ];
timerConfig = {
OnUnitInactiveSec = "1h";
RandomizedDelaySec = "1min";
};
};
};
}