swarm-tls: narrow each gateway's services leaf to the names it fronts

Every gateway asked the store's `pki/issue/swarm-services` for the whole
swarm's service set, so a private key on any gateway host could serve a
valid certificate for services that host does not front and never has.

`swarm.localServiceDomains` derives the per-host subset by filtering
`swarm.serviceDomains` against the vhosts this host actually renders —
the deploy flags those vhosts are already guarded on, read once rather
than copied into a second filter. The leaf request and the coverage
guard that decides whether to re-issue both read it, so they cannot
disagree about which names the leaf owes.

The sub-CA's name constraint and the role's `allowed_domains` stay the
swarm-wide set: every host's subset is inside it, and narrowing the
constraint per host would turn one signing into N.
This commit is contained in:
atlas 2026-09-23 22:31:57 +02:00 • committed by mara
commit ed2ec52fe5
5 changed files with 89 additions and 20 deletions

View file

@ -21,10 +21,19 @@ let
baoServicesPkiMount = baoDeploy.servicesPkiMountPath;
baoServicesPkiRole = baoDeploy.servicesPkiRoleName;
# Derived once in ./swarm.nix and read here + in ./swarm-bao.nix, so
# the names this leaf carries as SANs and the names the store's
# `pki/roles/swarm-services` is narrowed to cannot disagree.
swarmServiceDomains = hyperhiveCfg.swarm.serviceDomains;
# Derived once in ./swarm.nix: the swarm service names THIS host fronts
# a vhost for. Every one of them is inside the swarm-wide set that
# ./swarm-bao.nix narrows `pki/roles/swarm-services` to, so the leaf
# stays issuable while carrying no name this gateway does not serve.
localServiceDomains = hyperhiveCfg.swarm.localServiceDomains;
# The one name the request's `common_name` carries; the rest ride as
# SANs. Not `builtins.head`: a host fronting no swarm service renders
# this script too — its unit exits on `want_svc` long before running
# the request, but a bare `head []` would have failed the EVALUATION.
leafCommonName = lib.optionalString (localServiceDomains != [ ]) (
builtins.head localServiceDomains
);
# 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
@ -195,15 +204,16 @@ let
svcleaf="$d/swarm-services.pem"
svcroot="$d/swarm-services-root.pem"
hiveNames=${lib.escapeShellArg "${domain} *.${domain}"}
svcNames=${lib.escapeShellArg (lib.concatStringsSep " " swarmServiceDomains)}
svcNames=${lib.escapeShellArg (lib.concatStringsSep " " localServiceDomains)}
# A hive with no configured service names has no services leaf to
# hold, and its absence there is the correct state rather than a
# stale one. Anywhere else it is expected: the leaf comes from the
# A host fronting none of the swarm's service names has no services
# leaf to hold, and its absence there is the correct state rather
# than a stale one — its own vhosts are covered by the hive leaf
# above. Anywhere else it is expected: the leaf comes from the
# secret store's `pki` mount now, not from a sub-CA that exists on
# one host in the swarm, so "this host cannot issue it" is no longer
# one of the answers.
want_svc=${if swarmServiceDomains == [ ] then "0" else "1"}
want_svc=${if localServiceDomains == [ ] then "0" else "1"}
# Expiry is not the only way a leaf goes wrong. One signed when the
# configured name set was smaller stays valid for its whole lifetime
@ -681,10 +691,11 @@ in
# for it to be an intermediate OF.
#
# The names it may carry are not this unit's to assert: the role
# refuses anything outside `allowed_domains`, which is read from the
# same `swarm.serviceDomains` the SANs below are built from. A
# mismatch is a refusal from the store naming the offending name,
# not a certificate quietly issued for something nobody configured.
# refuses anything outside `allowed_domains`, read from the
# swarm-wide `swarm.serviceDomains` that the SANs below are a
# per-host SUBSET of. A mismatch is a refusal from the store naming
# the offending name, not a certificate quietly issued for something
# nobody configured.
systemd.services.swarm-services-cert = {
description = "Issue the swarm-services TLS leaf from the secret store's PKI";
wantedBy = [ "multi-user.target" ];
@ -847,8 +858,8 @@ in
# the store, not this host, decides what the certificate says.
if ! bao write -format=json \
${lib.escapeShellArg "${baoServicesPkiMount}/issue/${baoServicesPkiRole}"} \
common_name=${lib.escapeShellArg (builtins.head swarmServiceDomains)} \
alt_names=${lib.escapeShellArg (lib.concatStringsSep "," swarmServiceDomains)} \
common_name=${lib.escapeShellArg leafCommonName} \
alt_names=${lib.escapeShellArg (lib.concatStringsSep "," localServiceDomains)} \
> "$resp" 2>"$err"; then
echo "the store refused to issue the swarm-services certificate." >&2
cat "$err" >&2