Compare commits
5 changed files with 30 additions and 290 deletions
|
|
@ -15,10 +15,6 @@
|
||||||
let
|
let
|
||||||
cfg = config.services.hyperhive.gateway;
|
cfg = config.services.hyperhive.gateway;
|
||||||
hyperhiveDomain = config.services.hyperhive.domain;
|
hyperhiveDomain = config.services.hyperhive.domain;
|
||||||
# Derived once in ../swarm.nix; the vhosts that get the swarm-services
|
|
||||||
# cert are exactly the names that cert is issued for, so both read the
|
|
||||||
# same list rather than each deciding what "a swarm service" means.
|
|
||||||
swarmServiceDomains = config.services.hyperhive.swarm.serviceDomains;
|
|
||||||
matrixCfg = config.services.hyperhive.swarm.matrix;
|
matrixCfg = config.services.hyperhive.swarm.matrix;
|
||||||
forgeCfg = config.services.hyperhive.swarm.forge;
|
forgeCfg = config.services.hyperhive.swarm.forge;
|
||||||
networkCfg = config.services.hyperhive.network;
|
networkCfg = config.services.hyperhive.network;
|
||||||
|
|
@ -267,12 +263,6 @@ in
|
||||||
if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.certName}" else "${tlsDir}/cert.pem";
|
if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.certName}" else "${tlsDir}/cert.pem";
|
||||||
tlsKey =
|
tlsKey =
|
||||||
if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.keyName}" else "${tlsDir}/key.pem";
|
if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.keyName}" else "${tlsDir}/key.pem";
|
||||||
# The swarm-services pair, used only by the vhosts whose names
|
|
||||||
# this hive's CA cannot sign. Self-signed mode only: with an
|
|
||||||
# operator cert or ACME the operator owns every name and there
|
|
||||||
# is no second issuer in the picture.
|
|
||||||
svcCert = "${tlsDir}/swarm-services.pem";
|
|
||||||
svcKey = "${tlsDir}/swarm-services-key.pem";
|
|
||||||
nginxTree = import ./vhosts.nix {
|
nginxTree = import ./vhosts.nix {
|
||||||
inherit
|
inherit
|
||||||
lib
|
lib
|
||||||
|
|
@ -284,9 +274,6 @@ in
|
||||||
swaggerUiTheme
|
swaggerUiTheme
|
||||||
tlsCert
|
tlsCert
|
||||||
tlsKey
|
tlsKey
|
||||||
svcCert
|
|
||||||
svcKey
|
|
||||||
swarmServiceDomains
|
|
||||||
;
|
;
|
||||||
errorPages = import ./error-pages.nix { inherit pkgs; };
|
errorPages = import ./error-pages.nix { inherit pkgs; };
|
||||||
};
|
};
|
||||||
|
|
@ -375,23 +362,6 @@ in
|
||||||
# Permission denied`, blocking the unit. Cert is world-read.
|
# Permission denied`, blocking the unit. Cert is world-read.
|
||||||
install -m 0644 /run/hive-ca/gateway.pem ${tlsCert}
|
install -m 0644 /run/hive-ca/gateway.pem ${tlsCert}
|
||||||
install -m 0640 -g nginx /run/hive-ca/gateway-key.pem ${tlsKey}
|
install -m 0640 -g nginx /run/hive-ca/gateway-key.pem ${tlsKey}
|
||||||
|
|
||||||
# The swarm-services leaf, when this host issues one. It is
|
|
||||||
# a separate pair rather than more SANs on the one above
|
|
||||||
# because no hive CA can sign these names — each is
|
|
||||||
# constrained to its own hive's domain and the service
|
|
||||||
# names are siblings of it.
|
|
||||||
#
|
|
||||||
# Absent is a normal state, not a failure: the leaf exists
|
|
||||||
# only where the swarm CA is autoconfigured. Copying it
|
|
||||||
# conditionally keeps a hive whose certs come from its
|
|
||||||
# operator working unchanged.
|
|
||||||
if [ -s /run/hive-ca/swarm-services.pem ]; then
|
|
||||||
install -m 0644 /run/hive-ca/swarm-services.pem ${svcCert}
|
|
||||||
install -m 0640 -g nginx /run/hive-ca/swarm-services-key.pem ${svcKey}
|
|
||||||
else
|
|
||||||
rm -f ${svcCert} ${svcKey}
|
|
||||||
fi
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,6 @@
|
||||||
errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized }
|
errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized }
|
||||||
tlsCert,
|
tlsCert,
|
||||||
tlsKey,
|
tlsKey,
|
||||||
svcCert, # swarm-services leaf, for names the hive CA cannot sign
|
|
||||||
svcKey,
|
|
||||||
swarmServiceDomains, # which vhosts those are (../swarm.nix derives it)
|
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
# The gateway always terminates TLS: self-signed is the implicit
|
# The gateway always terminates TLS: self-signed is the implicit
|
||||||
|
|
@ -54,25 +51,6 @@ let
|
||||||
sslCertificateKey = tlsKey;
|
sslCertificateKey = tlsKey;
|
||||||
};
|
};
|
||||||
|
|
||||||
# TLS attrs for one vhost, by name. A swarm service's name may sit
|
|
||||||
# outside this hive's domain — and then the hive CA is
|
|
||||||
# name-constrained out of it, so its vhost must serve the
|
|
||||||
# swarm-services leaf instead. Everything else keeps the hive leaf.
|
|
||||||
#
|
|
||||||
# Only in self-signed mode: with ACME or an operator cert there is a
|
|
||||||
# single issuer that already covers every name, and a second pair
|
|
||||||
# would be a cert nobody asked for.
|
|
||||||
vhostTlsFor =
|
|
||||||
host:
|
|
||||||
if !cfg.tls.acme.enable && cfg.tls.certDir == null && builtins.elem host swarmServiceDomains then
|
|
||||||
{
|
|
||||||
addSSL = true;
|
|
||||||
sslCertificate = svcCert;
|
|
||||||
sslCertificateKey = svcKey;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
vhostTls;
|
|
||||||
|
|
||||||
# Public-facing scheme + port-suffix for URLs the gateway
|
# Public-facing scheme + port-suffix for URLs the gateway
|
||||||
# mints into responses (well-known JSON, the deprecated
|
# mints into responses (well-known JSON, the deprecated
|
||||||
# `<hive>/matrix/*` 301 redirect, future absolute-URL needs):
|
# `<hive>/matrix/*` 301 redirect, future absolute-URL needs):
|
||||||
|
|
@ -107,7 +85,7 @@ let
|
||||||
# `forge.sshPort`. See `docs/gateway.md`. Empty attrset when the
|
# `forge.sshPort`. See `docs/gateway.md`. Empty attrset when the
|
||||||
# forge isn't behind the gateway.
|
# forge isn't behind the gateway.
|
||||||
forgeVhost = lib.optionalAttrs (forgeCfg.behindGateway or false) {
|
forgeVhost = lib.optionalAttrs (forgeCfg.behindGateway or false) {
|
||||||
"${forgeCfg.domain}" = (vhostTlsFor forgeCfg.domain) // {
|
"${forgeCfg.domain}" = vhostTls // {
|
||||||
listen = vhostListen;
|
listen = vhostListen;
|
||||||
extraConfig = securityHeaders;
|
extraConfig = securityHeaders;
|
||||||
locations."/" = {
|
locations."/" = {
|
||||||
|
|
@ -129,7 +107,7 @@ let
|
||||||
# longer-prefix-wins puts `/_matrix/` ahead of `/`. See
|
# longer-prefix-wins puts `/_matrix/` ahead of `/`. See
|
||||||
# `docs/gateway.md`. Empty attrset when matrix has no gateway host.
|
# `docs/gateway.md`. Empty attrset when matrix has no gateway host.
|
||||||
matrixVhost = lib.optionalAttrs (matrixCfg.enable && matrixCfg.gatewayHost != null) {
|
matrixVhost = lib.optionalAttrs (matrixCfg.enable && matrixCfg.gatewayHost != null) {
|
||||||
"${matrixCfg.gatewayHost}" = (vhostTlsFor matrixCfg.gatewayHost) // {
|
"${matrixCfg.gatewayHost}" = vhostTls // {
|
||||||
listen = vhostListen;
|
listen = vhostListen;
|
||||||
extraConfig = securityHeaders;
|
extraConfig = securityHeaders;
|
||||||
locations = {
|
locations = {
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,6 @@ let
|
||||||
gatewayCfg = config.services.hyperhive.gateway;
|
gatewayCfg = config.services.hyperhive.gateway;
|
||||||
swarmCaCfg = config.services.hyperhive.swarm.ca;
|
swarmCaCfg = config.services.hyperhive.swarm.ca;
|
||||||
domain = hyperhiveCfg.domain;
|
domain = hyperhiveCfg.domain;
|
||||||
# Derived once in ./swarm.nix and read here + in ./swarm-ca.nix, so
|
|
||||||
# the names this leaf carries as SANs and the names the sub-CA is
|
|
||||||
# constrained to cannot disagree.
|
|
||||||
swarmServiceDomains = hyperhiveCfg.swarm.serviceDomains;
|
|
||||||
|
|
||||||
# The host-managed hive CA is the trust anchor for self-signed mode.
|
# 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
|
# It is only stood up when the gateway actually serves a self-signed
|
||||||
|
|
@ -24,6 +20,13 @@ let
|
||||||
# neither an operator cert (`tls.certDir`) nor ACME is set.
|
# neither an operator cert (`tls.certDir`) nor ACME is set.
|
||||||
active = hyperhiveCfg.enable && gatewayCfg.useSelfSigned;
|
active = hyperhiveCfg.enable && gatewayCfg.useSelfSigned;
|
||||||
|
|
||||||
|
# The leaf-signing action shared by the boot-time `hive-tls-ca`
|
||||||
|
# generation and the weekly `hive-tls-resign` renewal: fresh key +
|
||||||
|
# CSR, SAN ext-file, sign under the (stable) CA, tighten modes.
|
||||||
|
# Takes the TLS state dir as `$1`; each caller keeps its own
|
||||||
|
# when-to-sign condition. The leaf covers the bare hive domain plus
|
||||||
|
# `forge.`, `matrix.` and `*.<domain>` so all sub-domains validate
|
||||||
|
# under the same cert + the hive CA.
|
||||||
# How this hive's CA comes into existence when it is missing — and it
|
# How this hive's CA comes into existence when it is missing — and it
|
||||||
# is one of exactly two things, chosen by config rather than by what
|
# is one of exactly two things, chosen by config rather than by what
|
||||||
# happens to be on disk.
|
# happens to be on disk.
|
||||||
|
|
@ -94,36 +97,28 @@ let
|
||||||
-addext "keyUsage=critical,keyCertSign,cRLSign"
|
-addext "keyUsage=critical,keyCertSign,cRLSign"
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# Sign one leaf. Parameterised rather than hardcoded to `gateway.*`
|
|
||||||
# because there are now two: the hive's own leaf, issued by the hive
|
|
||||||
# CA, and the swarm-services leaf, issued by the services sub-CA that
|
|
||||||
# ./swarm-ca.nix maintains. Same ceremony, different issuer and names
|
|
||||||
# — and one script means the two cannot drift in how they are built.
|
|
||||||
#
|
|
||||||
# $1 stateDir $2 basename $3 CN $4 SAN list $5 issuer cert $6 issuer key
|
|
||||||
signLeafScript = pkgs.writeShellScript "hive-tls-sign-leaf" ''
|
signLeafScript = pkgs.writeShellScript "hive-tls-sign-leaf" ''
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
d="$1"
|
d="$1"
|
||||||
base="$2"
|
ca="$d/ca.pem"
|
||||||
cn="$3"
|
cak="$d/ca-key.pem"
|
||||||
sans="$4"
|
leaf="$d/gateway.pem"
|
||||||
ca="$5"
|
leafk="$d/gateway-key.pem"
|
||||||
cak="$6"
|
csr="$(mktemp "$d/gateway.csr.XXXXXX")"
|
||||||
leaf="$d/$base.pem"
|
ext="$(mktemp "$d/leaf.ext.XXXXXX")"
|
||||||
leafk="$d/$base-key.pem"
|
only="$(mktemp "$d/gateway.leaf.XXXXXX")"
|
||||||
csr="$(mktemp "$d/$base.csr.XXXXXX")"
|
|
||||||
ext="$(mktemp "$d/$base.ext.XXXXXX")"
|
|
||||||
only="$(mktemp "$d/$base.leaf.XXXXXX")"
|
|
||||||
trap 'rm -f "$csr" "$ext" "$only"' EXIT
|
trap 'rm -f "$csr" "$ext" "$only"' EXIT
|
||||||
|
|
||||||
openssl req -newkey rsa:4096 -nodes -sha256 \
|
openssl req -newkey rsa:4096 -nodes -sha256 \
|
||||||
-keyout "$leafk" -out "$csr" \
|
-keyout "$leafk" -out "$csr" \
|
||||||
-subj "/CN=$cn"
|
-subj "/CN=${domain}"
|
||||||
|
|
||||||
# printf (not a heredoc) so the ext-file lines carry no leading
|
# printf (not a heredoc) so the ext-file lines carry no leading
|
||||||
# whitespace once nix has stripped the indented-string indent.
|
# whitespace once nix has stripped the indented-string indent.
|
||||||
{
|
{
|
||||||
printf 'subjectAltName=%s\n' "$sans"
|
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 'basicConstraints=critical,CA:FALSE\n'
|
||||||
printf 'keyUsage=critical,digitalSignature,keyEncipherment\n'
|
printf 'keyUsage=critical,digitalSignature,keyEncipherment\n'
|
||||||
printf 'extendedKeyUsage=serverAuth\n'
|
printf 'extendedKeyUsage=serverAuth\n'
|
||||||
|
|
@ -145,35 +140,6 @@ let
|
||||||
chmod 0600 "$leafk"
|
chmod 0600 "$leafk"
|
||||||
chmod 0644 "$leaf"
|
chmod 0644 "$leaf"
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# The hive's own leaf: signed by the hive CA, covering the hive domain
|
|
||||||
# and its sub-domains.
|
|
||||||
signHiveLeaf = ''
|
|
||||||
${signLeafScript} "$d" gateway ${lib.escapeShellArg domain} \
|
|
||||||
${lib.escapeShellArg "DNS:${domain},DNS:forge.${domain},DNS:matrix.${domain},DNS:*.${domain}"} \
|
|
||||||
"$d/ca.pem" "$d/ca-key.pem"
|
|
||||||
'';
|
|
||||||
|
|
||||||
# The swarm-services leaf: signed by the services sub-CA, covering the
|
|
||||||
# swarm's service names. Those are *siblings* of the hive domain, not
|
|
||||||
# children, so the hive CA is name-constrained out of them and cannot
|
|
||||||
# sign this however its SAN list is written.
|
|
||||||
#
|
|
||||||
# Skipped when the sub-CA isn't on disk: it only exists where the
|
|
||||||
# swarm CA is autoconfigured, and a hive that gets its certs from its
|
|
||||||
# operator has nothing for this to do.
|
|
||||||
signServicesLeaf = lib.optionalString (swarmServiceDomains != [ ]) ''
|
|
||||||
servicesCa=${lib.escapeShellArg "${swarmCaCfg.stateDir}/services-ca.pem"}
|
|
||||||
servicesCaKey=${lib.escapeShellArg "${swarmCaCfg.stateDir}/services-ca-key.pem"}
|
|
||||||
if [ -s "$servicesCa" ] && [ -s "$servicesCaKey" ]; then
|
|
||||||
${signLeafScript} "$d" swarm-services \
|
|
||||||
${lib.escapeShellArg (builtins.head swarmServiceDomains)} \
|
|
||||||
${lib.escapeShellArg (lib.concatMapStringsSep "," (n: "DNS:${n}") swarmServiceDomains)} \
|
|
||||||
"$servicesCa" "$servicesCaKey"
|
|
||||||
else
|
|
||||||
echo "no swarm-services sub-CA at $servicesCa — skipping the services leaf"
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
# Host-side TLS trust root for the self-signed gateway mode.
|
# Host-side TLS trust root for the self-signed gateway mode.
|
||||||
|
|
@ -352,8 +318,7 @@ in
|
||||||
if [ ! -s "$leaf" ] || [ ! -s "$leafk" ] \
|
if [ ! -s "$leaf" ] || [ ! -s "$leafk" ] \
|
||||||
|| ! openssl x509 -in "$leaf" -noout -checkend 2592000 >/dev/null 2>&1; then
|
|| ! openssl x509 -in "$leaf" -noout -checkend 2592000 >/dev/null 2>&1; then
|
||||||
echo "signing fresh gateway leaf at $leaf"
|
echo "signing fresh gateway leaf at $leaf"
|
||||||
${signHiveLeaf}
|
${signLeafScript} "$d"
|
||||||
${signServicesLeaf}
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Trust bundle: what a consumer must TRUST, as opposed to
|
# --- Trust bundle: what a consumer must TRUST, as opposed to
|
||||||
|
|
@ -424,39 +389,22 @@ in
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
d=${lib.escapeShellArg cfg.stateDir}
|
d=${lib.escapeShellArg cfg.stateDir}
|
||||||
leaf="$d/gateway.pem"
|
leaf="$d/gateway.pem"
|
||||||
# ⚠️ EVERY leaf this host issues must be listed here. A leaf that
|
|
||||||
# first-boot issuance creates and this unit does not know about
|
|
||||||
# looks perfect for its entire validity and then expires with no
|
|
||||||
# warning — the failure is invisible until it is total.
|
|
||||||
svcleaf="$d/swarm-services.pem"
|
|
||||||
|
|
||||||
# Re-sign only when a leaf is within half its validity of expiry.
|
# Re-sign only when the leaf is within half its validity of expiry.
|
||||||
# The weekly cadence catches this window well before one lapses.
|
# The weekly cadence catches this window well before the leaf lapses.
|
||||||
halflife=$(( ${toString cfg.leafValidityDays} * 86400 / 2 ))
|
halflife=$(( ${toString cfg.leafValidityDays} * 86400 / 2 ))
|
||||||
|
if [ -s "$leaf" ] && \
|
||||||
fresh() { # a leaf is fresh if it exists and is not near expiry
|
openssl x509 -in "$leaf" -noout -checkend "$halflife" >/dev/null 2>&1; then
|
||||||
[ -s "$1" ] && openssl x509 -in "$1" -noout -checkend "$halflife" >/dev/null 2>&1
|
echo "gateway leaf valid for more than half its lifetime — no resign needed"
|
||||||
}
|
|
||||||
|
|
||||||
# The services leaf is only expected where the sub-CA exists;
|
|
||||||
# elsewhere its absence is the correct state, not a stale leaf.
|
|
||||||
want_svc=${if swarmServiceDomains == [ ] then "0" else "1"}
|
|
||||||
if [ ! -s ${lib.escapeShellArg "${swarmCaCfg.stateDir}/services-ca.pem"} ]; then
|
|
||||||
want_svc=0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if fresh "$leaf" && { [ "$want_svc" = 0 ] || fresh "$svcleaf"; }; then
|
|
||||||
echo "leaves valid for more than half their lifetime — no resign needed"
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "a leaf is missing or near expiry — re-signing under the current CAs"
|
echo "gateway leaf missing or near expiry — re-signing under current CA"
|
||||||
before="$(sha256sum "$leaf" "$svcleaf" 2>/dev/null || true)"
|
before="$(sha256sum "$leaf" 2>/dev/null || true)"
|
||||||
|
|
||||||
${signHiveLeaf}
|
${signLeafScript} "$d"
|
||||||
${signServicesLeaf}
|
|
||||||
|
|
||||||
after="$(sha256sum "$leaf" "$svcleaf" 2>/dev/null || true)"
|
after="$(sha256sum "$leaf" 2>/dev/null || true)"
|
||||||
if [ "$before" != "$after" ]; then
|
if [ "$before" != "$after" ]; then
|
||||||
echo "gateway leaf rotated — propagating into hive-gateway"
|
echo "gateway leaf rotated — propagating into hive-gateway"
|
||||||
systemctl -M hive-gateway restart hive-gateway-self-signed-cert.service || true
|
systemctl -M hive-gateway restart hive-gateway-self-signed-cert.service || true
|
||||||
|
|
|
||||||
|
|
@ -50,13 +50,6 @@ let
|
||||||
hyperhiveCfg.domain
|
hyperhiveCfg.domain
|
||||||
else
|
else
|
||||||
"hyperhive";
|
"hyperhive";
|
||||||
|
|
||||||
# Derived once in ./swarm.nix, read here and by ./hive-tls.nix: the
|
|
||||||
# CA that name-constrains these and the leaf that carries them as SANs
|
|
||||||
# must agree exactly, and two modules each assembling the list is how
|
|
||||||
# they stop agreeing. It is also this unit's *rotation trigger* below,
|
|
||||||
# which is why the ordering is stable there rather than here.
|
|
||||||
serviceDomains = hyperhiveCfg.swarm.serviceDomains;
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.services.hyperhive.swarm.ca = {
|
options.services.hyperhive.swarm.ca = {
|
||||||
|
|
@ -104,23 +97,6 @@ in
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
servicesValidityDays = lib.mkOption {
|
|
||||||
type = lib.types.int;
|
|
||||||
default = 1825;
|
|
||||||
description = ''
|
|
||||||
Validity window of the swarm-services sub-CA in days (~5y).
|
|
||||||
Deliberately far shorter than the root's: this CA is *meant* to
|
|
||||||
be re-issued — adding a swarm service changes its name
|
|
||||||
constraints and rotates it — so a long window buys nothing, and
|
|
||||||
a short one keeps the rotation path exercised rather than
|
|
||||||
theoretical.
|
|
||||||
|
|
||||||
Rotating it is cheap in the way rotating the root is not: it
|
|
||||||
touches only the swarm-service vhosts, and no peer hive holds it
|
|
||||||
as an anchor.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
validityDays = lib.mkOption {
|
validityDays = lib.mkOption {
|
||||||
type = lib.types.int;
|
type = lib.types.int;
|
||||||
default = 10950;
|
default = 10950;
|
||||||
|
|
@ -190,98 +166,5 @@ in
|
||||||
chmod 0644 "$root"
|
chmod 0644 "$root"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
# The swarm-services sub-CA: issues leaves for the swarm's own
|
|
||||||
# service names, which no hive CA can sign — each of those is
|
|
||||||
# name-constrained to its own hive's domain, and the service names
|
|
||||||
# are siblings of it, not children.
|
|
||||||
#
|
|
||||||
# Rotation is the point of it being separate (mara: "swarm services
|
|
||||||
# sub ca that can rotate independently of swarm root ca"): the
|
|
||||||
# constraint enumerates the exact service names, so adding a service
|
|
||||||
# re-issues *this* and never touches the root or any hive CA.
|
|
||||||
systemd.services.swarm-services-ca = {
|
|
||||||
description = "Issue the swarm-services sub-CA under the swarm root";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
after = [ "swarm-ca.service" ];
|
|
||||||
requires = [ "swarm-ca.service" ];
|
|
||||||
path = [ pkgs.openssl ];
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "oneshot";
|
|
||||||
RemainAfterExit = true;
|
|
||||||
UMask = "0077";
|
|
||||||
SyslogIdentifier = "swarm-services-ca";
|
|
||||||
};
|
|
||||||
script = ''
|
|
||||||
set -euo pipefail
|
|
||||||
d=${lib.escapeShellArg cfg.stateDir}
|
|
||||||
root="$d/root.pem"
|
|
||||||
rootk="$d/root-key.pem"
|
|
||||||
ca="$d/services-ca.pem"
|
|
||||||
cak="$d/services-ca-key.pem"
|
|
||||||
# The name set this CA was last issued for. Comparing against it
|
|
||||||
# is what makes re-issuance happen exactly when the service
|
|
||||||
# names change — not every boot, and not never.
|
|
||||||
names="$d/services-ca.names"
|
|
||||||
want=${lib.escapeShellArg (lib.concatStringsSep "\n" serviceDomains)}
|
|
||||||
|
|
||||||
if [ -z "$want" ]; then
|
|
||||||
echo "no swarm service domains configured — nothing to issue for"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Same half-provisioned guard as the root: a key beside a cert
|
|
||||||
# that did not sign it looks like it works and issues nothing
|
|
||||||
# anyone will trust.
|
|
||||||
if { [ -e "$ca" ] && [ ! -e "$cak" ]; } || { [ -e "$cak" ] && [ ! -e "$ca" ]; }; then
|
|
||||||
echo "services sub-CA half-provisioned ($ca / $cak) — refusing to generate over it" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -s "$ca" ] && [ -s "$cak" ] && [ -f "$names" ] \
|
|
||||||
&& [ "$(cat "$names")" = "$want" ]; then
|
|
||||||
echo "services sub-CA present and covers the configured names — leaving it alone"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -s "$root" ] || [ ! -s "$rootk" ]; then
|
|
||||||
echo "no swarm root CA at $root — cannot issue the services sub-CA under it" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "issuing services sub-CA at $ca for: $(echo "$want" | tr '\n' ' ')"
|
|
||||||
csr="$(mktemp "$d/services-ca.csr.XXXXXX")"
|
|
||||||
ext="$(mktemp "$d/services-ca.ext.XXXXXX")"
|
|
||||||
trap 'rm -f "$csr" "$ext"' EXIT
|
|
||||||
|
|
||||||
openssl req -newkey rsa:4096 -nodes -sha256 \
|
|
||||||
-keyout "$cak" -out "$csr" \
|
|
||||||
-subj "/CN=swarm-services-ca ${swarmLabel}"
|
|
||||||
|
|
||||||
{
|
|
||||||
# pathlen:0 — this signs leaves and delegates no further.
|
|
||||||
printf 'basicConstraints=critical,CA:TRUE,pathlen:0\n'
|
|
||||||
printf 'keyUsage=critical,keyCertSign,cRLSign\n'
|
|
||||||
printf 'subjectKeyIdentifier=hash\n'
|
|
||||||
printf 'authorityKeyIdentifier=keyid:always\n'
|
|
||||||
# Constrained to the exact service names, not to the whole
|
|
||||||
# swarm domain: a leaked services CA should mint `forge.`,
|
|
||||||
# `chat.`, `auth.` and nothing else. The IP exclusions are not
|
|
||||||
# redundant — a DNS constraint says nothing about an
|
|
||||||
# iPAddress SAN, and an unconstrained name type is a name
|
|
||||||
# type this CA is unconstrained for.
|
|
||||||
printf 'nameConstraints=critical,permitted;%s,excluded;IP:0.0.0.0/0.0.0.0,excluded;IP:0:0:0:0:0:0:0:0/0:0:0:0:0:0:0:0\n' \
|
|
||||||
"$(echo "$want" | sed 's/^/DNS:/' | paste -sd, -)"
|
|
||||||
} > "$ext"
|
|
||||||
|
|
||||||
openssl x509 -req -in "$csr" -CA "$root" -CAkey "$rootk" \
|
|
||||||
-CAcreateserial -days ${toString cfg.servicesValidityDays} -sha256 \
|
|
||||||
-extfile "$ext" -out "$ca"
|
|
||||||
|
|
||||||
printf '%s' "$want" > "$names"
|
|
||||||
chmod 0600 "$cak"
|
|
||||||
chmod 0644 "$ca" "$names"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,23 +23,6 @@
|
||||||
let
|
let
|
||||||
cfg = config.services.hyperhive;
|
cfg = config.services.hyperhive;
|
||||||
swarmCfg = cfg.swarm;
|
swarmCfg = cfg.swarm;
|
||||||
|
|
||||||
# Public hostnames of the swarm's own services, in declaration order.
|
|
||||||
# `serviceDomains` below is this set sorted + deduplicated.
|
|
||||||
#
|
|
||||||
# ⚠️ These are NOT required to be under `swarm.domain`. An earlier
|
|
||||||
# revision asserted that, reasoning that the services sub-CA is
|
|
||||||
# constrained to the swarm's tree — but the sub-CA is constrained to
|
|
||||||
# the **configured names** (./swarm-ca.nix) and the swarm root carries
|
|
||||||
# no name constraints at all, so any configured name is issuable. The
|
|
||||||
# assertion encoded an intended shape, not a property of the code, and
|
|
||||||
# it rejected the supported migration path: a hive pinning its old
|
|
||||||
# `forge.<hive domain>` while joining a swarm at a different apex.
|
|
||||||
serviceDomains' = [
|
|
||||||
swarmCfg.forge.domain
|
|
||||||
swarmCfg.matrix.gatewayHost
|
|
||||||
swarmCfg.authelia.domain
|
|
||||||
];
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.services.hyperhive.swarm.hives = lib.mkOption {
|
options.services.hyperhive.swarm.hives = lib.mkOption {
|
||||||
|
|
@ -170,31 +153,9 @@ in
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
options.services.hyperhive.swarm.serviceDomains = lib.mkOption {
|
|
||||||
type = lib.types.listOf lib.types.str;
|
|
||||||
readOnly = true;
|
|
||||||
internal = true;
|
|
||||||
description = ''
|
|
||||||
Read-only: the public hostnames of the swarm's own services, in a
|
|
||||||
stable sorted order. Second derived set alongside `peerHives`, and
|
|
||||||
here for the same reason — the CA that name-constrains these and
|
|
||||||
the leaf that carries them as SANs must agree exactly, and two
|
|
||||||
modules each assembling the list is how they stop agreeing.
|
|
||||||
|
|
||||||
Sorted and deduplicated deliberately: consumers compare this list
|
|
||||||
against what they issued last time to decide whether to re-issue,
|
|
||||||
so an unstable order would churn a certificate that other things
|
|
||||||
are meant to pin.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
services.hyperhive.swarm.peerHives = lib.filterAttrs (name: _: name != cfg.hiveName) swarmCfg.hives;
|
services.hyperhive.swarm.peerHives = lib.filterAttrs (name: _: name != cfg.hiveName) swarmCfg.hives;
|
||||||
|
|
||||||
services.hyperhive.swarm.serviceDomains = lib.sort (a: b: a < b) (
|
|
||||||
lib.unique (lib.filter (d: d != null && d != "") serviceDomains')
|
|
||||||
);
|
|
||||||
|
|
||||||
assertions = [
|
assertions = [
|
||||||
{
|
{
|
||||||
# Guarded on `hiveName != null` so the required-hiveName
|
# Guarded on `hiveName != null` so the required-hiveName
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue