Per review: the `tls.certDir == null && !tls.acme.enable` derivation was duplicated in hive-gateway, hive-tls, and hive-ci. Expose it once as a read-only internal option `services.hyperhive.gateway.useSelfSigned` (the gateway module's single source of truth) and have hive-tls and hive-ci consume it instead of re-deriving. Eval-proven: gateway.useSelfSigned is true on the self-signed default / false with tls.certDir, and the hive-tls (HIVE_TLS_CA_PATH) + hive-ci (NODE_EXTRA_CA_CERTS) wiring derives correctly from it.
165 lines
7 KiB
Nix
165 lines
7 KiB
Nix
{
|
|
lib,
|
|
config,
|
|
pkgs,
|
|
...
|
|
}:
|
|
let
|
|
cfg = config.services.hyperhive.tls;
|
|
hyperhiveCfg = config.services.hyperhive;
|
|
gatewayCfg = config.services.hyperhive.gateway;
|
|
domain = hyperhiveCfg.domain;
|
|
|
|
# The host-managed hive CA is the trust anchor for self-signed mode.
|
|
# It is only stood up when the gateway actually serves a self-signed
|
|
# cert: a domain must be set (the leaf SANs derive from it) and the
|
|
# gateway must 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 = hyperhiveCfg.enable && gatewayCfg.useSelfSigned && domain != null;
|
|
in
|
|
{
|
|
# Host-side TLS trust root for the self-signed gateway mode.
|
|
#
|
|
# `gateway.selfSignedTls` historically generated a *bare* self-signed
|
|
# leaf inside the gateway container at first boot. A bare leaf is its
|
|
# own trust anchor, so every regeneration is a new anchor and every
|
|
# consumer (agents, federation peers) would have to re-trust on each
|
|
# rotation — and a runtime-generated, in-container cert can't be wired
|
|
# into an agent's build-time trust store at all.
|
|
#
|
|
# This module moves the anchor to a long-lived **hive CA** held on the
|
|
# host. The gateway serves a **leaf** signed by that CA (via the
|
|
# existing `tls.certDir` bind-mount path); agents and federation peers
|
|
# trust the *CA* once, and leaf rotation never re-breaks them. See
|
|
# `docs/gateway.md` ("Self-signed TLS") and issue-tracker discussion of
|
|
# agent web-UI reachability.
|
|
|
|
options.services.hyperhive.tls = {
|
|
stateDir = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "/var/lib/hive-tls";
|
|
description = ''
|
|
Host directory holding the hive CA + gateway leaf cert for the
|
|
self-signed gateway mode. `ca.pem` (the anchor agents and
|
|
federation peers trust), `ca-key.pem` (0600, never leaves the
|
|
host), `gateway.pem` / `gateway-key.pem` (the leaf the gateway
|
|
container bind-mounts and nginx serves). Persistent so the CA
|
|
survives reboots — re-deriving it would re-break every consumer.
|
|
'';
|
|
};
|
|
|
|
caValidityDays = lib.mkOption {
|
|
type = lib.types.int;
|
|
default = 7300;
|
|
description = ''
|
|
Validity window of the hive CA in days (default ~20y). Kept long
|
|
and well beyond `leafValidityDays` so the CA outlives many leaf
|
|
rotations — the whole point of the CA is to be a stable anchor
|
|
that consumers trust once. The CA is regenerated only if missing
|
|
or already expired.
|
|
'';
|
|
};
|
|
|
|
leafValidityDays = lib.mkOption {
|
|
type = lib.types.int;
|
|
default = 3650;
|
|
description = ''
|
|
Validity window of the gateway leaf cert in days (default ~10y).
|
|
The leaf is re-signed by the (stable) CA when it is missing or
|
|
within 30 days of expiry; because it shares the CA anchor, a
|
|
rotation does not disturb consumer trust. Browsers may warn on
|
|
long-lived leaves, but agents and federation peers validate
|
|
against the CA, not browser CA/B-forum limits.
|
|
'';
|
|
};
|
|
};
|
|
|
|
config = lib.mkIf active {
|
|
# Generate (and rotate) the hive CA + gateway leaf before the gateway
|
|
# container starts. Idempotent: the CA is created once and reused; the
|
|
# leaf is re-signed on expiry under the same CA so the anchor is stable.
|
|
systemd.services.hive-tls-ca = {
|
|
description = "Generate hive CA + gateway leaf TLS cert (self-signed mode)";
|
|
wantedBy = [ "multi-user.target" ];
|
|
# Gateway nginx reads the leaf from the bind-mount, so the cert must
|
|
# exist before the container starts. Declarative nixos-containers are
|
|
# instances of the `container@.service` template.
|
|
before = [ "container@hive-gateway.service" ];
|
|
requiredBy = [ "container@hive-gateway.service" ];
|
|
path = [ pkgs.openssl ];
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
RemainAfterExit = true;
|
|
UMask = "0077";
|
|
};
|
|
script = ''
|
|
set -euo pipefail
|
|
d=${lib.escapeShellArg cfg.stateDir}
|
|
install -d -m 0755 "$d"
|
|
|
|
ca="$d/ca.pem"
|
|
cak="$d/ca-key.pem"
|
|
leaf="$d/gateway.pem"
|
|
leafk="$d/gateway-key.pem"
|
|
|
|
# --- CA: generate once, reuse across leaf rotations. Regenerate
|
|
# only if missing or already expired (checkend 0). A new CA means
|
|
# every consumer must re-trust, so the leaf is dropped to force a
|
|
# re-sign under the fresh CA.
|
|
if [ ! -s "$ca" ] || [ ! -s "$cak" ] \
|
|
|| ! openssl x509 -in "$ca" -noout -checkend 0 >/dev/null 2>&1; then
|
|
echo "generating fresh hive CA at $ca"
|
|
openssl req -x509 -newkey rsa:4096 -nodes -sha256 \
|
|
-days ${toString cfg.caValidityDays} \
|
|
-keyout "$cak" -out "$ca" \
|
|
-subj "/CN=hive-ca ${domain}" \
|
|
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
|
|
-addext "keyUsage=critical,keyCertSign,cRLSign"
|
|
chmod 0600 "$cak"
|
|
chmod 0644 "$ca"
|
|
rm -f "$leaf" "$leafk"
|
|
fi
|
|
|
|
# --- Leaf: (re)sign when missing or within 30 days of expiry,
|
|
# always under the current (stable) CA.
|
|
if [ ! -s "$leaf" ] || [ ! -s "$leafk" ] \
|
|
|| ! openssl x509 -in "$leaf" -noout -checkend 2592000 >/dev/null 2>&1; then
|
|
echo "signing fresh gateway leaf at $leaf"
|
|
csr="$(mktemp "$d/gateway.csr.XXXXXX")"
|
|
ext="$(mktemp "$d/leaf.ext.XXXXXX")"
|
|
trap 'rm -f "$csr" "$ext"' EXIT
|
|
|
|
openssl req -newkey rsa:4096 -nodes -sha256 \
|
|
-keyout "$leafk" -out "$csr" \
|
|
-subj "/CN=${domain}"
|
|
|
|
# printf (not a heredoc) so the ext-file lines carry no leading
|
|
# whitespace once nix has stripped the indented-string indent.
|
|
{
|
|
printf 'subjectAltName=DNS:%s,DNS:forge.%s,DNS:matrix.%s,DNS:*.%s\n' \
|
|
${lib.escapeShellArg domain} ${lib.escapeShellArg domain} \
|
|
${lib.escapeShellArg domain} ${lib.escapeShellArg domain}
|
|
printf 'basicConstraints=critical,CA:FALSE\n'
|
|
printf 'keyUsage=critical,digitalSignature,keyEncipherment\n'
|
|
printf 'extendedKeyUsage=serverAuth\n'
|
|
} > "$ext"
|
|
|
|
openssl x509 -req -in "$csr" -CA "$ca" -CAkey "$cak" \
|
|
-CAcreateserial -days ${toString cfg.leafValidityDays} -sha256 \
|
|
-extfile "$ext" -out "$leaf"
|
|
chmod 0600 "$leafk"
|
|
chmod 0644 "$leaf"
|
|
fi
|
|
'';
|
|
};
|
|
|
|
# Signal the hive-c0re lifecycle that a hive CA exists: it bind-mounts
|
|
# this file (read-only, the CA cert ONLY — never the 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. Only the `ca.pem` path is exposed; `ca-key.pem` stays on the
|
|
# host (an agent that could read it could mint trusted certs).
|
|
systemd.services.hive-c0re.environment.HIVE_TLS_CA_PATH = "${cfg.stateDir}/ca.pem";
|
|
};
|
|
}
|