hyperhive/docs/integrations/matrix.md
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

22 KiB

hive-matrix

Private Matrix homeserver (matrix-tuwunel — the conduwuit successor) wrapped in a nixos-container, plus optional fluffychat-web client at chat.<swarm-domain>/ (the gatewayHost vhost). Configured via services.hyperhive.swarm.matrix.*; vhost routing lives in gateway.md.

Container shape

Same shape as gateway.md::hive-forge container shape:

  • Container name hive-matrix (not h-*) so c0re's lifecycle scanner ignores it; operator manages via the standard nixos-container CLI.
  • Keeps hive-matrix from fighting any services.matrix-* the operator already runs on the host — separate systemd namespace, separate state dir.
  • Container shares the host network namespace (privateNetwork = false) for state + systemd-unit isolation. Agents reach the homeserver at chat.<swarm-domain> via the gateway (agents run in private netns and can't access host loopback directly).
  • Persistent state at /var/lib/nixos-containers/hive-matrix/var/lib/matrix-tuwunel/ survives container restart / host reboot. To wipe, destroy the container.

Identity vs API listener: serverName vs gatewayHost

Two distinct hostnames:

  • serverName — matrix-spec server_name, embedded irrevocably in every @user:<server_name> and !room:<server_name> identifier minted on this homeserver. You can't change it later without abandoning every account and chat history. Defaults to the bare services.hyperhive.swarm.domain; clients autodiscover the actual API endpoint via the .well-known/matrix/{client,server} routes the gateway serves at that domain.
  • gatewayHost — the API listener hostname, where the gateway's matrix vhost proxies /_matrix/* to tuwunel. Defaults to chat.<services.hyperhive.swarm.domain>. Set to null to skip the gateway vhost (tuwunel stays direct on httpPort).

Both default under the swarm domain, because a swarm runs one homeserver: tying its identity to a single hive's domain would make relocating the container between hives look like a different homeserver.

⚠️ they're still not interchangeable, and the difference is the cost of changing one. gatewayHost is a routing detail clients rediscover through .well-known, so it's safe to move on a running deployment. The matrix id format bakes serverName into every user and room id, so adopting a new one does not rename the existing users and rooms — it strands them, because their ids still name a homeserver that no longer answers.

Upgrading a homeserver that already has ids

serverName's default has changed across releases. A homeserver that has already minted ids under an older default must pin the value it actually minted them under, not adopt the new default — see above for why adopting a new one strands existing users and rooms:

services.hyperhive.swarm.matrix = {
  # whichever this deployment already uses
  serverName = config.services.hyperhive.domain;
  gatewayHost = "matrix.${config.services.hyperhive.domain}";
};

A rebuild on a host that already has a homeserver prints a hive-matrix: WARNING — … serverName is unset line when this is missing, naming the value it's about to default to. That warning is why this section exists; it never fails the rebuild, so it's on you to act on it before the homeserver mints the ids.

Default-closed firewall

openFirewall defaults to false (secure-by-default): the host reaches the homeserver on loopback, and agent containers reach it at chat.<swarm-domain> via the gateway — so the firewall hole only matters for access from outside the host. Flip to true when announcing the homeserver to other hives or when an external matrix client needs to reach the client-server API directly.

Federation port 8448 is intentionally not opened here — tuwunel serves the federation API on the same httpPort as client-server by default. Reaching it on 8448 needs either an explicit tuwunel bind to that port OR a reverse-proxy + .well-known/matrix/server delegation (the latter lives in gateway.md::Discovery flow).

Provisioning flow (appservice)

Registration is closed. The hive's own appservice creates accounts: hive-c0re holds the appservice token, agents never see it, and an agent only ever receives its own access_token.

The appservice has no URL (url: null in its registration), so the homeserver never calls out to it and there is no service to run. What the registration buys is an identity the homeserver recognises — which is why no secret has to be equal on both sides of the wire, and why account creation doesn't depend on registration being open to anyone who learns a token.

  1. System activation mints a 32-byte random hex appservice token (64 chars) at /var/lib/hyperhive/matrix-appservice-token and its spec-required hs_token sibling, mode 0600 root:root, then renders the registration to /var/lib/hyperhive/matrix-appservice/hyperhive.yaml (also 0600). hive-c0re mints the tokens only when missing; the registration is re-rendered every time, because the token file can be overwritten in place by the swarm secret store and a registration naming a stale token authenticates nobody. Runs at activation time, before any container start, because the directory is bind-mounted and nixos-container refuses to start when a bind source is missing.
  2. Read-only bind-mount maps that directory into the tuwunel container at the same path.
  3. systemd LoadCredential= inside the container copies the registration into /run/credentials/tuwunel.service/hyperhive-appservice.yaml, owned by tuwunel's dynamic user with mode 0400, at service start. The host file stays root:root 0600 — no chown :tuwunel / chmod 0640 / GID-pin gymnastics required. Keeps DynamicUser = true + PrivateUsers = true intact.
  4. tuwunel's appservice_dir points at the credentials directory, not at the bind-mount path. It reads only .yaml/.yml entries from there, so the sibling credentials are invisible to it. The .yaml suffix on the credential id is what makes this work.
  5. hive-c0re reads the appservice token and creates each account with one POST /register typed m.login.application_service, persisting the returned access_token to <agent-state>/matrix-token. It never mints the token itself: the value has to be the one the rendered registration names, and only the nix side writes that.
  6. An account that exists but has lost its token file is re-tokened by an appservice POST /login — no password and no admin rights involved. A stored-password login and an admin-room password reset remain behind that, for accounts created before the appservice existed or named outside its namespace.
  7. hive-c0re restarts hive-matrix-daemon for the agent immediately after writing the token so the daemon picks up the new credential without waiting for a full container restart. If the restart fails (for example daemon not yet running on first boot) hive-c0re logs the error as a warning and the .path-trigger sibling (hive-matrix-daemon.path watching for matrix-token appearance) brings the daemon up on the same boot cycle anyway.

The appservice's sender account, and why it isn't an admin

@hive-<hive>:<server_name> is the appservice's own sender_localpart, which the homeserver creates itself when it loads the registration — on a zero-user database, inside startup, before the HTTP listener accepts anything. It's an ordinary account: nothing promotes it, and the homeserver runs no admin_execute for it.

One account per hive. The localpart carries the hive's name, so a swarm whose hives share a homeserver gives each of them its own identity: the homeserver attributes an action to the hive that took it, and revoking one hive's standing leaves the others alone.

Its access token is the sender token, and it's the credential hive-c0re presents for every homeserver call it makes on the hive's behalf. It's per hive for the same reason the account is: swarm-matrix-ctl mints it inside the hive-matrix container and publishes it to swarm/hives/<hive>/matrix/sender-token, and the hive reads it from there under its own certificate. That path sits inside the hive's own read grant (swarm/hives/<hive>/*), so a hive fetches its own token and gets a refusal on any other hive's. The name says what it authenticates as — an account the appservice registration brings into being — rather than any privilege level, because it carries none.

It needs no promotion for what the hive does with it. Creating the hive Space and the chat room, writing their hierarchy and join rules, and inviting agents into them are all ordinary client calls that ride on being the rooms' own creator at power level 100 — there is no homeserver admin in any of it, and no Synapse admin API to reach for either, since tuwunel has none.

Two operations need an admin sender: hivectl matrix promote-user and hivectl matrix reset-password. Both are !admin … messages into #admins:<server_name>, and tuwunel only treats a message as a command when its sender is already an admin. They're swarm-level operations, rehomed to the swarm tier rather than granted here; from the hive, @hive-<hive>: has no admin sender to make that call with, so both get the admin room's refusal rather than an over-privileged credential that every other call site would also carry. The one hive-side path that depends on them is the automatic password recovery for an agent that has lost its stored password — the admin-sender limitation doesn't touch the ordinary appservice re-login above.

Upgrading a hive that shared one sender account with every other hive

Nothing to do, and no window where the hive is without an account.

  • The old shared value at swarm/services/matrix/sender-token is read by nothing. hive-c0re and swarm-matrix-ctl both build the path from the same function, and it now carries the hive's name — so the old object stays in the store, unread, until an operator deletes it. Delete it or leave it; the accounts it authenticates as keep their own standing either way, since an access token lives on the device that minted it.
  • The hive re-mints, per hive, on the next boot. ensure_hive_user runs from the startup sweep. It short-circuits on the token file it already has — so the first boot after the upgrade keeps running on the shared token until that file goes — and on a hive with no such file it reads the new per-hive store path, finds nothing on a store that has never held one, and falls through to the register-or-appservice-login ladder against @hive-<hive>:. That ladder needs only the as_token, which is per hive and on local disk, so it works with or without a reachable store.
  • To move a hive onto its own account now, delete its sender-token file (hivectl matrix sync-admin re-provisions, or the next sweep does) — the ladder then registers @hive-<hive>: and persists that account's token. Only then does the hive stop presenting the shared one.
  • The rooms the shared account created don't follow the new account, and this is the one step that needs a decision. Membership is per account. ensure_hive_space takes the stored room id first, so the sweep hands the new account the old Space's id, the invite it then sends comes back refused (a non-member can't invite), and the sweep logs it and carries on — degraded, not crashed. Two ways out, both operator-chosen. Either invite @hive-<hive>: into the existing Space and chat room from a client, which keeps the history; or delete the hive's stored room-id files, after which the next sweep creates a Space and chat room owned by the new account and invites every agent into them. Do one of the two; leaving it gives a hive that provisions no rooms.
Upgrading a hive that used the registration token

Nothing to do, and nothing to time. The activation script mints the appservice token and renders the registration before the homeserver restarts, so the first boot after the switch already has both halves.

  • Existing accounts keep working. An access token lives on the device that minted it; removing the registration token touches no device, no account and no session. login_with_password stays on, so the password fallback is still there too.
  • The per-agent sweep honours existing token files. It skips any agent that already has a matrix-token, so it re-registers no account and displaces no session.
  • The sender account may already be an admin on such a hive (it won the first-user grant when the hive was new). Nothing here demotes it; the homeserver no longer promotes it, so a hive built fresh has an ordinary account and an older one keeps whatever standing it acquired.
  • /var/lib/hyperhive/matrix-register-token stays on disk, read by nothing. Delete it or leave it; neither does any harm.
  • registrationTokenFile is a removed option. A config that still sets it fails to evaluate with a message naming the appservice — a hive that never set it (the default) is unaffected.
  • A swarm store holding the old matrix/registration-token path is no longer read at all. The value that matters now lives at matrix/appservice-token, and swarm-secret-publish on the authelia host mints and puts it there — the hive uses its locally minted token only until the first successful read. See ../swarm/secrets.md for how that mint stays idempotent across runs.

Initial rollout settings:

  • allow_federation = true at the protocol level so swarms can be wired up later by extending trustedServers without a homeserver restart. trusted_servers = [] keeps it effectively closed until you list peers.
  • allow_registration = false. tuwunel checks this flag only for requests that arrive without an appservice token, so hive-c0re provisions exactly as before and tuwunel refuses everyone else. It's not a hardening afterthought: with no registration token configured, allow_registration = true makes tuwunel refuse to start unless yes_i_am_very_very_sure_…_open_registration_… is also set.
  • allow_encryption — server-side E2EE switch, sourced from services.hyperhive.swarm.matrix.allowEncryption (default false, opt-in). Off by default because on the hive-internal homeserver the operator already controls the transport; turn it on for encrypted rooms on external / federated homeservers or to keep contents opaque to the homeserver admin. The agent matrix client always supports decryption regardless of this flag — it uses the e2e-encryption feature of matrix-sdk so it can read encrypted rooms it's invited to even when this homeserver doesn't permit room encryption. matrix-sdk stores crypto keys in the per-agent sqlite store under the state dir; they persist across restarts (lost on --purge). read_room decrypts via room.messages() — UTD events surface as event_type = "m.room.encrypted" with body = "[unable to decrypt]". Cross-signing and automatic key backup aren't enabled for the first pass: static bearer-token bot accounts can't bootstrap cross-signing without MSC3967.

Hive Matrix Space

On first boot, after hive-c0re provisions all agent accounts, it creates a private Matrix Space named "hive" using its own hive account (@hive-<hive>:<server_name>) and invites every provisioned agent into it. This gives the operator a single Space in FluffyChat or any Matrix client that groups all agent-to-agent + operator rooms in one place.

The sweep also provisions a default hive-chat room as an m.space.child of the Space. Joining a Space doesn't autojoin child rooms — the explicit room entry ensures the operator and every agent can find a common chat room without manual setup. Room join is restricted (any Space member including the operator can join; agents are explicitly invited). Room version pinned to 10 for the restricted join floor.

State: hive-c0re persists both room IDs to /var/lib/hyperhive/matrix/ (mode 0600, owned by the hive-c0re service user):

  • space-room-id — the Space itself
  • chat-room-id — the hive-chat room

These paths are outside every agent state dir and aren't deleted by nixos-container destroy --purge — both survive full agent purges, and hive-c0re reuses them on re-provision.

Idempotent: if the files exist and are non-empty, hive-c0re considers the Space and room already created. Delete the files to force re-creation (for example after a homeserver wipe).

Configuration tuning

services.hyperhive.swarm.matrix = {
  trustedServers  = [ "matrix.org" "example.com" ];   # default: []
  maxRequestSize  = 20000000;                          # default: 20 MB
};

trustedServers (default []) — list of peer homeserver names whose signing keys tuwunel fetches and trusts. tuwunel enables federation at the protocol level from first boot (allow_federation = true) but trusts no remote homeserver until you list it here. For a closed single-hive deployment the default empty list is correct — add peer hive domains here when connecting hives into a swarm (see docs/swarm/).

maxRequestSize (default 20_000_000 bytes = 20 MB) — maximum size of a single matrix client request body. Matches the matrix-spec recommendation for media uploads and the upstream tuwunel default. Raise for deployments that need large file transfers; lower for resource-constrained hosts where a 20 MB request is unexpectedly large.

Assertion rationale

config.assertions in this module fail eval early rather than ship surprising behaviour:

  • services.hyperhive.swarm.matrix.gatewayHost != "" — same footgun as forge.domain: empty string renders .<hive>-shaped garbage in both nginx server_name (treated as wildcard catch-all, surprising) and /etc/hosts (invalid entry). null is the right opt-out shape; a config assertion rejects empty string explicitly. SSO is unconditional, so the three below are requirements of running a homeserver at all rather than of a setting:

  • Set sso.clientSecretFile — fails at eval, not at boot: tuwunel reads its identity providers from the config file, so a half-configured one can stop the homeserver from starting outright rather than merely hiding a login button. On a host that also runs the swarm's authelia it's wired up for you.

  • Set swarm.authelia.url — without a provider URL there is nothing to discover against.

  • Set gatewayHost != null — the SSO callback URL is format-locked to <homeserver>/_matrix/client/unstable/login/sso/callback/<client_id>, and the identity provider needs a public name to redirect the browser to.

server_name's own bogus-value guard lives in hive-network.nix (services.hyperhive.domain != null), not here — see docs/networking/network.md.

fluffychat-web build fixes

pkgs.fluffychat-web ships from flutter341.buildFlutterApplication, which has two upstream gaps for fluffychat's web target:

  • The dart web-worker entry point (web/native_executor.dart) isn't compiled — buildFlutterApplication only runs flutter build web on the main entry.
  • native_imaging's C source isn't built — emscripten isn't a flutter-builder native build input.

Both fixed in nix/host-modules/hive-matrix.nix via two derivations:

  • fluffychat-web-imaging builds Imaging.{js,wasm} from the native_imaging C source via pkgs.emscripten. Source comes from pkgs.fluffychat-web.passthru.pubspecLock.dependencySources.native_imaging — already in the build closure of the flutter app, so no parallel hash pin and version autosyncs with nixpkgs bumps. Build closure is ~3.6 GiB (emscripten LLVM); runtime closure is just the two output files. dontConfigure = true because cmake runs inside js/Makefile via emcmake cmake, not at the package root. The build script needs HOME + EM_CACHE writable for emscripten's on-demand sysroot build (libc, libc++ → wasm).
  • fluffychat-web-fixed is pkgs.fluffychat-web plus a postInstall patch that (a) compiles web/native_executor.dart via dart compile js (dart from the flutter341 closure, no incremental cost) and (b) installs fluffychat-web-imaging's outputs into $out.

Two subtle details worth knowing before touching either derivation:

  • make -C js instead of cd js; make — keeps the build-phase pwd at the source root so installPhase doesn't have to know about the cd. Robust against future reorders / dontBuild.
  • web/native_executor.dart as a build-CWD-relative path, not $src/web/...dart's package_config.json walk-up needs to hit buildFlutterApplication's pub-get output (.dart_tool/ in the build CWD). Walking up from a read-only $src/ store path finds no .dart_tool/ and errors with "Couldn't resolve the package 'matrix'."

Drop both derivations when nixpkgs's flutter builder grows worker

  • emcc support upstream.

Mount point is chat.<swarm-domain>/ (the gatewayHost vhost); upstream --base-href "/" is correct at sub-domain root, no override.