hyperhive/nix/modules/hive-matrix.nix
atlas 8d25b087b6 nix/hive-matrix: trim option-description prose to docs/gateway.md refs (argus #775 🔴)
argus on #775 v3: "the `gatewayHost` option description's
server_name-vs-gatewayHost essay + federation SRV note are also
candidates for [docs/gateway.md] section."

Cuts the gatewayHost option's description from ~40 lines (with
inline duplication of the discovery flow, when-to-set-which, and
federation port caveat) down to ~8 lines pointing at
`docs/gateway.md`. The brief `server_name vs gatewayHost` clarifier
stays in code because it disambiguates two SIMILAR-LOOKING options
on the same module — operators reading option docs need the
distinction inline, not behind a doc link.

Also trimmed `matrix.gui.enable` + `matrix.gui.package` descriptions
to similar shapes — point at docs/gateway.md for the architecture,
keep the override-shape hints in code.

Push includes the rebase onto current main (#764 + 0af6ea1 + others
landed since #775 was opened; cherry-picked commits get skipped
cleanly).

Net: matrix.nix loses ~70 lines of inline prose. No behavioral
change (verified gatewayHost still resolves to `matrix.<hive>`).
2026-05-31 14:29:40 +02:00

471 lines
21 KiB
Nix

{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.hyperhive.matrix;
hyperhiveDomain = config.services.hyperhive.domain;
effectiveServerName = if cfg.serverName != null then cfg.serverName else hyperhiveDomain;
# Three files are missing from `pkgs.fluffychat-web` because
# `flutter341.buildFlutterApplication` doesn't run the dart
# web-worker compile pass + doesn't run the native_imaging emscripten
# build (#685). `fluffychat-web-imaging` below builds the latter from
# source via `pkgs.emscripten`; the worker compile is inline in
# `fluffychat-web-fixed.postInstall`. Drop both when nixpkgs's
# flutter builder grows worker + emcc support upstream.
# `Imaging.{js,wasm}` built from `native_imaging`'s C source via
# emscripten. Source comes from
# `pkgs.fluffychat-web.passthru.pubspecLock.dependencySources` so
# there's no parallel hash pin — version auto-syncs with nixpkgs
# bumps. Build closure +~3.6 GiB (emscripten LLVM); runtime closure
# is just the two output files.
fluffychat-web-imaging = pkgs.stdenv.mkDerivation {
pname = "fluffychat-web-imaging";
version = pkgs.fluffychat-web.passthru.pubspecLock.dependencyVersions.native_imaging;
# The pub-cache derivation that fluffychat-web's flutter build uses.
# Already in the build closure; no `fetchurl` or own hash pin.
src = pkgs.fluffychat-web.passthru.pubspecLock.dependencySources.native_imaging;
nativeBuildInputs = with pkgs; [
emscripten
cmake
gnumake
jq
];
# cmake config runs inside `js/Makefile` (via `emcmake cmake`) —
# skip the default `configurePhase` which would try to invoke
# cmake against the package root and fail (no CMakeLists at top).
dontConfigure = true;
buildPhase = ''
runHook preBuild
# emscripten needs HOME + a writable cache dir for its sysroot
# build (libc, libc++, etc. compiled to wasm on demand).
export HOME=$TMPDIR
export EM_CACHE=$TMPDIR/.emscriptencache
mkdir -p $EM_CACHE
# `make -C js` keeps the build phase pwd at the source root so
# installPhase doesn't have to know about the cd (argus 🟡 on
# PR #697 v2 robust against future reorders / `dontBuild`).
make -C js Imaging.js Imaging.wasm
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
install -m 644 js/Imaging.js $out/Imaging.js
install -m 644 js/Imaging.wasm $out/Imaging.wasm
runHook postInstall
'';
meta = with pkgs.lib; {
description = "Imaging.js + Imaging.wasm built from the native_imaging dart package for fluffychat-web (#685)";
homepage = "https://pub.dev/packages/native_imaging";
license = licenses.agpl3Plus;
};
};
# `pkgs.fluffychat-web` with #685's three missing files patched in
# via postInstall. Mount point is `matrix.<hive>/` (#772); upstream
# `--base-href "/"` is correct at sub-domain root, no override.
fluffychat-web-fixed = pkgs.fluffychat-web.overrideAttrs (old: {
# `dart` from the flutter341 closure (already pulled, no
# incremental closure cost) so we can compile the web-worker
# entry point that buildFlutterApplication skips.
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.flutter341.dart ];
postInstall =
(old.postInstall or "")
+ ''
# `web/...` is relative to build CWD so dart's package_config
# walk-up hits buildFlutterApplication's pub-get output (#685
# / #733 fixup `$src/web/...` would walk up to a read-only
# store path with no `.dart_tool/`).
${pkgs.flutter341.dart}/bin/dart compile js \
-o $out/native_executor.js \
web/native_executor.dart
install -m 644 ${fluffychat-web-imaging}/Imaging.js $out/Imaging.js
install -m 644 ${fluffychat-web-imaging}/Imaging.wasm $out/Imaging.wasm
'';
});
in
{
# Private Matrix homeserver (matrix-tuwunel — the official conduwuit
# successor) for hyperhive agents, wrapped in a nixos-container so it
# doesn't fight any existing `services.matrix-*` the operator may
# already run on the host. Same shape as `nix/modules/hive-forge.nix`:
# shared host netns (`privateNetwork = false`) so agents reach it at
# `http://localhost:<httpPort>` (or via the configured server_name
# for federation), nixos-container only here for state + systemd-unit
# isolation.
#
# Container name `hive-matrix` (not `h-*`) so the lifecycle scanner
# ignores it; operator manages via the standard `nixos-container` CLI.
#
# Persistent state at `/var/lib/nixos-containers/hive-matrix/var/lib/
# matrix-tuwunel/` (survives container restart / host reboot). To
# wipe, destroy the container.
#
# Initial rollout (#548): federation enabled (needed for multi-hive
# swarms; trusted_servers starts empty so no actual federation traffic
# leaves until peers are explicitly listed), registration enabled via
# a `registration_token_file` known only to hive-c0re (so agents can't
# self-register without going through the coordinator), e2ee disabled
# per operator call (tracked for follow-up at #551).
#
# Provisioning model (matches `nix/modules/hive-forge.nix` shape):
# hive-c0re generates a 32-byte random `registration_token` on first
# boot, writes it to `/var/lib/hyperhive/matrix-register-token` (mode
# 0600, root-only), and bind-mounts that file read-only into the
# tuwunel container at the same path so tuwunel can read it via
# `registration_token_file`. hive-c0re then uses the token to register
# each agent account via the matrix-spec UIAA registration flow, and
# persists the returned `access_token` to `<agent-state>/matrix-token`
# so the agent's matrix MCP client can authenticate without ever
# seeing the shared registration token.
options.services.hyperhive.matrix = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Run hive-matrix a private matrix-tuwunel homeserver (in a
nixos-container) for hyperhive agents. Off by default while
the integration phases in; flip to `true` once the operator
has set `services.hyperhive.domain` and is ready to onboard agents.
'';
};
package = lib.mkOption {
type = lib.types.package;
default = pkgs.matrix-tuwunel;
defaultText = lib.literalExpression "pkgs.matrix-tuwunel";
description = ''
matrix-tuwunel package to run inside the container. Defaults
to nixpkgs's `pkgs.matrix-tuwunel`. Override to pin a
specific upstream if you need an unreleased feature.
'';
};
serverName = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "chat.example.org";
description = ''
Matrix `server_name` the host part of every user ID
(`@argus:<server_name>`) and room ID minted on this
homeserver. CRITICAL: must be stable from day one because
it's embedded irrevocably in the identifiers. Defaults to
`services.hyperhive.domain` (the bare hive domain per mara
on #660). Combined with the `.well-known/matrix/{client,server}`
routes the hive-gateway serves at that domain (also #660),
clients auto-discover the actual matrix endpoint without
needing a subdomain. Override here only if you need a
different server_name shape (e.g. `matrix.<domain>` if you
want the subdomain split, or `chat.example.org` for a
bespoke hostname).
**Breaking change as of #660**: this used to default to
`matrix.''${services.hyperhive.domain}`. matrix IDs embed
the server_name irrevocably, so existing homeservers must
set `services.hyperhive.matrix.serverName = "matrix.''${services.hyperhive.domain}";`
explicitly to preserve their pre-#660 user / room IDs
before rebuilding.
'';
};
httpPort = lib.mkOption {
type = lib.types.port;
default = 8008;
description = ''
TCP port tuwunel serves the matrix client-server API on.
Default 8008 is the matrix-spec well-known port. Sits
outside hyperhive's claimed ranges (dashboard 7000, every
agent in 8100..8999 via FNV-1a hash). Federation listens on
`federationPort` separately.
'';
};
gatewayHost = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = if hyperhiveDomain != null then "matrix.${hyperhiveDomain}" else null;
defaultText = lib.literalExpression ''
if services.hyperhive.domain != null then
"matrix.''${services.hyperhive.domain}"
else
null
'';
example = "matrix.example.com";
description = ''
Public hostname for the matrix homeserver behind the gateway.
Defaults to `matrix.''${services.hyperhive.domain}` (sub-domain
shape per mara on #749:9609). Set to `null` to skip the gateway
vhost (tuwunel stays direct on `httpPort`). See
`docs/gateway.md` for the vhost map + matrix discovery flow,
and the federation port-8448 caveat at the bottom of that doc.
Note: `gatewayHost` is the API listener hostname (where nginx
proxies `/_matrix/*`); `serverName` is the matrix-identifier
domain embedded irrevocably in user/room IDs (per #660
default = bare hive-domain). The two are distinct.
'';
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Open `httpPort` in the host firewall. Off by default (#651,
secure-by-default): the homeserver is reachable from the
host + every agent container via `localhost` either way
(shared netns), so the firewall open 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 as of #651**: this used to default to
`true`. If you relied on the old default for external reach,
add `services.hyperhive.matrix.openFirewall = true;` to
your host config before rebuilding.
Note: federation (the matrix-spec well-known port 8448) is
intentionally not opened here. tuwunel serves the federation
API on the same `httpPort` as the client-server API by
default; reaching it on 8448 requires either binding tuwunel
to that port explicitly OR a reverse-proxy + `.well-known/
matrix/server` delegation, neither of which lives in this
module. Add that proxy config alongside whatever serves your
dashboard or forge on 443.
'';
};
trustedServers = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "matrix.org" ];
description = ''
List of trusted matrix servers (homeservers whose signing
keys this server will fetch identity-server-style). Empty
by default federation is enabled at the protocol level
but no peer is trusted until listed here, so the homeserver
is effectively closed until the operator declares hive
peers explicitly.
'';
};
maxRequestSize = lib.mkOption {
type = lib.types.ints.positive;
default = 20000000;
description = ''
Maximum size in bytes of a single matrix client request body.
Default 20 MB matches the matrix-spec recommendation for
media uploads + the upstream tuwunel default.
'';
};
registrationTokenFile = lib.mkOption {
type = lib.types.path;
default = "/var/lib/hyperhive/matrix-register-token";
description = ''
Host path to a file containing the matrix registration token
tuwunel reads to authorise new-account creation. The token is
generated automatically by `hive-c0re` on first boot (32-byte
random hex, mode 0600) and is bind-mounted read-only into the
tuwunel container at the same path. Agents never see this
token hive-c0re uses it to provision per-agent accounts
and the agent only receives the resulting `access_token`.
Override only when integrating with externally-managed
registration tokens.
'';
};
gui = {
enable = lib.mkOption {
type = lib.types.bool;
default = cfg.enable;
defaultText = lib.literalExpression "config.services.hyperhive.matrix.enable";
description = ''
Serve a matrix web client at `matrix.''${services.hyperhive.domain}/`.
Requires `gateway.enable` + `matrix.gatewayHost != null`
(default true / `matrix.<hive>` when hive-domain set). When
off, the dashboard's `M4TR1X ` tab is hidden. See
`docs/gateway.md` for the discovery flow that lets clients
auto-find the sub-domain.
'';
};
package = lib.mkOption {
type = lib.types.package;
default = fluffychat-web-fixed;
defaultText = lib.literalMD ''
`pkgs.fluffychat-web` + #685 `postInstall` patch (adds the
three files `flutter341.buildFlutterApplication` skips).
'';
description = ''
Static web client dist served at `matrix.<hive>/`. Override
to swap fluffychat for hydrogen-web, cinny, element-web, or
an out-of-tree dist any replacement is mounted at the
sub-domain root with the upstream-default `<base href "/">`,
no sub-path gymnastics needed.
'';
};
};
};
config = lib.mkIf cfg.enable {
# mara on #548: "there is no default, but it is required. add
# assertion." — fail eval with a helpful message rather than
# spawning a homeserver with a bogus server_name we can never
# change later. `services.hyperhive.domain` is host-wide; matrix derives
# the server_name from it (or from `cfg.serverName` if the
# operator wants to override).
assertions = [
{
assertion = hyperhiveDomain != null || cfg.serverName != null;
message = ''
services.hyperhive.matrix.enable = true requires either:
- services.hyperhive.domain set to your host's canonical domain
(recommended; shared with forge / dashboard), or
- services.hyperhive.matrix.serverName set explicitly.
The matrix server_name is embedded into every user ID and
room ID on this homeserver it cannot be changed later
without losing every account and chat history. Pick a
stable hostname before enabling.
'';
}
{
# Same footgun as forge.domain (#754): empty string renders
# `.<hive>` shaped garbage in both nginx server_name (treated
# as wildcard catch-all, surprising) and /etc/hosts (invalid
# entry). Argus 🟡 on #764 — fail loud here rather than ship
# the surprising behaviour.
assertion = cfg.gatewayHost == null || cfg.gatewayHost != "";
message = ''
services.hyperhive.matrix.gatewayHost = "" is rejected. The
rendered URLs would be invalid (nginx wildcard catch-all for
an empty server_name, /etc/hosts rejects empty entries).
Use `null` to disable the gateway vhost entirely (tuwunel
stays direct on httpPort), or set a non-empty hostname like
"matrix.example.com" or "homeserver.internal".
'';
}
];
# Generate the registration token at system activation time, BEFORE
# the hive-matrix container would otherwise start with an empty
# bind-mount target (argus nit on #565: nspawn creates an empty
# file when the host path is missing, tuwunel reads it as
# `registration_token_file=""` and rejects every registration
# until the next restart). Idempotent: only writes when the file
# doesn't exist. 32-byte hex = 64 chars, same shape hive-c0re's
# `matrix::ensure_register_token` would produce.
#
# Ownership: plain `root:root 0600` — tuwunel inside the container
# runs as a hardened dynamic user (#644) and reads the token via
# systemd's `LoadCredential=` mechanism (see container config
# below), so it never needs direct read access on the host-side
# file. No `chown :tuwunel` / `chmod 0640` / GID-pin gymnastics
# required (per iris on #644 8043, dropping the shape #649
# shipped with).
system.activationScripts.hive-matrix-register-token = lib.stringAfter [ "var" ] ''
tokenFile=${lib.escapeShellArg (toString cfg.registrationTokenFile)}
if [ ! -s "$tokenFile" ]; then
mkdir -p "$(dirname "$tokenFile")"
head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$tokenFile"
echo >> "$tokenFile"
echo "hive-matrix: generated registration token at $tokenFile"
fi
# Always re-apply 0600 (idempotent on already-correct files;
# also normalises any 0640 / world-readable carry-over from
# pre-LoadCredential deployments).
chmod 0600 "$tokenFile"
'';
containers.hive-matrix = {
autoStart = true;
ephemeral = false;
# Share host netns — tuwunel's listeners look exactly like
# host-side services, no port-forward plumbing, and agent
# containers (also host netns) reach it via plain `localhost`.
privateNetwork = false;
# Read-only bind of the host-managed registration token so
# tuwunel can resolve `registration_token_file` to a real
# file inside the container. The activation script above
# ensures the host path exists with a valid 64-char hex token
# before any container starts, so the bind always finds real
# content (no first-boot empty-file race; argus #565 nit).
bindMounts.${cfg.registrationTokenFile} = {
hostPath = cfg.registrationTokenFile;
isReadOnly = true;
};
config =
{ ... }:
{
system.stateVersion = "26.05";
services.matrix-tuwunel = {
enable = true;
package = cfg.package;
settings.global = {
server_name = effectiveServerName;
# `address` is `listOf nonEmptyStr` upstream (multi-bind
# support). Single-host bind goes through as a one-element list.
address = [ "0.0.0.0" ];
# `port` is `listOf port` upstream. Same shape.
port = [ cfg.httpPort ];
max_request_size = cfg.maxRequestSize;
# Federation enabled at the protocol level so swarms
# can be wired up later by extending `trustedServers`
# without a homeserver restart. Empty trusted_servers
# keeps it effectively closed until peers are listed.
allow_federation = true;
trusted_servers = cfg.trustedServers;
# Token-gated registration: hive-c0re holds the token,
# agents never see it. allow_registration must be true
# for the token flow to engage; the absent
# `yes_i_am_very_very_sure_…_open_registration_…` flag
# keeps the server closed to anyone without the token.
allow_registration = true;
# Read the registration token via systemd's
# `LoadCredential=` mechanism (wired below) instead of
# the bind-mount path directly. systemd copies the host-
# owned 0600 root:root file into a per-service
# credentials dir owned by tuwunel's dynamic user with
# mode 0400 — keeps `DynamicUser=true` + `PrivateUsers=true`
# intact, no host-side `chown :tuwunel` / GID-pin
# gymnastics required (#644 / iris on 8043).
registration_token_file = "/run/credentials/tuwunel.service/registration_token";
# E2EE disabled in initial rollout per operator call
# (#548) — re-enabling tracked at #551.
allow_encryption = false;
};
};
# `LoadCredential=<id>:<host-path>` makes systemd copy the
# bind-mounted host file into `/run/credentials/tuwunel.service/<id>`
# owned by the service's (dynamic) user with mode 0400 at
# service start. The hardcoded path in `registration_token_file`
# above is the systemd-stable credentials dir; see
# `man systemd.exec` → LoadCredential.
systemd.services.tuwunel.serviceConfig.LoadCredential = [
"registration_token:${toString cfg.registrationTokenFile}"
];
environment.systemPackages = [ cfg.package ];
};
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [
cfg.httpPort
];
};
};
}