{ pkgs, lib, config, ... }: let cfg = config.services.hyperhive.swarm.matrix; networkCfg = config.services.hyperhive.network; tlsCfg = config.services.hyperhive.deploy.hive-controller.tls; gatewayCfg = config.services.hyperhive.gateway; hyperhiveDomain = config.services.hyperhive.domain; # Same runtime→build-time bridge hive-ci and hive-forge already cross: # binds the hive trust bundle (which folds in the swarm root) into the # container and orders the container after `hive-tls-ca.service`. The # assembled bundle itself comes from `caTrust.trustBundle`, imported in # the container config below. caTrust = import ./lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; }; swarmDomain = config.services.hyperhive.swarm.domain; # `url` is the half of the authelia module that exists on EVERY hive — # null when no SSO provider is configured anywhere, which the assertion # below turns into an eval failure rather than a discovery request to # `null/.well-known/…`. autheliaCfg = config.services.hyperhive.swarm.authelia; deployCfg = config.services.hyperhive.deploy; # Where the swarm's secret store answers. Swarm-tier, identical on every # host, which is what lets the container address it without knowing whether # it stands here. baoCfg = config.services.hyperhive.swarm.bao; autheliaUrl = autheliaCfg.url; # The all-local case: this host runs BOTH the homeserver and the swarm's # authelia, so the secret can be moved without an operator. The other # two cases (swarm side, remote hive) leave `clientSecretFile` to be set # explicitly — same split the forge module documents. ssoLocal = deployCfg.authelia.enable; # Where the plaintext lands inside the matrix container. Under /var/lib # rather than /run: the homeserver may start before the delivery unit on # a later boot, and a secret that evaporates on reboot turns a working # login into an intermittent one. matrixSecretPath = "/var/lib/tuwunel-oidc/${cfg.sso.clientId}.secret"; # ⚠️ tuwunel does NOT read the host path directly, and this indirection # is not ceremony. Upstream's own words: "under systemd the path must be # visible to the service after sandboxing (ReadWritePaths / ProtectHome), # typically by placing the file under /etc/tuwunel/" — which this # container has no writable etc for. `LoadCredential` is the answer # already in use for the appservice registration below, and for the # same reason: it keeps `DynamicUser=true` + `PrivateUsers=true` intact # with no host-side chown or GID pinning. matrixSecretCredential = "/run/credentials/tuwunel.service/oidc_client_secret"; # The hive's appservice registration, whose `url` is null. Null is a legal # `url` (ruma's `Registration` types it `Option`), and it is the # whole point — with no URL the homeserver # never calls out, so there is no HTTP service to run and no daemon to # operate. tuwunel loads the registration and creates its sender account # (below). No client presents its `as_token`: `swarm-controller` mints the # sender's token with the swarm registration's. appserviceId = "hyperhive"; # The appservice's own user, and the account the hive acts as. An # ordinary user, not a homeserver admin: everything the hive does with # it — the Space, the chat room, the invites — it does as the creator of # those rooms. Loading a registration CREATES its `sender_localpart` user # when absent (tuwunel `src/service/appservice/mod.rs`), on a zero-user # database, inside `Services::start()` — before the HTTP listener accepts # anything, so the account exists on the very first boot of a fresh # homeserver without hive-c0re racing to register it. # # 🩸 **Derived from the hive name, so it is one account per hive.** It was # the bare `hive` and a swarm runs one homeserver: every hive on it acted # as the same `@hive:`, which is one identity with no attribution and no # way to revoke a single hive. The hives that do not run this container # register their own `hive-` through the appservice namespace below, # which already admits it. # # ⚠️ Must equal `swarm_secret_client::matrix::hive_localpart`, which # hive-c0re and swarm-controller both derive from independently with # nothing wiring an override across — same agreement, and same reason for # saying so, as the token path below. # # `hiveName` has no fallback here for the reason ./glue-matrix-bao-token.nix # gives at its own use of it: it is asserted set on every host that runs the # homeserver. hiveLocalpart = "hive-${toString config.services.hyperhive.hiveName}"; # The `as_token`, and the `hs_token` the spec requires alongside it. Both # minted by the render script below, mode 0600; the `as_token` is the one # the swarm secret store overwrites (see # `glue-matrix-bao-token.nix`). The `hs_token` authenticates the homeserver # TO the appservice, which with `url = null` is nobody — it exists because # the registration format requires it. # # `appserviceTokenPath` is the single source for the option's `default`, # its `config`-level `mkDefault` self-definition, and the assertion that # rejects a moved path. See `appserviceTokenFile`'s own comment below. appserviceTokenPath = "/var/lib/hyperhive/matrix-appservice-token"; appserviceHsTokenPath = "/var/lib/hyperhive/matrix-appservice-hs-token"; # The registration file, and the directory that holds it. A directory # rather than a `[global.appservice.]` stanza because a stanza's # `as_token` would be a nix literal, and a nix literal is a world-readable # store path — the same rule that sends the OIDC client secret through a # runtime file. `docs/swarm/secrets.md` has the general form of it. appserviceDir = "/var/lib/hyperhive/matrix-appservice"; appserviceRegistrationPath = "${appserviceDir}/${appserviceId}.yaml"; # ⚠️ tuwunel does not read the host path directly, for exactly the reason # given for the two secrets above: the file is 0600 root-owned and the # homeserver runs under `DynamicUser=true`. `LoadCredential` copies it to a # 0400 dynamic-user-owned path as root, before the sandbox and that user # exist. # # So `appservice_dir` points at the credentials directory ITSELF. That is # safe rather than clever: tuwunel's loader takes only `.yaml`/`.yml` # entries from the directory and skips every other file, so the sibling # credentials (the OIDC secret) are invisible to it. A credential id is a # free-form filename, which is what lets ours end in `.yaml`. appserviceCredentialId = "${appserviceId}-appservice.yaml"; appserviceCredentialDir = "/run/credentials/tuwunel.service"; # ── swarm-matrix-ctl ──────────────────────────────────────────────── # # The container's own store identity, which the swarm appservice units below # run under. Gated on the identity, not on `deploy.bao.enable`: a swarm's ONE # homeserver is the host least likely to also be the host running the store, # so "co-located with bao" would leave the intended deployment silently # publishing nothing. Same rule ./swarm-secret-publisher.nix's # `haveClientIdentity` states, for a sharper reason. ctlActive = deployCfg.matrix.ctlBaoClientCertFile != null && deployCfg.matrix.ctlBaoClientKeyFile != null; # The role on the store's `cert` auth mount, and so the single source of the # string both ends must agree on: ./swarm-bao.nix writes the role under # `matrixCtlPolicyName` and this hands it to the binary in the # environment, so the binary itself spells no role at all. ctlCertRole = "swarm-matrix-ctl"; # Every host directory matrix-ctl's bao identity is spread across. Normally # one — ./glue-bao-tls.nix puts all three files in ./glue's PKI dir — but # derived rather than assumed, because an operator naming paths by hand is # exactly the deployment the gate above is written for. # # Directories rather than the files, for the reason `appserviceDir`'s own # comment gives: a re-issued leaf is a new inode, and binding the file would # pin the one the container saw when it started. ctlPkiDirs = lib.optionals ctlActive ( lib.unique ( map builtins.dirOf ( [ deployCfg.matrix.ctlBaoClientCertFile deployCfg.matrix.ctlBaoClientKeyFile ] ++ lib.optional (deployCfg.bao.serverCaFile != null) deployCfg.bao.serverCaFile ) ) ); ctlBindMounts = lib.genAttrs ctlPkiDirs (dir: { hostPath = dir; isReadOnly = true; }); # ── the swarm's own appservice ────────────────────────────────────── # # A second registration beside the hive's `hyperhive` one, and the swarm's # identity on this homeserver: `swarm-controller` creates every agent's # account with its token. Minted INSIDE this container by # `swarm-matrix-ctl appservice render` and published to the store by # `appservice publish`, which is why it is gated on matrix-ctl's store # identity: with nobody to publish it, a registration here would be an # admin credential nobody reads. # # Its sender is promoted to homeserver admin at boot (`admin_execute` # below), so its token goes only to a store path no hive's policy reaches. # `swarm` is in ../reserved-names.nix, so no agent can be created as this # account. swarmSenderLocalpart = "swarm"; # Inside the container: the render unit's `StateDirectory`. `ephemeral = # false` keeps it across restarts, so "mint when absent" is mint once. swarmAppserviceDir = "/var/lib/swarm-matrix-appservice"; swarmAppserviceCredentialId = "swarm-appservice.yaml"; # Every local user this hive may provision — agents and `@hive-:` # itself — which is the whole matrix localpart charset. # # ⚠️ Anchored deliberately: tuwunel compiles a namespace into a `RegexSet` # and asks it for a MATCH, not a full match, so an unanchored # `@[a-z0-9]+:this.example` also matches `@x:this.example.evil.test`. # # Non-exclusive deliberately: an exclusive namespace does not widen what # the appservice may do, it narrows what everything ELSE may do — an SSO # login adopting an existing account, or `!admin users create-user`, would # start failing with `M_EXCLUSIVE`. The appservice needs the right to # create these names, not a monopoly on them. appserviceUserRegex = "^@[a-z0-9._=/-]+:${lib.escapeRegex effectiveServerName}$"; # Mint the tokens if they are absent, then render the registration from # whatever they now hold. **One script with two callers** — the activation # script below and `glue-matrix-bao-token.nix`, which overwrites the # `as_token` with the swarm store's copy and has to re-render afterwards. # # ⚠️ The `as_token` mint here is the FIRST-BOOT FALLBACK, not the normal # route. `swarm-secret-publisher.nix` mints one per hive swarm-side and # publishes it to the store; the reader above overwrites this file before # the homeserver's container starts, so what a hive actually runs on is the # swarm's value. This keeps a hive that has never reached the store working # — and it fires only when the file is absent, so it never clobbers one the # store delivered. (`hs_token` has no swarm half: it is how the homeserver # authenticates to *us*, so it stays hive-local and is minted here for real.) # A second copy of the registration's shape would be a second source of # truth for a format whose mismatch is silent: the homeserver would load a # registration naming a token nobody holds, and every request hive-c0re # makes would come back 401 naming nothing. # # Re-rendering unconditionally is the point rather than thoroughness: "the # token file exists" does not mean "the registration carries what is in # it". appserviceRegistrationScript = pkgs.writeShellApplication { name = "hive-matrix-appservice-registration"; runtimeInputs = [ pkgs.coreutils ]; text = '' # Both the tokens and the rendered registration are secrets; 077 # covers every file this script creates rather than each one # separately. umask 077 mkdir -p ${lib.escapeShellArg appserviceDir} for f in ${lib.escapeShellArg appserviceTokenPath} ${lib.escapeShellArg appserviceHsTokenPath}; do if [ ! -s "$f" ]; then head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$f" echo >> "$f" echo "hive-matrix: generated appservice token at $f" fi chmod 0600 "$f" done # Read into shell variables and emitted with `printf`, a shell # builtin: a token passed as an argument to a real command would land # in that process's argv, which is world-readable for its lifetime. asToken="$(cat ${lib.escapeShellArg appserviceTokenPath})" hsToken="$(cat ${lib.escapeShellArg appserviceHsTokenPath})" # The quoted heredoc keeps the regex's own `$` and `\` out of the # shell's hands; the YAML single quotes keep them out of YAML's. { cat <<'REGISTRATION' id: ${appserviceId} url: null sender_localpart: ${hiveLocalpart} rate_limited: false namespaces: users: - exclusive: false regex: '${appserviceUserRegex}' aliases: [] rooms: [] REGISTRATION printf 'as_token: %s\nhs_token: %s\n' "$asToken" "$hsToken" } > ${lib.escapeShellArg appserviceRegistrationPath} chmod 0600 ${lib.escapeShellArg appserviceRegistrationPath} chmod 0700 ${lib.escapeShellArg appserviceDir} ''; }; # Format-locked by tuwunel, not chosen here: the callback host must point # directly at the matrix server and the path is fixed at # `/_matrix/client/unstable/login/sso/callback/`. Built once # and read twice — by the homeserver's own config and by the client entry # handed to authelia — because a redirect-URI mismatch is a rejected # login with no error text worth reading. ssoCallbackUrl = "https://${toString cfg.gatewayHost}/_matrix/client/unstable/login/sso/callback/${cfg.sso.clientId}"; # Falls back to the SWARM domain: a swarm runs one homeserver, so its # identifier belongs to the swarm rather than to whichever hive happens # to host it — otherwise moving the container between hives would look # like a different homeserver. # # ⚠️ Changing this default is a BREAKING change in a way that moving # `gatewayHost` was not: `serverName` is baked irrevocably into every # user and room id, so a deployment that rebuilds onto a new one is a # *different homeserver*, not a renamed one. Existing hives pin the old # value explicitly (see the option's description); the default is what # a fresh swarm gets. # # Total on a null swarm domain, deliberately: the required-domain # assertion in hive-network.nix is what should fire, not a coercion # error from an unrelated option interpolating null. effectiveServerName = if cfg.serverName != null then cfg.serverName else if swarmDomain != null then swarmDomain else "invalid"; # 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/integrations/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 ''; }); # Shared host netns — agents reach tuwunel at localhost:. privateNetwork = false; in { # Private matrix-tuwunel homeserver wrapped in a nixos-container, # optional fluffychat-web client at chat./ (gatewayHost's # default). Container shape, # serverName vs gatewayHost split, provisioning flow (registration # token + LoadCredential), assertion rationale, initial rollout # settings: docs/integrations/matrix.md. Vhost map + discovery flow + tuning # knobs: docs/networking/gateway.md. # Matrix moved under `swarm` when the swarm-global services were # consolidated. One rename for the namespace: the subtree comes with it, # so existing hives keep evaluating and get one warning naming both paths. imports = [ (lib.mkRenamedOptionModule [ "services" "hyperhive" "matrix" ] [ "services" "hyperhive" "swarm" "matrix" ] ) # "does THIS host run it" is a per-host decision, and `swarm.*` is the # namespace every host in the swarm agrees on. What stays under # `swarm.matrix` is the homeserver's identity as every hive sees it — # its server name, its addresses, its federation policy. (lib.mkRenamedOptionModule [ "services" "hyperhive" "swarm" "matrix" "enable" ] [ "services" "hyperhive" "deploy" "matrix" "enable" ] ) (lib.mkRemovedOptionModule [ "services" "hyperhive" "swarm" "matrix" "sso" "enable" ] '' SSO is no longer optional: a homeserver that runs at all delegates login to the swarm's authelia. Removed rather than defaulted to true so a config that turned it OFF fails here, where the line is, instead of silently gaining a login flow on the next rebuild. Drop the line; if it was false, set services.hyperhive.deploy.matrix.sso.clientSecretFile and services.hyperhive.swarm.authelia.url as the assertions describe. '') ]; # `swarm.matrix`, in ./hive-matrix-service.nix, is what the homeserver IS from # any hive's point of view: the name it answers to, the ports and URLs it is # reached on, and the client id it is registered under. What lives here is what the host running it # decides — which build it runs, whether it is exposed, which peers it trusts, # how large a request it accepts, and where its host-local secrets sit. Same rule as # ./swarm-victorialogs.nix; `enable` already lives in ./deploy.nix, which also # carries the renames. options.services.hyperhive.deploy.matrix = { 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. ''; }; 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 host reaches the homeserver on loopback, and agent containers reach it at `gatewayHost`'s vhost (`chat.` by default) via the gateway — 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.deploy.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. This is the only limit to set: the gateway's own body cap is derived from it, with headroom, so the homeserver stays the one that rejects an oversized upload. ''; }; appserviceTokenFile = lib.mkOption { type = lib.types.path; internal = true; default = appserviceTokenPath; description = '' Host path to a file containing this hive's matrix appservice token (`as_token`). Minted automatically on first activation (32-byte random hex, mode 0600) and rendered into the appservice registration the homeserver loads at boot. Agents never see it; an agent only ever receives its own `access_token`. Not operator-settable — the module's registration renderer reads this same path as a literal, not through the option, with nothing wiring an override across, so a moved path desyncs the two silently. An externally-managed token is delivered by writing into *this* fixed path instead of moving it — see `glue-matrix-bao-token.nix`, which fetches from the swarm secret store, overwrites this file in place, and re-renders the registration that names it. Enforced by an `assertions` entry below rather than `readOnly`: the `config` block gives this option its own `mkDefault` definition (lowest priority) so a real override still resolves cleanly instead of crashing eval with nixpkgs' generic "read-only, set multiple times" message; the assertion then names the actual problem. (`readOnly` was the first attempt on this option's predecessor — atlas caught that it only rejects a *second* definition, so with nothing else defining the option a lone override sailed through silently, same desync as before with a lock that wasn't locked. Fixed by defining the value here instead of leaving it on `default` alone, but a proper `assertions` message beat re-adding `readOnly` on top once the option had a real definition either way.) ''; }; appserviceRegistrationScript = lib.mkOption { type = lib.types.path; internal = true; default = "${appserviceRegistrationScript}/bin/hive-matrix-appservice-registration"; defaultText = lib.literalMD "the module's own registration renderer"; description = '' The script that mints the appservice tokens when absent and (re-)renders the registration file from them. An option only so that `glue-matrix-bao-token.nix` can run the same one after overwriting the token with the swarm store's copy, rather than carrying a second copy of the registration's shape. Not operator-settable, and not a hook: replacing it means deciding what the homeserver's appservice registration says, which is this module's job. ''; }; gui.enable = lib.mkOption { type = lib.types.bool; default = deployCfg.matrix.enable; defaultText = lib.literalExpression "config.services.hyperhive.deploy.matrix.enable"; description = '' Serve a matrix web client at `gatewayHost`'s vhost (`chat.` by default). Requires `gatewayHost != null`; the gateway itself always runs. When off, the dashboard's `M4TR1X →` tab is hidden. See `docs/networking/gateway.md` for the discovery flow that lets clients auto-find the sub-domain. The client build itself is `deploy.matrix.gui.package` — which client, as opposed to whether this host serves it. ''; }; gui.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 `gatewayHost`'s vhost (`chat.` by default). 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. ''; }; sso.clientSecretFile = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; example = "/var/lib/tuwunel-oidc/tuwunel.secret"; description = '' Path **inside the matrix container** holding the client secret's plaintext. A path, never a value: an OIDC client secret has two holders in two containers (authelia keeps a hash, this homeserver needs the plaintext), and a literal written here would be rendered into the world-readable nix store. Deliberately no fallback — a homeserver that boots with SSO half-configured is worse than one that fails to evaluate: tuwunel reads OIDC from its config file rather than a database row, so a malformed block can stop the server outright instead of merely hiding a button. The id this pairs with is `swarm.matrix.sso.clientId`, which is swarm-wide because it must match authelia's register. ''; }; ctlPackage = lib.mkOption { type = lib.types.package; defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-matrix-ctl"; description = '' The `swarm-matrix-ctl` build run inside the matrix container. ⚠️ Named `ctlPackage`, not folded into `package` above: that one is the homeserver, and this is a hyperhive binary that happens to run beside it. Same split, and same reason, as {option}`services.hyperhive.deploy.nats.authPackage`. ''; }; ctlBaoClientCertFile = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = '' Client certificate the matrix container's `swarm-matrix-ctl` presents to the swarm's secret store. Its subject must be {option}`services.hyperhive.deploy.bao.matrixCtlCommonName` — cert auth matches on the CN, and the role accepts nothing else. ⚠️ **Not the hive's own leaf**, and that is the whole deliverable of giving this container an identity: the hive's certificate reads every secret in the store, while this one may write a single path. Pointing this at `deploy.bao.clientCertFile` would evaluate, deploy and work — and give away the separation in one line. No default: a module that guessed would be holding the CA opinion ./swarm-bao.nix deliberately does not hold. ./glue-matrix-ctl-bao-identity.nix points it at the leaf ./glue-bao-tls.nix mints, where this host mints one. The file and its key are bind-mounted into the container read-only. Co-located in the hive's filesystem and therefore readable by hive **root** — accepted: the boundary this buys is identity (no hive *process* holds the appservice token), not physical isolation. ''; }; ctlBaoClientKeyFile = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = '' Private key for {option}`services.hyperhive.deploy.matrix.ctlBaoClientCertFile`. Both or neither — the unit does not exist unless each is set. ''; }; }; config = lib.mkIf deployCfg.matrix.enable { # The option's own value, defined explicitly rather than left on its # bare `default` — `mkDefault` so a real override still resolves # cleanly rather than crashing eval; the `assertions` entry below is # what actually rejects it, with a message naming the reason. See that # option's own comment above. services.hyperhive.deploy.matrix.appserviceTokenFile = lib.mkDefault appserviceTokenPath; # Matrix's own gateway surface: the sub-domain vhost, the name the # hive resolver answers for, and the Accept-header map that vhost's # SPA fallback reads. All three are matrix knowledge and none of # them is the gateway's business. # # `gatewayHost = null` means matrix is reachable directly rather # than fronted, so there is no name to claim and no vhost to serve — # every clause below carries that guard. services.hyperhive.gateway.localNames = lib.optional (cfg.gatewayHost != null) cfg.gatewayHost; services.hyperhive.gateway.enable = lib.mkIf (cfg.gatewayHost != null) (lib.mkDefault true); # Unguarded, unlike the two above: the homeserver container resolves # through the hive's dnsmasq whether or not the gateway fronts it. services.hyperhive.gateway.dns.enable = lib.mkDefault true; # This swarm-ui quick-links entry. Gated on `gui.enable` too, not just # `gatewayHost != null`: `/` on that vhost only serves fluffychat # (below) when the GUI is on — otherwise the link would 404. Same # `gatewayHost && gui.enable` condition the removed hive-dashboard # H0M3 tile used to check via `state.matrix_gui_enabled` before this # link became the only way in (docs/web-ui/dashboard.md::M4TR1X # page). See `services.hyperhive.swarm.controller.links`'s # description. services.hyperhive.swarm.controller.links = lib.optional (cfg.gatewayHost != null && deployCfg.matrix.gui.enable) { label = "Matrix"; icon = "💬"; url = "https://${cfg.gatewayHost}/"; }; # Accept-header SPA map, used only by the `/` location below (see # docs/networking/gateway.md "SPA fallback"): text/html → index.html, else a # sentinel so `try_files` falls through to 404. `appendHttpConfig` # is a `lines` option, so this merges with anything else the host # contributes instead of replacing it. # # The dashboard needs no equivalent — it routes by path. services.nginx.appendHttpConfig = lib.optionalString deployCfg.matrix.gui.enable '' map $http_accept $matrix_spa_target { default "/__matrix_spa_no_html_fallback"; "~*text/html" "/index.html"; } ''; # `server_name = gatewayHost`. `/_matrix/*` → tuwunel (CORS `*`, 50M # body cap, 1h long-poll timeout). `/` serves fluffychat, or 404 # with the GUI off. nginx's longest-prefix rule puts `/_matrix/` # ahead of `/` with no ordering needed. # # ⚠️ The `.well-known/matrix/*` delegation is deliberately NOT here. # It stays on the hive's own vhost because the spec requires it to # be served at the *server name*, which is the hive domain — it is # the hive answering "where is my homeserver", not the homeserver # answering for itself. services.nginx.virtualHosts = lib.optionalAttrs (cfg.gatewayHost != null) { "${cfg.gatewayHost}" = (gatewayCfg.lib.tlsFor cfg.gatewayHost) // { listen = gatewayCfg.lib.listen; extraConfig = gatewayCfg.lib.securityHeaders; locations = { "/_matrix/" = { proxyPass = "http://127.0.0.1:${toString cfg.httpPort}"; proxyWebsockets = true; extraConfig = '' proxy_buffering off; # Tracks `maxRequestSize` with headroom so the homeserver stays # the tighter limit: its rejection is a matrix error a client can # act on, where a 413 here names neither matrix nor the option the # operator just raised. A second literal beside it was free to # disagree, and did — raising the option past the old 50M changed # nothing. client_max_body_size ${toString (deployCfg.matrix.maxRequestSize + 1048576)}; proxy_read_timeout 1h; proxy_send_timeout 1h; ${gatewayCfg.lib.securityHeaders} add_header Access-Control-Allow-Origin *; ''; }; } // lib.optionalAttrs deployCfg.matrix.gui.enable { # fluffychat at sub-domain root, SPA-fallback via the # Accept-header `$matrix_spa_target` map above. "/" = { alias = "${deployCfg.matrix.gui.package}/"; extraConfig = '' try_files $uri $uri/ $matrix_spa_target =404; ''; }; # FluffyChat boot-config pre-fill so the client's # `.well-known/matrix/client` lookup hits the right delegation # endpoint. `domain` is required, so this is always present. "= /config.json" = { extraConfig = '' default_type application/json; return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}'; ''; }; } // lib.optionalAttrs (!deployCfg.matrix.gui.enable) { "/" = { return = "404"; }; }; }; }; # `serverName` is irrevocably embedded in user/room IDs; it derives # from `services.hyperhive.domain` (required, asserted in # hive-network.nix) when not set explicitly, so no separate # domain/serverName assertion is needed here. gatewayHost may not be # "" (same footgun as forge.domain — nginx rejects an empty # server_name). docs/integrations/matrix.md::Assertion rationale. assertions = [ { assertion = cfg.gatewayHost == null || cfg.gatewayHost != ""; message = '' services.hyperhive.swarm.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". ''; } { # Fail at EVAL, not at boot. tuwunel reads its identity providers # from the config file, so a half-configured one does not hide a # login button — it can stop the homeserver from starting at all. assertion = deployCfg.matrix.sso.clientSecretFile != null; message = '' This homeserver's SSO login flow requires services.hyperhive.deploy.matrix.sso.clientSecretFile — the path (inside the matrix container) holding the OIDC client secret's plaintext. On a hive that also runs the swarm's authelia this is wired up for you. Set it explicitly when authelia lives on another host: see docs/swarm/ for which secret goes where. ''; } { # The callback URL must name the homeserver itself, and with no # gateway vhost there is no public name for it to be built from. assertion = cfg.gatewayHost != null; message = '' This homeserver's SSO login flow requires services.hyperhive.swarm.matrix.gatewayHost. tuwunel's SSO callback URL is format-locked to `/_matrix/client/unstable/login/sso/callback/`, and the identity provider redirects a browser to it — so it has to be a name the browser can reach, which is exactly what `gatewayHost` is. With it null the homeserver is direct on httpPort and has no such name. ''; } { # mkDefault above lets an override resolve instead of crashing # eval — this is the actual rejection, with a message that names # the real fix instead of nixpkgs' generic conflicting-definition # text. assertion = deployCfg.matrix.appserviceTokenFile == appserviceTokenPath; message = '' services.hyperhive.deploy.matrix.appserviceTokenFile is fixed at ${appserviceTokenPath} and cannot be moved — the registration renderer reads this same path as a literal and has no way to learn an override, so moving it desyncs the two silently instead of loudly. Integrating an externally-managed appservice token? Deliver it by writing into ${appserviceTokenPath} instead of pointing this option elsewhere — see glue-matrix-bao-token.nix, which does exactly that from the swarm secret store. ''; } ]; # One declaration, two readers. The homeserver knows its own callback # URL; making the operator restate it in authelia's client list would # be a second source of truth for a string whose mismatch is a silent # rejected login. services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf ssoLocal [ { id = cfg.sso.clientId; description = "HyperHive matrix"; redirectUris = [ ssoCallbackUrl ]; # tuwunel authenticates at the token endpoint by putting the # secret in the POST body. Authelia enforces the *registered* # method rather than accepting whichever one arrives, and its # default is `client_secret_basic` — so without this the browser # flow completes, consent is granted, and the very last hop fails # with a 401 that names neither the secret nor the redirect. tokenEndpointAuthMethod = "client_secret_post"; } ]; # Same case, same reasoning: this host minted the secret, so it can say # where the homeserver will find it. services.hyperhive.deploy.matrix.sso.clientSecretFile = lib.mkIf ssoLocal ( lib.mkDefault matrixSecretPath ); # The delivery. It runs on the HOST because that is the only place both # container trees are addressable: they share this host's network # namespace, which makes them feel co-located, but their filesystem # roots are separate — the homeserver cannot open a path inside # authelia's tree however local the port looks. # # ⚠️ Deliberately a copy and not a `bindMounts` entry. # nixos-container refuses to start when a bind source is missing, and # this secret does not exist until authelia's first boot has minted it # — so binding it would make the homeserver wait on a file that waits # on a container that starts after it. On a fresh hive that is a # permanent stall presenting as "matrix is broken", several layers from # its cause. # # The appservice registration dodges that with an activation script # that renders the file first. ⚠️ That dodge is NOT available here: # tuwunel requires the secret file to exist *and be non-empty*, so a # zero-byte placeholder would satisfy the bind mount and then stop the # homeserver from starting. systemd.services.hive-matrix-oidc-secret = lib.mkIf ssoLocal { description = "deliver the homeserver's OIDC client secret from authelia"; after = [ "container@${autheliaCfg.machine}.service" ]; requires = [ "container@${autheliaCfg.machine}.service" ]; before = [ "container@hive-matrix.service" ]; wantedBy = [ "container@hive-matrix.service" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; SyslogIdentifier = "hive-matrix-oidc-secret"; # Longer than the 120s bounded wait below, and that is the whole # point: `DefaultTimeoutStartSec` is 90s, so without this systemd # kills the unit at 90 — before it can emit the message naming the # file it was waiting for. The failure then reads as a timeout with # no cause rather than "authelia has not minted ", which is # the one line that makes a fresh-hive SSO stall diagnosable. TimeoutStartSec = "180s"; }; path = [ pkgs.coreutils ]; script = '' set -euo pipefail src=${lib.escapeShellArg "${deployCfg.authelia.hostClientSecretDir}/${cfg.sso.clientId}.secret"} dst=${lib.escapeShellArg "/var/lib/nixos-containers/hive-matrix${toString deployCfg.matrix.sso.clientSecretFile}"} # authelia's container is up, but its first-boot generator may # still be minting. Bounded wait, then fail: a silent skip here # produces a homeserver whose SSO login dead-ends, which is the # failure this whole design is trying not to ship. for _ in $(seq 1 60); do [ -s "$src" ] && break sleep 2 done if [ ! -s "$src" ]; then echo "authelia has not minted $src after 120s" >&2 exit 1 fi # root-owned 0400, and deliberately NOT the forge's `stat -c %u` # uid discovery: that reads the service's state dir to learn which # uid to hand the file to, and tuwunel runs under `DynamicUser`, so # there is no stable uid to discover. It never reads this path # directly anyway — `LoadCredential` does, as root, before the # sandbox and the dynamic user exist. install -D -m 0400 -o root -g root "$src" "$dst" ''; }; # ⚠️ Deliberately NO `networking.hosts` entry for authelia's name. This # container resolves through the hive's dnsmasq at `bridgeIp` (see the # static resolv.conf below), and every `gateway.localNames` entry — # authelia's domain among them — is already mapped there. Adding a # loopback override would only create a second answer that can # disagree with the first. # 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/integrations/matrix.md::Provisioning flow. # Tell an operator whose homeserver already exists that `serverName` is # unpinned, at the one moment they are looking: the rebuild. # # ⚠️ An activation script and NOT `warnings`, which is where this # obviously belongs and does not work. The condition needs the host # filesystem — does a homeserver already exist here? — and # `nixos-rebuild switch --flake` evaluates PURELY, where # `builtins.pathExists "/var/lib/…"` answers **false** rather than # throwing. A `warnings` entry gated on it would evaluate, deploy, and # print nothing, on every real deployment. Same shape as an option whose # consumer is disabled: renders perfectly, does nothing. # # Only rendered when `serverName` is null, so a hive that pinned it # cannot be nagged — the script does not exist there rather than # existing and choosing to stay quiet. A guard that cries wolf at a # correctly-configured deployment is worse than no guard, because the # next real one is read as noise too. # # Never fails. This warns about a choice that cannot be undone; refusing # the activation would break the rebuild of a hive that had already made # that choice deliberately, which is the opposite of helping. system.activationScripts.hive-matrix-servername-pin = lib.mkIf (cfg.serverName == null) ( lib.stringAfter [ "var" ] '' # The homeserver's own database, asked of the container's evaluated # config rather than hardcoded: a guessed path resolves cleanly and # silently never matches, which is exactly the failure this guard # exists to avoid one level up. dbDir=${ lib.escapeShellArg ( # Same host-side container-root prefix this module already writes # by hand for the SSO secret copy above — not a second convention. "/var/lib/nixos-containers/hive-matrix" + config.containers.hive-matrix.config.services.matrix-tuwunel.settings.global.database_path ) } if [ -d "$dbDir" ]; then echo "hive-matrix: WARNING — services.hyperhive.swarm.matrix.serverName is unset, and this host already has a homeserver at $dbDir." echo "hive-matrix: it is defaulting to ${effectiveServerName}, which is baked into every NEW user and room id." echo "hive-matrix: if ids here were minted under a different name, existing accounts and rooms are stranded — reverting the config does NOT undo it." echo "hive-matrix: pin whichever name this homeserver already uses, e.g.:" echo "hive-matrix: services.hyperhive.swarm.matrix.serverName = \"''${HIVE_MATRIX_EXISTING_SERVER_NAME:-}\";" fi '' ); # Mint the appservice tokens and render the registration, before any # container start. # # An activation script rather than a unit: the directory below is # bind-mounted into the container and nixos-container refuses to start # when a bind source is missing, so this has to have run first. # Activation is what runs before the container both on a switch and on # every boot. (The registration token this replaced used an activation # script for the same reason, and additionally to dodge nspawn creating # an empty file at a missing bind target — which tuwunel then read as # "no token", refusing every registration until the next restart. A # missing registration file is not silent in that way: the homeserver # fails its appservice load loudly.) system.activationScripts.hive-matrix-appservice = lib.stringAfter [ "var" ] '' ${deployCfg.matrix.appserviceRegistrationScript} ''; containers.hive-matrix = { autoStart = true; ephemeral = false; # Journal files on the host, not inside the container: nixpkgs hardcodes # --link-journal=try-guest, and EXTRA_NSPAWN_FLAGS expands after it. extraFlags = [ "--link-journal=host" ]; inherit privateNetwork; # Read-only bind of the host-managed appservice registration; tuwunel # reads it via systemd LoadCredential below (not directly). # # The directory, not the file inside it: the registration is # re-rendered rather than edited, and binding the file would pin the # inode the container saw when it started. bindMounts = { ${appserviceDir} = { hostPath = appserviceDir; isReadOnly = true; }; } // caTrust.bindMount # matrix-ctl's bao client identity, and nothing else of the hive's. See # `ctlPkiDirs` above for why it is a derived set of directories # rather than one literal. // ctlBindMounts; config = { ... }: { imports = [ ../container-modules/swarm-container.nix # tuwunel's rustls verifier resolves through `rustls-native-certs` # → `openssl-probe`, which reads `SSL_CERT_FILE` — so the # openssl-shaped variable is the lever despite tuwunel linking no # openssl. # # ⚠️ The helper CONCATENATES, and that is load-bearing here beyond # the usual reason: `SSL_CERT_FILE` replaces the default location, # so naming the hive anchor alone would drop every public CA and # break federation with the wider matrix network — trading a small # outage for a much larger one. # A literal, not an option: this module names its container # `containers.hive-matrix` directly and declares no `machine` # option to derive it from. (caTrust.trustBundle { inherit pkgs; name = "hive-matrix"; consumers = [ "tuwunel" ]; }) ]; services.hyperhive.swarmContainer = { inherit privateNetwork; }; # Swarm-internal trust reaches tuwunel at RUNTIME, not via # `security.pki.certificateFiles`. That option is read when the # system is BUILT, and the swarm root is deliberately a runtime # file (`swarm.ca.stateDir`) because its key must never enter the # store — so there is nothing build-time to name. The bind-mount # above plus the bundle service below are what replaced it. # 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. # # Trusting resolvconf to honour `networking.nameservers` doesn't # work either — that's a RUNTIME resolvconf behaviour, not # verifiable at eval time, and it still comes 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 agent-container isolation. See `docs/networking/network.md`. # resolvconf is off through `writesOwnResolvConf` (its default); # the static `environment.etc."resolv.conf"` below is the sole # source of the resolver file (no `nameservers` — nothing would # read it). networking.useHostResolvConf = lib.mkForce false; # 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."resolv.conf".text = '' nameserver ${networkCfg.bridgeIp} options edns0 ''; services.matrix-tuwunel = { enable = true; package = deployCfg.matrix.package; settings.global = { server_name = effectiveServerName; # `address` + `port` are upstream `listOf` — wrap singles. address = [ "0.0.0.0" ]; port = [ cfg.httpPort ]; max_request_size = deployCfg.matrix.maxRequestSize; # Federation enabled at the protocol level; empty # trustedServers keeps it effectively closed. allow_federation = true; trusted_servers = deployCfg.matrix.trustedServers; # Nobody registers themselves here. Accounts are created by # the hive's appservice, which this flag does not gate — # tuwunel checks it only for requests that arrive WITHOUT an # appservice token, so hive-c0re provisions exactly as before # and everyone else is refused outright. # # ⚠️ Not a hardening afterthought: `allow_registration = true` # with no registration token configured makes tuwunel REFUSE # TO START (it demands # `yes_i_am_very_very_sure_…_open_registration_…` instead). So # dropping the token and leaving this true is not a lax # homeserver, it is a homeserver that does not boot. allow_registration = false; # Where the hive's appservice registration is read from — the # credentials directory, for the reasons at # `appserviceCredentialDir`'s own definition. Loaded inside # `Services::start()`, before the listener accepts anything. appservice_dir = appserviceCredentialDir; } # Exactly one account is promoted to homeserver admin at boot: # the swarm appservice's sender. Its token is read by # matrix-ctl and swarm-controller only. `@${hiveLocalpart}` # stays an ordinary user, because every hive reads its # credential; what it does (the Space, the chat room, the # invites) rides on being the rooms' creator, not on admin. # # The registration load creates the sender inside # `Services::start()`, and `admin_execute` runs after that and # before the listener accepts anything. `make-user-admin` is a # no-op on an account that is already admin. # `admin_execute_errors_ignore` is load-bearing: a failing # command aborts startup when it is false. // lib.optionalAttrs ctlActive { admin_execute = [ "users make-user-admin @${swarmSenderLocalpart}:${effectiveServerName}" ]; admin_execute_errors_ignore = true; } // { # Server-side E2EE is opt-in (default off); the agent matrix # client always supports decryption regardless. allow_encryption = cfg.allowEncryption; # Tuwunel's default suffix is " 💕" — suppress it so agent # display names are clean (just the agent name, no emoji). new_user_displayname_suffix = ""; # tuwunel's OIDC server and this list are the two ends of one # pipe: `oidc_native_auth` stays false (its default), which # upstream defines as "the OIDC server runs only to broker # for a configured identity_provider". So the client-facing # half needs no configuration — only the upstream half does. identity_provider = [ { # A free, case-insensitive string, not an enum: a # recognised brand gets defaults and provider-specific # workarounds, an unrecognised one simply gets neither. # Which is why `issuer_url` below is not optional for us # — the pre-supplied issuers cover public providers only. brand = "authelia"; client_id = cfg.sso.clientId; client_secret_file = matrixSecretCredential; issuer_url = toString autheliaUrl; callback_url = ssoCallbackUrl; # Explicit, though a lone provider is auto-defaulted: # relying on that logs a warning every startup, and a # recurring warning that is expected is one nobody reads. default = true; # Upstream's rule is "only ever set `trusted` for # identity providers you self-host and fully control", # and this module cannot point anywhere else: the issuer # is `swarm.authelia.url`, whose client, secret and user # database are all ours. It does mean whoever can make # authelia emit a given name gets that account — for our # own identity provider that IS the identity. # Without it, an SSO login cannot adopt an account that # already exists; it can only ever create a new one. trusted = true; # One claim instead of upstream's ladder # (`preferred_username` → `username` → `nickname` → # `login` → `email`). The tail is the hazard: an email # local part is a different namespace, so a login can # land on a name that means someone else here. userid_claims = [ "preferred_username" ]; # The default (`true`) makes a name collision SILENT — # tuwunel invents a random localpart and the login # succeeds as the wrong user. `false` errors instead, # which is the only form of this an operator can act on. unique_id_fallbacks = false; } ]; }; }; # 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 = [ # The credential id carries a `.yaml` suffix on purpose: # `appservice_dir` above names this very directory, and tuwunel # takes only `.yaml`/`.yml` entries from it. "${appserviceCredentialId}:${appserviceRegistrationPath}" # Same mechanism, second secret. tuwunel re-reads this file on # every OAuth exchange, not just at startup, so it has to # outlive the unit's start — a credentials path does. "oidc_client_secret:${toString deployCfg.matrix.sso.clientSecretFile}" ] # The swarm registration, a second `.yaml` in the same directory: # tuwunel loads every one it finds (`appservice/mod.rs`), and # refuses only a duplicate `id` or `as_token`, never an # overlapping namespace. A missing source fails this unit, which # is why the render below is `requiredBy` it and local only. ++ lib.optional ctlActive "${swarmAppserviceCredentialId}:${swarmAppserviceDir}/swarm.yaml"; # The swarm registration, minted and rendered before the homeserver # loads it. No network and no store: this is on tuwunel's start # path, and a store outage must not keep the homeserver down. systemd.services.swarm-matrix-appservice-render = lib.mkIf ctlActive { description = "render the swarm's appservice registration"; before = [ "tuwunel.service" ]; requiredBy = [ "tuwunel.service" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; ExecStart = "${deployCfg.matrix.ctlPackage}/bin/swarm-matrix-ctl appservice render"; StateDirectory = baseNameOf swarmAppserviceDir; StateDirectoryMode = "0700"; UMask = "0077"; SyslogIdentifier = "swarm-matrix-appservice"; }; environment = { MATRIX_APPSERVICE_DIR = swarmAppserviceDir; MATRIX_APPSERVICE_SENDER = swarmSenderLocalpart; # The hive registration's namespace. Agents' localparts are # bare `[a-z0-9-]`, so nothing narrower covers them without # also covering people. MATRIX_APPSERVICE_USER_REGEX = appserviceUserRegex; }; }; # Hand the swarm registration's token to swarm-controller, through # the store, under matrix-ctl's identity; the write lands at a path # only matrix-ctl and the controller are granted (./swarm-bao.nix). # `Restart=on-failure` covers a sealed store or one still starting; # the start timeout is bounded for the reason # ./swarm-secret-publisher.nix states: a sealed store answers on the # port and never answers the read. systemd.services.swarm-matrix-appservice-publish = lib.mkIf ctlActive { description = "publish the swarm's appservice token to the swarm secret store"; after = [ "swarm-matrix-appservice-render.service" ]; requires = [ "swarm-matrix-appservice-render.service" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "oneshot"; ExecStart = "${deployCfg.matrix.ctlPackage}/bin/swarm-matrix-ctl appservice publish"; Restart = "on-failure"; RestartSec = 30; TimeoutStartSec = 60; SyslogIdentifier = "swarm-matrix-appservice"; }; environment = { BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}"; BAO_CLIENT_CERT = deployCfg.matrix.ctlBaoClientCertFile; BAO_CLIENT_KEY = deployCfg.matrix.ctlBaoClientKeyFile; MATRIX_APPSERVICE_CERT_ROLE = ctlCertRole; MATRIX_APPSERVICE_DIR = swarmAppserviceDir; } // lib.optionalAttrs (deployCfg.bao.serverCaFile != null) { BAO_CACERT = deployCfg.bao.serverCaFile; }; }; environment.systemPackages = [ deployCfg.matrix.package ]; }; }; networking.firewall = lib.mkIf deployCfg.matrix.openFirewall { allowedTCPPorts = [ cfg.httpPort ]; }; # The matrix container's resolver is the hive's dnsmasq (bound at # `bridgeIp`). Order the matrix container start after it 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 guards against is 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 resolver's. # # `mkMerge`, not a bare assignment: `caTrust.containerOrdering` also # sets `after`/`requires` (so the bound trust bundle exists before # nspawn wires the mount up), and two plain assignments to the same # unit would conflict rather than combine. systemd.services."container@hive-matrix" = lib.mkMerge [ { after = [ "dnsmasq.service" ]; } caTrust.containerOrdering ]; }; }