Compare commits

...
Author SHA1 Message Date
atlas
30551464c1 fix(nix): drop the swarm-service-domain assertion, it asserted intent
Measured against a real config, not reasoned about: pinning
`swarm.forge.domain = "forge.<hive domain>"` while `swarm.domain` is a
different apex -- the documented way an existing deployment keeps its
names while joining a swarm -- FAILED eval under this assertion.

And the reason it gave was false. It said those names are certified by
the services sub-CA, "which is constrained to the swarm's own tree, so a
name outside it has no issuer here". The sub-CA is constrained to the
**configured names** (swarm-ca.nix builds its nameConstraints from the
derived list), and the swarm root carries no name constraints at all --
only basicConstraints and keyUsage. So any configured name is issuable,
and a legacy name under the hive domain is additionally signable by the
hive CA.

The assertion therefore encoded the shape I had in mind rather than a
constraint the code implements, and it rejected a configuration that
works. Removed rather than weakened: a narrower version would still
carry a claim nothing backs.

`serviceDomains` stays -- it feeds the sub-CA's name constraints, the
leaf's SANs and the per-vhost cert choice, and those three agreeing is
the thing that mattered. The (option, value) pairs collapse back to a
plain list, since the provenance existed only for the assertion message.
2026-08-05 22:43:03 +02:00
atlas
93b89fd566 feat(nix): assert swarm service names live under swarm.domain
Those names are certified by the swarm-services sub-CA, which is
name-constrained to the swarm's own tree. A service hostname outside
it has no issuer in this deployment -- not a misconfiguration that
degrades, one that cannot produce a matching certificate at all.

Asserting it turns "your certs mysteriously don't work" into an eval
failure that names the option to edit. It applies to every
deployment, not only the autoconfigured one: an operator bringing
their own certificates still needs to know which names they are
bringing them for.

The message names the option rather than only the value, which is why
the hostnames are carried as (option, value) pairs and flattened into
serviceDomains afterwards -- flattening drops exactly the provenance
an error message needs.

Guarded on a null swarm.domain so hive-network.nix's required-domain
assertion stays the one that fires for that case.
2026-08-05 22:43:03 +02:00
atlas
11b8140981 feat(nix): serve swarm-service vhosts the swarm-services leaf
nginx already carried TLS per vhost, so this is a cert choice rather
than a restructure: a vhost whose name this hive's CA cannot sign
gets the swarm-services pair, and every other vhost keeps the hive
pair.

Which vhosts those are is not decided here. Both the sub-CA's name
constraints and this choice read swarm.serviceDomains, so "a swarm
service" means one thing in one place -- a vhost served a cert its
issuer is constrained out of would fail at TLS, and the two lists
drifting is the only way to get there.

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.

The container import copies the pair only when the host issued one,
and removes a stale copy otherwise: the leaf exists only where the
swarm CA is autoconfigured, so absent is a normal state and a
leftover from a host that stopped issuing it is not.
2026-08-05 22:43:03 +02:00
atlas
5a83c40dca feat(nix): issue a swarm-services leaf, and renew it with the hive one
The swarm's service names cannot go on the hive leaf: the hive CA is
name-constrained to the hive domain and those names are siblings of
it. So there is a second leaf, signed by the services sub-CA.

signLeafScript is parameterised rather than duplicated -- same
ceremony, different issuer and names -- so the two cannot drift in
how they are built. The name list itself is derived once, as a
read-only swarm.serviceDomains, and read by both the sub-CA that
name-constrains those names and the leaf that carries them as SANs:
two modules each assembling the list is how they stop agreeing.

The renewal unit is the point of this commit as much as the leaf.
hive-tls-resign now knows about both, because a leaf that first-boot
issuance creates and weekly renewal ignores looks perfect for its
entire validity and then expires with no warning -- the failure is
invisible until it is total. The freshness test became a function
over a leaf rather than a check of one, so adding a third leaf is a
line rather than a rewrite.

