nix(tls): host hive-CA + gateway leaf for self-signed mode

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.
This commit is contained in:
atlas 2026-06-17 18:49:08 +02:00 committed by mara
commit 74a90fd7d6
4 changed files with 216 additions and 71 deletions

View file

@ -168,7 +168,7 @@ Four modes:
| mode | config | cert source | `.well-known` scheme |
|---|---|---|---|
| self-signed (default) | `selfSignedTls = true` | auto-generated RSA-4096, 10-year | `https` |
| self-signed (default) | `selfSignedTls = true` | host hive-CA signs a gateway leaf (RSA-4096) | `https` |
| ACME (Let's Encrypt) | `selfSignedTls = false` + `tls.acme.enable = true` | nginx inside container via HTTP-01 | `https` |
| operator cert | `selfSignedTls = false` + `tls.certDir` set | bind-mounted from host | `https` |
| http-only | `selfSignedTls = false`, no `tls.certDir`, no `tls.acme` | none | `http` |
@ -198,15 +198,19 @@ Mutual exclusion: `selfSignedTls = true` or `tls.certDir` set together with `tls
### Self-signed TLS (`selfSignedTls`)
On by default. The gateway generates a self-signed RSA-4096 cert at first boot (10-year validity) and listens on `httpsPort` (default 443) on every vhost beside the plain-http `port` (default 80).
On by default, and listens on `httpsPort` (default 443) on every vhost beside the plain-http `port` (default 80).
The anchor is a **host-held hive CA**, not a bare self-signed leaf. A host service (`hive-tls-ca.service`, from the `hive-tls` module) generates a long-lived CA (`services.hyperhive.tls.caValidityDays`, default ~20y) under `services.hyperhive.tls.stateDir` (default `/var/lib/hive-tls`), then signs a gateway **leaf** (`leafValidityDays`, default ~10y) with it. The leaf dir is bind-mounted read-only into the gateway container at `/run/hive-ca`; an in-container import unit copies the leaf into nginx's state dir (`/var/lib/hive-gateway/tls/{cert,key}.pem`) with the owner/mode nginx needs.
**Why a CA, not a bare leaf**: a bare self-signed leaf is its own trust anchor, so every regeneration is a new anchor every consumer must re-trust — and a runtime-generated, in-container leaf can't be wired into an agent's build-time trust store at all. With a stable CA, agents and federation peers trust the CA *once* (`ca.pem`); leaf rotation never re-breaks them.
**Why on by default**: matrix-dart-sdk (FluffyChat's SDK) hardcodes `https://<host>/.well-known/matrix/client` for homeserver discovery and refuses to fall back to plain http. Without TLS the browser client cannot bootstrap.
**Cert shape**: subject CN = bare hive domain; subjectAltName covers `<hive>` + wildcard `*.<hive>` so all current and future sub-domain vhosts (matrix, forge, ...) validate under the same cert. Stored at `/var/lib/hive-gateway/tls/{cert,key}.pem` inside the gateway container (`ephemeral = false`, so persisted across container restart).
**Cert shape**: leaf subject CN = bare hive domain; subjectAltName covers `<hive>`, `forge.<hive>`, `matrix.<hive>`, and wildcard `*.<hive>` so all current and future sub-domain vhosts validate under the same leaf + the hive CA.
**Regeneration**: the generator unit (`hive-gateway-self-signed-cert.service`) always runs (idempotent). To rotate (e.g. cert leak, expiry approaching), delete `cert.pem` inside the gateway container and restart `nginx.service`.
**Rotation**: `hive-tls-ca.service` is idempotent — it re-signs the leaf when it is missing or within 30 days of expiry, always under the same CA (so consumer trust is undisturbed). The CA itself is regenerated only if missing or already expired. To force a leaf rotation, delete `gateway.pem` under the state dir and restart the unit, then reload `nginx`.
**Cert prompts**: browsers warn once per host on first visit. With the wildcard SAN, `https://<hive>/`, `https://matrix.<hive>/`, and `https://forge.<hive>/` are covered by the same cert, but the browser still prompts per origin.
**Cert prompts**: browsers still warn once per host until the hive CA is added to the browser/OS trust store (the CA, not the leaf, is the thing to trust). Agent trust of the CA is wired separately (see the agent-trust work for `/run/hive-ca`).
### Operator-provided cert (`tls.certDir`)

View file

@ -120,6 +120,7 @@ in
./hive-gateway.nix
./hive-matrix.nix
./hive-network.nix
./hive-tls.nix
];
# Top-level hyperhive enable flag. When true, automatically enables

View file

