hive-tls-ca no longer deletes a ca-key.pem an earlier generation left: nothing reads it, and leaving it means a rollback finds the old key instead of minting a new anchor. Deleting it moves to PR 3, beside dropping ca.pem from the trust bundle. hive-tls-resign now also re-issues when the leaf's public key differs from the key file's. The leaf and key are moved into place by two renames, and an interrupted pair left a certificate beside the wrong key that the validity check accepted. Refs #4588
1004 lines
47 KiB
Nix
1004 lines
47 KiB
Nix
{
|
||
lib,
|
||
config,
|
||
pkgs,
|
||
...
|
||
}:
|
||
let
|
||
cfg = config.services.hyperhive.deploy.hive-controller.tls;
|
||
hyperhiveCfg = config.services.hyperhive;
|
||
gatewayCfg = config.services.hyperhive.gateway;
|
||
swarmCaCfg = config.services.hyperhive.swarm.ca;
|
||
domain = hyperhiveCfg.domain;
|
||
baoCfg = hyperhiveCfg.swarm.bao;
|
||
baoDeploy = hyperhiveCfg.deploy.bao;
|
||
|
||
# The endpoint the services leaf comes out of. Read off the store's own
|
||
# options rather than spelled here: ./swarm-bao.nix enables the mount,
|
||
# generates its root and writes the grant that names this exact path, and a
|
||
# second module composing its own is how the grant and the caller stop
|
||
# agreeing.
|
||
baoServicesPkiMount = baoDeploy.servicesPkiMountPath;
|
||
baoServicesPkiRole = baoDeploy.servicesPkiRoleName;
|
||
|
||
# Derived once in ./swarm.nix: the swarm service names THIS host fronts
|
||
# a vhost for. Every one of them is inside the swarm-wide set that
|
||
# ./swarm-bao.nix narrows `pki/roles/swarm-services` to, so the leaf
|
||
# stays issuable while carrying no name this gateway does not serve.
|
||
localServiceDomains = hyperhiveCfg.swarm.localServiceDomains;
|
||
|
||
# The one name the request's `common_name` carries; the rest ride as
|
||
# SANs. Not `builtins.head`: a host fronting no swarm service renders
|
||
# this script too — its unit exits on `want_svc` long before running
|
||
# the request, but a bare `head []` would have failed the EVALUATION.
|
||
leafCommonName = lib.optionalString (localServiceDomains != [ ]) (
|
||
builtins.head localServiceDomains
|
||
);
|
||
|
||
# Whether the gateway's start waits for the services leaf. Not when the
|
||
# store's own browser UI is the only swarm name this host fronts: that
|
||
# host's nginx also carries the stream passthrough every reader dials to
|
||
# reach the store, and waiting on a login to that store would take that
|
||
# path down whenever it is sealed. There the gateway starts on the hive
|
||
# leaf fallback, and the issuing script re-imports the services leaf into
|
||
# the running nginx once it lands.
|
||
#
|
||
# Nor on a host fronting no swarm name: it asks the store for the root
|
||
# alone, which nginx never reads, and a sealed store would otherwise hold
|
||
# its gateway down.
|
||
gatewayWaitsForServicesLeaf =
|
||
localServiceDomains != [ ] && localServiceDomains != [ baoCfg.ui.domain ];
|
||
|
||
# The gateway's own leaf is only issued when the gateway actually serves a
|
||
# self-signed cert: the gateway must run here and be in self-signed mode.
|
||
# The self-signed condition is the gateway module's single source of truth
|
||
# (`gateway.useSelfSigned`): true when neither an operator cert
|
||
# (`tls.certDir`) nor ACME is set.
|
||
active = gatewayCfg.enable && gatewayCfg.useSelfSigned;
|
||
|
||
# The role this hive's gateway leaf is issued through. ./swarm-bao.nix
|
||
# writes one per hive in the swarm directory, under the same prefix, and
|
||
# narrows it to that hive's domain and `*.` that domain. So the leaf carries
|
||
# those two names and no others: a configured service name goes on the
|
||
# swarm-services leaf below, and asking this role for one is a refusal.
|
||
hiveLeafRole =
|
||
if hyperhiveCfg.hiveName == null then
|
||
null
|
||
else
|
||
"${baoDeploy.hiveLeafPkiRolePrefix}-${hyperhiveCfg.hiveName}";
|
||
|
||
# The hive's own store identity: its subject is
|
||
# `<hiveLeafCommonNamePrefix>-<hiveName>`, and its policy may issue from
|
||
# `hiveLeafRole` alone. It also carries the root fetch's handshake.
|
||
haveHiveIdentity = cfg.baoHiveIdentityCertFile != null && cfg.baoHiveIdentityKeyFile != null;
|
||
showPath = p: if p == null then "unset" else p;
|
||
hiveIdentityOptions = "services.hyperhive.deploy.hive-controller.tls.baoHiveIdentityCertFile (${showPath cfg.baoHiveIdentityCertFile}) and services.hyperhive.deploy.hive-controller.tls.baoHiveIdentityKeyFile (${showPath cfg.baoHiveIdentityKeyFile})";
|
||
|
||
# Fails the unit, naming both options, when the identity is unset or a file
|
||
# is unreadable. Every hive must hold one.
|
||
requireHiveIdentity =
|
||
purpose:
|
||
if haveHiveIdentity then
|
||
''
|
||
missing=""
|
||
for f in ${
|
||
lib.escapeShellArgs [
|
||
cfg.baoHiveIdentityCertFile
|
||
cfg.baoHiveIdentityKeyFile
|
||
]
|
||
}; do
|
||
[ -r "$f" ] || missing="$missing $f"
|
||
done
|
||
if [ -n "$missing" ]; then
|
||
echo "this hive has no usable store identity, so it cannot ${purpose}: cannot read$missing." >&2
|
||
echo "place the files named by ${hiveIdentityOptions}." >&2
|
||
exit 1
|
||
fi
|
||
''
|
||
else
|
||
''
|
||
echo "this hive has no store identity, so it cannot ${purpose}." >&2
|
||
echo "set ${hiveIdentityOptions} and place both files." >&2
|
||
exit 1
|
||
'';
|
||
|
||
# Shared by every unit that decides whether to re-issue a leaf. It
|
||
# lives here rather than in one of them because the guards have to
|
||
# agree: they answer the same question for different leaves
|
||
# (`hive-tls-resign` for the hive leaf, at boot, on the deploy and from
|
||
# its timer; `swarm-services-cert` for the services leaf). A rule
|
||
# implemented in one and not the others is worse than one implemented
|
||
# in none — it looks fixed and only fires on whichever path you did not
|
||
# take, which is exactly how a corrected `serviceDomains` still served a
|
||
# stale leaf.
|
||
#
|
||
# Expects `$d` (state dir) to be set; defines `$leaf`-adjacent names and
|
||
# `covers`.
|
||
leafCoverage = ''
|
||
svcleaf="$d/swarm-services.pem"
|
||
svcroot="$d/swarm-services-root.pem"
|
||
hiveNames=${lib.escapeShellArg "${domain} *.${domain}"}
|
||
svcNames=${lib.escapeShellArg (lib.concatStringsSep " " localServiceDomains)}
|
||
|
||
# A host fronting none of the swarm's service names has no services
|
||
# leaf to hold, and its absence there is the correct state rather
|
||
# than a stale one — its own vhosts are covered by the hive leaf
|
||
# above. Anywhere else it is expected: the leaf comes from the
|
||
# secret store's `pki` mount now, not from a sub-CA that exists on
|
||
# one host in the swarm, so "this host cannot issue it" is no longer
|
||
# one of the answers.
|
||
want_svc=${if localServiceDomains == [ ] then "0" else "1"}
|
||
|
||
# Expiry is not the only way a leaf goes wrong. One signed when the
|
||
# configured name set was smaller stays valid for its whole lifetime
|
||
# while omitting every name added since — so a config change can
|
||
# evaluate, build and deploy cleanly while the gateway keeps serving a
|
||
# certificate that does not cover the new service.
|
||
#
|
||
# Reads the names out of the CERTIFICATE, not a sidecar file: the pem
|
||
# is what nginx serves, and a bookkeeping file drifts from it the
|
||
# moment anyone replaces a leaf by hand.
|
||
covers() { # $1 = leaf, $2 = space-separated names it must carry
|
||
[ -s "$1" ] || return 1
|
||
_have="$(openssl x509 -in "$1" -noout -ext subjectAltName 2>/dev/null \
|
||
| tr ',' '\n' | sed -n 's/^[[:space:]]*DNS:\(.*\)$/\1/p' || true)"
|
||
[ -n "$_have" ] || return 1
|
||
_missing=0
|
||
# ⚠️ The hive leaf carries `*.<domain>`; without this the shell
|
||
# expands it against the cwd and the comparison silently tests a
|
||
# filename instead of a name.
|
||
set -f
|
||
for _n in $2; do
|
||
printf '%s\n' "$_have" | grep -qxF "$_n" || _missing=1
|
||
done
|
||
set +f
|
||
[ "$_missing" = 0 ]
|
||
}
|
||
|
||
# True when the hive leaf is present and carries its configured
|
||
# names. Expiry is the callers' own business — they use different
|
||
# windows.
|
||
#
|
||
# The services leaf is not part of this: `swarm-services-cert` holds
|
||
# the same rule for it, using the `covers` helper above so the two
|
||
# cannot drift.
|
||
leavesCoverNames() {
|
||
covers "$leaf" "$hiveNames"
|
||
}
|
||
'';
|
||
|
||
# What `swarm-services-cert` and `swarm-services-cert-renew` both run the
|
||
# issuance with. Bound here rather than read back off the first unit: its
|
||
# merged `path` already carries systemd's defaults, so the second would
|
||
# render a different `PATH` and fail to merge.
|
||
servicesCertPath = [
|
||
baoDeploy.package
|
||
pkgs.jq
|
||
pkgs.openssl
|
||
pkgs.coreutils
|
||
pkgs.systemd
|
||
# `cmp`, which is NOT in coreutils. Without it the root-changed test
|
||
# below is `command not found` — 127, invisible under `if !`, and
|
||
# therefore always "changed", so the bundle rebuild it guards fired
|
||
# on every issuance instead of on a new root.
|
||
pkgs.diffutils
|
||
# `flock`, for the lock at the top of the script.
|
||
pkgs.util-linux
|
||
# The root-only fetch on a host fronting no swarm name.
|
||
pkgs.curl
|
||
];
|
||
servicesCertEnvironment = {
|
||
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
|
||
}
|
||
// lib.optionalAttrs (cfg.baoClientCertFile != null) {
|
||
BAO_CLIENT_CERT = cfg.baoClientCertFile;
|
||
}
|
||
// lib.optionalAttrs (cfg.baoClientKeyFile != null) {
|
||
BAO_CLIENT_KEY = cfg.baoClientKeyFile;
|
||
}
|
||
# Absent means the system trust store, which is what a deployment
|
||
# with a real CA wants and what a self-signed one must not be left
|
||
# with.
|
||
// lib.optionalAttrs (baoDeploy.serverCaFile != null) {
|
||
BAO_CACERT = baoDeploy.serverCaFile;
|
||
};
|
||
|
||
# The root-only fetch. Same store and server CA as `servicesCertEnvironment`,
|
||
# but the hive's own identity: a host fronting no swarm name holds no
|
||
# services-issuer pair.
|
||
storeRootCurlTls = lib.escapeShellArgs (
|
||
lib.optionals (baoDeploy.serverCaFile != null) [
|
||
"--cacert"
|
||
baoDeploy.serverCaFile
|
||
]
|
||
++ [
|
||
"--cert"
|
||
cfg.baoHiveIdentityCertFile
|
||
"--key"
|
||
cfg.baoHiveIdentityKeyFile
|
||
]
|
||
);
|
||
|
||
# What `hive-tls-resign` issues the hive leaf with: the services leaf's
|
||
# store and server CA, presenting the hive's own identity.
|
||
hiveLeafEnvironment = {
|
||
inherit (servicesCertEnvironment) BAO_ADDR;
|
||
}
|
||
// lib.optionalAttrs (cfg.baoHiveIdentityCertFile != null) {
|
||
BAO_CLIENT_CERT = cfg.baoHiveIdentityCertFile;
|
||
}
|
||
// lib.optionalAttrs (cfg.baoHiveIdentityKeyFile != null) {
|
||
BAO_CLIENT_KEY = cfg.baoHiveIdentityKeyFile;
|
||
}
|
||
// lib.optionalAttrs (baoDeploy.serverCaFile != null) {
|
||
BAO_CACERT = baoDeploy.serverCaFile;
|
||
};
|
||
storeRetry = import ./lib/store-retry.nix { };
|
||
in
|
||
{
|
||
# Host-side TLS for the self-signed gateway mode.
|
||
#
|
||
# The gateway serves a short-lived **leaf** issued by the secret store's
|
||
# `pki` mount through this hive's own role, and consumers (agents,
|
||
# federation peers) anchor on that mount's root, which ships in
|
||
# `trust-bundle.pem`. Leaf rotation never re-breaks them, and nothing here
|
||
# generates or signs with a CA key. See
|
||
# `docs/networking/gateway.md` ("Self-signed TLS") and docs/swarm/ca.md.
|
||
#
|
||
# The trust bundle still carries the retired hive CA's public `ca.pem`
|
||
# where one exists, so a leaf it signed keeps verifying until it is
|
||
# replaced.
|
||
|
||
options.services.hyperhive.deploy.hive-controller.tls = {
|
||
stateDir = lib.mkOption {
|
||
type = lib.types.str;
|
||
default = "/var/lib/hive-tls";
|
||
description = ''
|
||
Host directory holding the gateway leaf and trust bundle for the
|
||
self-signed gateway mode: `gateway.pem` / `gateway-key.pem` (the
|
||
leaf the store issues and nginx serves) and `trust-bundle.pem` (the
|
||
anchors agents and federation peers trust). `ca.pem` and
|
||
`ca-previous.pem`, where present, are the retired hive CA's public
|
||
certificates and stay in the bundle. A `ca-key.pem` an earlier
|
||
generation left is neither read nor removed.
|
||
'';
|
||
};
|
||
|
||
baoClientCertFile = lib.mkOption {
|
||
type = lib.types.nullOr lib.types.str;
|
||
default = null;
|
||
example = "/var/lib/swarm-bao-pki/services-issuer.pem";
|
||
description = ''
|
||
Certificate `swarm-services-cert` presents to the secret store when it
|
||
asks for the swarm-services leaf the gateway serves.
|
||
|
||
Its subject has to be
|
||
{option}`services.hyperhive.deploy.bao.servicesIssuerCommonName` — cert
|
||
auth matches on the common name, and this is the one role whose policy
|
||
permits `pki/issue/swarm-services`.
|
||
|
||
A **separate** credential from
|
||
{option}`services.hyperhive.deploy.bao.clientCertFile`, which is this
|
||
hive's own reader identity and whose policy reads every secret the
|
||
hive is entitled to, and from
|
||
{option}`services.hyperhive.deploy.hive-controller.tls.baoHiveIdentityCertFile`,
|
||
which the root fetch on a hive fronting no swarm name presents.
|
||
|
||
On a hive that mints its own store PKI a glue module supplies the leaf
|
||
as a `mkDefault`; everywhere else it is the credential an operator
|
||
places, for the reason the store's own client certificate is.
|
||
|
||
A path, never a value.
|
||
'';
|
||
};
|
||
|
||
baoClientKeyFile = lib.mkOption {
|
||
type = lib.types.nullOr lib.types.str;
|
||
default = null;
|
||
example = "/var/lib/swarm-bao-pki/services-issuer-key.pem";
|
||
description = ''
|
||
Private key for
|
||
{option}`services.hyperhive.deploy.hive-controller.tls.baoClientCertFile`.
|
||
Both or neither — a certificate with no key authenticates nothing.
|
||
'';
|
||
};
|
||
|
||
baoHiveIdentityCertFile = lib.mkOption {
|
||
type = lib.types.nullOr lib.types.str;
|
||
default = null;
|
||
example = "/var/lib/swarm-bao-pki/hive-leaf.pem";
|
||
description = ''
|
||
This hive's own store identity. `hive-tls-resign` presents it to ask
|
||
the secret store for the gateway leaf, and `swarm-services-cert` on a
|
||
hive fronting no swarm name presents it for the handshake of the root
|
||
fetch.
|
||
|
||
Its subject has to be
|
||
`<services.hyperhive.deploy.bao.hiveLeafCommonNamePrefix>-<hiveName>`;
|
||
that cert-auth role's policy may issue from this hive's gateway-leaf
|
||
role and nothing else.
|
||
|
||
Every hive with a self-signed gateway needs it: without it both units
|
||
fail, naming this option and
|
||
{option}`services.hyperhive.deploy.hive-controller.tls.baoHiveIdentityKeyFile`.
|
||
On the host that runs the store a glue module supplies the leaf as a
|
||
`mkDefault`; on every other host an operator places it.
|
||
|
||
A path, never a value.
|
||
'';
|
||
};
|
||
|
||
baoHiveIdentityKeyFile = lib.mkOption {
|
||
type = lib.types.nullOr lib.types.str;
|
||
default = null;
|
||
example = "/var/lib/swarm-bao-pki/hive-leaf-key.pem";
|
||
description = ''
|
||
Private key for
|
||
{option}`services.hyperhive.deploy.hive-controller.tls.baoHiveIdentityCertFile`.
|
||
Both or neither — a certificate with no key authenticates nothing.
|
||
'';
|
||
};
|
||
};
|
||
|
||
config = lib.mkIf active {
|
||
# 🚫 The "these service names are outside this hive's domain and this
|
||
# host cannot sign for them" warning is GONE, and its absence is the
|
||
# deliverable rather than a tidy-up. It fired on
|
||
# `!swarm.ca.autoConfigure` — i.e. "this host does not hold the swarm
|
||
# root key" — which was the whole reason a hive could end up serving
|
||
# its own leaf on a swarm-service name. The services leaf now comes
|
||
# from the secret store's `pki` mount, which every hive reaches over
|
||
# the network with its own identity, so holding the root key stopped
|
||
# being the thing that decides. Left in place it would warn, on every
|
||
# rebuild of every hive that is not the CA host, about a fallback
|
||
# that no longer happens.
|
||
|
||
# The state dir and the trust bundle. Never waits on the store: the
|
||
# store's container is ordered after this unit, which writes the
|
||
# bundle it bind-mounts, so the leaf is `hive-tls-resign`'s.
|
||
systemd.services.hive-tls-ca = {
|
||
description = "Assemble the hive TLS trust bundle (self-signed mode)";
|
||
wantedBy = [ "multi-user.target" ];
|
||
# The consumer is `hive-gateway-self-signed-cert`, which copies the
|
||
# leaf out of the state dir this unit creates, and which nginx in
|
||
# turn `Requires=`. Order against the unit that reads the dir.
|
||
before = [ "hive-gateway-self-signed-cert.service" ];
|
||
requiredBy = [ "hive-gateway-self-signed-cert.service" ];
|
||
# The bundle carries the swarm root where this host generates it.
|
||
#
|
||
# `swarm-services-cert` is NOT ordered against this unit in either
|
||
# direction, and the absence is load-bearing — see the cycle spelled
|
||
# out at that unit. It runs after the store's container, the container
|
||
# runs after this unit, so an edge back to here is a loop and systemd
|
||
# resolves a loop by dropping a job.
|
||
#
|
||
# What that costs: the trust bundle written at the end of this script
|
||
# anchors on every root a consumer must hold, and the services root is
|
||
# one of them, so on a first boot the `cat` below runs before that file
|
||
# exists and the bundle is one restart short of complete. The `if [ -s
|
||
# "$svcroot" ]` guard is what makes that state legal rather than fatal,
|
||
# and `swarm-services-cert` restarts this unit once the root it wrote
|
||
# is new — so the bundle converges instead of being a boot behind.
|
||
after = lib.optionals swarmCaCfg.autoConfigure [ "swarm-ca.service" ];
|
||
serviceConfig = {
|
||
Type = "oneshot";
|
||
RemainAfterExit = true;
|
||
UMask = "0077";
|
||
# Pin the journal identity (else it's the `script` store-path wrapper).
|
||
SyslogIdentifier = "hive-tls-ca";
|
||
};
|
||
script = ''
|
||
set -euo pipefail
|
||
d=${lib.escapeShellArg cfg.stateDir}
|
||
install -d -m 0755 "$d"
|
||
|
||
ca="$d/ca.pem"
|
||
prev="$d/ca-previous.pem"
|
||
root=${lib.escapeShellArg "${swarmCaCfg.stateDir}/root.pem"}
|
||
svcroot="$d/swarm-services-root.pem"
|
||
|
||
# --- Trust bundle: what a consumer must TRUST. Why it is a set of
|
||
# anchors rather than one file is in docs/swarm/ca.md. Three
|
||
# constraints the code cannot state:
|
||
#
|
||
# Written IN PLACE, never renamed into position — containers
|
||
# bind-mount this file and a bind mount follows the inode, so a
|
||
# rename leaves every consumer holding the old one.
|
||
#
|
||
# Dropping `ca.pem` and `ca-previous.pem` is deliberately NOT done
|
||
# here: "long enough" is a deployment fact, not a unit's call.
|
||
#
|
||
# Built as an array with `if` guards, not
|
||
# `[ -s f ] && anchors+=(f)`: under `set -e` an && list whose test
|
||
# fails IS a failing command and kills the unit — on exactly the
|
||
# hive where the optional file is legitimately absent.
|
||
#
|
||
# `swarm-services-root.pem` is the store's `pki` root, which both
|
||
# the gateway leaf and the swarm-services leaf chain to. `ca.pem`
|
||
# and `ca-previous.pem` are the retired hive CA's public
|
||
# certificates, kept so a leaf they signed verifies until replaced.
|
||
bundle="$d/trust-bundle.pem"
|
||
anchors=()
|
||
if [ -s "$ca" ]; then anchors+=("$ca"); fi
|
||
if [ -s "$prev" ]; then anchors+=("$prev"); fi
|
||
if [ -s "$root" ]; then anchors+=("$root"); fi
|
||
if [ -s "$svcroot" ]; then anchors+=("$svcroot"); fi
|
||
# `cat` with no arguments reads stdin, so no anchor at all is an
|
||
# empty file until `swarm-services-cert` writes the root.
|
||
if [ "''${#anchors[@]}" = 0 ]; then
|
||
echo "no trust anchor on disk yet — $bundle is empty until the store's root is fetched" >&2
|
||
: > "$bundle"
|
||
else
|
||
cat "''${anchors[@]}" > "$bundle"
|
||
fi
|
||
chmod 0644 "$bundle"
|
||
'';
|
||
};
|
||
|
||
# The gateway leaf: issued by the secret store's `pki` mount through
|
||
# this hive's own role, at boot, on every deploy and from the daily
|
||
# timer below, whenever it is missing, past half its lifetime, missing
|
||
# a configured name, not chained to the store's root, or not matching
|
||
# its key.
|
||
#
|
||
# ⚠️ Nothing orders the gateway against this unit, which is what lets a
|
||
# hive holding a valid leaf start its gateway with the store sealed or
|
||
# unreachable. Only a hive with no leaf yet has a gateway that waits
|
||
# for the store: its cert import fails until this unit lands a leaf
|
||
# and starts nginx.
|
||
#
|
||
# Failure leaves the current leaf alone: every file the script writes
|
||
# is a `.new` until the store has answered with both fields.
|
||
#
|
||
# Propagation mechanism: nginx serves a *copy* of the leaf, written by
|
||
# `hive-gateway-self-signed-cert` at the mode nginx can read. Issuing
|
||
# the source therefore changes nothing on its own — the copy has to be
|
||
# remade and nginx reloaded, which is what the calls at the end do.
|
||
# Neither of the running-nginx calls is `|| true`: a swallowed
|
||
# propagation failure means the leaf rotates on disk while nginx keeps
|
||
# serving the old copy until it expires, with this unit reporting
|
||
# success the whole time.
|
||
systemd.services.hive-tls-resign = {
|
||
description = "Issue or renew the gateway TLS leaf from the secret store's PKI";
|
||
wantedBy = [ "multi-user.target" ];
|
||
# The store's container and its granting unit where they run here;
|
||
# ignored elsewhere, as for `swarm-services-cert`, which writes the
|
||
# store's root this unit checks the leaf against.
|
||
after = [
|
||
"hive-tls-ca.service"
|
||
"container@${baoCfg.machine}.service"
|
||
"swarm-bao-pki.service"
|
||
"swarm-bao-hive-leaf-policy.service"
|
||
"swarm-services-cert.service"
|
||
];
|
||
wants = [ "container@${baoCfg.machine}.service" ];
|
||
path = [
|
||
baoDeploy.package
|
||
pkgs.jq
|
||
pkgs.openssl
|
||
pkgs.coreutils
|
||
pkgs.systemd
|
||
];
|
||
environment = hiveLeafEnvironment;
|
||
# ./lib/store-retry.nix: the login below fails for as long as the
|
||
# store is sealed or down.
|
||
inherit (storeRetry) startLimitBurst startLimitIntervalSec;
|
||
serviceConfig = storeRetry.serviceConfig // {
|
||
Type = "oneshot";
|
||
UMask = "0077";
|
||
SyslogIdentifier = "hive-tls-resign";
|
||
};
|
||
script = ''
|
||
set -euo pipefail
|
||
d=${lib.escapeShellArg cfg.stateDir}
|
||
install -d -m 0755 "$d"
|
||
leaf="$d/gateway.pem"
|
||
leafk="$d/gateway-key.pem"
|
||
|
||
fail() {
|
||
echo "$1" >&2
|
||
if [ -s "$leaf" ]; then
|
||
echo "the current gateway leaf at $leaf stays in place" >&2
|
||
fi
|
||
exit 1
|
||
}
|
||
|
||
# Checked before the leaf is: a hive without its identity is red
|
||
# even while its current leaf is still valid.
|
||
${
|
||
if hiveLeafRole == null then
|
||
''fail "services.hyperhive.hiveName is unset, so this hive has no gateway-leaf role to issue from."''
|
||
else
|
||
requireHiveIdentity "ask the store for its gateway leaf"
|
||
}
|
||
|
||
${leafCoverage}
|
||
|
||
# Half the lifetime the role grants, as for the services leaf, so
|
||
# the daily timer has half the lifetime to retry through a sealed
|
||
# store.
|
||
renewat=$(( ${toString baoDeploy.servicesPkiLeafTtlHours} * 3600 / 2 ))
|
||
reissue=0
|
||
{ [ -s "$leaf" ] && [ -s "$leafk" ]; } || reissue=1
|
||
openssl x509 -in "$leaf" -noout -checkend "$renewat" >/dev/null 2>&1 || reissue=1
|
||
leavesCoverNames || reissue=1
|
||
# A leaf the retired hive CA signed, or one under a store root since
|
||
# replaced, fails this and is re-issued.
|
||
{ [ -s "$svcroot" ] && openssl verify -CAfile "$svcroot" "$leaf" >/dev/null 2>&1; } || reissue=1
|
||
# The leaf and the key are two renames, so an interrupted rotation can
|
||
# leave a certificate beside a key it does not belong to.
|
||
leafpub="$(openssl x509 -in "$leaf" -noout -pubkey 2>/dev/null || true)"
|
||
keypub="$(openssl pkey -in "$leafk" -pubout 2>/dev/null || true)"
|
||
{ [ -n "$leafpub" ] && [ "$leafpub" = "$keypub" ]; } || reissue=1
|
||
|
||
if [ "$reissue" = 0 ]; then
|
||
echo "gateway leaf valid, matching its key, chained to the store's root and covering the configured names — leaving it alone"
|
||
exit 0
|
||
fi
|
||
|
||
err="$(mktemp)"
|
||
resp="$(mktemp "$d/gateway.json.XXXXXX")"
|
||
trap 'rm -f "$err" "$resp" "$leaf.new" "$leafk.new"' EXIT
|
||
|
||
# Cert auth is a login, not a transport setting; `-token-only`
|
||
# skips the token helper, for the reason `swarm-services-cert` gives.
|
||
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
|
||
fail "could not log in to the swarm secret store with this hive's identity: $(cat "$err")"
|
||
fi
|
||
export BAO_TOKEN
|
||
|
||
echo "requesting the gateway leaf from the store"
|
||
if ! bao write -format=json \
|
||
${lib.escapeShellArg "${baoServicesPkiMount}/issue/${toString hiveLeafRole}"} \
|
||
common_name=${lib.escapeShellArg domain} \
|
||
alt_names=${lib.escapeShellArg "*.${domain}"} \
|
||
> "$resp" 2>"$err"; then
|
||
fail "the store refused to issue the gateway leaf: $(cat "$err")"
|
||
fi
|
||
|
||
# One issuance, split, for the reasons `swarm-services-cert` gives:
|
||
# the key never enters an argv, and `// empty` keeps a missing field
|
||
# from becoming the four characters `null`. The leaf alone, without
|
||
# its issuer: the issuer is the self-signed root in the bundle.
|
||
umask 077
|
||
jq -r '.data.private_key // empty' < "$resp" > "$leafk.new"
|
||
jq -r '.data.certificate // empty' < "$resp" > "$leaf.new"
|
||
for f in "$leafk.new" "$leaf.new"; do
|
||
[ -s "$f" ] || fail "the store's response was missing a field: $f is empty"
|
||
done
|
||
|
||
mv -f "$leaf.new" "$leaf"
|
||
mv -f "$leafk.new" "$leafk"
|
||
chmod 0644 "$leaf"
|
||
chmod 0600 "$leafk"
|
||
|
||
# A gateway already running gets the copy remade and a reload. One
|
||
# that is not running — a hive that had no leaf — gets started,
|
||
# `--no-block` so a start job still queued absorbs the request.
|
||
if systemctl is-active --quiet nginx.service; then
|
||
echo "gateway leaf rotated — re-importing and reloading nginx"
|
||
systemctl restart hive-gateway-self-signed-cert.service
|
||
systemctl reload nginx.service
|
||
elif [ "$(systemctl show -P LoadState nginx.service)" = loaded ]; then
|
||
echo "gateway leaf landed with nginx not running — starting it"
|
||
systemctl reset-failed nginx.service
|
||
systemctl start --no-block nginx.service
|
||
fi
|
||
'';
|
||
};
|
||
|
||
# The swarm-services leaf, issued by the secret store's `pki` mount.
|
||
#
|
||
# ⚠️ This unit replaces a chain, not a command. What used to happen
|
||
# was two openssl hops on whichever host held the swarm root key: a
|
||
# name-constrained `swarm-services` sub-CA (../swarm-ca.nix), then a
|
||
# leaf signed under it here. Both are gone. The store's
|
||
# `pki/roles/swarm-services` carries the same narrowing as an
|
||
# `allowed_domains` list enforced at issue time, so the intermediate
|
||
# that used to encode it in x509 `nameConstraints` has nothing left
|
||
# to express — and with the root inside the mount there is nothing
|
||
# for it to be an intermediate OF.
|
||
#
|
||
# The names it may carry are not this unit's to assert: the role
|
||
# refuses anything outside `allowed_domains`, read from the
|
||
# swarm-wide `swarm.serviceDomains` that the SANs below are a
|
||
# per-host SUBSET of. A mismatch is a refusal from the store naming
|
||
# the offending name, not a certificate quietly issued for something
|
||
# nobody configured.
|
||
systemd.services.swarm-services-cert = {
|
||
description = "Issue the swarm-services TLS leaf from the secret store's PKI";
|
||
wantedBy = [ "multi-user.target" ];
|
||
# ⚠️ The ordering the old shape got by ACCIDENT, written down.
|
||
# Nothing used to declare "no TLS until the store is up" — it held
|
||
# because `swarm-services-ca.service` happened to block the gateway.
|
||
# Now it is a real edge: the gateway's certificate comes out of the
|
||
# store, so the store being up is a precondition rather than a
|
||
# coincidence, and `requiredBy` is what makes a store that never
|
||
# comes up an outage that says so instead of a gateway serving the
|
||
# wrong name.
|
||
#
|
||
# 🩸 NOT `Before=hive-tls-ca.service`, however much the trust bundle
|
||
# wants it. That edge closed a cycle — the store's container is
|
||
# `After=hive-tls-ca` (it serves the material that unit writes), and
|
||
# this unit is `After=` the container — so systemd broke the loop the
|
||
# only way it can, by deleting a job:
|
||
#
|
||
# Found ordering cycle: container@swarm-bao.service/start after
|
||
# hive-tls-ca.service/start after swarm-services-cert.service/start
|
||
# Job swarm-services-cert.service/start deleted to break ordering cycle
|
||
#
|
||
# With the job gone the leaf was never issued, `Requires=` took the
|
||
# gateway's cert import down with it, and nginx never started at all —
|
||
# so the swarm served nothing rather than serving the wrong name. The
|
||
# bundle reaches the root the other way round, at the end of the script
|
||
# below: it restarts `hive-tls-ca` when the root CHANGED, which is the
|
||
# same path that already covered a store coming up hours late.
|
||
before = lib.optional gatewayWaitsForServicesLeaf "hive-gateway-self-signed-cert.service";
|
||
requiredBy = lib.optional gatewayWaitsForServicesLeaf "hive-gateway-self-signed-cert.service";
|
||
# The store's container, where it runs here. On a hive that reads a
|
||
# store hosted elsewhere no such unit exists and systemd ignores
|
||
# the name, which is the correct behaviour rather than a gap: what
|
||
# this unit actually needs is the store reachable, and on a remote
|
||
# store nothing local can order against that.
|
||
#
|
||
# `swarm-bao-pki` mints the services-issuer leaf the login below
|
||
# presents, and is absent on a hive holding an operator-placed one —
|
||
# ignored there for the same reason, rather than being a dependency
|
||
# only some hives may declare.
|
||
after = [
|
||
"container@${baoCfg.machine}.service"
|
||
"swarm-bao-pki.service"
|
||
"swarm-bao-services-issuer-policy.service"
|
||
];
|
||
wants = [ "container@${baoCfg.machine}.service" ];
|
||
path = servicesCertPath;
|
||
# ./lib/store-retry.nix: the login below fails for as long as the
|
||
# store is sealed or down.
|
||
inherit (storeRetry) startLimitBurst startLimitIntervalSec;
|
||
serviceConfig = storeRetry.serviceConfig // {
|
||
Type = "oneshot";
|
||
RemainAfterExit = true;
|
||
UMask = "0077";
|
||
SyslogIdentifier = "swarm-services-cert";
|
||
};
|
||
environment = servicesCertEnvironment;
|
||
script = ''
|
||
set -euo pipefail
|
||
d=${lib.escapeShellArg cfg.stateDir}
|
||
install -d -m 0755 "$d"
|
||
|
||
# `swarm-services-cert-renew` runs this same script, and two runs
|
||
# issuing at once can leave one's key beside the other's leaf. The
|
||
# second waits and then finds the leaf fresh.
|
||
exec 9>"$d/.swarm-services-cert.lock"
|
||
if ! flock -w 600 9; then
|
||
echo "another swarm-services issuance has held $d/.swarm-services-cert.lock for 10 minutes" >&2
|
||
exit 1
|
||
fi
|
||
|
||
${leafCoverage}
|
||
|
||
# No leaf to ask for, but the root still belongs in the trust
|
||
# bundle: every other hive's swarm-service names chain to it.
|
||
# `ca/pem` takes no token; the TLS client certificate is the
|
||
# store's listener's demand, not bao's, so this presents the
|
||
# hive's own identity and never logs in with it.
|
||
# A hive without a readable identity fails here, naming the
|
||
# options: every hive must hold one.
|
||
if [ "$want_svc" = 0 ]; then
|
||
${
|
||
if haveHiveIdentity then
|
||
requireHiveIdentity "fetch the store's ${baoServicesPkiMount} root"
|
||
+ ''
|
||
echo "no swarm service domains configured — fetching only the store's pki root"
|
||
err="$(mktemp)"
|
||
raw="$(mktemp "$d/swarm-services-root.XXXXXX")"
|
||
trap 'rm -f "$err" "$raw"' EXIT
|
||
if ! curl --fail --silent --show-error \
|
||
${storeRootCurlTls} --output "$raw" \
|
||
"$BAO_ADDR/v1/${baoServicesPkiMount}/ca/pem" 2>"$err"; then
|
||
echo "could not read the store's ${baoServicesPkiMount} root." >&2
|
||
cat "$err" >&2
|
||
exit 1
|
||
fi
|
||
# Re-encoded rather than copied: it fails on a body that does not
|
||
# parse as a certificate, and ends the file in the newline the
|
||
# bundle's `cat` relies on.
|
||
if ! openssl x509 -in "$raw" -out "$svcroot.new" 2>"$err"; then
|
||
echo "the store's ${baoServicesPkiMount} root does not parse as a certificate." >&2
|
||
cat "$err" >&2
|
||
exit 1
|
||
fi
|
||
if cmp -s "$svcroot.new" "$svcroot"; then
|
||
rm -f "$svcroot.new"
|
||
echo "the store's pki root is unchanged"
|
||
exit 0
|
||
fi
|
||
mv -f "$svcroot.new" "$svcroot"
|
||
chmod 0644 "$svcroot"
|
||
if systemctl is-active --quiet hive-tls-ca.service; then
|
||
echo "the services root changed — rebuilding the trust bundle"
|
||
systemctl restart --no-block hive-tls-ca.service
|
||
fi
|
||
exit 0''
|
||
else
|
||
requireHiveIdentity "fetch the store's ${baoServicesPkiMount} root"
|
||
}
|
||
fi
|
||
|
||
# Re-issue when the file is missing, past half the lifetime the
|
||
# store's role grants it, or no longer carrying every configured
|
||
# name. The name check is what makes adding a swarm service take
|
||
# effect on the rebuild that added it rather than whenever the
|
||
# certificate happens to lapse.
|
||
#
|
||
# Half, as `hive-tls-resign` does for the hive leaf, so the daily
|
||
# renewal has half the lifetime to retry through a sealed store.
|
||
# Not the whole lifetime: a leaf issued with exactly that much left
|
||
# is inside it the second after, so every run re-issued.
|
||
renewat=$(( ${toString baoDeploy.servicesPkiLeafTtlHours} * 3600 / 2 ))
|
||
reissue=0
|
||
{ [ -s "$svcleaf" ] && [ -s "$d/swarm-services-key.pem" ]; } || reissue=1
|
||
openssl x509 -in "$svcleaf" -noout -checkend "$renewat" >/dev/null 2>&1 || reissue=1
|
||
covers "$svcleaf" "$svcNames" || reissue=1
|
||
[ -s "$svcroot" ] || reissue=1
|
||
|
||
# The ROOT's window, not the leaf's, and it is what keeps the two
|
||
# sides of this from needing to talk. The store replaces an issuer
|
||
# that has less than a leaf's window left (./swarm-bao.nix's
|
||
# regeneration guard) — so a hive testing its own copy of that same
|
||
# certificate against the same threshold, read from the same option,
|
||
# asks for a new leaf on the same activation, and gets the
|
||
# replacement root back with it. Without this a leaf stays "valid"
|
||
# while the anchor it chains to no longer exists in the mount, which
|
||
# no expiry check would ever catch.
|
||
openssl x509 -in "$svcroot" -noout -checkend ${
|
||
toString (baoDeploy.servicesPkiLeafTtlHours * 3600)
|
||
} >/dev/null 2>&1 || reissue=1
|
||
|
||
if [ "$reissue" = 0 ]; then
|
||
echo "swarm-services leaf valid and covering the configured names — leaving it alone"
|
||
exit 0
|
||
fi
|
||
|
||
${
|
||
if cfg.baoClientCertFile == null || cfg.baoClientKeyFile == null then
|
||
''
|
||
echo "no bao client certificate configured for this host, so the gateway's" >&2
|
||
echo "swarm-services certificate cannot be requested from the store." >&2
|
||
echo "Set services.hyperhive.deploy.hive-controller.tls.baoClient{Cert,Key}File" >&2
|
||
echo "to a leaf the store's CA signed with CN=${baoDeploy.servicesIssuerCommonName}." >&2
|
||
exit 1''
|
||
else
|
||
""
|
||
}
|
||
|
||
# `bao`'s own message is the only thing separating an unreachable
|
||
# store from a refused identity from a role that would not issue
|
||
# these names, and this unit retries on all three — so it reports
|
||
# which one rather than asserting all three in a sentence of ours.
|
||
err="$(mktemp)"
|
||
trap 'rm -f "$err"' EXIT
|
||
|
||
# Cert auth is a login, not a transport setting: the
|
||
# `BAO_CLIENT_*` variables only decide which certificate the
|
||
# handshake presents. Without a token `bao` asks its token
|
||
# helper, and that is a `sh` this unit's `path` does not carry —
|
||
# `-token-only` answers on stdout and skips the helper.
|
||
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
|
||
echo "could not log in to the swarm secret store with this host's services-issuer certificate." >&2
|
||
cat "$err" >&2
|
||
exit 1
|
||
fi
|
||
export BAO_TOKEN
|
||
|
||
echo "requesting the swarm-services leaf from the store"
|
||
resp="$(mktemp "$d/swarm-services.json.XXXXXX")"
|
||
trap 'rm -f "$err" "$resp"' EXIT
|
||
|
||
# ONE call, and the response carries all three artefacts —
|
||
# certificate, private key and issuing CA. Splitting it into a
|
||
# local CSR plus `pki/sign` would buy a key that never crosses
|
||
# the wire, and cost the thing that makes this unit worth having:
|
||
# the store, not this host, decides what the certificate says.
|
||
if ! bao write -format=json \
|
||
${lib.escapeShellArg "${baoServicesPkiMount}/issue/${baoServicesPkiRole}"} \
|
||
common_name=${lib.escapeShellArg leafCommonName} \
|
||
alt_names=${lib.escapeShellArg (lib.concatStringsSep "," localServiceDomains)} \
|
||
> "$resp" 2>"$err"; then
|
||
echo "the store refused to issue the swarm-services certificate." >&2
|
||
cat "$err" >&2
|
||
exit 1
|
||
fi
|
||
|
||
# Three fields out of ONE issuance, which is why the response is
|
||
# captured and then split rather than asked for three times with
|
||
# `bao write -field=…`: each of those calls would mint a separate
|
||
# certificate, and the key would belong to a different one than
|
||
# the leaf.
|
||
#
|
||
# `jq -r` into a file the shell opened, never into a variable and
|
||
# never into an argv — the private key does not become visible in
|
||
# /proc on its way to disk.
|
||
#
|
||
# `// empty` on every one of them: `jq -r` renders a missing
|
||
# field as the four characters `null`, which is a non-empty file
|
||
# and would sail past the check below into a certificate slot.
|
||
umask 077
|
||
jq -r '.data.private_key // empty' < "$resp" > "$d/swarm-services-key.pem.new"
|
||
jq -r '.data.certificate // empty' < "$resp" > "$d/swarm-services-leaf.new"
|
||
jq -r '.data.issuing_ca // empty' < "$resp" > "$svcroot.new"
|
||
|
||
for f in "$d/swarm-services-key.pem.new" "$d/swarm-services-leaf.new" "$svcroot.new"; do
|
||
if [ ! -s "$f" ]; then
|
||
echo "the store's response was missing a field: $f is empty" >&2
|
||
exit 1
|
||
fi
|
||
done
|
||
|
||
# nginx serves this file verbatim. It carries the leaf ALONE and
|
||
# not leaf-plus-issuer, which is the one visible consequence of
|
||
# flattening the chain: the issuer here is a self-signed root, so
|
||
# appending it would ship an anchor a client either already holds
|
||
# — in which case it is redundant — or does not, in which case
|
||
# shipping it proves nothing. The old file appended the services
|
||
# sub-CA because that WAS an intermediate, and a client anchored
|
||
# on the swarm root could not build the middle of the chain by
|
||
# itself.
|
||
mv -f "$d/swarm-services-leaf.new" "$svcleaf"
|
||
mv -f "$d/swarm-services-key.pem.new" "$d/swarm-services-key.pem"
|
||
chmod 0644 "$svcleaf"
|
||
chmod 0600 "$d/swarm-services-key.pem"
|
||
|
||
# The anchor, world-readable, beside the leaf it signed. This is
|
||
# the file an operator adds to a browser's trust store, and the
|
||
# one `hive-tls-ca` folds into `trust-bundle.pem`. Public
|
||
# certificate material only — the private half never leaves the
|
||
# store's `pki` mount, which is what `root/generate/internal`
|
||
# buys.
|
||
rootchanged=0
|
||
if ! cmp -s "$svcroot.new" "$svcroot"; then
|
||
rootchanged=1
|
||
fi
|
||
mv -f "$svcroot.new" "$svcroot"
|
||
chmod 0644 "$svcroot"
|
||
|
||
# Propagation. nginx serves a *copy* of the leaf, so re-issuing the
|
||
# source changes nothing until the copy is remade. A running
|
||
# gateway (`swarm-services-cert-renew` rotating the leaf) gets the
|
||
# copy remade and a reload. A gateway that is not running gets
|
||
# started: a start job still waiting on this unit absorbs the
|
||
# request, and a start that already ended on this unit's
|
||
# `requiredBy` has nothing else that would start it again.
|
||
#
|
||
# ⚠️ `--no-block`, and it is not a preference. This unit declares
|
||
# `Before=` both of these, so a blocking `systemctl restart` or
|
||
# `start` enqueues a job that systemd will not start until this
|
||
# unit is active — and this unit is not active until its ExecStart
|
||
# returns, which is waiting on that job. A deadlock, held until
|
||
# the 24h retry window's `TimeoutStartSec` fires. Queueing the
|
||
# job and letting it run once we exit is the only ordering that
|
||
# terminates.
|
||
#
|
||
# The cost is real and is the reason `hive-tls-resign` does NOT
|
||
# do this: a propagation that fails after we return does not fail
|
||
# us, so the leaf can rotate on disk while nginx keeps the old
|
||
# copy. That unit blocks because nothing orders it before its
|
||
# consumers; this one cannot.
|
||
if systemctl is-active --quiet nginx.service; then
|
||
echo "swarm-services leaf rotated — re-importing and reloading nginx"
|
||
systemctl restart --no-block hive-gateway-self-signed-cert.service
|
||
systemctl reload --no-block nginx.service
|
||
elif [ "$(systemctl show -P LoadState nginx.service)" = loaded ]; then
|
||
echo "swarm-services leaf landed with nginx not running — starting it"
|
||
systemctl reset-failed nginx.service
|
||
systemctl start --no-block nginx.service
|
||
fi
|
||
|
||
# The bundle is assembled by `hive-tls-ca`, which runs BEFORE this
|
||
# unit — it has to, or the store's container cannot start and there
|
||
# is nothing to ask for a leaf. So on a first boot it wrote a bundle
|
||
# without the root below, and this restart is what completes it
|
||
# rather than a retry-path fallback. Only on a CHANGED root: every
|
||
# boot otherwise bounces a unit with nothing to do.
|
||
if [ "$rootchanged" = 1 ] && systemctl is-active --quiet hive-tls-ca.service; then
|
||
echo "the services root changed — rebuilding the trust bundle"
|
||
systemctl restart --no-block hive-tls-ca.service
|
||
fi
|
||
'';
|
||
};
|
||
|
||
# The services leaf's renewal: `swarm-services-cert`'s own script, run
|
||
# again from the daily timer below.
|
||
#
|
||
# ⚠️ A unit of its own, not a timer on `swarm-services-cert`. That unit
|
||
# is `RemainAfterExit`, so once it has run it stays active and a timer
|
||
# starting it does nothing. And it cannot be restarted instead:
|
||
# `hive-gateway-self-signed-cert` `Requires=` it, so a restart restarts
|
||
# the gateway's cert import and nginx behind it, and a store that is
|
||
# sealed at that moment fails the start and takes both down over a leaf
|
||
# that was still valid. Nothing requires or orders against this unit, so
|
||
# its failure is its own.
|
||
#
|
||
# Failure leaves the leaf alone: every file the script writes is a
|
||
# `.new` until the store has answered with all three fields. No
|
||
# `Restart=`: the unit stays failed until the next tick, which is how
|
||
# a sealed store shows up in `systemctl --failed`.
|
||
systemd.services.swarm-services-cert-renew = {
|
||
description = "Renew the swarm-services TLS leaf from the secret store's PKI";
|
||
# The timer's `Persistent=` can fire it during boot. Behind the boot
|
||
# issuance, so it finds the leaf that run just wrote.
|
||
after = [ "swarm-services-cert.service" ];
|
||
path = servicesCertPath;
|
||
environment = servicesCertEnvironment;
|
||
inherit (config.systemd.services.swarm-services-cert) script;
|
||
serviceConfig = {
|
||
Type = "oneshot";
|
||
UMask = "0077";
|
||
SyslogIdentifier = "swarm-services-cert-renew";
|
||
};
|
||
};
|
||
|
||
# Daily, for the reason `swarm-services-cert-renew` below is: the leaf
|
||
# needs the store, which can be sealed or unreachable.
|
||
systemd.timers.hive-tls-resign = {
|
||
description = "Daily renewal check for the gateway TLS leaf";
|
||
wantedBy = [ "timers.target" ];
|
||
timerConfig = {
|
||
OnCalendar = "daily";
|
||
Persistent = true;
|
||
};
|
||
};
|
||
|
||
# Daily rather than weekly, for the reason
|
||
# ./swarm-nats.nix's `swarm-bao-nats-tls` timer is: this leaf needs the
|
||
# store, which can be sealed or unreachable, so there is a retry per day
|
||
# from half-life to expiry rather than two.
|
||
systemd.timers.swarm-services-cert-renew = {
|
||
description = "Daily renewal check for the swarm-services TLS leaf";
|
||
wantedBy = [ "timers.target" ];
|
||
timerConfig = {
|
||
OnCalendar = "daily";
|
||
Persistent = true;
|
||
};
|
||
};
|
||
|
||
# Signal the hive-c0re lifecycle that a trust bundle exists: it bind-mounts
|
||
# this file (read-only, public certs ONLY — never a key) into each
|
||
# agent container so agents + their tools can trust the gateway's
|
||
# self-signed leaf, and the meta flake wires the per-agent trust
|
||
# bundle. It is the ANCHOR bundle rather than `ca.pem` for the reason
|
||
# spelled out where the bundle is written above; no key path is ever
|
||
# exposed (an agent that could read one could mint trusted certs).
|
||
#
|
||
# ⚠️ Every line here that names another daemon's unit is gated on that
|
||
# daemon's own enable: the gateway also runs on hosts with no hive and no
|
||
# controller, and defining an environment key on a unit that does not
|
||
# exist CREATES a unit fragment for it — inert (no `ExecStart`, empty
|
||
# `wantedBy`, never activated), and it evaluates and builds clean.
|
||
systemd.services.hive-c0re.environment.HIVE_TLS_CA_PATH =
|
||
lib.mkIf hyperhiveCfg.deploy.hive-controller.enable "${cfg.stateDir}/trust-bundle.pem";
|
||
|
||
# The same anchor, named for the swarm-queue clients that need it when
|
||
# they mint a token from authelia over TLS. Declared HERE, beside the
|
||
# bundle, rather than in each consumer's module: the path is this
|
||
# module's fact, and two consumers re-deriving `${stateDir}/…` would be
|
||
# two places to fix the day it moves.
|
||
#
|
||
# ⚠️ This is what was missing. `swarm-queue-client` built a bare
|
||
# `reqwest::Client`, so it trusted only the platform roots and died at
|
||
# `invalid peer certificate: UnknownIssuer` against a swarm whose
|
||
# authelia is signed by the swarm CA — while this very bundle sat on
|
||
# disk, already assembled, already handed to hive-c0re under a different
|
||
# variable name. The anchor was never missing; nothing pointed the queue
|
||
# client at it.
|
||
#
|
||
# Where the gateway is not self-signed this module contributes nothing, and
|
||
# the clients then fall back to the platform roots — which is correct for
|
||
# a swarm fronted by a public certificate.
|
||
systemd.services.hive-c0re.environment.HIVE_C0RE_OIDC_CA_FILE =
|
||
lib.mkIf hyperhiveCfg.deploy.hive-controller.enable "${cfg.stateDir}/trust-bundle.pem";
|
||
systemd.services.swarm-controller.environment.SWARM_CONTROLLER_OIDC_CA_FILE =
|
||
lib.mkIf hyperhiveCfg.deploy.swarm-controller.enable "${cfg.stateDir}/trust-bundle.pem";
|
||
};
|
||
}
|