22 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(noth-*) so c0re's lifecycle scanner ignores it; operator manages via the standardnixos-containerCLI. - 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 atchat.<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-specserver_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 bareservices.hyperhive.swarm.domain.gatewayHost— the API listener hostname, where the gateway's matrix vhost proxies/_matrix/*to tuwunel. Defaults tochat.<services.hyperhive.swarm.domain>. Set tonullto 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.
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.
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.
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).
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.
swarm-matrix-ctl appservice render, inside thehive-matrixcontainer and before tuwunel starts, renders the registration to/var/lib/swarm-matrix-appservice/swarm.yaml(mode0600 root:root) from theas_tokenand spec-requiredhs_tokenbeside 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.- systemd
LoadCredential=copies the registration into/run/credentials/tuwunel.service/swarm-appservice.yaml, owned by tuwunel's dynamic user with mode0400, at service start. The source file staysroot:root 0600— nochown :tuwunel/chmod 0640/ GID-pin gymnastics required. KeepsDynamicUser = true+PrivateUsers = trueintact. - tuwunel's
appservice_dirpoints at the credentials directory. It reads only.yaml/.ymlentries from there, so the sibling credentials are invisible to it. The.yamlsuffix on the credential id is what makes this work. - hive-c0re holds no appservice token, creates no account and does not talk to matrix.
swarm-controllercreates every account: each agent's, with its token stored atswarm/agents/<agent>/matrix/mainfor the agent'shive-matrix-daemonto 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
adminsrevokes 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 inadminsbut 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 inservices.hyperhive.swarm.matrix.adminSync.ignoreUsersare never revoked. - The last admin stays. A revoke that would leave no admin in
#adminsbesides@swarmand 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
#adminsafter the controller sends its grant gets amake-user-admin … failed to takewarning in the controller log on each later pass, and no second command until the controller restarts; a revoke that leaves its account in#adminsgetsforce-leave-room … failed to takethe same way. - Runs only on a controller with both
services.hyperhive.deploy.swarm-controller.authBridgeUrlandservices.hyperhive.deploy.swarm-controller.matrixHomeserverUrlset.
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), so
both are swarm-level operations.
Appservice identity
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.
- Access tokens live on the device that minted them.
login_with_passwordstays on, so the password fallback works too. - The homeserver's
admin_executepromotes only@swarmat boot (above); a hive's sender account is never promoted. - The value that matters lives at
swarm/controller/swarm-controller/matrix/appservice-token.appservice syncwrites it there whenever the store's differs from the registration'sas_token, andswarm-controllerreads it from there.
Initial rollout settings:
allow_federation = trueat the protocol level so swarms can be wired up later by extendingtrustedServerswithout 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 = truemakes tuwunel refuse to start unlessyes_i_am_very_very_sure_…_open_registration_…is also set.allow_encryption— server-side E2EE switch, sourced fromservices.hyperhive.swarm.matrix.allowEncryption(defaultfalse, 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 thee2e-encryptionfeature ofmatrix-sdkso 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_roomdecrypts viaroom.messages()— UTD events surface asevent_type = "m.room.encrypted"withbody = "[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-controllermints a random recovery passphrase for every matrix account an agent has, atswarm/agents/<agent>/matrix-recovery/<account>in the swarm secret store, and never replaces it. Whenhive-matrix-daemonbrings 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 linematrix recovery enabled), unless the account already has secret storage, which it never replaces (aWARNinstead). 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--purgeor 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 aWARNand 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
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 asforge.domain: empty string renders.<hive>-shaped garbage in both nginxserver_name(treated as wildcard catch-all, surprising) and/etc/hosts(malformed entry).nullis 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.clientSecretFileset — it defaults to/run/hive-matrix-oidc/<client id>.secretwherever the homeserver runs, andswarm-bao-matrix-oidcwrites 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.
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 —buildFlutterApplicationonly runsflutter build webon 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-imagingbuildsImaging.{js,wasm}from thenative_imagingC source viapkgs.emscripten. Source comes frompkgs.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 = truebecause cmake runs insidejs/Makefileviaemcmake cmake, not at the package root. The build script needsHOME+EM_CACHEwritable for emscripten's on-demand sysroot build (libc, libc++ → wasm).fluffychat-web-fixedispkgs.fluffychat-webplus apostInstallpatch that (a) compilesweb/native_executor.dartviadart compile js(dart from the flutter341 closure, no incremental cost) and (b) installsfluffychat-web-imaging's outputs into$out.
Two subtle details worth knowing before touching either derivation:
make -C jsinstead ofcd js; make— keeps the build-phase pwd at the source root soinstallPhasedoesn't have to know about the cd. Robust against future reorders /dontBuild.web/native_executor.dartas a build-CWD-relative path, not$src/web/...—dart'spackage_config.jsonwalk-up needs to hitbuildFlutterApplication'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.