hyperhive/nix/modules/hive-matrix.nix
atlas ed6f40ba40 fix(#1061): suppress tuwunel default displayname suffix
Tuwunel 1.6.1 appends " 💕" to every new user's display name via the
new_user_displayname_suffix config option. Set it to empty string so
agent and operator matrix accounts get clean names.

Existing users (atlas, mara, root) need a one-time display name reset.
Atlas's was already corrected via the client API.
2026-06-02 11:18:38 +02:00

383 lines
15 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;
# fluffychat-web build fixes: nixpkgs's `flutter341.buildFlutterApplication`
# skips the dart web-worker compile + the emscripten native_imaging
# build. Two derivations below cover both. Full rationale (why
# passthru.pubspecLock.dependencySources, why `dontConfigure`, why
# `make -C js`, why build-CWD-relative dart path): docs/matrix.md::
# fluffychat-web build fixes.
fluffychat-web-imaging = pkgs.stdenv.mkDerivation {
pname = "fluffychat-web-imaging";
version = pkgs.fluffychat-web.passthru.pubspecLock.dependencyVersions.native_imaging;
src = pkgs.fluffychat-web.passthru.pubspecLock.dependencySources.native_imaging;
nativeBuildInputs = with pkgs; [
emscripten
cmake
gnumake
jq
];
# cmake runs inside js/Makefile via `emcmake cmake`; the default
# configurePhase would invoke cmake at the package root (no
# CMakeLists) and fail.
dontConfigure = true;
buildPhase = ''
runHook preBuild
# emscripten on-demand sysroot build needs writable HOME + cache.
export HOME=$TMPDIR
export EM_CACHE=$TMPDIR/.emscriptencache
mkdir -p $EM_CACHE
# `make -C js` keeps pwd at source root for the installPhase.
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";
homepage = "https://pub.dev/packages/native_imaging";
license = licenses.agpl3Plus;
};
};
fluffychat-web-fixed = pkgs.fluffychat-web.overrideAttrs (old: {
# dart from the flutter341 closure (already pulled, no incremental
# cost) to compile the web-worker entry point.
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.flutter341.dart ];
postInstall = (old.postInstall or "") + ''
# `web/...` is BUILD-CWD-relative (not `$src/...`) so dart's
# package_config walk-up hits buildFlutterApplication's
# pub-get output `.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-tuwunel homeserver wrapped in a nixos-container,
# optional fluffychat-web client at matrix.<hive>/. Container shape,
# serverName vs gatewayHost split, provisioning flow (registration
# token + LoadCredential), assertion rationale, initial rollout
# settings: docs/matrix.md. Vhost map + discovery flow + tuning
# knobs: docs/gateway.md.
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). Combined
with the `.well-known/matrix/{client,server}` routes the
hive-gateway serves at that domain, 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**: 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 existing 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 see `docs/gateway.md`). 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 (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
(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**: 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` with a `postInstall` patch that 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 {
# serverName must exist (irrevocably embedded in user/room IDs);
# gatewayHost may not be "" (same footgun as forge.domain —
# nginx rejects an empty server_name). docs/matrix.md::Assertion
# rationale.
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.
'';
}
{
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".
'';
}
];
# Activation-time token generation — without this the bind-mount
# would hand tuwunel an empty file on first boot and break every
# registration until restart. Idempotent;
# docs/matrix.md::Provisioning flow.
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
# Re-apply 0600 (normalises any pre-LoadCredential carry-over).
chmod 0600 "$tokenFile"
'';
containers.hive-matrix = {
autoStart = true;
ephemeral = false;
# Shared host netns — agents reach tuwunel at localhost:<port>.
privateNetwork = false;
# Read-only bind of the host-managed registration token; tuwunel
# reads it via systemd LoadCredential below (not directly).
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` + `port` are upstream `listOf` — wrap singles.
address = [ "0.0.0.0" ];
port = [ cfg.httpPort ];
max_request_size = cfg.maxRequestSize;
# Federation enabled at the protocol level; empty
# trustedServers keeps it effectively closed.
allow_federation = true;
trusted_servers = cfg.trustedServers;
# Token-gated registration. The absent
# `yes_i_am_very_very_sure_…_open_registration_…` flag
# keeps the server closed to anyone without the token.
allow_registration = true;
# LoadCredential below copies the host file into a
# 0400 dynamic-user-owned path; tuwunel reads from there.
registration_token_file = "/run/credentials/tuwunel.service/registration_token";
# E2EE disabled in initial rollout; tracked in the issue tracker.
allow_encryption = false;
# Tuwunel's default suffix is " 💕" — suppress it so agent
# display names are clean (just the agent name, no emoji).
new_user_displayname_suffix = "";
};
};
# Keeps DynamicUser=true + PrivateUsers=true intact — no
# host-side chown :tuwunel / GID-pin gymnastics needed.
# 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
];
};
};
}