Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/docs/integrations/matrix.md
atlas 25d282427f hive-matrix: keep tuwunel's OIDC secret on the host's /run, not in the container's root
swarm-bao-matrix-oidc still reads the secret from bao under matrix-ctl's
leaf, but now writes it to /run/hive-matrix-oidc on the host (tmpfs), which
the container bind-mounts read-only for tuwunel's LoadCredential. Before, it
wrote it to
/var/lib/nixos-containers/hive-matrix/var/lib/tuwunel-oidc/<id>.secret on
persistent disk, where a superseded secret stayed after rotation and went
into backups of the container root. Host tmpfiles now removes that old file.

clientSecretFile must now lie under /run/hive-matrix-oidc/, the one directory
the container shares; any other value fails at evaluation.

Rotation is unchanged: the ten-minute timer rewrites the file when the value
differs and refresh_consumer restarts tuwunel. After a reboot the secret is
fetched again; until the store answers, tuwunel does not start.

Part of #4998 (slice 4: tuwunel).
2026-10-15 23:31:32 +02:00

23 KiB

Swarm Matrix homeserver

Private Matrix homeserver (matrix-tuwunel — the conduwuit successor) wrapped in a nixos-container (hive-matrix), plus optional fluffychat-web client at chat.<swarm-domain>/ (the gatewayHost vhost). A swarm runs one homeserver, on one host. Two namespaces configure it:

  • services.hyperhive.swarm.matrix.* — what the homeserver is, as every hive sees it: serverName, gatewayHost, apiUrl, allowEncryption.
  • services.hyperhive.deploy.matrix.* — what the host running it decides: enable, gui.package, trustedServers, maxRequestSize, sso.clientSecretFile.

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 runs in a network namespace of its own (privateNetwork = true) on the hive bridge, and binds no TCP port. Agents reach the homeserver at chat.<swarm-domain> via the gateway, which dials its unix socket (see Reach).
  • 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.
  • 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, which leaves nothing able to reach the homeserver.

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.

Pinning serverName on a homeserver with existing ids

serverName defaults to the bare services.hyperhive.swarm.domain. A homeserver that changes serverName after minting ids strands its existing users and rooms — keep the value it minted them under:

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. It never fails the rebuild, so pin the value before the homeserver mints any ids under the default.

Reach

tuwunel binds no TCP port. It listens on the unix socket /run/hive-matrix/tuwunel.sock, in a host directory (root:nginx, 0770) bind-mounted into the container, and the gateway's /_matrix/ location proxies to that socket. Clients, agents, hive-c0re and federation peers all reach the homeserver at chat.<swarm-domain> through the gateway; from outside the host that takes gateway.openFirewall.

hive-c0re provisions matrix (its own sender login, the hive Space and chat room, invites) through swarm.matrix.apiUrl, which defaults to https://<swarm.matrix.gatewayHost> on every hive. That login carries the hive's matrix access token, and on a hive that doesn't run the homeserver it crosses the network, so the default is TLS; hive-c0re verifies the gateway's certificate against the hive trust bundle.

Federation peers find the homeserver through .well-known/matrix/server on the hive domain, which names <gatewayHost>:<httpsPort> (gateway.md::Discovery flow). Port 8448 isn't used.

Outbound, the container sits on the hive bridge like the CI runner: it leases an address from the gateway's DHCP, resolves through its dnsmasq, and leaves through the bridge's NAT. It reaches the secret store, authelia and federation peers by name.

Provisioning flow (appservice)

Registration is closed. The swarm's appservice creates accounts: agents never see an appservice token, 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. swarm-matrix-ctl appservice render, inside the hive-matrix container and before tuwunel starts, mints the registration's as_token and its spec-required hs_token when they're missing, and renders the registration to /var/lib/swarm-matrix-appservice/swarm.yaml (mode 0600 root:root).
  2. systemd LoadCredential= copies the registration into /run/credentials/tuwunel.service/swarm-appservice.yaml, owned by tuwunel's dynamic user with mode 0400, at service start. The source file stays root:root 0600 — no chown :tuwunel / chmod 0640 / GID-pin gymnastics required. Keeps DynamicUser = true + PrivateUsers = true intact.
  3. tuwunel's appservice_dir points at the credentials directory. 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.
  4. hive-c0re holds no appservice token and creates no account. Its @hive-<hive>: token comes from the swarm store (below).
  5. swarm-controller creates every account: each agent's, with its token stored at swarm/agents/<agent>/matrix/main for the agent's hive-matrix-daemon to read as the agent itself, and each hive's sender account (below).

A homeserver whose swarm-matrix-ctl has no store identity (services.hyperhive.deploy.matrix.ctlBaoClientCertFile) loads no registration, and nothing creates accounts on it.

On the store's host ctlBaoClientCertFile / ctlBaoClientKeyFile default to the leaf glue-bao-tls.nix mints there. A homeserver host without the store gets no default, even when it holds the hive's own reader deploy.bao.clientCertFile: issue a leaf whose subject is deploy.bao.matrixCtlCommonName, place it, and name it in both options. Until you do, swarm-bao-matrix-oidc fails naming both.

The swarm's appservice, and its admin sender

The registration's id is swarm, its sender @swarm, and its user namespace non-exclusive. It's the swarm's identity on the homeserver: swarm-controller creates every agent's account and every hive's sender account with it.

