matrix: deliver an appservice registration to the homeserver

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.<id>]`
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
This commit is contained in:
atlas 2026-09-15 19:21:15 +02:00
commit 5809077924

View file

@ -56,6 +56,74 @@ let
# with no host-side chown or GID pinning. # with no host-side chown or GID pinning.
matrixSecretCredential = "/run/credentials/tuwunel.service/oidc_client_secret"; 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<String>`), 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.<id>]` 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 # Format-locked by tuwunel, not chosen here: the callback host must point
# directly at the matrix server and the path is fixed at # directly at the matrix server and the path is fixed at
# `/_matrix/client/unstable/login/sso/callback/<client_id>`. Built once # `/_matrix/client/unstable/login/sso/callback/<client_id>`. Built once
@ -902,6 +970,64 @@ in
chmod 0600 "$tokenFile" 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 = { containers.hive-matrix = {
autoStart = true; autoStart = true;
ephemeral = false; ephemeral = false;
@ -917,6 +1043,13 @@ in
hostPath = deployCfg.matrix.registrationTokenFile; hostPath = deployCfg.matrix.registrationTokenFile;
isReadOnly = true; 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; // caTrust.bindMount;
config = config =
@ -1015,6 +1148,37 @@ in
# LoadCredential below copies the host file into a # LoadCredential below copies the host file into a
# 0400 dynamic-user-owned path; tuwunel reads from there. # 0400 dynamic-user-owned path; tuwunel reads from there.
registration_token_file = "/run/credentials/tuwunel.service/registration_token"; 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 # Server-side E2EE is opt-in (default off); the agent matrix
# client always supports decryption regardless. # client always supports decryption regardless.
allow_encryption = cfg.allowEncryption; allow_encryption = cfg.allowEncryption;
@ -1076,6 +1240,11 @@ in
# See `man systemd.exec` → LoadCredential. # See `man systemd.exec` → LoadCredential.
systemd.services.tuwunel.serviceConfig.LoadCredential = [ systemd.services.tuwunel.serviceConfig.LoadCredential = [
"registration_token:${toString deployCfg.matrix.registrationTokenFile}" "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 # Same mechanism, second secret. tuwunel re-reads this file on
# every OAuth exchange, not just at startup, so it has to # every OAuth exchange, not just at startup, so it has to
# outlive the unit's start — a credentials path does. # outlive the unit's start — a credentials path does.