matrix.md no longer documents the .well-known/discovery domain mismatch as a fact to work around (that's #4878's fix to make); same fix applied to gateway.md's Discovery flow section, which stated the identical bug and told the operator how to route around it. The serverName-pinning note no longer says the module requires pinning it (nothing enforces that) — it states the consequence of not pinning it instead. Refs #3902
19 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). 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.
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 shares the host network namespace
(
privateNetwork = false) for state + systemd-unit isolation. Agents reach the homeserver atchat.<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-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 (tuwunel stays direct onhttpPort).
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.
Default-closed firewall
deploy.matrix.openFirewall defaults to false: 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. 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.
- System activation mints a 32-byte random hex appservice token (64
chars) at
/var/lib/hyperhive/matrix-appservice-tokenand its spec-requiredhs_tokensibling, mode0600 root:root, then renders the registration to/var/lib/hyperhive/matrix-appservice/hyperhive.yaml(also0600). 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. - Read-only bind-mount maps that directory into the tuwunel container at the same path.
- systemd
LoadCredential=inside the container copies the registration into/run/credentials/tuwunel.service/hyperhive-appservice.yaml, owned by tuwunel's dynamic user with mode0400, at service start. The host file staysroot:root 0600— nochown :tuwunel/chmod 0640/ GID-pin gymnastics required. KeepsDynamicUser = true+PrivateUsers = trueintact. - tuwunel's
appservice_dirpoints at the credentials directory, not at the bind-mount path. 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 doesn't read the appservice token and creates no
account. Its
@hive-<hive>:token comes from the swarm store (below). - Agents' accounts aren't this hive's.
swarm-controllercreates each one with the swarm's own appservice token (next section), stores its token atswarm/agents/<agent>/matrix/main, and the agent'shive-matrix-daemonreads 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-<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.
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; 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 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:<server_name>, 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/<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
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_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
matrix/appservice-token.swarm-secret-publishon the authelia host mints andputs it there; the hive uses its locally minted token only until the first successful read. See../swarm/secrets.mdfor how that mint stays idempotent across runs.
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 appservices still create 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 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 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 (lost on--purge).read_roomdecrypts viaroom.messages()— UTD events surface asevent_type = "m.room.encrypted"withbody = "[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-<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 itselfchat-room-id— thehive-chatroom
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 asforge.domain: empty string renders.<hive>-shaped garbage in both nginxserver_name(treated as wildcard catch-all, surprising) and/etc/hosts(invalid 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: -
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<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.