From bb62bf1aa9d3872ab5a216c28a7b57bf969567e6 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 11 Sep 2026 22:20:00 +0200 Subject: [PATCH] swarm: guard hive names where the roster is declared, and reserve the cert subjects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two hive-name guards lived in swarm-otel.nix, inside its `config = lib.mkIf (… && deployCfg.swarm-otel.enable)`. A swarm running the secret store and the controller but no collector therefore had no hive-name check at all, while the names were still composed into OIDC client ids, bao policies and cert-auth roles exactly the same way. They move to swarm.nix, which declares `swarm.hives` and is unconditional. swarm-otel keeps the assertion that its own entry is still in the shared list — that one is about this module's stake in a file it no longer controls. The equality guard also takes the store's cert-auth subjects now. Cert auth trusts the CA, so `allowed_common_names` is the whole of what narrows a role to one identity, and the same CA signs every hive's leaf with the hive's name as its CN. A hive named after a role's subject presents a certificate that role accepts, which for the controller is write access to every hive's credentials and policies. A list rather than the one string, because the next role added beside it widens what a hive name must not collide with, and because the subject is an option an operator sets — a literal deny entry covers the default and nothing else. Four module-eval cases, two of them controls. The fixture overrides the subject to `ctl` on purpose: the default contains `swarm`, which the substring guard catches whatever the new arm does, so a fixture using it could not tell the two apart. The controls are that a legal roster trips neither guard, and that all three fixtures really do have the collector disabled — without the second, every case would pass while testing the arrangement they exist to rule out. --- nix/host-modules/swarm-otel.nix | 36 +++-------------- nix/host-modules/swarm.nix | 56 +++++++++++++++++++++++++++ nix/module-eval.nix | 68 +++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 31 deletions(-) diff --git a/nix/host-modules/swarm-otel.nix b/nix/host-modules/swarm-otel.nix index db813dcd..f9de162f 100644 --- a/nix/host-modules/swarm-otel.nix +++ b/nix/host-modules/swarm-otel.nix @@ -57,15 +57,13 @@ let # is asserted to CONTAIN `swarmTierName`, so a rename that dropped it from # the file (or a `producerName` override the file was never updated for) # would be an eval error rather than a silently missing guard. + # ⚠️ The guards themselves live in ./swarm.nix, which declares + # `swarm.hives` and is unconditional. They were here and gated on this + # module's own `enable`, so a swarm without the collector had no hive-name + # check at all. What stays here is the assertion below: this module's own + # entry must still be in that shared list. reservedOwners = import ../reserved-names.nix; - # Words no hive name may CONTAIN, because identifiers are composed from a - # hive name. That file explains why it is not merged into the list above. - reservedFragments = import ../reserved-hive-fragments.nix; - - nameGuards = import ./lib/name-guards.nix { inherit lib; }; - hiveNames = lib.attrNames hyperhiveCfg.swarm.hives; - # A published target is declared as ONE url, because that url is also the # audience its token is minted for — but prometheus wants the same fact in # three fields. Split it here rather than asking a service to state it @@ -775,30 +773,6 @@ in Put it back, or give this module a different swarmTierName. ''; } - (nameGuards.mustNotEqual { - option = "services.hyperhive.swarm.hives"; - names = hiveNames; - reserved = reservedOwners; - why = '' - The collector names components `/` with the hive name - as owner, and `//` resolves the clash silently: the swarm tier's - parts win, that hive's pipeline and `hive=` stamp disappear, and it - keeps pushing into a route that goes nowhere. Rename the hive. - ''; - }) - (nameGuards.mustNotContain { - option = "services.hyperhive.swarm.hives"; - names = hiveNames; - fragments = reservedFragments; - why = '' - Hive-scoped identifiers are composed from a hive name — - `hive-`, `hive--agent` — so a name containing one of - these produces an identifier that is also somebody else's. The - failure is a wrong grant rather than an error: the client - authenticates and receives another principal's permissions, and a - NATS denial arrives as a timeout. Rename the hive. - ''; - }) { # A published target that is not an `https://host/path` url. Without # this the split returns null and the failure surfaces as diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index cfb577f4..6052cc8f 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -25,6 +25,26 @@ let swarmCfg = cfg.swarm; deployCfg = cfg.deploy; + hiveNames = lib.attrNames swarmCfg.hives; + + nameGuards = import ./lib/name-guards.nix { inherit lib; }; + + # Names no hive may BE, and words no hive name may CONTAIN. Both files + # explain their own admission rules; the second says why they are not one + # list. They are applied HERE rather than in the module that happens to + # consume them, because a hive name composes identifiers whatever else is + # enabled — ./swarm-otel.nix used to hold these guards and gated them on its + # own `enable`, so a swarm without the collector had no check at all. + reservedNames = import ../reserved-names.nix; + reservedFragments = import ../reserved-hive-fragments.nix; + + # Subjects the store's cert-auth roles accept. A LIST, not one string: cert + # auth trusts the CA and `allowed_common_names` is the whole narrowing, so + # every role added beside these widens what a hive name must not collide + # with. A hive's own leaf carries its name as the CN, so a hive named after + # one of these presents a certificate that role accepts. + certAuthCns = [ deployCfg.bao.controllerCommonName ]; + # Public hostnames of the swarm's own services, in declaration order. # `serviceDomains` below is this set sorted + deduplicated. # @@ -387,6 +407,42 @@ in swarm, or set all three to null to turn publishing off. ''; } + (nameGuards.mustNotEqual { + option = "services.hyperhive.swarm.hives"; + names = hiveNames; + reserved = reservedNames ++ certAuthCns; + why = '' + A hive's name is what other components address it by, and two + of those uses resolve the clash silently rather than erroring. + + The collector names components `/` with the hive + name as owner, and `//` merges them: the swarm tier's parts + win, that hive's pipeline and `hive=` stamp disappear, and it + keeps pushing into a route that goes nowhere. + + The secret store's cert-auth roles match on a certificate's + common name, and a hive's own leaf carries its name. A hive + named after a role's subject presents a certificate that role + accepts — so it receives that principal's grants, which for + the controller means write access to every hive's credentials + and policies. + + Rename the hive. + ''; + }) + (nameGuards.mustNotContain { + option = "services.hyperhive.swarm.hives"; + names = hiveNames; + fragments = reservedFragments; + why = '' + Hive-scoped identifiers are composed from a hive name — + `hive-`, `hive--agent` — so a name containing one of + these produces an identifier that is also somebody else's. The + failure is a wrong grant rather than an error: the client + authenticates and receives another principal's permissions, and a + NATS denial arrives as a timeout. Rename the hive. + ''; + }) ]; }; diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 7eb5dcd5..a6e4a81c 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -507,8 +507,76 @@ let bao = h.deploy.bao.enable; }; + # The hive-name guards, with the collector explicitly OFF. That is the whole + # property: the guards live where `swarm.hives` is declared, so they run in a + # deployment that has a secret store and no collector — which used to skip + # them entirely, because they were assertions inside swarm-otel's own `mkIf`. + # + # ⚠️ `controllerCommonName` is overridden to a name containing NO reserved + # fragment. Its default (`swarm-controller`) contains `swarm` and is caught + # by the substring guard whatever the cert-auth arm does — so a fixture using + # the default could not tell the two apart, and the arm under test would pass + # on the neighbour's work. + hiveNamedAfterCertSubject = hive { + deploy.swarm-otel.enable = false; + deploy.bao.controllerCommonName = "ctl"; + swarm.hives.ctl.domain = "ctl.t.local"; + }; + + # The control for both arms below: same shape, a roster nothing objects to. + hiveNamesAllLegal = hive { + deploy.swarm-otel.enable = false; + deploy.bao.controllerCommonName = "ctl"; + }; + + hiveNameWithComposedWord = hive { + deploy.swarm-otel.enable = false; + swarm.hives."h1-agent".domain = "a.t.local"; + }; + + # Markers from `lib/name-guards.nix`'s two `problem` strings. Matching the + # problem rather than the `why` prose keeps the messages rewordable. + equalityGuardFired = + h: lib.any (a: !a.assertion && lib.hasInfix "has reserved name(s)" a.message) h.assertions; + fragmentGuardFired = + h: + lib.any ( + a: !a.assertion && lib.hasInfix "has name(s) containing a reserved word" a.message + ) h.assertions; + # Each case: a name stating the property, and `ok`. cases = [ + { + # `ctl` is in no deny list — it is reserved *because it is the subject a + # cert-auth role accepts*, which is a value an operator sets, so a + # literal deny entry could never have covered it. + name = "a hive named after a cert-auth subject is refused, with the collector off"; + ok = + equalityGuardFired hiveNamedAfterCertSubject + && lib.any (a: !a.assertion && lib.hasInfix "'ctl'" a.message) hiveNamedAfterCertSubject.assertions; + } + { + # Without this the case above proves nothing: an arm that fires for every + # roster is not a guard, and `hives` is non-empty in both fixtures. + name = "a legal hive roster trips neither name guard"; + ok = !(equalityGuardFired hiveNamesAllLegal) && !(fragmentGuardFired hiveNamesAllLegal); + } + { + # The substring guard came along in the move and has to still work. + # `h1-agent` mints exactly the client id hive `h1`'s agents present. + name = "a hive name containing a composed-identifier word is refused, with the collector off"; + ok = fragmentGuardFired hiveNameWithComposedWord; + } + { + # ⚠️ The control that makes "with the collector off" mean anything. If a + # fixture silently had swarm-otel enabled, all three cases above would + # pass while testing the arrangement they exist to rule out. + name = "the guard fixtures really do have the collector disabled"; + ok = + !hiveNamedAfterCertSubject.services.hyperhive.deploy.swarm-otel.enable + && !hiveNamesAllLegal.services.hyperhive.deploy.swarm-otel.enable + && !hiveNameWithComposedWord.services.hyperhive.deploy.swarm-otel.enable; + } { # `lib.all` over an empty set holds vacuously, so the roster is counted # before it is read: a helper that lost a member would otherwise turn