Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/docs/integrations/matrix.md

438 lines
22 KiB
Markdown

# 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`](../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 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](#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 controller's host serves that `.well-known` on the swarm
domain, and a homeserver pinned to a hive domain gets it on that hive's
vhost. 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.
<details><summary>Pinning <code>serverName</code> on a homeserver with existing ids</summary>
`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:
```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.
A pinned hive domain answers `.well-known/matrix/*` itself, built from
`gatewayHost`, on any host that runs that hive's gateway. It doesn't need
the homeserver on the same host.
</details>
## 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 does not talk to the homeserver: matrix is swarm-level, and
the hive has no matrix API URL to set.
Federation peers find the homeserver through `.well-known/matrix/server`
at the server name, which names `<gatewayHost>:443`
([`gateway.md::Discovery flow`](../networking/gateway.md#discovery-flow-matrix)).
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)
<!-- vale write-good.Passive = NO -->
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`.
<!-- vale write-good.Passive = YES -->
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, renders the registration to
`/var/lib/swarm-matrix-appservice/swarm.yaml` (mode `0600 root:root`)
from the `as_token` and spec-required `hs_token` beside it. It mints
them only on a container that has none, and never reaches the store, so
a store outage can't keep the homeserver down.
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, creates no account and does
not talk to matrix.
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.
The swarm secret store holds the registration's tokens, at
`swarm/controller/swarm-controller/matrix/appservice-registration`.
`swarm-matrix-ctl appservice render` renders the registration tuwunel
loads as a credential from the copy of them in the `hive-matrix`
container, before tuwunel starts and without the store.
`appservice sync` runs beside tuwunel on a timer. The store's tokens win:
when they differ from the container's, it writes them there. It
restarts tuwunel whenever the registration is newer than tuwunel's last
start, so the next run retries one that failed after writing. A store that holds none takes the container's
tokens with a create-only write, so a running swarm keeps its value, and
a container that has none mints a pair. `sync` then writes the `as_token`
to `swarm/controller/swarm-controller/matrix/appservice-token`. No hive's
policy reaches either path: only matrix-ctl (which holds `create` and
`read` on the registration, and writes the token) and `swarm-controller`
(which reads only the token) hold a grant.
`@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**. `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`, inside the hive's own
read grant (`swarm/hives/<hive>/*`). Nothing on the hive uses it any more:
hive-c0re used to present it for its Space and invite calls and no longer
makes any. The name says what it authenticates as — an account the
swarm's appservice creates — rather than any privilege level, because it
carries 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](#operators-are-homeserver-admins)), so
both are swarm-level operations.
<details><summary>Appservice identity</summary>
`swarm-matrix-ctl appservice render` renders the registration from the
container's copy of the appservice tokens before tuwunel starts, so every
boot has both halves, with the store up or down.
<!-- vale write-good.Passive = NO -->
- **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 sync` writes it there whenever the store's differs from the
registration's `as_token`, and `swarm-controller` reads it from there.
<!-- vale write-good.Passive = YES -->
</details>
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.
## Retired hive-side matrix code
Earlier versions of hive-c0re created a private "hive" Matrix Space and a
`hive-chat` room, invited every agent to both, kept the hive's sender token
on disk and offered `hivectl matrix invite`. All of that is gone: matrix is
swarm-level, so the hive no longer talks to the homeserver. The Space, the
room and the files `/var/lib/hyperhive/matrix/{access-token,space-room-id,chat-room-id}`
are harmless leftovers, so delete them by hand if you like.
## 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 `.<hive>`-shaped garbage in both nginx
`server_name` (treated as wildcard catch-all, surprising) and
`/etc/hosts` (malformed 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. After
a reboot the homeserver doesn't start until the store answers; the
store-retry timer then starts it with no manual step. 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`](../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.