Replace the gateway's bare in-container self-signed leaf with a leaf signed by a host-held hive CA. A bare self-signed leaf is its own trust anchor, so every regeneration is a fresh anchor every consumer would have to re-trust, and a runtime-generated in-container leaf cannot be wired into an agent's build-time trust store at all. A stable CA fixes both: a single anchor that agents and federation peers trust once, surviving leaf rotation. New hive-tls module: a host oneshot generates a long-lived CA (default ~20y) under services.hyperhive.tls.stateDir and signs a gateway leaf (default ~10y, SAN covering the bare domain, forge., matrix. and the wildcard). It is ordered before the gateway container so the leaf exists when nginx starts. Active only when the gateway uses self-signed TLS (default) and a domain is set; inert under operator-cert or ACME modes. Gateway: bind-mount the host CA dir read-only at /run/hive-ca; the existing in-container cert unit now imports the host leaf into nginx's state dir (copy as root, key left root:nginx 0640 for the pre-start config test) instead of generating one. Cert/key paths nginx serves are unchanged. Foundational step toward agent + federation trust of self-signed hives; no behaviour change for agents yet (they still reach the forge over plain http on port 80). Eval-proven across self-signed, certDir and the inert default paths.
157 lines
6.4 KiB
Nix
157 lines
6.4 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
|
|
# `gateway.selfSignedTls` must be on. With an operator-supplied
|
|
# `tls.certDir` or ACME the public/operator CA already validates, so
|
|
# the hive CA is unnecessary.
|
|
active = hyperhiveCfg.enable && gatewayCfg.selfSignedTls && 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
|
|
'';
|
|
};
|
|
};
|
|
}
|