The services leaf is skipped where the sub-CA is absent: it exists
only where the swarm CA is autoconfigured, and on a hive whose certs
come from its operator the correct state is no leaf, not a stale one.

Also drops a comment that documented signLeafScript's old signature
from above an unrelated binding.
2026-08-05 22:43:03 +02:00
atlas
240ae79ad6 feat(nix): issue a swarm-services sub-CA under the swarm root
The swarm's own service names cannot be signed by any hive CA: each
hive CA is name-constrained to its hive's domain, and the service
names are siblings of that domain, not children. Adding them to a
leaf's SAN list only trades a name-mismatch error for a
constraint-violation one.

So the root issues one more intermediate, constrained to exactly the
configured service names -- not to the whole swarm domain. A leaked
services CA mints forge./chat./auth. and nothing else.

Rotation is why this is separate rather than a second leaf off the
root: the constraint enumerates the names, so adding a service
re-issues this CA and never touches the root or any hive CA. The
name set is written beside the cert and compared on each start, which
is what makes re-issuance happen exactly when the names change --
not every boot, and not never. The list is sorted and deduplicated
for the same reason: an unstable order would churn a CA that things
are meant to pin.

Validity is deliberately much shorter than the root's. This CA is
meant to be re-issued, so a long window buys nothing and a short one
keeps the rotation path exercised rather than theoretical.

Carries the root's half-provisioned guard: a key beside a cert that
did not sign it looks like it works and issues nothing anyone trusts.
2026-08-05 22:43:03 +02:00
5 changed files with 290 additions and 30 deletions

View file

@ -15,6 +15,10 @@
let
cfg = config.services.hyperhive.gateway;
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;
forgeCfg = config.services.hyperhive.swarm.forge;
networkCfg = config.services.hyperhive.network;
@ -263,6 +267,12 @@ in
if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.certName}" else "${tlsDir}/cert.pem";
tlsKey =
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 {
inherit
lib
@ -274,6 +284,9 @@ in
swaggerUiTheme
tlsCert
tlsKey
svcCert
svcKey
swarmServiceDomains
;
errorPages = import ./error-pages.nix { inherit pkgs; };
};
@ -362,6 +375,23 @@ in
# 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}
# 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
'';
};

View file