@ -479,6 +479,15 @@ in
hostPath = cfg.tls.certDir;
isReadOnly = true;
};
# Self-signed mode: the host `hive-tls-ca` service generates a hive
# CA + a leaf signed by it under `services.hyperhive.tls.stateDir`.
# Bind-mount that dir read-only so the in-container import service
# (below) can copy the leaf into nginx's state dir with the right
# owner/mode. Source files: `gateway.pem` + `gateway-key.pem`.
bindMounts."/run/hive-ca" = lib.mkIf cfg.selfSignedTls {
hostPath = config.services.hyperhive.tls.stateDir;
isReadOnly = true;
};
config =
{ pkgs, ... }:
let
@ -773,19 +782,23 @@ in
defaults.email = cfg.tls.acme.email;
};
# Ensure a valid self-signed cert exists before nginx starts.
# nginx `Requires=` this via `requiredBy`, so systemd refuses
# to start nginx until the script succeeds. ALWAYS runs (no
# ConditionPathExists) and is idempotent — that's necessary
# to reconcile broken state left over from prior failed
# boots (a 0700 dir from a stale UMask, a truncated cert
# from an interrupted oneshot, etc.) which a guarded-on-
# missing-cert script would silently skip and leave broken.
# Cert covers the bare hive domain plus `*.${hyperhiveDomain}`
# so the matrix + forge sub-domains are valid under the same
# cert. See `docs/gateway.md` ("Self-signed TLS").
# Import the host-generated leaf cert before nginx starts.
# The hive CA + gateway leaf are generated on the HOST by
# `hive-tls-ca` (see `hive-tls.nix`) and bind-mounted read-only
# at `/run/hive-ca`; this service copies the leaf into nginx's
# state dir with the owner/mode nginx needs, rather than reading
# the bind-mount directly (the host key is 0600 root:root and a
# cross-namespace bind-mount can't be relaxed in place). nginx
# `Requires=` this via `requiredBy`, so it refuses to start until
# the copy succeeds. ALWAYS runs (no ConditionPathExists) and is
# idempotent — necessary to reconcile broken state from prior
# failed boots (a 0700 dir from a stale UMask, a truncated copy
# from an interrupted oneshot, etc.). The leaf covers the bare
# hive domain plus `forge.`, `matrix.` and `*.${hyperhiveDomain}`
# so all sub-domains validate under the same cert + the hive CA.
# See `docs/gateway.md` ("Self-signed TLS").
systemd.services.hive-gateway-self-signed-cert = lib.mkIf cfg.selfSignedTls {
description = "Ensure self-signed TLS cert for hive-gateway";
description = "Import host-generated TLS leaf for hive-gateway";
wantedBy = [ "multi-user.target" ];
before = [ "nginx.service" ];
requiredBy = [ "nginx.service" ];
@ -793,60 +806,30 @@ in
Type = "oneshot";
RemainAfterExit = true;
};
path = [
pkgs.openssl
pkgs.coreutils
];
script =
let
subjectCN = if hyperhiveDomain != null then hyperhiveDomain else "hyperhive.local";
# `subjectAltName` covers the bare hive + the canonical
# sub-domains. Includes a wildcard `*.${domain}` so any
# future sub-domain we mint (per-agent UI under a
# sub-domain, etc.) inherits the cert without a rebuild.
sanLines = lib.concatStringsSep "," (
[ "DNS:${subjectCN}" ] ++ lib.optional (hyperhiveDomain != null) "DNS:*.${hyperhiveDomain}"
);
in
''
set -eu
mkdir -p ${tlsDir}
# 0755 on BOTH the cert dir and its parent so the
# nginx user can traverse the full path. The parent
# `/var/lib/hive-gateway` lands at 0700 by default
# (systemd StateDirectory / mkdir umask depending on
# which service created it first), which on its own
# blocks traversal. Re-applied every boot in case a
# prior run left a tighter mode behind.
chmod 0755 ${builtins.dirOf tlsDir}
chmod 0755 ${tlsDir}
# Generate the cert when EITHER the cert or key is
# missing/empty, OR the cert fails an openssl parse —
# catches truncated / corrupt leftovers from a previous
# interrupted run AND the "cert clean but key absent"
# edge case (argus 🟡 on the first revision) which
# otherwise tripped `chmod 0600 ${tlsKey}` below with
# ENOENT under `set -eu`. The whole oneshot is safe to
# re-run; a healthy cert+key pair is left alone.
if [ ! -s ${tlsCert} ] || [ ! -s ${tlsKey} ] || ! openssl x509 -in ${tlsCert} -noout >/dev/null 2>&1; then
echo "generating fresh self-signed cert at ${tlsCert}"
openssl req -x509 -newkey rsa:4096 -nodes -sha256 -days 3650 \
-keyout ${tlsKey} \
-out ${tlsCert} \
-subj "/CN=${subjectCN}" \
-addext "subjectAltName=${sanLines}"
fi
# Key owned by root:nginx, mode 0640 so nginx-pre-start
# (which runs `nginx -t` as the nginx user, not root)
# can read it. A 0600 root:root key passes the master-
# process load (master starts as root) but fails the
# pre-start config test with `BIO_new_file() …
# Permission denied`, blocking the unit from starting
# at all. Cert is world-readable.
chown root:nginx ${tlsKey}
chmod 0640 ${tlsKey}
chmod 0644 ${tlsCert}
'';
path = [ pkgs.coreutils ];
script = ''
set -eu
mkdir -p ${tlsDir}
# 0755 on BOTH the cert dir and its parent so the nginx
# user can traverse the full path. The parent
# `/var/lib/hive-gateway` lands at 0700 by default (systemd
# StateDirectory / mkdir umask depending on which service
# created it first), which on its own blocks traversal.
# Re-applied every boot in case a prior run left a tighter
# mode behind.
chmod 0755 ${builtins.dirOf tlsDir}
chmod 0755 ${tlsDir}
# Copy the host leaf in. `install` writes atomically with the
# target mode; run as root (container root == host root,
# privateUsers=false) so the 0600 root:root host key is
# readable. Key ends up root:nginx 0640 so nginx-pre-start
# (which runs `nginx -t` as the nginx user, not root) can
# read it — a 0600 root:root key passes the master load but
# fails the pre-start config test with `BIO_new_file() …
# Permission denied`, blocking the unit. Cert is world-read.
install -m 0644 /run/hive-ca/gateway.pem ${tlsCert}
install -m 0640 -g nginx /run/hive-ca/gateway-key.pem ${tlsKey}
'';
};
# nginx reload is triggered from the HOST side by hive-c0re

157
nix/modules/hive-tls.nix Normal file
View file

@ -0,0 +1,157 @@
{
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
'';
};
};
}