Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/modules/hive-matrix.nix
atlas 922057e81b docs/gateway.md: extract big-picture gateway docs, trim in-code comments (mara #775:9988)
mara on PR #775: "this is too much docs in code - move bigger picture
stuff to md files and put refs in code"

New `docs/gateway.md` consolidates the gateway architecture story
that was spreading across long inline comments in `hive-gateway.nix`,
`hive-matrix.nix`, and `hive-forge.nix`:

- vhost map (which URL serves what, which upstream, which option)
- matrix discovery flow (.well-known → sub-domain delegation
  sequence)
- Accept-header SPA fallback pattern (#686 / #729 design history)
- local-dev `localHostsEntry` story
- sub-domain rationale (mara verdict tracking) + when sub-path is
  right (hyperhive-internal apps)
- per-vhost tuning knobs (forge LFS, matrix long-poll, agent SSE)
- sequencing history (which PR added which routing piece)

In-code comments in the two nix modules get trimmed to short refs
into the doc — keeps the *why* in the markdown while the *what*
stays alongside the code:

- hive-gateway.nix: top-of-file comment, `agentPortsTable`,
  `appendHttpConfig`, every location block + vhost
- hive-matrix.nix: `fluffychat-web-fixed`, `fluffychat-web-imaging`,
  the dart compile postInstall

README.md gets a new row in the docs table pointing at gateway.md.

Verified `nix eval` still resolves the same vhost + location layout
after the comment trim — no behavioral change, just less in-code
prose.
2026-05-31 14:29:40 +02:00

524 lines
23 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
hive-gateway nginx (#747, mara verdict on #749:9609 — sub-domain
over sub-path for matrix, but **not user-visible** because the
`.well-known/matrix/{client,server}` redirect routes clients
through automatically).
When set + gateway is on, the gateway adds a `server { server_name
= gatewayHost; }` block that proxies `/_matrix/...` →
`http://127.0.0.1:''${httpPort}/_matrix/...`. The
`.well-known/matrix/{client,server}` endpoints (served by the
gateway at the bare hive-domain) then point at
`http(s)://''${gatewayHost}/` — matrix clients automatically
discover + follow that delegation.
Defaults to `matrix.''${services.hyperhive.domain}` when the
hive-domain is set (idiomatic matrix-spec shape — `matrix`
labelled under the hive's bare server_name domain). Defaults to
`null` when the hive-domain is unset (gateway vhost not added;
clients reach tuwunel directly on `httpPort`).
Set to a full hostname (`matrix.example.com`,
`homeserver.internal.lan`) for a bespoke vhost shape. Set to
`null` to disable the gateway vhost entirely (tuwunel stays
direct on `httpPort`).
**server_name vs gatewayHost**: `serverName` is the matrix
identifier domain embedded in user/room IDs irrevocably (per
#660 default = bare hive-domain). `gatewayHost` is just where
the API listens behind nginx. The two are different — see the
matrix-spec server-discovery flow.
**Federation port caveat**: the `.well-known/matrix/server`
delegation advertises `''${gatewayHost}` with no port suffix
when the gateway listens on 80. Per the matrix federation
spec, peers fall back to port 8448 when no explicit port is
present — but the gateway only listens on the configured
`services.hyperhive.gateway.port`. Cross-hive federation
therefore needs either:
- a DNS SRV record (`_matrix._tcp.''${gatewayHost}` → port 80),
- or `services.hyperhive.matrix.openFirewall = true` so peers
can reach tuwunel's federation port directly.
Hyperhive is mostly closed/internal, so this rarely bites in
practice — but flagging for the federation-curious operator.
'';
};
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 the root of the matrix sub-domain vhost
(`matrix.''${services.hyperhive.domain}/`, via the hive-gateway
nginx — #607 / #634 / #772). 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) +
`services.hyperhive.matrix.gatewayHost != null` (default
`matrix.<hive>`); when either is off no one hosts the GUI
and the `M4TR1X →` dashboard tab is hidden.
The `.well-known/matrix/{client,server}` delegation (served
on the bare hive-domain) advertises this sub-domain, so
clients opened at `http://<hive>/` auto-discover the
fluffychat root automatically.
'';
};
package = lib.mkOption {
type = lib.types.package;
default = fluffychat-web-fixed;
defaultText = lib.literalMD ''
`pkgs.fluffychat-web` patched via `postInstall` to add the
three files `flutter341.buildFlutterApplication` skips:
`native_executor.js` (compiled via `dart compile js` from
`web/native_executor.dart`), plus `Imaging.js` +
`Imaging.wasm` (built from the `native_imaging` dart
package's C source via `pkgs.emscripten`). See the `let`
block in `nix/modules/hive-matrix.nix` for the full
rationale (#685, #772).
'';
description = ''
Static web client dist to serve at the matrix sub-domain
root (`matrix.''${services.hyperhive.domain}/`). Defaults
to `pkgs.fluffychat-web` with the #685 `postInstall` patch
for the three missing files. Override to swap for
`hydrogen-web` (lightest), `cinny` (no threads),
`element-web` (heaviest, full features), or an out-of-tree
client dist — any replacement gets served 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
];
};
};
}