# hive-matrix Private Matrix homeserver (matrix-tuwunel — the conduwuit successor) wrapped in a nixos-container, plus optional fluffychat-web client at `chat./` (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`, ports, `allowEncryption`. - `services.hyperhive.deploy.matrix.*` — what the host running it decides: `enable`, `gui.enable`, `openFirewall`, `trustedServers`, `maxRequestSize`, `sso.clientSecretFile`. Vhost routing lives in [`gateway.md`](../networking/gateway.md). ## Container shape Same shape as [`gateway.md::hive-forge container shape`](../networking/gateway.md): - 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.` 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:` and `!room:` 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`. The gateway serves the `.well-known/matrix/{client,server}` discovery routes on the matrix host's **hive** domain, not the swarm domain ([Discovery flow](../networking/gateway.md#discovery-flow-matrix)). - **`gatewayHost`** — the API listener hostname, where the gateway's matrix vhost proxies `/_matrix/*` to tuwunel. Defaults to `chat.`. 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.
Pinning serverName on a homeserver with existing ids `serverName` defaults to the bare `services.hyperhive.swarm.domain`. A homeserver must set `serverName` to the value that minted its existing ids — see above for why a different value strands existing users and rooms: ```nix 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.
## Default-closed firewall `deploy.matrix.openFirewall` defaults to `false`: the host reaches the homeserver on loopback, and agent containers reach it at `chat.` 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. **Appservices** create 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. **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`). The script 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** doesn't read the appservice token and creates no account. Its `@hive-:` token comes from the swarm store (below). 6. **Agents' accounts aren't this hive's.** `swarm-controller` creates each one with the swarm's own appservice token (next section), stores its token at `swarm/agents//matrix/main`, and the agent's `hive-matrix-daemon` reads it from there as the agent itself. ### The swarm's appservice, and its admin sender A second registration sits beside the hive's: id `swarm`, sender `@swarm`, the same non-exclusive namespace. It's the swarm's identity on the homeserver, and `swarm-controller` creates every agent's account with it. tuwunel loads every `.yaml` in `appservice_dir` and refuses only a duplicate `id` or `as_token`, so the two overlapping namespaces coexist. `swarm-matrix-ctl appservice render` mints its tokens inside the `hive-matrix` container, before tuwunel starts, and renders the registration tuwunel loads as a second 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-`, 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. ### The appservice's sender account, and why it isn't an admin `@hive-:` 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; 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//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//*`), 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. Promoting a user to homeserver admin and resetting a password both need an admin **sender**: `!admin …` messages into `#admins:`, and tuwunel only treats a message as a command when its sender is already an admin. Only `@swarm` is a homeserver admin, so both are swarm-level operations.
Store precedence for a hive's sender account `ensure_hive_user` reads the per-hive path `swarm/hives//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 The activation script mints the appservice token and renders the registration before the homeserver restarts, 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 `matrix/appservice-token`.** `swarm-secret-publish` on the authelia host mints and `put`s it there; the hive uses its locally minted token only until the first successful read. See [`../swarm/secrets.md`](../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 the appservices still create 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 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 hive-c0re creates a private **Matrix Space** named `"hive"` using its own hive account (`@hive-:`) 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 ```nix 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/`](../swarm/README.md)). **`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 `.`-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 `deploy.matrix.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 `swarm.matrix.gatewayHost != null`** — the SSO callback URL is format-locked to `/_matrix/client/unstable/login/sso/callback/`, 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`](../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./` (the `gatewayHost` vhost); upstream `--base-href "/"` is correct at sub-domain root, no override.