A swarm runs one homeserver and a homeserver has one appservice sender account, so "mint it once" is a property of the thing being minted rather than something a lock has to enforce. That is what makes this account the one to move first: no trigger route, no controller change and no agent list — a boot-time oneshot beside tuwunel is the whole mechanism. `swarm-matrix-minter` runs inside `containers.hive-matrix`, which already holds the appservice token: the rendered registration is bound in read-only because that is how tuwunel is handed it. What the container lacked was an identity of its own, so this adds one — a leaf from the store's CA with a grant of exactly one path, not the hive's leaf, which reads every secret in the store. Both ends of the credential ship here. The minter reads the path it publishes to before it touches the homeserver, and returning on a non-empty read IS the "only once"; `hive-c0re`'s `ensure_hive_user` reads the same path, authenticating with the hive name already in `HYPERHIVE_HIVE_NAME`. The existing mint-then-`M_USER_IN_USE`-login ladder stays as the fallback for a store that is empty, unconfigured or unreachable, which is every swarm deployed before this — so nothing needs backfilling and nothing breaks if the rest of the sequence never lands. The credential is not an admin credential, and is not named like one. It is the access token of the appservice registration's own `sender_localpart` — `@hive:<server_name>`, an account the homeserver creates for itself when it loads the registration. The store path is `swarm/services/matrix/sender-token`, the host path is `matrix/access-token`, and the homeserver no longer runs an `admin_execute` promotion for that account at boot. Everything the hive provisions with it — the Space, the chat room, their hierarchy and join rules, the invites — rides on being the creator of those rooms at power level 100, not on homeserver admin; there is no Synapse admin API here to need, tuwunel has none. Two operations do need an admin *sender* and therefore stop working: `hivectl matrix promote-user` and `hivectl matrix reset-password`, both `!admin …` messages into `#admins:<server>`, plus the password-reset recovery path that an agent with a lost password file falls back to. They are swarm-level operations and are left failing loudly rather than served by an over-privileged token every other call site would also carry. The sweep's own admin-rights check and self-repair go with them: an account that is deliberately not an admin has nothing to check. `ephemeral = false` stays, and hive root can still read the container's filesystem. Accepted: what this buys is identity separation — no hive *process* holds or reads the appservice token — not physical isolation. Refs #4345
138 lines
5.5 KiB
Nix
138 lines
5.5 KiB
Nix
# `checks.module-eval-name-guards` — see ./lib.nix for the shared
|
|
# rationale (why this suite exists, naming convention, "evaluates
|
|
# not executes").
|
|
{
|
|
pkgs,
|
|
lib,
|
|
self,
|
|
nixosSystem,
|
|
}:
|
|
let
|
|
inherit
|
|
(import ./lib.nix {
|
|
inherit
|
|
pkgs
|
|
lib
|
|
self
|
|
nixosSystem
|
|
;
|
|
})
|
|
hive
|
|
runGroup
|
|
;
|
|
|
|
# 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";
|
|
};
|
|
|
|
# The reserved subjects are a LIST, and a list with one consulted element and
|
|
# one dead one looks identical from the first element's case. This fixture
|
|
# collides with the SECOND, leaving the controller's at its default.
|
|
hiveNamedAfterPublisherSubject = hive {
|
|
deploy.swarm-otel.enable = false;
|
|
deploy.bao.secretPublisherCommonName = "pubctl";
|
|
swarm.hives.pubctl.domain = "p.t.local";
|
|
};
|
|
|
|
# The THIRD element of the same list, colliding on its own so neither of the
|
|
# two above can carry it. The minter's grant is one path rather than a whole
|
|
# prefix, which is exactly why a dead entry here would be easy to miss: a
|
|
# hive that inherited it would not obviously break anything, it would
|
|
# silently gain the ability to overwrite the swarm's matrix credential.
|
|
hiveNamedAfterMinterSubject = hive {
|
|
deploy.swarm-otel.enable = false;
|
|
deploy.bao.matrixMinterCommonName = "mintctl";
|
|
swarm.hives.mintctl.domain = "m.t.local";
|
|
};
|
|
|
|
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;
|
|
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;
|
|
}
|
|
{
|
|
# Every cert-auth subject is reserved, not just the first one in the
|
|
# list. Without this case the second element could be dead and the case
|
|
# above would still pass.
|
|
name = "a hive named after the secret publisher's subject is refused too";
|
|
ok =
|
|
equalityGuardFired hiveNamedAfterPublisherSubject
|
|
&& lib.any (
|
|
a: !a.assertion && lib.hasInfix "'pubctl'" a.message
|
|
) hiveNamedAfterPublisherSubject.assertions;
|
|
}
|
|
{
|
|
# And the third, for the reason the second one's comment gives one list
|
|
# element earlier. `certAuthCns` is where a role added beside the others
|
|
# has to register itself, and nothing but a case per element notices when
|
|
# one forgets.
|
|
name = "a hive named after the matrix minter's subject is refused too";
|
|
ok =
|
|
equalityGuardFired hiveNamedAfterMinterSubject
|
|
&& lib.any (
|
|
a: !a.assertion && lib.hasInfix "'mintctl'" a.message
|
|
) hiveNamedAfterMinterSubject.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;
|
|
}
|
|
];
|
|
in
|
|
runGroup "name-guards" cases
|