hyperhive/swarm-matrix-ctl
Repository files (latest commit first)
Filename Latest commit message Latest commit date
atlas 1261b525d6 matrix: one sender account and one sender token per hive
A swarm runs one homeserver and every hive on it logged in as the same
`@hive:` localpart, holding the same access token out of one swarm-wide
store path. That is one matrix identity for N hives: the homeserver
cannot attribute an action to the hive that took it, and revoking one
hive's standing revokes every hive's.

Three changes, and the third is the one that makes the other two real:

- **The localpart carries the hive's name** (`hive-<hive>`), derived in
  one place, `swarm_secret_client::matrix::hive_localpart`.
  `hive-matrix.nix` renders the same string as the appservice
  registration's `sender_localpart`, so the shared account stops being
  created rather than merely stops being used.
- **The store path is templated by hive**, not a constant. The
  "a swarm runs one homeserver, so this is a constant rather than a
  parameter" rationale went with it; it stopped holding the moment two
  hives shared the homeserver it describes.
- **The path moved out from under the grant every hive has.** It sat at
  `swarm/services/matrix/sender-token`, inside the
  `secret/data/swarm/services/*` read stanza `policy::render` gives every
  hive. It now sits under that hive's own stanza,
  `secret/data/swarm/hives/<hive>/*`, which interpolates the reader's
  name — so a hive reads its own token and is refused another's. The
  policy renderer itself is unchanged: narrowing the `services/*` grant
  would break the OIDC-secret read it exists for, and moving the
  credential is what this needed instead. A policy test walks the
  rendered stanzas and asserts none of hive alpha's covers hive beta's
  sender token, so a later stanza that widened it fails here.

`swarm-matrix-ctl` takes a new required `MATRIX_MINT_HIVE` and writes
that hive's path; its store grant in `swarm-bao.nix` follows, scoped to
one hive's leaf via the new `deploy.bao.matrixCtlHiveName` (defaulting to
this host's `hiveName`) rather than a `hives/*` wildcard, which would
hand the matrix container every hive's token back.

Migration: no outage at deploy. `ensure_hive_user` short-circuits on the
local token file, so a hive keeps running on what it has; with no such
file it reads the new per-hive path, finds nothing, and falls through to
the existing register-or-appservice-login ladder against its own
localpart — which needs only the per-hive `as_token` on local disk. The
old shared object is read by nothing afterwards. Rooms do not follow the
identity, and that is the one operator step; both ways out are written
into `docs/integrations/matrix.md`.

No admin standing is granted to the per-hive accounts: `admin_execute`
stays empty and the assertion pinning it is untouched.
2026-09-20 22:07:16 +02:00
..
src matrix: one sender account and one sender token per hive 2026-09-20 22:07:16 +02:00
Cargo.toml swarm-matrix-ctl: one control binary for the matrix container, not one per job 2026-09-20 22:07:16 +02:00
README.md swarm-matrix-ctl: one control binary for the matrix container, not one per job 2026-09-20 22:07:16 +02:00

swarm-matrix-ctl

The rust that runs inside containers.hive-matrix, beside the homeserver.

One binary with subcommands rather than one binary per job. Running code in that container is not free: it needs its own store identity, its own cert role and its own bind mounts, and every one of those is per-container, not per-task. A second single-purpose crate would have had to duplicate that plumbing to add one action, so the next thing that has to run in here is a verb, not a new crate.

Verbs

mint

Puts the appservice sender account's access token into the swarm's secret store, under an identity of its own. A boot-time oneshot.

Configured entirely by the MATRIX_MINT_* environment the unit sets — no flags. A systemd Environment= block is what a nix module can render; a command line full of paths is not. The prefix is scoped to the verb rather than to the binary so the next verb brings its own, instead of widening a shared one nobody can then narrow.

Why this lives in the matrix container

The credential mint writes is authorised by the appservice as_token, and the container already holds that: nix/host-modules/hive-matrix.nix bind-mounts the rendered appservice registration into it read-only, because that is how tuwunel itself is handed the registration. Minting anywhere else would mean copying the as_token to a second holder — and the point of this component is that the hive stops being one.

It is not the swarm controller for the same reason, plus a structural one: a homeserver has exactly one appservice registration and so one sender account, and a swarm runs one homeserver, so "mint it once" needs no lock, no lease and no trigger surface — it is a property of the thing being minted.

Idempotency

The store is the key, not the homeserver. A mint run reads swarm/services/matrix/sender-token first and returns without touching the homeserver when something is already there. Only an empty path reaches the mint ladder:

  1. POST /_matrix/client/v3/register with "type": "m.login.application_service" — one round trip, no UIAA.
  2. M_USER_IN_USE (the expected arm on a homeserver that has already loaded the registration, since the account is the appservice's own sender_localpart) → POST /_matrix/client/v3/login as the appservice, same pinned device_id, so the old device is replaced rather than duplicated.
  3. Write the result to the store.

A crash between the homeserver call and the store write is recoverable: the next run takes arm 2.

🩸 A secret is a path, never a value

Nothing here logs, prints or interpolates a token. The mint ladder's errors are built from the homeserver's status and its errcode, never its body, because a /login response body is an access token. The one identifier this binary logs is the store path it wrote.