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:
parent
5ce0a357b4
commit
5809077924
1 changed files with 169 additions and 0 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue