hyperhive/nix/modules/hive-matrix.nix
atlas 71211e5722 nix/matrix: add breaking-change note to serverName description (argus #661)
argus on #661 🟡: "matrix IDs embed server_name irrevocably — anyone
already running with the old default would be broken."

Append a `**Breaking change as of #660**` paragraph to the
`serverName` option description with the exact opt-back-in string,
matching the pattern from #651's openFirewall flip. PR body + commit
already documented the breakage; this surfaces it in the option's
own description so it shows up in `nix flake show` + the auto-
generated options docs right next to the option.
2026-05-30 21:05:10 +02:00

370 lines
17 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;
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, manager
8000, sub-agents 8100..8999). Federation listens on
`federationPort` separately.
'';
};
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 (default `pkgs.fluffychat-web`) as
a static dist at `/matrix/` via the hive-gateway nginx
(#607 / #634). Defaults to whatever
`services.hyperhive.matrix.enable` is turning on the
homeserver gives you the web client by default; set to
`false` explicitly to opt out of the GUI while keeping
the homeserver running for agents. Requires
`services.hyperhive.gateway.enable` (default on); when
gateway is off no one hosts the GUI and the
`M4TR1X ` dashboard tab is hidden.
fluffychat-web supports per-login server pick point it at
the in-host tuwunel URL (`http://localhost:8008` by
default) the first time. The post-#15 nginx-front re-root
(`https://matrix.''${services.hyperhive.domain}`) is tracked
separately in #609.
'';
};
package = lib.mkOption {
type = lib.types.package;
default = pkgs.fluffychat-web.overrideAttrs (old: {
# fluffychat-web ships with `<base href="/">` baked into its
# index.html — flutter's `--base-href` build flag replaces
# that placeholder. Default upstream build is `--base-href "/"`
# which is wrong for hyperhive's `/matrix/` sub-path mount:
# the browser resolves relative asset paths (`Imaging.js`,
# `flutter.js`, `splash/*`) against the document ROOT,
# producing 404s for every asset (#634). Inject the right
# base-href into the actual `flutter build web` invocation
# via the upstream derivation's `flutterBuildFlags` string
# (the buildPhase is literally
# `flutter build web -v $flutterBuildFlags`). When subdomain
# routing lands (#609 — `matrix.${hyperhiveDomain}`), drop
# this override; upstream's `/` base-href is correct at the
# root of a dedicated subdomain.
flutterBuildFlags = (old.flutterBuildFlags or [ ]) ++ [
"--base-href"
"/matrix/"
];
});
defaultText = lib.literalMD "`pkgs.fluffychat-web` rebuilt with `--base-href /matrix/` via `flutterBuildFlags`.";
description = ''
Static web client dist to serve at `/matrix/`. Defaults to
`pkgs.fluffychat-web` rebuilt with `--base-href "/matrix/"`
so relative asset paths resolve under the sub-path mount
(#634). Override to swap for `hydrogen-web` (lightest),
`cinny` (no threads), `element-web` (heaviest, full
features), or an out-of-tree client dist any replacement
also needs its `<base href>` aligned with the mount path.
'';
};
};
};
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.
'';
}
];
# 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
];
};
};
}