{ pkgs, lib, config, ... }: let cfg = config.services.hyperhive.matrix; networkCfg = config.services.hyperhive.network; 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./. 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:`) 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.` 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 `matrix.gatewayHost != null` (default `matrix.` when hive-domain set); the gateway itself always runs. 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./`. 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 ``, 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:. 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"; # tuwunel hard-fails to boot if `/etc/resolv.conf` has no # `nameserver` line (`Failed to configure DNS resolver ... no # nameservers found in config` → exit 1). This declarative # nixos-container comes up with an EMPTY resolv.conf even with # `networking.nameservers` set: the nixos-container default # `useHostResolvConf = true` puts in-container resolvconf in # host-tracking mode (ignores `networking.nameservers`, and never # gets the host file across the shared-netns boundary), so it # regenerates an empty file and tuwunel dies at boot. # # The earlier fix turned host-tracking off and trusted resolvconf # to honour `networking.nameservers` — but that's a RUNTIME # resolvconf behaviour, not verifiable at eval time, and it STILL # came up empty in practice. So take resolvconf out of the # loop entirely and write a STATIC `/etc/resolv.conf` from # `bridgeIp` that nothing regenerates. Eval-proven: the generated # `environment.etc."resolv.conf".text` is `nameserver `. # This container always shares the host netns # (`privateNetwork = false`), so it reaches `bridgeIp` regardless # of `isolateContainers`. Network module off → inherit the host's # resolv.conf. See `docs/network.md`. networking = lib.mkMerge [ (lib.mkIf networkCfg.enable { # resolvconf is taken out of the loop entirely; the static # `environment.etc."resolv.conf"` below is the sole source of # the resolver file (no `nameservers` — nothing would read it). useHostResolvConf = lib.mkForce false; resolvconf.enable = lib.mkForce false; }) (lib.mkIf (!networkCfg.enable) { useHostResolvConf = true; }) ]; # resolvconf is disabled above, so write the static resolver file # explicitly — NixOS won't synthesise one from `nameservers` once # resolvconf is off, and this is the file tuwunel parses at boot. environment.etc = lib.mkIf networkCfg.enable { "resolv.conf".text = '' nameserver ${networkCfg.bridgeIp} options edns0 ''; }; 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 ]; }; # When the hive network module is on, the matrix container's resolver # is the dnsmasq that runs in the gateway container (bound at # `bridgeIp`). Order the matrix container start after the gateway # container so the resolver is up before tuwunel's first federation # lookups. tuwunel boots fine without this — it configures the resolver # from `/etc/resolv.conf` at startup and only queries on-demand (the # boot failure this module fixes was an *empty* resolv.conf, a parse # error, not a connectivity one) — so this is robustness, not a boot # requirement. Soft `after` ordering (not `requires`) keeps the matrix # container's lifecycle decoupled from the gateway's. The gateway # always runs alongside hyperhive, so the gateway container unit always # exists here. (Declarative `containers.` → `container@.service` — the # nspawn template NixOS generates, confirmed from the live # `container@hive-matrix.service` host unit.) systemd.services."container@hive-matrix".after = lib.mkIf networkCfg.enable [ "container@hive-gateway.service" ]; }; }