swarm-matrix-ctl appservice render mints its tokens inside the hive-matrix container, before tuwunel starts, and renders the registration tuwunel loads as a credential. appservice publish then writes its as_token to swarm/controller/swarm-controller/matrix/appservice-token. No hive's policy reaches that path: only matrix-ctl (which writes it) and swarm-controller (which reads it) hold a grant to it.

@swarm is the one account the homeserver promotes to admin at boot (admin_execute). Only those two principals read its token, which is the difference from the hive sender below: every hive reads its own sender's token. swarm is a reserved name, so nothing can create an agent as this account.

swarm-controller mints each agent's account with the device id hyperhive-<agent>, and a login on that device replaces its token. The controller therefore reads the stored token back with whoami every five minutes and re-mints only when it's missing, unknown to the homeserver, or someone else's. An agent's daemon picks a re-minted token up on its next start, and a timer restarts it while it's down.

Operators are homeserver admins

Every member of authelia's admins group who has a matrix account is a homeserver admin. swarm-controller reads the group through swarm-authelia-bridge at start and every five minutes after, and for each member not yet in #admins:<server_name> it sends !admin users make-user-admin @<name>:<server_name> there as @swarm. <name> is the authelia username lowercased, the account an SSO login lands on.

  • Leaving admins revokes what the sync granted. The controller records each account it sends a grant for in /var/lib/swarm-controller/operator-matrix-admins.json. When one of them is no longer in admins but still in #admins, it sends !admin users force-leave-room @<name>:<server_name> <admin room id> there as @swarm; leaving that room is what ends homeserver admin.
  • Admins the sync didn't make keep admin. An admin missing from that file, @swarm, the server user @conduit:<server_name>, every agent's account, and every user id in services.hyperhive.swarm.matrix.adminSync.ignoreUsers are never revoked.
  • The last admin stays. A revoke that would leave no admin in #admins besides @swarm and the server user isn't sent; the controller logs a warning on each pass instead.
  • No account, no grant. A member who has never logged in to matrix has no account to promote. The first pass after their first SSO login promotes them.
  • One command per account per controller run. A member still missing from #admins after the controller sends its grant gets a make-user-admin … failed to take warning in the controller log on each later pass, and no second command until the controller restarts; a revoke that leaves its account in #admins gets force-leave-room … failed to take the same way.
  • Runs only on a controller with both services.hyperhive.deploy.swarm-controller.authBridgeUrl and services.hyperhive.deploy.swarm-controller.matrixHomeserverUrl set.

A hive's sender account, and why it isn't an admin

@hive-<hive>:<server_name> is the account a hive acts as. swarm-controller creates it with the swarm's appservice token, for every hive in its directory. It's an ordinary account: nothing promotes it; the homeserver's admin_execute promotes only @swarm.

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-controller, its only minter, mints it for every hive with the swarm's appservice token 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 swarm's appservice creates — 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.

Promoting a user to homeserver admin and resetting a password both need an admin sender: !admin … messages into #admins:<server_name>, and tuwunel only treats a message as a command when its sender is already an admin. Of the swarm's own accounts only @swarm is a homeserver admin (operators are too, see Operators are homeserver admins), so both are swarm-level operations.

Store precedence for a hive's sender account

ensure_hive_user reads the per-hive path swarm/hives/<hive>/matrix/sender-token first, on every sweep, not just when that file is missing (sender_source's decision). swarm-controller mints a per-hive token within five minutes of a hive appearing; the sweep then overwrites the per-hive file, no boot required.

Appservice identity

swarm-matrix-ctl appservice render mints the appservice tokens and renders the registration before tuwunel starts, so every boot has both halves.

  • Access tokens live on the device that minted them. login_with_password stays on, so the password fallback works too.
  • The homeserver's admin_execute promotes only @swarm at boot (above); a hive's sender account is never promoted.
  • The value that matters lives at swarm/controller/swarm-controller/matrix/appservice-token. appservice publish writes the container's copy there whenever the store's differs, and swarm-controller reads it from there.

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 the swarm's appservice still creates accounts and tuwunel refuses everyone else. 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 swarm's own 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. read_room decrypts via room.messages() — UTD events surface as event_type = "m.room.encrypted" with body = "[unable to decrypt]". The daemon bootstraps cross-signing on the bearer token alone, which works where the homeserver skips re-auth for a first key upload (MSC3967, as tuwunel does).
  • Key backup. swarm-controller mints a random recovery passphrase for every matrix account an agent has, at swarm/agents/<agent>/matrix-recovery/<account> in the swarm secret store, and never replaces it. When hive-matrix-daemon brings an account up it reads that passphrase: with no backup on the account's homeserver it turns on server-side key backup and secret storage (log line matrix recovery enabled), unless the account already has secret storage, which it never replaces (a WARN instead). With a backup there it recovers from it (recovered from the server-side key backup), which downloads the room keys and signs the new device with the account's existing cross-signing identity. A crypto store lost to --purge or to a primary-token reset therefore comes back with its history. With no passphrase stored yet the account comes up without backup and picks it up on the next daemon start. A failure is a WARN and never stops the daemon.

Hive Matrix Space

On first boot hive-c0re 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.deploy.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:

  • Keep deploy.matrix.sso.clientSecretFile set — it defaults to /run/hive-matrix-oidc/<client id>.secret wherever the homeserver runs, and swarm-bao-matrix-oidc writes the secret there from the swarm secret store under matrix-ctl's leaf. The path is on the host's tmpfs, so every reboot fetches the secret again and it never reaches persistent disk. A path outside /run/hive-matrix-oidc/, or a null, 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.

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

  • Set swarm.matrix.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.