From 580907792451124ed1c2d0f47af600245028cda7 Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 15 Sep 2026 19:21:15 +0200 Subject: [PATCH] matrix: deliver an appservice registration to the homeserver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hive creates matrix accounts with a shared registration token today, which means the secret that authorises account creation has to reach both hive-c0re and tuwunel and stay identical in both. An appservice registration replaces that with an identity: one token that says "this is the hive's provisioner", carried in an ordinary credential file. `url = null`, so nothing is served and no daemon is introduced — with no URL the homeserver never calls out, and the registration exists purely to give the `as_token` meaning. Delivered through `appservice_dir` 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 file is minted and rendered by a host activation script, bind-mounted into the container, and handed to the homeserver by `LoadCredential` — the same two steps the registration token and the OIDC client secret already take, and for the same reason (0600 root on the host, `DynamicUser=true` in the container). `sender_localpart` is the hive admin account on purpose: loading a registration creates its sender user on a zero-user database inside `Services::start()`, and the `admin_execute` promotion runs after that and still before the HTTP listener accepts anything. So a fresh homeserver has a joined, power-level-100 admin on its first boot without anyone having won the first-registered-user grant. `admin_execute_errors_ignore` is set because a failing startup command otherwise aborts startup outright. Nothing reads the registration yet — hive-c0re still provisions through the registration token, which is untouched here. Refs #4402 --- nix/host-modules/hive-matrix.nix | 169 +++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 8d087b0a..48605f58 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -56,6 +56,74 @@ let # with no host-side chown or GID pinning. matrixSecretCredential = "/run/credentials/tuwunel.service/oidc_client_secret"; + # How this hive creates matrix accounts: an 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. What the registration delivers is the `as_token`: hive-c0re's + # standing authority to create, and log in as, the accounts named by the + # namespace below, with no shared registration secret in the picture. + appserviceId = "hyperhive"; + + # The appservice's own user, and deliberately the hive admin account. + # 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. + # That is what lets the `admin_execute` promotion below land on the very + # first boot of a fresh homeserver, instead of depending on hive-c0re + # racing to register the first account and win the auto-admin grant. + # + # ⚠️ Must equal `matrix::HIVE_ADMIN_LOCALPART` in hive-c0re, which derives + # it independently with nothing wiring an override across — same + # agreement, and same reason for saying so, as the token path below. + adminLocalpart = "hive"; + + # The `as_token`, and the `hs_token` the spec requires alongside it. Both + # minted by the activation script below, mode 0600; the `as_token` is the + # one hive-c0re reads and 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 = "/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"; + + # Every local user this hive may provision — agents, the hive admin, and + # the operator accounts `hivectl matrix create-user` makes, 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}$"; + # 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 @@ -902,6 +970,64 @@ in chmod 0600 "$tokenFile" ''; + # The appservice registration: mint the two tokens once, then re-render + # the registration file from them on EVERY activation. + # + # Re-rendering unconditionally is the point, not thoroughness. The token + # file is overwritten in place by `glue-matrix-bao-token.nix` when the + # swarm secret store has a value for this hive, so "the file exists" does + # not mean "the registration carries what is in it" — and a registration + # carrying a stale token is a homeserver that refuses every request + # hive-c0re makes, with a 401 that names nothing. + # + # An activation script rather than a unit, same as the token above: the + # directory is bind-mounted into the container, and nixos-container + # refuses to start when a bind source is missing. Activation runs before + # the container on a switch and on every boot. + system.activationScripts.hive-matrix-appservice = lib.stringAfter [ "var" ] '' + appserviceDir=${lib.escapeShellArg appserviceDir} + regFile=${lib.escapeShellArg appserviceRegistrationPath} + + # 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 "$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 bash 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: ${adminLocalpart} + rate_limited: false + namespaces: + users: + - exclusive: false + regex: '${appserviceUserRegex}' + aliases: [] + rooms: [] + REGISTRATION + printf 'as_token: %s\nhs_token: %s\n' "$asToken" "$hsToken" + } > "$regFile" + chmod 0600 "$regFile" + chmod 0700 "$appserviceDir" + ''; + containers.hive-matrix = { autoStart = true; ephemeral = false; @@ -917,6 +1043,13 @@ in hostPath = deployCfg.matrix.registrationTokenFile; isReadOnly = true; }; + # The directory, not the file inside it: the registration is + # re-rendered on every activation, and binding the file would pin + # the inode the container saw when it started. + ${appserviceDir} = { + hostPath = appserviceDir; + isReadOnly = true; + }; } // caTrust.bindMount; config = @@ -1015,6 +1148,37 @@ in # 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"; + + # 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; + + # The zero-user bootstrap, and the only thing here that needs + # no account to already exist. These run after startup and + # BEFORE the HTTP listener, with no sender and no permission + # check — which is what makes them the one lever that can + # promote the hive admin on a homeserver where nobody is admin + # yet. The appservice registration above creates + # `@${adminLocalpart}` as its sender user moments earlier; + # this joins it to the admin room at power level 100. + # + # Idempotent by upstream's own guard: `make_user_admin` + # short-circuits when the user is already joined at 100, so a + # hive that has had an admin for months emits nothing. + admin_execute = [ + "users make-user-admin @${adminLocalpart}:${effectiveServerName}" + ]; + + # ⚠️ Load-bearing, not tidiness. An `admin_execute` command + # that fails aborts startup outright when this is false — so + # the one boot where the promotion cannot work (a homeserver + # that has no `@${adminLocalpart}` and no appservice user yet, + # e.g. a registration file that arrived late) would take the + # homeserver down with it rather than converging on the next + # start. + 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; @@ -1076,6 +1240,11 @@ in # See `man systemd.exec` → LoadCredential. systemd.services.tuwunel.serviceConfig.LoadCredential = [ "registration_token:${toString deployCfg.matrix.registrationTokenFile}" + # Same mechanism, third secret — and the one whose credential id + # carries a `.yaml` suffix on purpose, since `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.