matrix: remove the registration token
Nothing reads it any more: hive-c0re creates accounts as the hive's appservice, so the mint, the host file, the bind mount, the `LoadCredential` entry and tuwunel's `registration_token_file` all go. ⚠️ `allow_registration` has to go to `false` in the same change, and not as hardening. tuwunel refuses to START when registration is allowed with no token configured — it demands `yes_i_am_very_very_sure_…_open_registration_…` instead — so dropping the token and leaving the flag true is not a lax homeserver, it is one that does not boot. The flag is checked only for requests arriving without an appservice token, so hive-c0re provisions exactly as before and everyone else is refused outright. The swarm secret store keeps its role, repointed at the credential that replaced the token (`swarm/hives/<hive>/matrix/appservice-token`). Its unit now also re-runs hive-matrix's own registration renderer after writing the file: the token is half an agreement, and a registration still naming the previous value authenticates nobody. The renderer is shared through an internal option rather than copied, so the registration's shape has one home. Both spellings of `registrationTokenFile` become `mkRemovedOptionModule` with a message naming what replaced them. A hive that never set the option — the default — is unaffected; one that pinned it fails to evaluate with instructions instead of a silent no-op. An upgraded hive needs no intervention: the activation script has both halves in place before the homeserver restarts, existing agents keep the tokens their devices already hold, and the old token file is left on disk read by nothing. docs/integrations/matrix.md spells the path out. Refs #4402
This commit is contained in:
parent
43cd8607ba
commit
7ee7080b21
11 changed files with 410 additions and 242 deletions
|
|
@ -90,39 +90,54 @@ by default. Reaching it on 8448 needs either an explicit tuwunel
|
|||
bind to that port OR a reverse-proxy + `.well-known/matrix/server`
|
||||
delegation (the latter lives in `gateway.md::Discovery flow`).
|
||||
|
||||
## Provisioning flow (registration token)
|
||||
## Provisioning flow (appservice)
|
||||
|
||||
Token-gated registration: hive-c0re holds the token, agents never
|
||||
see it. The agent only receives the resulting `access_token`.
|
||||
Registration is closed. Accounts are created by the hive's own
|
||||
**appservice**: hive-c0re holds the appservice token, agents never see
|
||||
it, and an agent only ever receives its own `access_token`.
|
||||
|
||||
1. **System activation** writes a 32-byte random hex token (64
|
||||
chars) to `services.hyperhive.deploy.matrix.registrationTokenFile`
|
||||
(`/var/lib/hyperhive/matrix-register-token` by default), mode
|
||||
`0600 root:root`, before any container start. Idempotent — only
|
||||
writes when the file is missing or empty; always re-applies 0600
|
||||
(normalises any 0640 / world-readable carry-over from
|
||||
pre-LoadCredential deployments). This runs at activation time
|
||||
(not first container start) to dodge a race where nspawn creates
|
||||
an empty file when the bind-mount target is missing and tuwunel
|
||||
reads `registration_token_file=""`, rejecting every registration
|
||||
until next restart.
|
||||
2. **Read-only bind-mount** maps the host file into the tuwunel
|
||||
The appservice has no URL (`url: null` in its registration), so the
|
||||
homeserver never calls out to it and there is no service to run. What the
|
||||
registration buys is an identity the homeserver recognises — which is why
|
||||
no secret has to be equal on both sides of the wire, and why account
|
||||
creation doesn't depend on registration being open to anyone who learns
|
||||
a token.
|
||||
|
||||
1. **System activation** mints a 32-byte random hex appservice token (64
|
||||
chars) at `/var/lib/hyperhive/matrix-appservice-token` and its
|
||||
spec-required `hs_token` sibling, mode `0600 root:root`, then renders
|
||||
the registration to
|
||||
`/var/lib/hyperhive/matrix-appservice/hyperhive.yaml` (also `0600`).
|
||||
The tokens are minted only when missing; the registration is
|
||||
re-rendered every time, because the token file can be overwritten in
|
||||
place by the swarm secret store and a registration naming a stale
|
||||
token authenticates nobody. Runs at activation time, before any
|
||||
container start, because the directory is bind-mounted and
|
||||
nixos-container refuses to start when a bind source is missing.
|
||||
2. **Read-only bind-mount** maps that directory into the tuwunel
|
||||
container at the same path.
|
||||
3. **systemd `LoadCredential=`** inside the container copies the
|
||||
bind-mounted file into
|
||||
`/run/credentials/tuwunel.service/registration_token`, owned by
|
||||
tuwunel's dynamic user with mode `0400`, at service start. The
|
||||
host file stays `root:root 0600` — no `chown :tuwunel` /
|
||||
`chmod 0640` / GID-pin gymnastics required. Keeps
|
||||
`DynamicUser = true` + `PrivateUsers = true` intact.
|
||||
4. tuwunel's `registration_token_file` points at the credentials
|
||||
path, not the original bind-mount path.
|
||||
5. **hive-c0re** uses the token to register each agent account via
|
||||
the matrix-spec UIAA registration flow, persists the returned
|
||||
`access_token` to `<agent-state>/matrix-token`. The agent's
|
||||
matrix MCP client authenticates with that access_token and
|
||||
never touches the shared registration token.
|
||||
6. **hive-c0re restarts `hive-matrix-daemon`** for the agent
|
||||
registration into
|
||||
`/run/credentials/tuwunel.service/hyperhive-appservice.yaml`, owned by
|
||||
tuwunel's dynamic user with mode `0400`, at service start. The host
|
||||
file stays `root:root 0600` — no `chown :tuwunel` / `chmod 0640` /
|
||||
GID-pin gymnastics required. Keeps `DynamicUser = true` +
|
||||
`PrivateUsers = true` intact.
|
||||
4. tuwunel's `appservice_dir` points at the credentials directory, not at
|
||||
the bind-mount path. It reads only `.yaml`/`.yml` entries from there,
|
||||
so the sibling credentials are invisible to it. The `.yaml` suffix on
|
||||
the credential id is what makes this work.
|
||||
5. **hive-c0re** reads the appservice token and creates each account with
|
||||
one `POST /register` typed `m.login.application_service`, persisting
|
||||
the returned `access_token` to `<agent-state>/matrix-token`. It never
|
||||
mints the token itself: the value has to be the one the rendered
|
||||
registration names, and only the nix side writes that.
|
||||
6. **An account that exists but has lost its token file** is re-tokened
|
||||
by an appservice `POST /login` — no password and no admin rights
|
||||
involved. A stored-password login and an admin-room password reset
|
||||
remain behind that, for accounts created before the appservice existed
|
||||
or named outside its namespace.
|
||||
7. **hive-c0re restarts `hive-matrix-daemon`** for the agent
|
||||
immediately after writing the token so the daemon picks up the
|
||||
new credential without waiting for a full container restart. If
|
||||
the restart fails (for example daemon not yet running on first boot)
|
||||
|
|
@ -130,16 +145,70 @@ see it. The agent only receives the resulting `access_token`.
|
|||
(`hive-matrix-daemon.path` watching for `matrix-token` appearance)
|
||||
brings the daemon up on the same boot cycle anyway.
|
||||
|
||||
### The admin account, and why it needs no first-user luck
|
||||
|
||||
`@hive:<server_name>` is the appservice's own `sender_localpart`, which
|
||||
the homeserver creates itself when it loads the registration — on a
|
||||
zero-user database, inside startup, before the HTTP listener accepts
|
||||
anything. Its **admin rights** then come from an explicit
|
||||
`make_user_admin`, run by tuwunel's `admin_execute` in the same startup
|
||||
and likewise before the listener — so a fresh hive has a joined,
|
||||
power-level-100 admin on its first boot.
|
||||
|
||||
This replaces a dependency on being the first account ever registered,
|
||||
which was fragile in both directions: an appservice-created account is
|
||||
excluded from that automatic grant by design, and on a homeserver that
|
||||
already had users the rule never fired at all.
|
||||
|
||||
Promotion can't be bootstrapped over the API, and that's upstream's
|
||||
design rather than a gap: tuwunel only treats an admin-room message as a
|
||||
command when its sender is already an admin. `admin_execute` is the one
|
||||
lever with no sender to check. hive-c0re re-checks the result on every
|
||||
sweep by reading the admin account's own joined-rooms list; if the rights
|
||||
are missing it says so, names
|
||||
`systemctl restart container@hive-matrix` as the fix, and carries on —
|
||||
agent accounts, the hive Space and the chat room need no admin.
|
||||
|
||||
<details><summary>Upgrading a hive that used the registration token</summary>
|
||||
|
||||
Nothing to do, and nothing to time. The activation script mints the
|
||||
appservice token and renders the registration before the homeserver
|
||||
restarts, so the first boot after the switch already has both halves.
|
||||
|
||||
- **Existing accounts keep working.** An access token lives on the
|
||||
device that minted it; removing the registration token touches no
|
||||
device, no account and no session. `login_with_password` stays on, so
|
||||
the password fallback is still there too.
|
||||
- **Existing token files are honoured.** The per-agent sweep skips any
|
||||
agent that already has a `matrix-token`, so no account is re-registered
|
||||
and no session is displaced.
|
||||
- **The admin account is already admin** on such a hive (it won the
|
||||
first-user grant when the hive was new), so the startup promotion is a
|
||||
no-op — upstream's `make_user_admin` short-circuits when the user is
|
||||
already joined at power level 100.
|
||||
- **`/var/lib/hyperhive/matrix-register-token` is left on disk**, read by
|
||||
nothing. Delete it or leave it; neither does any harm.
|
||||
- **`registrationTokenFile` is a removed option.** A config that still
|
||||
sets it fails to evaluate with a message naming the appservice — a hive
|
||||
that never set it (the default) is unaffected.
|
||||
- **A swarm store holding the old `matrix/registration-token` path** is
|
||||
no longer read at all; the hive uses its locally minted appservice
|
||||
token until someone `put`s a value at `matrix/appservice-token`.
|
||||
|
||||
</details>
|
||||
|
||||
Initial rollout settings:
|
||||
|
||||
- `allow_federation = true` at the protocol level so swarms can be
|
||||
wired up later by extending `trustedServers` without a homeserver
|
||||
restart. `trusted_servers = []` keeps it effectively closed
|
||||
until you list peers.
|
||||
- `allow_registration = true` (required for the token flow to
|
||||
engage). The absent
|
||||
`yes_i_am_very_very_sure_…_open_registration_…` flag keeps the
|
||||
server closed to anyone without the token.
|
||||
- `allow_registration = false`. tuwunel checks this flag only for
|
||||
requests that arrive **without** an appservice token, so hive-c0re
|
||||
provisions exactly as before and everyone else is refused. It's not a
|
||||
hardening afterthought: with no registration token configured,
|
||||
`allow_registration = true` makes tuwunel refuse to start unless
|
||||
`yes_i_am_very_very_sure_…_open_registration_…` is also set.
|
||||
- `allow_encryption` — server-side E2EE switch, sourced from
|
||||
`services.hyperhive.swarm.matrix.allowEncryption` (**default `false`**, opt-in).
|
||||
Off by default because on the hive-internal homeserver the operator
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ when the system builds. The server names the offending file and refuses to run.
|
|||
| ---------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| hive CA cert + key | `hive-tls.nix` first-boot unit | `<deploy.hive-controller.tls.stateDir>/ca.pem`, `ca-key.pem` (`0600`) |
|
||||
| hive leaf certs | `hive-tls.nix`, signed by the hive CA | `<deploy.hive-controller.tls.stateDir>/<name>.pem` |
|
||||
| matrix registration token | a host activation script, on first boot | `/var/lib/hyperhive/matrix-register-token` (`0600`) |
|
||||
| matrix appservice token | a host activation script, on first boot | `/var/lib/hyperhive/matrix-appservice-token` (`0600`) |
|
||||
| the forge's copy of its OIDC secret | `hive-forge-oidc-secret.service` copies it from authelia's tree | `/var/lib/forgejo-oidc/<id>.secret` inside the forge container |
|
||||
| the homeserver's copy of its OIDC secret | `hive-matrix-oidc-secret.service`, same shape | `/var/lib/tuwunel-oidc/<id>.secret`, handed to tuwunel through `LoadCredential` |
|
||||
| the agent containers' queue credential | authelia, published to the store by `swarm-secret-publish` | `<deploy.hive-controller.queue.agentCredentialDir>/secret` (`0600`) and `/client_id` (`0644`) |
|
||||
|
|
@ -146,18 +146,26 @@ bounded wait, 120s — and then **fail loudly** rather than skipping. A silent s
|
|||
produces a service whose login button always fails, which is a symptom many
|
||||
layers from its cause.
|
||||
|
||||
The store's **first reader** is the matrix registration token, and it's worth
|
||||
The store's **first reader** is the matrix appservice token, and it's worth
|
||||
saying why that one: it's an opaque 32-byte value with no second file and no
|
||||
format. Authelia's OIDC secret needs a `.secret` _and_ a matching `.digest`, so
|
||||
starting there would have meant debugging "can a reader authenticate and get
|
||||
bytes back" and "is authelia's file format right" at once, with an
|
||||
SSO outage as the failure mode.
|
||||
|
||||
`glue-matrix-bao-token.nix` fetches it and writes the file `hive-matrix.nix`
|
||||
already reads, so the homeserver never learns the store exists. Every failure
|
||||
path — no such key, sealed store, unreachable store, empty value — leaves the
|
||||
locally minted token in place, so a hive with no store behaves exactly as it
|
||||
did before.
|
||||
`glue-matrix-bao-token.nix` fetches it, writes the file `hive-matrix.nix`
|
||||
already reads, and then runs that module's own renderer to re-stamp the
|
||||
appservice registration naming the token — so the homeserver never learns the
|
||||
store exists. The re-render isn't housekeeping: the token is half an
|
||||
agreement, and a registration carrying the previous value authenticates
|
||||
nobody. Every failure path — no such key, sealed store, unreachable store,
|
||||
empty value — leaves the locally minted token in place, so a hive with no store
|
||||
behaves exactly as it did before.
|
||||
|
||||
The store path is `swarm/hives/<hive>/matrix/appservice-token`. It was
|
||||
`…/matrix/registration-token` while the homeserver still took a shared
|
||||
registration secret; a value left at the old path is read by nothing, and the
|
||||
hive falls back to its local token until someone `put`s the new one.
|
||||
|
||||
The **second reader** is the agent containers' queue credential:
|
||||
`glue-queue-agent-credential.nix` lands it as two files, the client secret and
|
||||
|
|
|
|||
|
|
@ -168,9 +168,9 @@ Two consequences worth stating plainly:
|
|||
|
||||
- **tuwunel re-reads its secret file on every OAuth exchange**, not only
|
||||
at startup, and its own sandboxing hides most paths from it. It gets the
|
||||
file through `LoadCredential` for the same reason the registration token
|
||||
does — that keeps `DynamicUser` and `PrivateUsers` intact, with no
|
||||
host-side ownership arrangement to maintain.
|
||||
file through `LoadCredential` for the same reason the appservice
|
||||
registration does — that keeps `DynamicUser` and `PrivateUsers` intact,
|
||||
with no host-side ownership arrangement to maintain.
|
||||
- **Matrix SSO lives inside the homeserver.** The client-server API is
|
||||
spoken by non-browser clients holding matrix access tokens — every
|
||||
agent's own daemon — as well as by federation, so the homeserver
|
||||
|
|
|
|||
|
|
@ -982,9 +982,8 @@ pub async fn ensure_admin_user(client: &reqwest::Client, as_token: &str) -> Resu
|
|||
/// admin is degraded, not broken. Only `hivectl matrix promote-user` /
|
||||
/// `reset-password` need it.
|
||||
///
|
||||
/// # Errors
|
||||
/// Never — the outcome is the return value. `false` means "not admin and
|
||||
/// could not be made one", already logged with what to do about it.
|
||||
/// Returns whether the account holds admin rights now. A `false` has
|
||||
/// already been logged, with what to do about it.
|
||||
async fn ensure_admin_rights(
|
||||
client: &reqwest::Client,
|
||||
admin_token: &str,
|
||||
|
|
@ -1003,7 +1002,9 @@ async fn ensure_admin_rights(
|
|||
return true;
|
||||
}
|
||||
Some(_) => {
|
||||
tracing::warn!("matrix: hive admin is not in the admin room — attempting to promote it")
|
||||
tracing::warn!(
|
||||
"matrix: hive admin is not in the admin room — attempting to promote it"
|
||||
);
|
||||
}
|
||||
None => tracing::debug!(
|
||||
"matrix: could not read the hive admin's joined rooms; attempting to promote it"
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@
|
|||
//! to start sync.
|
||||
//!
|
||||
//! No OAuth dance / cross-signing setup (in contrast to damocles-daemon's
|
||||
//! ccc.de connection): for the in-hive tuwunel the `registration_token`
|
||||
//! UIAA flow already minted the token + user/device, hive-c0re just
|
||||
//! handed us the bearer in a file. matrix-sdk's `restore_session` with
|
||||
//! a constructed `MatrixSession` skips the login flow entirely.
|
||||
//! ccc.de connection): for the in-hive tuwunel hive-c0re already minted
|
||||
//! the token + user/device as the hive's appservice and handed us the
|
||||
//! bearer in a file. matrix-sdk's `restore_session` with a constructed
|
||||
//! `MatrixSession` skips the login flow entirely.
|
||||
//!
|
||||
//! E2EE is enabled via `with_encryption_settings(EncryptionSettings::default())`.
|
||||
//! Crypto keys are persisted in the sqlite store under `state_dir`
|
||||
|
|
|
|||
|
|
@ -247,10 +247,29 @@ in
|
|||
[ "services" "hyperhive" "swarm" "matrix" "maxRequestSize" ]
|
||||
[ "services" "hyperhive" "deploy" "matrix" "maxRequestSize" ]
|
||||
)
|
||||
(lib.mkRenamedOptionModule
|
||||
[ "services" "hyperhive" "swarm" "matrix" "registrationTokenFile" ]
|
||||
[ "services" "hyperhive" "deploy" "matrix" "registrationTokenFile" ]
|
||||
)
|
||||
# Both spellings of the registration token, removed rather than renamed:
|
||||
# the homeserver no longer accepts a shared registration secret at all,
|
||||
# so there is no path to point a definition at. Accounts are created by
|
||||
# the hive's appservice, whose token lives at a fixed path this module
|
||||
# deliberately does not let anyone move.
|
||||
(lib.mkRemovedOptionModule [ "services" "hyperhive" "swarm" "matrix" "registrationTokenFile" ] ''
|
||||
The matrix registration token is gone: hive-c0re creates accounts as the
|
||||
hive's appservice instead, so there is no shared secret for tuwunel to
|
||||
check. Remove this definition — nothing needs replacing, the appservice
|
||||
token is minted and delivered automatically
|
||||
(/var/lib/hyperhive/matrix-appservice-token, not operator-settable).
|
||||
The old token file at /var/lib/hyperhive/matrix-register-token is read by
|
||||
nothing now and can be deleted. See docs/integrations/matrix.md.
|
||||
'')
|
||||
(lib.mkRemovedOptionModule [ "services" "hyperhive" "deploy" "matrix" "registrationTokenFile" ] ''
|
||||
The matrix registration token is gone: hive-c0re creates accounts as the
|
||||
hive's appservice instead, so there is no shared secret for tuwunel to
|
||||
check. Remove this definition — nothing needs replacing, the appservice
|
||||
token is minted and delivered automatically
|
||||
(/var/lib/hyperhive/matrix-appservice-token, not operator-settable).
|
||||
The old token file at /var/lib/hyperhive/matrix-register-token is read by
|
||||
nothing now and can be deleted. See docs/integrations/matrix.md.
|
||||
'')
|
||||
(lib.mkRenamedOptionModule
|
||||
[ "services" "hyperhive" "swarm" "matrix" "gui" "enable" ]
|
||||
[ "services" "hyperhive" "deploy" "matrix" "gui" "enable" ]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
# Glue: the matrix registration token comes from the secret store.
|
||||
# Glue: the matrix appservice token comes from the secret store.
|
||||
#
|
||||
# The store's first reader, and deliberately a small one. It fetches an opaque
|
||||
# 32-byte value and writes it where ./hive-matrix.nix already looks — the
|
||||
# homeserver never learns the store exists, and its config is unchanged.
|
||||
# 32-byte value and writes it where ./hive-matrix.nix already looks, then asks
|
||||
# that module's own renderer to re-stamp the appservice registration naming it
|
||||
# — the homeserver never learns the store exists, and its config is unchanged.
|
||||
#
|
||||
# ⚠️ Why this credential first. It has no second file and no format: authelia's
|
||||
# OIDC secret needs a `.secret` *and* a matching `.digest`, so shipping that
|
||||
|
|
@ -47,12 +48,12 @@ let
|
|||
# own grant covers. The store's read policy grants `swarm/agents/*` and
|
||||
# `swarm/hives/<this hive>/*` and nothing else, so a path outside those is a
|
||||
# 403 rather than a miss, however correct it looks. `swarm-secret-client`'s
|
||||
# `matrix::registration_token_path` builds the same string from the same
|
||||
# `matrix::appservice_token_path` builds the same string from the same
|
||||
# pieces; this literal is the nix half of that one agreement.
|
||||
#
|
||||
# `hiveName` has no fallback here for the reason ./glue-bao-tls.nix gives at
|
||||
# its own use of it: it is asserted set for every hyperhive host.
|
||||
tokenPath = "secret/swarm/hives/${hyperhiveCfg.hiveName}/matrix/registration-token";
|
||||
tokenPath = "secret/swarm/hives/${hyperhiveCfg.hiveName}/matrix/appservice-token";
|
||||
|
||||
# A literal, not an option — ./hive-matrix.nix names its container
|
||||
# `containers.hive-matrix` directly and declares no `machine` to derive it
|
||||
|
|
@ -70,7 +71,7 @@ in
|
|||
services.hyperhive.swarm.otel.journaldUnits = [ "swarm-bao-matrix-token" ];
|
||||
|
||||
systemd.services.swarm-bao-matrix-token = {
|
||||
description = "fetch the matrix registration token from the swarm secret store";
|
||||
description = "fetch the matrix appservice token from the swarm secret store";
|
||||
# Every one of these names a unit that exists only where the store runs.
|
||||
# `Requires=` on an absent unit fails the job outright, so the ordering is
|
||||
# conditional even though the read is not: off-host there is nothing local
|
||||
|
|
@ -176,8 +177,20 @@ in
|
|||
fi
|
||||
|
||||
umask 077
|
||||
printf '%s\n' "$token" > ${lib.escapeShellArg (toString deployCfg.matrix.registrationTokenFile)}
|
||||
chmod 0600 ${lib.escapeShellArg (toString deployCfg.matrix.registrationTokenFile)}
|
||||
printf '%s\n' "$token" > ${lib.escapeShellArg (toString deployCfg.matrix.appserviceTokenFile)}
|
||||
chmod 0600 ${lib.escapeShellArg (toString deployCfg.matrix.appserviceTokenFile)}
|
||||
|
||||
# Re-stamp the registration file from the token just written. The
|
||||
# token is half an agreement — the registration the homeserver loads
|
||||
# has to carry the same value — so writing the file and stopping
|
||||
# would leave the homeserver authenticating hive-c0re against
|
||||
# whatever activation put there: a 401 on every request, naming
|
||||
# nothing. Unconditional rather than on-change, because this unit
|
||||
# has no way to know what the registration currently says.
|
||||
#
|
||||
# hive-matrix's own renderer rather than a `printf` here, so the
|
||||
# registration's shape has one home.
|
||||
${deployCfg.matrix.appserviceRegistrationScript}
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
# that is correct rather than degraded. The publisher runs on the authelia
|
||||
# host and authelia mints on its first boot, so "nothing at that path yet" is
|
||||
# the ordinary early state of a swarm. Nothing here writes a local stand-in:
|
||||
# unlike a matrix registration token there is no such thing as a locally valid
|
||||
# unlike a matrix appservice token there is no such thing as a locally valid
|
||||
# OIDC client secret, so a placeholder would turn a hive that cannot connect
|
||||
# into one that is refused, which reaches the agent as a timeout.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -40,18 +40,12 @@ let
|
|||
# login into an intermittent one.
|
||||
matrixSecretPath = "/var/lib/tuwunel-oidc/${cfg.sso.clientId}.secret";
|
||||
|
||||
# Single source for the registration-token path: the option's `default`,
|
||||
# its `config`-level `mkDefault` self-definition, and the assertion that
|
||||
# rejects a moved path all read this same binding rather than repeating
|
||||
# the string literal. See `registrationTokenFile`'s own comment below.
|
||||
registrationTokenPath = "/var/lib/hyperhive/matrix-register-token";
|
||||
|
||||
# ⚠️ tuwunel does NOT read the path above directly, and this indirection
|
||||
# ⚠️ 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 two units below for the registration token, and for the
|
||||
# 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";
|
||||
|
|
@ -79,11 +73,15 @@ let
|
|||
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
|
||||
# minted by the render 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` 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";
|
||||
|
||||
|
|
@ -124,6 +122,64 @@ let
|
|||
# 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.
|
||||
# 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: ${adminLocalpart}
|
||||
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/<client_id>`. Built once
|
||||
|
|
@ -511,40 +567,59 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
registrationTokenFile = lib.mkOption {
|
||||
appserviceTokenFile = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
internal = true;
|
||||
default = registrationTokenPath;
|
||||
default = appserviceTokenPath;
|
||||
description = ''
|
||||
Host path to a file containing the matrix registration token
|
||||
tuwunel reads to authorise new-account creation. The token is
|
||||
generated automatically by `hive-c0re` on first boot (32-byte
|
||||
random hex, mode 0600) and is bind-mounted read-only into the
|
||||
tuwunel container at the same path. Agents never see this
|
||||
token — hive-c0re uses it to provision per-agent accounts
|
||||
and the agent only receives the resulting `access_token`.
|
||||
Host path to a file containing this hive's matrix appservice
|
||||
token (`as_token`) — the identity `hive-c0re` creates and logs
|
||||
into accounts with. 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 — `hive-c0re`'s Rust side derives this
|
||||
same path independently (`paths::matrix_register_token()`) with
|
||||
nothing wiring an override across, so a moved path used to
|
||||
desync 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 and overwrites this file in place.
|
||||
same path independently (`paths::matrix_appservice_token()`)
|
||||
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 any real override — an operator's,
|
||||
or the pre-rename `swarm.matrix.registrationTokenFile` shim's —
|
||||
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 —
|
||||
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.)
|
||||
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.
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
@ -606,11 +681,11 @@ in
|
|||
|
||||
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 (an operator's, or
|
||||
# the pre-rename shim's) 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.registrationTokenFile = lib.mkDefault registrationTokenPath;
|
||||
# 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
|
||||
|
|
@ -791,19 +866,17 @@ in
|
|||
# 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. Covers both the current path and the pre-rename
|
||||
# `swarm.matrix.registrationTokenFile` shim in one check, since
|
||||
# both land on the same merged option.
|
||||
assertion = deployCfg.matrix.registrationTokenFile == registrationTokenPath;
|
||||
# text.
|
||||
assertion = deployCfg.matrix.appserviceTokenFile == appserviceTokenPath;
|
||||
message = ''
|
||||
services.hyperhive.deploy.matrix.registrationTokenFile is fixed at
|
||||
${registrationTokenPath} and cannot be moved — hive-c0re's Rust
|
||||
services.hyperhive.deploy.matrix.appserviceTokenFile is fixed at
|
||||
${appserviceTokenPath} and cannot be moved — hive-c0re's Rust
|
||||
side derives this same path independently and has no way to learn
|
||||
an override, so moving it desyncs the two silently instead of
|
||||
loudly.
|
||||
|
||||
Integrating an externally-managed registration token? Deliver it
|
||||
by writing into ${registrationTokenPath} instead of pointing this
|
||||
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.
|
||||
'';
|
||||
|
|
@ -849,8 +922,8 @@ in
|
|||
# permanent stall presenting as "matrix is broken", several layers from
|
||||
# its cause.
|
||||
#
|
||||
# The registration token above dodges that with an activation script
|
||||
# that pre-creates the file. ⚠️ That dodge is NOT available here:
|
||||
# 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.
|
||||
|
|
@ -958,74 +1031,21 @@ in
|
|||
''
|
||||
);
|
||||
|
||||
system.activationScripts.hive-matrix-register-token = lib.stringAfter [ "var" ] ''
|
||||
tokenFile=${lib.escapeShellArg (toString deployCfg.matrix.registrationTokenFile)}
|
||||
if [ ! -s "$tokenFile" ]; then
|
||||
mkdir -p "$(dirname "$tokenFile")"
|
||||
head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$tokenFile"
|
||||
echo >> "$tokenFile"
|
||||
echo "hive-matrix: generated registration token at $tokenFile"
|
||||
fi
|
||||
# Re-apply 0600 (normalises any pre-LoadCredential carry-over).
|
||||
chmod 0600 "$tokenFile"
|
||||
'';
|
||||
|
||||
# The appservice registration: mint the two tokens once, then re-render
|
||||
# the registration file from them on EVERY activation.
|
||||
# Mint the appservice tokens and render the registration, before any
|
||||
# container start.
|
||||
#
|
||||
# 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.
|
||||
# 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" ] ''
|
||||
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"
|
||||
${deployCfg.matrix.appserviceRegistrationScript}
|
||||
'';
|
||||
|
||||
containers.hive-matrix = {
|
||||
|
|
@ -1036,16 +1056,13 @@ in
|
|||
extraFlags = [ "--link-journal=host" ];
|
||||
# Shared host netns — agents reach tuwunel at localhost:<port>.
|
||||
privateNetwork = false;
|
||||
# Read-only bind of the host-managed registration token; tuwunel
|
||||
# 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 = {
|
||||
${deployCfg.matrix.registrationTokenFile} = {
|
||||
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;
|
||||
|
|
@ -1141,13 +1158,19 @@ in
|
|||
# trustedServers keeps it effectively closed.
|
||||
allow_federation = true;
|
||||
trusted_servers = deployCfg.matrix.trustedServers;
|
||||
# Token-gated registration. The absent
|
||||
# `yes_i_am_very_very_sure_…_open_registration_…` flag
|
||||
# keeps the server closed to anyone without the token.
|
||||
allow_registration = true;
|
||||
# LoadCredential below copies the host file into a
|
||||
# 0400 dynamic-user-owned path; tuwunel reads from there.
|
||||
registration_token_file = "/run/credentials/tuwunel.service/registration_token";
|
||||
# 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
|
||||
|
|
@ -1239,11 +1262,9 @@ in
|
|||
# host-side chown :tuwunel / GID-pin gymnastics needed.
|
||||
# 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.
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -116,24 +116,45 @@ let
|
|||
];
|
||||
};
|
||||
|
||||
# The homeserver's turn to split. All eight host-side options are set through
|
||||
# their pre-rename paths — including `gui.enable`, whose value is deliberately
|
||||
# the opposite of its default so the definition has to actually land, and both
|
||||
# packages, stubbed to a derivation neither option defaults to for the same
|
||||
# reason. All eight so that dropping any single shim entry fails the eval, not
|
||||
# just the ones the assertions read.
|
||||
# The homeserver's turn to split. All seven remaining host-side options are
|
||||
# set through their pre-rename paths — including `gui.enable`, whose value is
|
||||
# deliberately the opposite of its default so the definition has to actually
|
||||
# land, and both packages, stubbed to a derivation neither option defaults to
|
||||
# for the same reason. All seven so that dropping any single shim entry fails
|
||||
# the eval, not just the ones the assertions read.
|
||||
#
|
||||
# `registrationTokenFile` used to be the eighth: both spellings of it are now
|
||||
# `mkRemovedOptionModule`, exercised by `matrixRemovedToken` below instead —
|
||||
# setting it here would make every case that reads this fixture fail on that
|
||||
# one removed option.
|
||||
matrixOldPath = hive {
|
||||
deploy.matrix.enable = true;
|
||||
swarm.matrix.openFirewall = true;
|
||||
swarm.matrix.trustedServers = [ "matrix.example.invalid" ];
|
||||
swarm.matrix.maxRequestSize = 31457280;
|
||||
swarm.matrix.registrationTokenFile = "/etc/matrix/register.token";
|
||||
swarm.matrix.gui.enable = false;
|
||||
swarm.matrix.package = pkgs.emptyDirectory;
|
||||
swarm.matrix.gui.package = pkgs.emptyDirectory;
|
||||
swarm.matrix.sso.clientSecretFile = "/etc/matrix/oidc.secret";
|
||||
};
|
||||
|
||||
# Moving the appservice token, which desyncs hive-c0re's independently
|
||||
# derived path from the homeserver's. Its own fixture because the assertion
|
||||
# it trips would otherwise fire for every case reading `matrixOldPath`.
|
||||
matrixMovedAppserviceToken = hive {
|
||||
deploy.matrix.enable = true;
|
||||
deploy.matrix.appserviceTokenFile = "/etc/matrix/as.token";
|
||||
swarm.matrix.sso.clientSecretFile = "/etc/matrix/oidc.secret";
|
||||
};
|
||||
|
||||
# A config still naming the removed registration token, through the
|
||||
# pre-rename spelling — the one an old deployment is most likely to carry.
|
||||
matrixRemovedToken = hive {
|
||||
deploy.matrix.enable = true;
|
||||
swarm.matrix.registrationTokenFile = "/etc/matrix/register.token";
|
||||
swarm.matrix.sso.clientSecretFile = "/etc/matrix/oidc.secret";
|
||||
};
|
||||
|
||||
# The queue's callout identity, fourth split slice. `autoGenerateCallout` is
|
||||
# left FALSE on purpose: that is what makes the seed paths the thing deciding
|
||||
# `responderConfigured`, so the assertion below is about the seeds rather
|
||||
|
|
@ -840,33 +861,41 @@ let
|
|||
}
|
||||
{
|
||||
# Third split, and the one whose readers were hardest to see: the
|
||||
# registration token is read only through a `let` alias in another
|
||||
# appservice token is read only through a `let` alias in another
|
||||
# module, so no full path names it anywhere. Both arms read a rendered
|
||||
# effect — the host firewall and the container's bind-mount table — so a
|
||||
# rename that resolves but stops reaching the module still fails.
|
||||
name = "a config written against the pre-rename matrix paths still opens the port and mounts the token";
|
||||
name = "a config written against the pre-rename matrix paths still opens the port and mounts the appservice registration";
|
||||
ok =
|
||||
let
|
||||
ports = matrixOldPath.networking.firewall.allowedTCPPorts;
|
||||
httpPort = matrixOldPath.services.hyperhive.swarm.matrix.httpPort;
|
||||
in
|
||||
builtins.elem httpPort ports
|
||||
&& matrixOldPath.containers.hive-matrix.bindMounts ? "/etc/matrix/register.token";
|
||||
&& matrixOldPath.containers.hive-matrix.bindMounts ? "/var/lib/hyperhive/matrix-appservice";
|
||||
}
|
||||
{
|
||||
# registrationTokenFile lost its override capability entirely
|
||||
# (bao-delivered secrets don't need one — glue-matrix-bao-token.nix
|
||||
# already writes into the fixed path instead of moving it), unlike its
|
||||
# five siblings in the same rename. `matrixOldPath` above proves the
|
||||
# override still *resolves* (mkDefault, not a crash) — this proves it
|
||||
# also gets *rejected*, by a named assertion rather than nixpkgs' generic
|
||||
# conflicting-definition text. Reads `.assertions` directly (cheap: a
|
||||
# list of `{assertion; message;}`, not `system.build.toplevel`) rather
|
||||
# than forcing a real build just to observe a boolean.
|
||||
name = "overriding registrationTokenFile (even via the pre-rename shim) trips a named assertion, not a silent desync";
|
||||
# appserviceTokenFile has no override capability (a store-delivered
|
||||
# secret doesn't need one — glue-matrix-bao-token.nix writes into the
|
||||
# fixed path instead of moving it), unlike its siblings in the same
|
||||
# rename. The fixture proves the override still *resolves* (mkDefault,
|
||||
# not a crash); this proves it also gets *rejected*, by a named
|
||||
# assertion rather than nixpkgs' generic conflicting-definition text.
|
||||
# Reads `.assertions` directly (cheap: a list of `{assertion;
|
||||
# message;}`, not `system.build.toplevel`) rather than forcing a real
|
||||
# build just to observe a boolean.
|
||||
name = "moving appserviceTokenFile trips a named assertion, not a silent desync";
|
||||
ok = lib.any (
|
||||
a: !a.assertion && lib.hasInfix "registrationTokenFile" a.message
|
||||
) matrixOldPath.assertions;
|
||||
a: !a.assertion && lib.hasInfix "appserviceTokenFile" a.message
|
||||
) matrixMovedAppserviceToken.assertions;
|
||||
}
|
||||
{
|
||||
# The removal has to SAY so. A config carrying the old option through
|
||||
# an upgrade is the common case, and nixpkgs' bare "option does not
|
||||
# exist" would name neither what replaced the token nor where to read
|
||||
# about it.
|
||||
name = "a config still setting registrationTokenFile is refused with a message naming the appservice";
|
||||
ok = lib.any (a: !a.assertion && lib.hasInfix "appservice" a.message) matrixRemovedToken.assertions;
|
||||
}
|
||||
{
|
||||
# Reads the DELIVERY UNIT, not the options: `responderConfigured` gates
|
||||
|
|
@ -1518,7 +1547,7 @@ let
|
|||
s = baoWithMatrix.systemd.services.swarm-bao-matrix-token.script;
|
||||
in
|
||||
lib.hasInfix "secret/swarm/hives/" s
|
||||
&& lib.hasInfix "/matrix/registration-token" s
|
||||
&& lib.hasInfix "/matrix/appservice-token" s
|
||||
# The shape it used to have: `matrix` where a principal kind belongs,
|
||||
# which no grant covers.
|
||||
&& !(lib.hasInfix "secret/swarm/matrix/" s);
|
||||
|
|
@ -2471,7 +2500,7 @@ let
|
|||
# The store's first reader. Its unit belongs to the pairing, not to
|
||||
# either service: matrix must not learn the store exists, and the store
|
||||
# must not know who reads it.
|
||||
name = "a store deployed beside the homeserver fetches its registration token";
|
||||
name = "a store deployed beside the homeserver fetches its appservice token";
|
||||
ok = baoWithMatrix.systemd.services ? swarm-bao-matrix-token;
|
||||
}
|
||||
{
|
||||
|
|
|
|||
|
|
@ -26,18 +26,25 @@ pub fn account_path(agent: &str, account: &str) -> Result<String, Error> {
|
|||
Ok(format!("{prefix}/matrix/{account}"))
|
||||
}
|
||||
|
||||
/// The path holding `hive`'s matrix registration token.
|
||||
/// The path holding `hive`'s matrix appservice token (`as_token`).
|
||||
///
|
||||
/// Keyed per **hive**, not per agent, like [`crate::queue::agent_client_path`]
|
||||
/// and unlike [`account_path`] above: one homeserver admits one hive's
|
||||
/// accounts, so the token that creates them is the hive's.
|
||||
/// accounts, so the identity that creates them is the hive's.
|
||||
///
|
||||
/// ⚠️ Renamed from `registration-token` along with what it holds: the
|
||||
/// homeserver no longer accepts a shared registration secret at all, so a
|
||||
/// value still stored under the old path would be read by nothing. A hive
|
||||
/// whose store has only the old path falls back to its locally minted
|
||||
/// token — see `glue-matrix-bao-token.nix` — so the rename degrades rather
|
||||
/// than breaks, but the store needs a fresh `put` to take effect again.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::PathSegment`] when `hive` contains anything but `[A-Za-z0-9_-]`,
|
||||
/// which is what keeps one hive's name from addressing another hive's secret.
|
||||
pub fn registration_token_path(hive: &str) -> Result<String, Error> {
|
||||
pub fn appservice_token_path(hive: &str) -> Result<String, Error> {
|
||||
let prefix = principal_prefix(Kind::Hive, hive)?;
|
||||
Ok(format!("{prefix}/matrix/registration-token"))
|
||||
Ok(format!("{prefix}/matrix/appservice-token"))
|
||||
}
|
||||
|
||||
/// What an account's path holds: the token, plus the homeserver it belongs to.
|
||||
|
|
@ -80,28 +87,29 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn the_registration_token_lands_under_the_hive_prefix_the_grant_covers() {
|
||||
fn the_appservice_token_lands_under_the_hive_prefix_the_grant_covers() {
|
||||
// Spelled out for the same reason as above, and with a second job here:
|
||||
// the read policy grants `secret/data/swarm/hives/<hive>/*`, so this
|
||||
// string is what makes the path reachable at all.
|
||||
assert_eq!(
|
||||
registration_token_path("pr1ma").expect("a plain name is legal"),
|
||||
"swarm/hives/pr1ma/matrix/registration-token"
|
||||
appservice_token_path("pr1ma").expect("a plain name is legal"),
|
||||
"swarm/hives/pr1ma/matrix/appservice-token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_registration_token_is_not_a_top_level_namespace() {
|
||||
// The path it used to hold. `Kind` is a closed set and `matrix` is not
|
||||
// one of its members, so a path with `matrix` as the second segment is
|
||||
// outside every grant — which is how it came to 403 on every read.
|
||||
let p = registration_token_path("pr1ma").expect("legal");
|
||||
fn the_appservice_token_is_not_a_top_level_namespace() {
|
||||
// The path its predecessor used to hold. `Kind` is a closed set and
|
||||
// `matrix` is not one of its members, so a path with `matrix` as the
|
||||
// second segment is outside every grant — which is how it came to 403
|
||||
// on every read.
|
||||
let p = appservice_token_path("pr1ma").expect("legal");
|
||||
assert!(!p.starts_with("swarm/matrix/"), "{p}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_traversal_in_the_hive_name_is_refused() {
|
||||
let e = registration_token_path("../beta").expect_err("a traversal is not");
|
||||
let e = appservice_token_path("../beta").expect_err("a traversal is not");
|
||||
assert!(matches!(e, Error::PathSegment { kind: "hive", .. }), "{e}");
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue