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>`).
471 lines
21 KiB
Nix
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
|
|
];
|
|
};
|
|
};
|
|
}
|