@ -15,6 +15,9 @@
errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized }
tlsCert,
tlsKey,
svcCert, # swarm-services leaf, for names the hive CA cannot sign
svcKey,
swarmServiceDomains, # which vhosts those are (../swarm.nix derives it)
}:
let
# The gateway always terminates TLS: self-signed is the implicit
@ -51,6 +54,25 @@ let
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
# mints into responses (well-known JSON, the deprecated
# `<hive>/matrix/*` 301 redirect, future absolute-URL needs):
@ -85,7 +107,7 @@ let
# `forge.sshPort`. See `docs/gateway.md`. Empty attrset when the
# forge isn't behind the gateway.
forgeVhost = lib.optionalAttrs (forgeCfg.behindGateway or false) {
"${forgeCfg.domain}" = vhostTls // {
"${forgeCfg.domain}" = (vhostTlsFor forgeCfg.domain) // {
listen = vhostListen;
extraConfig = securityHeaders;
locations."/" = {
@ -107,7 +129,7 @@ let
# longer-prefix-wins puts `/_matrix/` ahead of `/`. See
# `docs/gateway.md`. Empty attrset when matrix has no gateway host.
matrixVhost = lib.optionalAttrs (matrixCfg.enable && matrixCfg.gatewayHost != null) {
"${matrixCfg.gatewayHost}" = vhostTls // {
"${matrixCfg.gatewayHost}" = (vhostTlsFor matrixCfg.gatewayHost) // {
listen = vhostListen;
extraConfig = securityHeaders;
locations = {

View file

@ -10,6 +10,10 @@ let
gatewayCfg = config.services.hyperhive.gateway;
swarmCaCfg = config.services.hyperhive.swarm.ca;
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.
# It is only stood up when the gateway actually serves a self-signed
@ -20,13 +24,6 @@ let
# neither an operator cert (`tls.certDir`) nor ACME is set.
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
# is one of exactly two things, chosen by config rather than by what
# happens to be on disk.
@ -97,28 +94,36 @@ let
-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" ''
set -euo pipefail
d="$1"
ca="$d/ca.pem"
cak="$d/ca-key.pem"
leaf="$d/gateway.pem"
leafk="$d/gateway-key.pem"
csr="$(mktemp "$d/gateway.csr.XXXXXX")"
ext="$(mktemp "$d/leaf.ext.XXXXXX")"
only="$(mktemp "$d/gateway.leaf.XXXXXX")"
base="$2"
cn="$3"
sans="$4"
ca="$5"
cak="$6"
leaf="$d/$base.pem"
leafk="$d/$base-key.pem"
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
openssl req -newkey rsa:4096 -nodes -sha256 \
-keyout "$leafk" -out "$csr" \
-subj "/CN=${domain}"
-subj "/CN=$cn"
# 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 'subjectAltName=%s\n' "$sans"
printf 'basicConstraints=critical,CA:FALSE\n'
printf 'keyUsage=critical,digitalSignature,keyEncipherment\n'
printf 'extendedKeyUsage=serverAuth\n'
@ -140,6 +145,35 @@ let
chmod 0600 "$leafk"
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
{
# Host-side TLS trust root for the self-signed gateway mode.
@ -318,7 +352,8 @@ in
if [ ! -s "$leaf" ] || [ ! -s "$leafk" ] \
|| ! openssl x509 -in "$leaf" -noout -checkend 2592000 >/dev/null 2>&1; then
echo "signing fresh gateway leaf at $leaf"
${signLeafScript} "$d"
${signHiveLeaf}
${signServicesLeaf}
fi
# --- Trust bundle: what a consumer must TRUST, as opposed to
@ -389,22 +424,39 @@ in
set -euo pipefail
d=${lib.escapeShellArg cfg.stateDir}
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 the leaf is within half its validity of expiry.
# The weekly cadence catches this window well before the leaf lapses.
# Re-sign only when a leaf is within half its validity of expiry.
# The weekly cadence catches this window well before one lapses.
halflife=$(( ${toString cfg.leafValidityDays} * 86400 / 2 ))
if [ -s "$leaf" ] && \
openssl x509 -in "$leaf" -noout -checkend "$halflife" >/dev/null 2>&1; then
echo "gateway leaf valid for more than half its lifetime no resign needed"
fresh() { # a leaf is fresh if it exists and is not near expiry
[ -s "$1" ] && openssl x509 -in "$1" -noout -checkend "$halflife" >/dev/null 2>&1
}
# 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
fi
echo "gateway leaf missing or near expiry re-signing under current CA"
before="$(sha256sum "$leaf" 2>/dev/null || true)"
echo "a leaf is missing or near expiry re-signing under the current CAs"
before="$(sha256sum "$leaf" "$svcleaf" 2>/dev/null || true)"
${signLeafScript} "$d"
${signHiveLeaf}
${signServicesLeaf}
after="$(sha256sum "$leaf" 2>/dev/null || true)"
after="$(sha256sum "$leaf" "$svcleaf" 2>/dev/null || true)"
if [ "$before" != "$after" ]; then
echo "gateway leaf rotated propagating into hive-gateway"
systemctl -M hive-gateway restart hive-gateway-self-signed-cert.service || true

View file

@ -50,6 +50,13 @@ let
hyperhiveCfg.domain
else
"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
{
options.services.hyperhive.swarm.ca = {
@ -97,6 +104,23 @@ 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 {
type = lib.types.int;
default = 10950;
@ -166,5 +190,98 @@ in
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"
'';
};
};
}

View file

@ -23,6 +23,23 @@
let
cfg = config.services.hyperhive;
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
{
options.services.hyperhive.swarm.hives = lib.mkOption {
@ -153,9 +170,31 @@ 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 = {
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 = [
{
# Guarded on `hiveName != null` so the required-hiveName