{ 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 nixpkgs's `pkgs.fluffychat-web` dist # because `flutter341.buildFlutterApplication` doesn't run the dart # web-worker compile pass + doesn't run the native_imaging package's # emscripten build (#685): # # - native_executor.js ← flutter web worker entry, compiled # from web/native_executor.dart via # `dart compile js` (handled inline # in `fluffychat-web-fixed.postInstall` # below — dart SDK is already in the # flutter341 closure) # # - Imaging.js / Imaging.wasm ← emscripten-compiled C library from # the native_imaging dart package # (vendored by Famedly). The package # ships C source + a Makefile that # builds them via emcc; nixpkgs's # flutter builder doesn't run that # pipeline. Built from source via # `fluffychat-web-imaging` below # (per mara's #685 call: "fix the # compile … dont use the prebuilt # binary"). # # When `flutter341.buildFlutterApplication` grows worker + emcc # support upstream, drop both this derivation and the postInstall. # Imaging.{js,wasm} built from source: native_imaging's `js/Makefile` # runs `emcmake cmake` → `make -C build` → `emcc` to produce the # emscripten-wrapped C library that fluffychat's main.dart.js # references at runtime. # # Source: the exact native_imaging derivation that `pkgs.fluffychat-web` # already pulls in via its `pubspecLock` (resolved by nixpkgs's flutter # pub-cache machinery), reached via `passthru.pubspecLock.dependencySources`. # This means **no parallel hash pin** — when nixpkgs bumps # `pkgs.fluffychat-web` (and with it the pubspec.lock-resolved # native_imaging version), our build automatically picks up the # matching source. Version is also pulled from passthru for the # derivation's `version` attr so it stays in lockstep. # # Closure cost: `pkgs.emscripten` is ~3.6 GiB build-time (LLVM + # toolchain). Runtime closure is only the two produced files — # nothing emscripten-shaped survives into the deployed dist. 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, plus the existing `--base-href "/matrix/"` # override (#634) for the sub-path mount. fluffychat-web-fixed = pkgs.fluffychat-web.overrideAttrs (old: { # `--base-href "/matrix/"` so relative asset paths resolve # under the sub-path mount (#634). Upstream default is `/`, # wrong for hyperhive's `/matrix/` location. flutterBuildFlags = (old.flutterBuildFlags or [ ]) ++ [ "--base-href" "/matrix/" ]; # `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 "") + '' # #685: compile web/native_executor.dart → native_executor.js. # The flutter web bootstrap loads this from /matrix/native_executor.js # at startup; without it, main.dart.js logs a network-error and # the SPA renders blank (see #643 for the symptom). # # `dart compile js` needs `.dart_tool/package_config.json` to # resolve `package:matrix/...` and the rest of fluffychat's # `pubspec.lock` deps. buildFlutterApplication's pub-get step # writes that file to the build CWD (the unpacked source dir), # not to `$src` (the read-only nix store path). So we must use # a relative path that walks up from `web/` to the build CWD # where pub-get's package_config lives — pointing at # `$src/web/native_executor.dart` walks up to `$src/`, finds # no `.dart_tool/`, and fails with `Couldn't resolve the # package 'matrix'` (mara's first build attempt on #685). # # nixpkgs's buildFlutterApplication leaves CWD at the source # root for postInstall (see `pkgs/development/compilers/flutter/ # build-support/build-flutter-application.nix` — installPhase # is `cp -r build/web "$out"` with no `cd` first). So `web/...` # resolves correctly here. ${pkgs.flutter341.dart}/bin/dart compile js \ -o $out/native_executor.js \ web/native_executor.dart # #685: install Imaging.{js,wasm} built from the native_imaging # dart package's C source via emscripten (see # `fluffychat-web-imaging` above for the build-time rationale). 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:` (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 `/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:`) 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.` 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 = fluffychat-web-fixed; defaultText = lib.literalMD '' `pkgs.fluffychat-web` rebuilt with `--base-href /matrix/` (#634) and 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). ''; 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), plus a `postInstall` patch for #685's 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 also needs its `` 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=:` makes systemd copy the # bind-mounted host file into `/run/credentials/tuwunel.service/` # 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 ]; }; }; }