hive-matrix
Private Matrix homeserver (matrix-tuwunel — the conduwuit
successor) wrapped in a nixos-container, plus optional fluffychat-web
client at matrix.<hive>/. Configured via
services.hyperhive.matrix.*; 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) so agents reach tuwunel athttp://localhost:<httpPort>without extra plumbing — the nixos-container is here for state + systemd-unit isolation, not network isolation. - 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. Cannot be changed later without abandoning every account and chat history. Defaults to the bareservices.hyperhive.domain; clients auto-discover the actual API endpoint via the.well-known/matrix/{client,server}routes the hive-gateway serves at that domain.gatewayHost— the API listener hostname, where the gateway's matrix vhost proxies/_matrix/*to tuwunel. Defaults tomatrix.<services.hyperhive.domain>(sub-domain shape). Set tonullto skip the gateway vhost (tuwunel stays direct onhttpPort).
Breaking change: serverName used to default to
matrix.${services.hyperhive.domain}. Existing homeservers must set
the option explicitly to preserve their existing user / room IDs
before rebuilding. The default flipped because the bare hive-domain
makes for cleaner matrix IDs and .well-known delegation hides the
sub-domain from the user-facing identifier.
Default-closed firewall
openFirewall defaults to false (secure-by-default): the
homeserver is reachable from the host + every agent container via
loopback either way (shared netns), 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.
Breaking change: used to default to true. Operators relying on
external reach must add
services.hyperhive.matrix.openFirewall = true; before rebuilding.
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 (registration token)
Token-gated registration: hive-c0re holds the token, agents never
see it. The agent only receives the resulting access_token.
- System activation writes a 32-byte random hex token (64
chars) to
cfg.registrationTokenFile(/var/lib/hyperhive/matrix-register-tokenby default), mode0600 root:root, before any container start. Idempotent — only writes when the file is missing or empty; always re-applies 0600 (normalises any 0640 / world-readable carry-over from pre-LoadCredential deployments). This runs at activation time (not first container start) to dodge a race where nspawn creates an empty file when the bind-mount target is missing and tuwunel readsregistration_token_file="", rejecting every registration until next restart. - Read-only bind-mount maps the host file into the tuwunel container at the same path.
- systemd
LoadCredential=inside the container copies the bind-mounted file into/run/credentials/tuwunel.service/registration_token, 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
registration_token_filepoints at the credentials path, not the original bind-mount path. - hive-c0re uses the token to register each agent account via
the matrix-spec UIAA registration flow, persists the returned
access_tokento<agent-state>/matrix-token. The agent's matrix MCP client authenticates with that access_token and never touches the shared registration token. - hive-c0re restarts
hive-matrix-daemonfor the agent immediately after writing the token so the daemon picks up the new credential without waiting for a full container restart. If the restart fails (e.g. daemon not yet running on first boot) the error is logged as a warning and the.path-trigger sibling (hive-matrix-daemon.pathwatching formatrix-tokenappearance) brings the daemon up on the same boot cycle anyway.
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 peers are listed.allow_registration = true(required for the token flow to engage). The absentyes_i_am_very_very_sure_…_open_registration_…flag keeps the server closed to anyone without the token.allow_encryption = falseper operator call; E2EE re-enabling is deferred to a follow-up.
Hive Matrix Space
On first boot (after all agent accounts are provisioned), hive-c0re
creates a private Matrix Space named "hive" using the admin
account (@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 auto-join
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: both room IDs are persisted 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 are NOT deleted by
nixos-container destroy --purge — both survive full agent purges and
are reused on re-provision.
Idempotent: if the files exist and are non-empty, the Space and room are considered already created. Delete the files to force re-creation (e.g. after a homeserver wipe).
Configuration tuning
services.hyperhive.matrix = {
trustedServers = [ "matrix.org" "example.com" ]; # default: []
maxRequestSize = 20000000; # default: 20 MB
};
trustedServers (default []) — list of peer homeserver names
whose signing keys tuwunel will fetch and trust. Federation is enabled
at the protocol level from first boot (allow_federation = true) but
no remote homeserver is trusted until listed 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.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
Two config.assertions entries fail eval early rather than ship
surprising behaviour:
hyperhiveDomain != null || cfg.serverName != null—server_nameis embedded into every user / room ID irrevocably; we refuse to spawn the homeserver with a bogusserver_namewe can never change later.cfg.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; empty string is rejected explicitly.
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/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 auto-syncs 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 non-obvious fixes from review history:
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 matrix.<hive>/; upstream --base-href "/" is
correct at sub-domain root, no override.