Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
199afa41c8 | ||
|
|
7ee7080b21 | ||
|
|
43cd8607ba | ||
|
|
5809077924 |
14 changed files with 1047 additions and 352 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`
|
bind to that port OR a reverse-proxy + `.well-known/matrix/server`
|
||||||
delegation (the latter lives in `gateway.md::Discovery flow`).
|
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
|
Registration is closed. Accounts are created by the hive's own
|
||||||
see it. The agent only receives the resulting `access_token`.
|
**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
|
The appservice has no URL (`url: null` in its registration), so the
|
||||||
chars) to `services.hyperhive.deploy.matrix.registrationTokenFile`
|
homeserver never calls out to it and there is no service to run. What the
|
||||||
(`/var/lib/hyperhive/matrix-register-token` by default), mode
|
registration buys is an identity the homeserver recognises — which is why
|
||||||
`0600 root:root`, before any container start. Idempotent — only
|
no secret has to be equal on both sides of the wire, and why account
|
||||||
writes when the file is missing or empty; always re-applies 0600
|
creation doesn't depend on registration being open to anyone who learns
|
||||||
(normalises any 0640 / world-readable carry-over from
|
a token.
|
||||||
pre-LoadCredential deployments). This runs at activation time
|
|
||||||
(not first container start) to dodge a race where nspawn creates
|
1. **System activation** mints a 32-byte random hex appservice token (64
|
||||||
an empty file when the bind-mount target is missing and tuwunel
|
chars) at `/var/lib/hyperhive/matrix-appservice-token` and its
|
||||||
reads `registration_token_file=""`, rejecting every registration
|
spec-required `hs_token` sibling, mode `0600 root:root`, then renders
|
||||||
until next restart.
|
the registration to
|
||||||
2. **Read-only bind-mount** maps the host file into the tuwunel
|
`/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.
|
container at the same path.
|
||||||
3. **systemd `LoadCredential=`** inside the container copies the
|
3. **systemd `LoadCredential=`** inside the container copies the
|
||||||
bind-mounted file into
|
registration into
|
||||||
`/run/credentials/tuwunel.service/registration_token`, owned by
|
`/run/credentials/tuwunel.service/hyperhive-appservice.yaml`, owned by
|
||||||
tuwunel's dynamic user with mode `0400`, at service start. The
|
tuwunel's dynamic user with mode `0400`, at service start. The host
|
||||||
host file stays `root:root 0600` — no `chown :tuwunel` /
|
file stays `root:root 0600` — no `chown :tuwunel` / `chmod 0640` /
|
||||||
`chmod 0640` / GID-pin gymnastics required. Keeps
|
GID-pin gymnastics required. Keeps `DynamicUser = true` +
|
||||||
`DynamicUser = true` + `PrivateUsers = true` intact.
|
`PrivateUsers = true` intact.
|
||||||
4. tuwunel's `registration_token_file` points at the credentials
|
4. tuwunel's `appservice_dir` points at the credentials directory, not at
|
||||||
path, not the original bind-mount path.
|
the bind-mount path. It reads only `.yaml`/`.yml` entries from there,
|
||||||
5. **hive-c0re** uses the token to register each agent account via
|
so the sibling credentials are invisible to it. The `.yaml` suffix on
|
||||||
the matrix-spec UIAA registration flow, persists the returned
|
the credential id is what makes this work.
|
||||||
`access_token` to `<agent-state>/matrix-token`. The agent's
|
5. **hive-c0re** reads the appservice token and creates each account with
|
||||||
matrix MCP client authenticates with that access_token and
|
one `POST /register` typed `m.login.application_service`, persisting
|
||||||
never touches the shared registration token.
|
the returned `access_token` to `<agent-state>/matrix-token`. It never
|
||||||
6. **hive-c0re restarts `hive-matrix-daemon`** for the agent
|
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
|
immediately after writing the token so the daemon picks up the
|
||||||
new credential without waiting for a full container restart. If
|
new credential without waiting for a full container restart. If
|
||||||
the restart fails (for example daemon not yet running on first boot)
|
the restart fails (for example daemon not yet running on first boot)
|
||||||
|
|
@ -130,16 +145,74 @@ see it. The agent only receives the resulting `access_token`.
|
||||||
(`hive-matrix-daemon.path` watching for `matrix-token` appearance)
|
(`hive-matrix-daemon.path` watching for `matrix-token` appearance)
|
||||||
brings the daemon up on the same boot cycle anyway.
|
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 value that matters now lives at
|
||||||
|
`matrix/appservice-token`, and `swarm-secret-publish` on the authelia
|
||||||
|
host mints and `put`s it there — the hive uses its locally minted
|
||||||
|
token only until the first successful read. See
|
||||||
|
[`../swarm/secrets.md`](../swarm/secrets.md) for how that mint stays
|
||||||
|
idempotent across runs.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
Initial rollout settings:
|
Initial rollout settings:
|
||||||
|
|
||||||
- `allow_federation = true` at the protocol level so swarms can be
|
- `allow_federation = true` at the protocol level so swarms can be
|
||||||
wired up later by extending `trustedServers` without a homeserver
|
wired up later by extending `trustedServers` without a homeserver
|
||||||
restart. `trusted_servers = []` keeps it effectively closed
|
restart. `trusted_servers = []` keeps it effectively closed
|
||||||
until you list peers.
|
until you list peers.
|
||||||
- `allow_registration = true` (required for the token flow to
|
- `allow_registration = false`. tuwunel checks this flag only for
|
||||||
engage). The absent
|
requests that arrive **without** an appservice token, so hive-c0re
|
||||||
`yes_i_am_very_very_sure_…_open_registration_…` flag keeps the
|
provisions exactly as before and everyone else is refused. It's not a
|
||||||
server closed to anyone without the token.
|
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
|
- `allow_encryption` — server-side E2EE switch, sourced from
|
||||||
`services.hyperhive.swarm.matrix.allowEncryption` (**default `false`**, opt-in).
|
`services.hyperhive.swarm.matrix.allowEncryption` (**default `false`**, opt-in).
|
||||||
Off by default because on the hive-internal homeserver the operator
|
Off by default because on the hive-internal homeserver the operator
|
||||||
|
|
|
||||||
|
|
@ -133,10 +133,10 @@ when the system builds. The server names the offending file and refuses to run.
|
||||||
## Hive-level — one of each per hive
|
## Hive-level — one of each per hive
|
||||||
|
|
||||||
| secret | generated by | lives at |
|
| secret | generated by | lives at |
|
||||||
| ---------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- |
|
||||||
| hive CA cert + key | `hive-tls.nix` first-boot unit | `<deploy.hive-controller.tls.stateDir>/ca.pem`, `ca-key.pem` (`0600`) |
|
| 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` |
|
| 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 | `swarm-secret-publish`, published to the store; a host activation script only as a first-boot fallback | `/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 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 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`) |
|
| 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,48 @@ 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
|
produces a service whose login button always fails, which is a symptom many
|
||||||
layers from its cause.
|
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
|
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
|
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
|
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
|
bytes back" and "is authelia's file format right" at once, with an
|
||||||
SSO outage as the failure mode.
|
SSO outage as the failure mode.
|
||||||
|
|
||||||
`glue-matrix-bao-token.nix` fetches it and writes the file `hive-matrix.nix`
|
`glue-matrix-bao-token.nix` fetches it, writes the file `hive-matrix.nix`
|
||||||
already reads, so the homeserver never learns the store exists. Every failure
|
already reads, and then runs that module's own renderer to re-stamp the
|
||||||
path — no such key, sealed store, unreachable store, empty value — leaves the
|
appservice registration naming the token — so the homeserver never learns the
|
||||||
locally minted token in place, so a hive with no store behaves exactly as it
|
store exists. The re-render isn't housekeeping: the token is half an
|
||||||
did before.
|
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`, and the
|
||||||
|
**producer is `swarm-secret-publish`** — the same unit that copies authelia's
|
||||||
|
OIDC secrets in. It mints one token per hive in the swarm's roster and `put`s
|
||||||
|
it there, so the store is the source of truth and every hive converges on the
|
||||||
|
value it holds. The hive's own activation mint is still there, but it's a
|
||||||
|
first-boot fallback now: it fires only when the token file is absent, and the
|
||||||
|
reader overwrites whatever it produced.
|
||||||
|
|
||||||
|
The mint is **idempotent by keeping its own record**, not by asking the store.
|
||||||
|
The publisher's grant is `create`/`update` under `swarm/hives/*` with no
|
||||||
|
`read` — write-only on purpose, so a file-copier can't recover every hive's
|
||||||
|
credentials — which means it can't check whether a hive already has a token.
|
||||||
|
Instead it keeps the value it minted under its `StateDirectory`,
|
||||||
|
`/var/lib/swarm-secret-publish/matrix-appservice-token/<hive>` (`0700` dir,
|
||||||
|
`0600` file), and mints only when that file is missing or empty. The `put`
|
||||||
|
itself runs every time: re-putting the same bytes changes nothing for any
|
||||||
|
reader, while a mint whose publish failed must not be left as a token this
|
||||||
|
host holds and no hive can reach.
|
||||||
|
|
||||||
|
Lose the state directory and the next run mints once more and republishes.
|
||||||
|
That rotates the token, which readers pick up on their next start — nothing
|
||||||
|
that already registered breaks, because the token authenticates the
|
||||||
|
appservice rather than living inside any account it created.
|
||||||
|
|
||||||
|
The path was `…/matrix/registration-token` while the homeserver still took a
|
||||||
|
shared registration secret; a value left at the old path is read by nothing.
|
||||||
|
|
||||||
The **second reader** is the agent containers' queue credential:
|
The **second reader** is the agent containers' queue credential:
|
||||||
`glue-queue-agent-credential.nix` lands it as two files, the client secret and
|
`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
|
- **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
|
at startup, and its own sandboxing hides most paths from it. It gets the
|
||||||
file through `LoadCredential` for the same reason the registration token
|
file through `LoadCredential` for the same reason the appservice
|
||||||
does — that keeps `DynamicUser` and `PrivateUsers` intact, with no
|
registration does — that keeps `DynamicUser` and `PrivateUsers` intact,
|
||||||
host-side ownership arrangement to maintain.
|
with no host-side ownership arrangement to maintain.
|
||||||
- **Matrix SSO lives inside the homeserver.** The client-server API is
|
- **Matrix SSO lives inside the homeserver.** The client-server API is
|
||||||
spoken by non-browser clients holding matrix access tokens — every
|
spoken by non-browser clients holding matrix access tokens — every
|
||||||
agent's own daemon — as well as by federation, so the homeserver
|
agent's own daemon — as well as by federation, so the homeserver
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,19 @@
|
||||||
//! Optional matrix-tuwunel wiring: shared registration token (host) +
|
//! Optional matrix-tuwunel wiring: the hive's appservice identity (host) +
|
||||||
//! per-agent UIAA registration → `<agent-state>/matrix-token`. No-op
|
//! per-agent account creation → `<agent-state>/matrix-token`. No-op
|
||||||
//! when the `hive-matrix` container isn't running, so operators who
|
//! when the `hive-matrix` container isn't running, so operators who
|
||||||
//! haven't flipped `services.hyperhive.deploy.matrix.enable = true` pay
|
//! haven't flipped `services.hyperhive.deploy.matrix.enable = true` pay
|
||||||
//! nothing.
|
//! nothing.
|
||||||
//!
|
//!
|
||||||
//! See `docs/integrations/matrix.md::Provisioning flow (registration token)` for
|
//! Accounts are created **as the hive's appservice**, not by presenting a
|
||||||
//! the full UIAA round-trip, token-file shape, and host/container
|
//! shared registration token in a UIAA flow. The difference that matters
|
||||||
//! bind-mount layout.
|
//! here is not the round-trip count: an appservice token is an *identity*
|
||||||
|
//! the homeserver knows, so the secret never has to be the same on both
|
||||||
|
//! sides of the wire, and account creation does not depend on registration
|
||||||
|
//! being open to anyone who learns a token.
|
||||||
|
//!
|
||||||
|
//! See `docs/integrations/matrix.md::Provisioning flow (appservice)` for the
|
||||||
|
//! registration file's shape, how its token reaches both halves, and the
|
||||||
|
//! host/container bind-mount layout.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
|
@ -49,11 +56,8 @@ fn matrix_base() -> Result<&'static str> {
|
||||||
this path should have been gated on matrix::is_present()",
|
this path should have been gated on matrix::is_present()",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/// Length (bytes) of the random registration token. 32 raw bytes ⇒
|
/// HTTP timeout for registration round-trips. Account creation is one
|
||||||
/// 64-char hex string; comfortable for a long-lived shared secret.
|
/// POST; even the slow path should finish well inside this budget.
|
||||||
const REGISTER_TOKEN_BYTES: usize = 32;
|
|
||||||
/// HTTP timeout for registration round-trips. UIAA is two POSTs; even
|
|
||||||
/// the slow path should finish well inside this budget.
|
|
||||||
const HTTP_TIMEOUT_SECS: u64 = 10;
|
const HTTP_TIMEOUT_SECS: u64 = 10;
|
||||||
/// Length (bytes) of the throwaway per-agent matrix password. Random
|
/// Length (bytes) of the throwaway per-agent matrix password. Random
|
||||||
/// 32-byte hex — agents never log in with the password (they
|
/// 32-byte hex — agents never log in with the password (they
|
||||||
|
|
@ -61,10 +65,17 @@ const HTTP_TIMEOUT_SECS: u64 = 10;
|
||||||
/// store it nowhere.
|
/// store it nowhere.
|
||||||
const PASSWORD_BYTES: usize = 32;
|
const PASSWORD_BYTES: usize = 32;
|
||||||
|
|
||||||
/// Matrix localpart for the hive system admin account. Registered
|
/// Matrix localpart for the hive system admin account. Not an agent; has
|
||||||
/// before any agent account in [`ensure_all`] so it becomes the first
|
/// no state dir.
|
||||||
/// user on the homeserver — Conduit/tuwunel grants admin rights to the
|
///
|
||||||
/// first registered user automatically. Not an agent; has no state dir.
|
/// Also the `sender_localpart` of the hive's appservice registration,
|
||||||
|
/// which is what creates this account on a homeserver that has never had
|
||||||
|
/// one: the homeserver creates a registration's sender user itself, at
|
||||||
|
/// startup, before it accepts a request. The account's *admin rights*
|
||||||
|
/// then come from the `admin_execute` promotion beside that registration
|
||||||
|
/// — see `nix/host-modules/hive-matrix.nix`, where this same literal
|
||||||
|
/// appears as `adminLocalpart`. **The two must match**; nothing wires an
|
||||||
|
/// override across.
|
||||||
pub const HIVE_ADMIN_LOCALPART: &str = "hive";
|
pub const HIVE_ADMIN_LOCALPART: &str = "hive";
|
||||||
|
|
||||||
/// Display name of the hive Space. Plain text, no special characters, so
|
/// Display name of the hive Space. Plain text, no special characters, so
|
||||||
|
|
@ -164,30 +175,36 @@ fn random_hex(n: usize) -> Result<String> {
|
||||||
Ok(hex)
|
Ok(hex)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure the registration token file exists; returns its contents.
|
/// Read the hive's appservice token — the `as_token` of the registration
|
||||||
/// Generates a fresh 32-byte hex token on first call (mode 0600,
|
/// the homeserver loaded at boot. Every account this module creates is
|
||||||
/// root-only), then re-reads on subsequent calls. The hive-matrix
|
/// authorised by it.
|
||||||
/// nixos module bind-mounts this file read-only into the tuwunel
|
///
|
||||||
/// container so the homeserver can authenticate registration requests
|
/// **Reads, never mints**, unlike the registration token it replaced.
|
||||||
/// against the same secret hive-c0re holds.
|
/// That token was the whole agreement, so whichever side wrote it first
|
||||||
pub fn ensure_register_token() -> Result<String> {
|
/// was right; this one has a second half — the registration file naming
|
||||||
use std::os::unix::fs::PermissionsExt;
|
/// it, which only the nix side writes. A token minted here would be a
|
||||||
let path = crate::paths::matrix_register_token();
|
/// token the homeserver has never heard of, and the failure would surface
|
||||||
if let Ok(existing) = std::fs::read_to_string(&path) {
|
/// as every request being refused rather than as a missing file.
|
||||||
let trimmed = existing.trim().to_owned();
|
///
|
||||||
if !trimmed.is_empty() {
|
/// # Errors
|
||||||
return Ok(trimmed);
|
/// When the file is absent or empty. That means the host activation
|
||||||
}
|
/// script has not run on this generation yet; callers log it and leave
|
||||||
}
|
/// existing accounts alone rather than trying to proceed.
|
||||||
let token = random_hex(REGISTER_TOKEN_BYTES)?;
|
pub fn read_appservice_token() -> Result<String> {
|
||||||
if let Some(parent) = path.parent() {
|
let path = crate::paths::matrix_appservice_token();
|
||||||
std::fs::create_dir_all(parent).ok();
|
std::fs::read_to_string(&path)
|
||||||
}
|
.ok()
|
||||||
std::fs::write(&path, format!("{token}\n"))
|
.map(|s| s.trim().to_owned())
|
||||||
.with_context(|| format!("write registration token to {}", path.display()))?;
|
.filter(|s| !s.is_empty())
|
||||||
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
|
.with_context(|| {
|
||||||
tracing::info!(path = %path.display(), "matrix: generated registration token");
|
format!(
|
||||||
Ok(token)
|
"matrix appservice token not found at {} — it is minted by the \
|
||||||
|
hive-matrix activation script, which also renders the registration \
|
||||||
|
file naming it; deploy the hive-matrix module (or re-run \
|
||||||
|
`nixos-rebuild switch`) before provisioning matrix users",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the localpart of a matrix user id for `agent`. Matrix
|
/// Build the localpart of a matrix user id for `agent`. Matrix
|
||||||
|
|
@ -198,19 +215,20 @@ fn user_localpart(agent: &str) -> &str {
|
||||||
agent
|
agent
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a single registration POST and parse the response. Returns
|
/// Send the registration POST as the appservice and parse the response.
|
||||||
/// `Ok((status, body))` on any successful HTTP round-trip (including
|
/// Returns `Ok((status, body))` on any completed HTTP round-trip
|
||||||
/// the expected 401 from the first UIAA leg); errors only on transport
|
/// (including the `M_USER_IN_USE` 400 the caller treats as "already
|
||||||
/// failure. Body is the parsed JSON value — UIAA passes session +
|
/// exists"); errors only on transport failure.
|
||||||
/// flow state via JSON, never via headers.
|
|
||||||
async fn register_post(
|
async fn register_post(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
|
as_token: &str,
|
||||||
body: &serde_json::Value,
|
body: &serde_json::Value,
|
||||||
) -> Result<(StatusCode, serde_json::Value)> {
|
) -> Result<(StatusCode, serde_json::Value)> {
|
||||||
let base = matrix_base()?;
|
let base = matrix_base()?;
|
||||||
let url = format!("{base}/_matrix/client/v3/register");
|
let url = format!("{base}/_matrix/client/v3/register");
|
||||||
let resp = client
|
let resp = client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
|
.bearer_auth(as_token)
|
||||||
.json(body)
|
.json(body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
|
|
@ -233,24 +251,38 @@ pub fn random_password() -> Result<String> {
|
||||||
random_hex(PASSWORD_BYTES)
|
random_hex(PASSWORD_BYTES)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the matrix-spec UIAA flow to register `agent` with the given
|
/// Create the matrix account for `agent` as the hive's appservice and
|
||||||
/// `password` and return the resulting access token. Two round-trips:
|
/// return an access token for it. One round-trip: an appservice-typed
|
||||||
/// first POST elicits the 401 + session id, second POST supplies the
|
/// registration needs no UIAA stage at all, so there is no session to
|
||||||
/// registration token in the `auth` block. If the homeserver returns
|
/// carry and no shared secret to present.
|
||||||
/// 200 on the first POST (no flow stages required — `allow_registration`
|
///
|
||||||
/// with no token), we take the access token directly.
|
/// The account is created **by** the appservice but is an ordinary user
|
||||||
|
/// afterwards — it gets its own device and its own access token, and the
|
||||||
|
/// agent authenticates with that rather than with anything the hive
|
||||||
|
/// holds. The `as_token` never leaves the host.
|
||||||
///
|
///
|
||||||
/// Caller picks the password: agents use [`random_password`] (throwaway
|
/// Caller picks the password: agents use [`random_password`] (throwaway
|
||||||
/// — they auth by `access_token`), operators on the `hivectl` path
|
/// — they auth by `access_token`), operators on the `hivectl` path
|
||||||
/// supply their own so they can log into matrix web clients.
|
/// supply their own so they can log into matrix web clients.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Propagates the homeserver's own body, which is what the
|
||||||
|
/// `M_USER_IN_USE` callers match on. A `M_EXCLUSIVE` body means the
|
||||||
|
/// localpart falls outside the appservice's namespace — the registration
|
||||||
|
/// file's `namespaces.users` regex is the place to look, not this call.
|
||||||
async fn register_user(
|
async fn register_user(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
agent: &str,
|
agent: &str,
|
||||||
register_token: &str,
|
as_token: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let localpart = user_localpart(agent);
|
let localpart = user_localpart(agent);
|
||||||
let initial = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
|
// What makes this an appservice registration rather than an
|
||||||
|
// ordinary one. Without it the homeserver treats the request as a
|
||||||
|
// normal client's and asks for a UIAA flow — even holding the
|
||||||
|
// as_token.
|
||||||
|
"type": "m.login.application_service",
|
||||||
"username": localpart,
|
"username": localpart,
|
||||||
"password": password,
|
"password": password,
|
||||||
// device_id stays stable across re-runs of ensure_user_for so
|
// device_id stays stable across re-runs of ensure_user_for so
|
||||||
|
|
@ -259,37 +291,53 @@ async fn register_user(
|
||||||
"initial_device_display_name": format!("hyperhive ({agent})"),
|
"initial_device_display_name": format!("hyperhive ({agent})"),
|
||||||
"inhibit_login": false,
|
"inhibit_login": false,
|
||||||
});
|
});
|
||||||
let (status, body) = register_post(client, &initial).await?;
|
let (status, body) = register_post(client, as_token, &body).await?;
|
||||||
// Some servers accept the first POST when allow_registration is
|
|
||||||
// open. Take the access_token + we're done.
|
|
||||||
if status.is_success() {
|
|
||||||
return extract_access_token(&body);
|
|
||||||
}
|
|
||||||
if status != StatusCode::UNAUTHORIZED {
|
|
||||||
anyhow::bail!("matrix: /register first leg HTTP {status}, body: {body}");
|
|
||||||
}
|
|
||||||
let session = body["session"]
|
|
||||||
.as_str()
|
|
||||||
.with_context(|| format!("matrix: missing UIAA session in 401, body: {body}"))?;
|
|
||||||
let authed = serde_json::json!({
|
|
||||||
"username": localpart,
|
|
||||||
"password": password,
|
|
||||||
"device_id": format!("hyperhive-{agent}"),
|
|
||||||
"initial_device_display_name": format!("hyperhive ({agent})"),
|
|
||||||
"inhibit_login": false,
|
|
||||||
"auth": {
|
|
||||||
"type": "m.login.registration_token",
|
|
||||||
"token": register_token,
|
|
||||||
"session": session,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
let (status, body) = register_post(client, &authed).await?;
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
anyhow::bail!("matrix: /register auth leg HTTP {status}, body: {body}");
|
anyhow::bail!("matrix: /register as appservice HTTP {status}, body: {body}");
|
||||||
}
|
}
|
||||||
extract_access_token(&body)
|
extract_access_token(&body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Log in as an **existing** account using the hive's appservice token,
|
||||||
|
/// and return a fresh access token for it. No password involved: the
|
||||||
|
/// appservice is authorised for every localpart in its namespace, so it
|
||||||
|
/// can mint a session for one without knowing anything about the account.
|
||||||
|
///
|
||||||
|
/// This is the recovery path that used to need a stored password or an
|
||||||
|
/// admin-room password reset — an account whose token file was lost is
|
||||||
|
/// re-tokened from the hive's own identity instead. The device id matches
|
||||||
|
/// [`register_user`]'s, so a re-login replaces that device's token rather
|
||||||
|
/// than accumulating devices.
|
||||||
|
async fn appservice_login(client: &reqwest::Client, as_token: &str, agent: &str) -> Result<String> {
|
||||||
|
let base = matrix_base()?;
|
||||||
|
let url = format!("{base}/_matrix/client/v3/login");
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"type": "m.login.application_service",
|
||||||
|
"identifier": {
|
||||||
|
"type": "m.id.user",
|
||||||
|
"user": user_localpart(agent),
|
||||||
|
},
|
||||||
|
"device_id": format!("hyperhive-{agent}"),
|
||||||
|
"initial_device_display_name": format!("hyperhive ({agent})"),
|
||||||
|
});
|
||||||
|
let resp = client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(as_token)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("matrix: POST /login as appservice")?;
|
||||||
|
let status = resp.status();
|
||||||
|
let json = resp
|
||||||
|
.json::<serde_json::Value>()
|
||||||
|
.await
|
||||||
|
.context("matrix: parse appservice /login response")?;
|
||||||
|
if !status.is_success() {
|
||||||
|
anyhow::bail!("matrix: appservice /login HTTP {status} for {agent}, body: {json}");
|
||||||
|
}
|
||||||
|
extract_access_token(&json)
|
||||||
|
}
|
||||||
|
|
||||||
/// Pull `access_token` out of a successful /register response.
|
/// Pull `access_token` out of a successful /register response.
|
||||||
fn extract_access_token(body: &serde_json::Value) -> Result<String> {
|
fn extract_access_token(body: &serde_json::Value) -> Result<String> {
|
||||||
body["access_token"]
|
body["access_token"]
|
||||||
|
|
@ -637,11 +685,7 @@ async fn admin_room_reset_password(
|
||||||
///
|
///
|
||||||
/// `client` is shared across the sweep so we build one reqwest
|
/// `client` is shared across the sweep so we build one reqwest
|
||||||
/// connection pool for all agents rather than one per call.
|
/// connection pool for all agents rather than one per call.
|
||||||
pub async fn ensure_user_for(
|
pub async fn ensure_user_for(client: &reqwest::Client, name: &str, as_token: &str) -> Result<()> {
|
||||||
client: &reqwest::Client,
|
|
||||||
name: &str,
|
|
||||||
register_token: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
let agent = hive_types::Ident::parse(name)
|
let agent = hive_types::Ident::parse(name)
|
||||||
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
|
||||||
|
|
@ -678,7 +722,7 @@ pub async fn ensure_user_for(
|
||||||
}
|
}
|
||||||
|
|
||||||
let password = random_password()?;
|
let password = random_password()?;
|
||||||
let access_token = match register_user(client, name, register_token, &password).await {
|
let access_token = match register_user(client, name, as_token, &password).await {
|
||||||
Ok(token) => {
|
Ok(token) => {
|
||||||
// Successful registration — persist the password so we can
|
// Successful registration — persist the password so we can
|
||||||
// fall back to login if the token file is deleted later.
|
// fall back to login if the token file is deleted later.
|
||||||
|
|
@ -694,8 +738,21 @@ pub async fn ensure_user_for(
|
||||||
token
|
token
|
||||||
}
|
}
|
||||||
Err(reg_err) if reg_err.to_string().contains("M_USER_IN_USE") => {
|
Err(reg_err) if reg_err.to_string().contains("M_USER_IN_USE") => {
|
||||||
// Account already exists — try to re-login with the stored password.
|
// Account already exists and its token file was lost. The
|
||||||
tracing::info!(%name, "matrix: user already exists, attempting login with stored password");
|
// appservice can mint a session for any account in its
|
||||||
|
// namespace, so this needs no password and no admin rights —
|
||||||
|
// try it before the password paths below, which exist for
|
||||||
|
// accounts created before the appservice did (or named
|
||||||
|
// outside its namespace) and stay as the fallback.
|
||||||
|
tracing::info!(%name, "matrix: user already exists, logging in as the appservice");
|
||||||
|
match appservice_login(client, as_token, name).await {
|
||||||
|
Ok(token) => return finish_user_provisioning(name, &token).await,
|
||||||
|
Err(e) => tracing::warn!(
|
||||||
|
%name,
|
||||||
|
error = ?e,
|
||||||
|
"matrix: appservice login failed; falling back to the stored password"
|
||||||
|
),
|
||||||
|
}
|
||||||
let pw_path = password_path(name);
|
let pw_path = password_path(name);
|
||||||
let stored = if let Some(pw) = std::fs::read_to_string(&pw_path)
|
let stored = if let Some(pw) = std::fs::read_to_string(&pw_path)
|
||||||
.ok()
|
.ok()
|
||||||
|
|
@ -734,11 +791,19 @@ pub async fn ensure_user_for(
|
||||||
Err(other) => return Err(other),
|
Err(other) => return Err(other),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
finish_user_provisioning(name, &access_token).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist a freshly-obtained agent access token and kick the agent's
|
||||||
|
/// matrix daemon. Shared by every way [`ensure_user_for`] can end up
|
||||||
|
/// holding a token — creation, appservice login, password login — so a
|
||||||
|
/// new recovery path cannot forget half of it.
|
||||||
|
async fn finish_user_provisioning(name: &str, access_token: &str) -> Result<()> {
|
||||||
// Write the token via hive-priv (root helper): hive-c0re runs as the
|
// Write the token via hive-priv (root helper): hive-c0re runs as the
|
||||||
// unprivileged `hive-core` user and cannot write to agent-owned state
|
// unprivileged `hive-core` user and cannot write to agent-owned state
|
||||||
// directories directly. hive-priv writes the file 0600 and chowns it
|
// directories directly. hive-priv writes the file 0600 and chowns it
|
||||||
// to the agent user so it is readable from inside the container.
|
// to the agent user so it is readable from inside the container.
|
||||||
crate::priv_client::write_agent_matrix_token(name, &access_token, None, None)
|
crate::priv_client::write_agent_matrix_token(name, access_token, None, None)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("matrix: write matrix-token for {name} via hive-priv"))?;
|
.with_context(|| format!("matrix: write matrix-token for {name} via hive-priv"))?;
|
||||||
tracing::info!(%name, "matrix: provisioned access token");
|
tracing::info!(%name, "matrix: provisioned access token");
|
||||||
|
|
@ -767,30 +832,30 @@ pub async fn ensure_user_for(
|
||||||
/// behaviour.
|
/// behaviour.
|
||||||
///
|
///
|
||||||
/// **Not idempotent** (unlike [`crate::forge::provision_user_token`]): the
|
/// **Not idempotent** (unlike [`crate::forge::provision_user_token`]): the
|
||||||
/// matrix UIAA `/register` endpoint returns `M_USER_IN_USE` (HTTP 400)
|
/// matrix `/register` endpoint returns `M_USER_IN_USE` (HTTP 400) on a
|
||||||
/// on second call for the same localpart. Callers re-running this for
|
/// second call for the same localpart, appservice-authorised or not.
|
||||||
/// a known-existing matrix user should expect a hard error from this
|
/// Callers re-running this for a known-existing matrix user should expect
|
||||||
/// fn and route to a password-reset path instead.
|
/// a hard error from this fn and route to a password-reset path instead.
|
||||||
pub async fn provision_user_token(
|
pub async fn provision_user_token(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
name: &str,
|
name: &str,
|
||||||
register_token: &str,
|
as_token: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
register_user(client, name, register_token, password).await
|
register_user(client, name, as_token, password).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-agent matrix sync: ensure the agent has a matrix account + token.
|
/// Per-agent matrix sync: ensure the agent has a matrix account + token.
|
||||||
/// All operations are idempotent; failures are logged as warnings but
|
/// All operations are idempotent; failures are logged as warnings but
|
||||||
/// don't abort the caller.
|
/// don't abort the caller.
|
||||||
pub async fn sync_agent(client: &reqwest::Client, name: &str, register_token: &str) {
|
pub async fn sync_agent(client: &reqwest::Client, name: &str, as_token: &str) {
|
||||||
if let Err(e) = ensure_user_for(client, name, register_token).await {
|
if let Err(e) = ensure_user_for(client, name, as_token).await {
|
||||||
tracing::warn!(%name, error = ?e, "matrix: ensure_user failed");
|
tracing::warn!(%name, error = ?e, "matrix: ensure_user failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Standalone per-agent sync that handles its own setup: checks if
|
/// Standalone per-agent sync that handles its own setup: checks if
|
||||||
/// hive-matrix is present, reads the registration token, and builds
|
/// hive-matrix is present, reads the appservice token, and builds
|
||||||
/// an HTTP client before delegating to [`sync_agent`]. Mirrors the
|
/// an HTTP client before delegating to [`sync_agent`]. Mirrors the
|
||||||
/// setup in [`ensure_all`] so the rebuild path and the startup sweep
|
/// setup in [`ensure_all`] so the rebuild path and the startup sweep
|
||||||
/// stay equivalent. No-op when the matrix container is absent.
|
/// stay equivalent. No-op when the matrix container is absent.
|
||||||
|
|
@ -798,10 +863,10 @@ pub async fn sync_agent_standalone(name: &str) {
|
||||||
if !is_present() {
|
if !is_present() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let register_token = match ensure_register_token() {
|
let as_token = match read_appservice_token() {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(%name, error = ?e, "matrix: ensure_register_token failed");
|
tracing::warn!(%name, error = ?e, "matrix: read_appservice_token failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -815,22 +880,26 @@ pub async fn sync_agent_standalone(name: &str) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
sync_agent(&client, name, ®ister_token).await;
|
sync_agent(&client, name, &as_token).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure the hive system admin matrix user exists and its token is
|
/// Ensure the hive system admin matrix user exists, that its token is
|
||||||
/// persisted at [`admin_token_path()`]. Must be called BEFORE
|
/// persisted at [`admin_token_path()`], and that it actually holds admin
|
||||||
/// [`ensure_all`]'s agent loop so this account is the first to register
|
/// rights.
|
||||||
/// and becomes the homeserver admin automatically (Conduit/tuwunel:
|
|
||||||
/// first registered user = admin).
|
|
||||||
///
|
///
|
||||||
/// Idempotent — skips when the token file already exists and is
|
/// **Nothing here depends on registration order any more.** The account
|
||||||
/// non-empty. Does NOT promote the account via API (that requires
|
/// used to have to be the first ever registered, to win tuwunel's
|
||||||
/// admin rights which this fn bootstraps); on a fresh homeserver the
|
/// automatic first-user grant — a rule that cannot fire for an
|
||||||
/// first-registered rule fires automatically; on an existing homeserver
|
/// appservice-created account at all, and one that silently did nothing
|
||||||
/// the operator must promote the account once via
|
/// on a homeserver that already had users. Admin rights now come from an
|
||||||
/// `hivectl matrix promote-user hive` or the conduit admin room.
|
/// explicit `make_user_admin`: the `admin_execute` entry beside the
|
||||||
pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) -> Result<()> {
|
/// appservice registration performs it at homeserver startup, and
|
||||||
|
/// [`ensure_admin_rights`] checks the result and says so when it is
|
||||||
|
/// missing.
|
||||||
|
///
|
||||||
|
/// Idempotent — skips the account work when the token file already exists
|
||||||
|
/// and is non-empty.
|
||||||
|
pub async fn ensure_admin_user(client: &reqwest::Client, as_token: &str) -> Result<()> {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
let path = admin_token_path();
|
let path = admin_token_path();
|
||||||
if path.exists()
|
if path.exists()
|
||||||
|
|
@ -841,8 +910,7 @@ pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) -
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let password = random_password()?;
|
let password = random_password()?;
|
||||||
let access_token = match register_user(client, HIVE_ADMIN_LOCALPART, register_token, &password)
|
let access_token = match register_user(client, HIVE_ADMIN_LOCALPART, as_token, &password).await
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
Ok(token) => {
|
Ok(token) => {
|
||||||
let pw_path = password_path(HIVE_ADMIN_LOCALPART);
|
let pw_path = password_path(HIVE_ADMIN_LOCALPART);
|
||||||
|
|
@ -857,7 +925,17 @@ pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) -
|
||||||
token
|
token
|
||||||
}
|
}
|
||||||
Err(reg_err) if reg_err.to_string().contains("M_USER_IN_USE") => {
|
Err(reg_err) if reg_err.to_string().contains("M_USER_IN_USE") => {
|
||||||
tracing::info!("matrix: hive admin user already exists, re-logging in");
|
// The expected path, not an edge case: this account is the
|
||||||
|
// appservice's own `sender_localpart`, so the homeserver
|
||||||
|
// creates it when it loads the registration — before
|
||||||
|
// hive-c0re gets a chance to ask. An appservice login needs
|
||||||
|
// no password, which is just as well since an account the
|
||||||
|
// homeserver created has none.
|
||||||
|
tracing::info!("matrix: hive admin user already exists, logging in as the appservice");
|
||||||
|
match appservice_login(client, as_token, HIVE_ADMIN_LOCALPART).await {
|
||||||
|
Ok(token) => token,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, "matrix: appservice login for the hive admin failed; falling back to the stored password");
|
||||||
let pw_path = password_path(HIVE_ADMIN_LOCALPART);
|
let pw_path = password_path(HIVE_ADMIN_LOCALPART);
|
||||||
let stored = std::fs::read_to_string(&pw_path)
|
let stored = std::fs::read_to_string(&pw_path)
|
||||||
.ok()
|
.ok()
|
||||||
|
|
@ -865,13 +943,17 @@ pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) -
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.with_context(|| {
|
.with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
"matrix: hive admin user exists but password missing at {} — \
|
"matrix: hive admin user exists, appservice login failed, and no \
|
||||||
manual recovery: reset password via admin API or conduit admin room",
|
password is stored at {} — check that the registration file's \
|
||||||
|
namespace covers @{HIVE_ADMIN_LOCALPART} and that the homeserver \
|
||||||
|
loaded it",
|
||||||
pw_path.display()
|
pw_path.display()
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
login_user(client, HIVE_ADMIN_LOCALPART, &stored).await?
|
login_user(client, HIVE_ADMIN_LOCALPART, &stored).await?
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(other) => return Err(other),
|
Err(other) => return Err(other),
|
||||||
};
|
};
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
|
|
@ -884,9 +966,149 @@ pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) -
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check that the hive admin account holds admin rights, and repair it
|
||||||
|
/// through the admin room when it does not.
|
||||||
|
///
|
||||||
|
/// Admin-ness in tuwunel is membership of the admin room, so that is what
|
||||||
|
/// this reads: the account's own joined-rooms list, which needs no
|
||||||
|
/// privileges to fetch. When the admin room is in it there is nothing to
|
||||||
|
/// do and nothing is sent — worth insisting on, because the repair is a
|
||||||
|
/// message in a room and this runs on every sweep.
|
||||||
|
///
|
||||||
|
/// When it is absent, the repair is attempted anyway (a stale or failed
|
||||||
|
/// read is cheaper to retry than to reason about) and a failure is
|
||||||
|
/// reported rather than raised: agent accounts, the hive Space and the
|
||||||
|
/// chat room all work without an admin, so a hive with an unpromoted
|
||||||
|
/// admin is degraded, not broken. Only `hivectl matrix promote-user` /
|
||||||
|
/// `reset-password` need 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,
|
||||||
|
server_name: &str,
|
||||||
|
) -> bool {
|
||||||
|
let room_id = match discover_admin_room_id(client, admin_token, server_name).await {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, "matrix: cannot resolve #admins — not checking the hive admin's rights");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match joined_rooms(client, admin_token).await {
|
||||||
|
Some(rooms) if rooms.iter().any(|r| r == &room_id) => {
|
||||||
|
tracing::debug!("matrix: hive admin is in the admin room");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
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"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if let Err(e) =
|
||||||
|
promote_user_to_admin(client, admin_token, HIVE_ADMIN_LOCALPART, server_name).await
|
||||||
|
{
|
||||||
|
// The honest message. Promoting through the admin room requires
|
||||||
|
// being admin already, so the one lever that works on a
|
||||||
|
// homeserver with no admin at all is the `admin_execute` entry in
|
||||||
|
// the hive-matrix module — which runs at startup, which means a
|
||||||
|
// restart is the fix rather than another sweep.
|
||||||
|
tracing::warn!(
|
||||||
|
error = ?e,
|
||||||
|
"matrix: hive admin '@{HIVE_ADMIN_LOCALPART}' holds no admin rights. \
|
||||||
|
The homeserver promotes it at startup (admin_execute in hive-matrix.nix), \
|
||||||
|
so `systemctl restart container@hive-matrix` grants it. Agent accounts and \
|
||||||
|
room provisioning are unaffected; `hivectl matrix promote-user` and \
|
||||||
|
`reset-password` need it."
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
tracing::info!("matrix: promoted the hive admin through the admin room");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rooms `token`'s account has joined, or `None` when the list could
|
||||||
|
/// not be read. `None` is deliberately not an empty list: "no rooms" and
|
||||||
|
/// "no answer" lead to different decisions in [`ensure_admin_rights`].
|
||||||
|
async fn joined_rooms(client: &reqwest::Client, token: &str) -> Option<Vec<String>> {
|
||||||
|
let base = matrix_http()?;
|
||||||
|
let url = format!("{base}/_matrix/client/v3/joined_rooms");
|
||||||
|
let resp = client.get(&url).bearer_auth(token).send().await.ok()?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let body = resp.json::<serde_json::Value>().await.ok()?;
|
||||||
|
Some(
|
||||||
|
body["joined_rooms"]
|
||||||
|
.as_array()?
|
||||||
|
.iter()
|
||||||
|
.filter_map(|r| r.as_str().map(ToOwned::to_owned))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an admin-room reply says a `make-user-admin` succeeded.
|
||||||
|
///
|
||||||
|
/// Three spellings, because the reply is prose and prose changes between
|
||||||
|
/// builds. tuwunel v1.9.0's is `"<user id> has been granted admin
|
||||||
|
/// privileges."` — which the original two patterns here (`done…`,
|
||||||
|
/// `made…admin`) do not match at all, so a promotion that had already
|
||||||
|
/// worked was reported as a 15-second timeout. The older spellings are
|
||||||
|
/// kept: a homeserver is not necessarily the version this was written
|
||||||
|
/// against.
|
||||||
|
fn is_make_admin_success(body: &str) -> Option<()> {
|
||||||
|
let lower = body.to_ascii_lowercase();
|
||||||
|
let says_ok = lower.starts_with("done")
|
||||||
|
|| lower.contains("granted admin privileges")
|
||||||
|
|| (lower.contains("made") && lower.contains("admin"));
|
||||||
|
says_ok.then_some(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod is_make_admin_success_tests {
|
||||||
|
use super::is_make_admin_success;
|
||||||
|
|
||||||
|
/// The reply tuwunel v1.9.0 actually sends
|
||||||
|
/// (`src/admin/user/make_user_admin.rs`). This is the case the
|
||||||
|
/// pre-existing matcher missed.
|
||||||
|
#[test]
|
||||||
|
fn tuwunel_1_9_grant_reply() {
|
||||||
|
let msg = "@hive:pr1ma.darkest.space has been granted admin privileges.";
|
||||||
|
assert_eq!(is_make_admin_success(msg), Some(()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn older_spellings_still_match() {
|
||||||
|
assert_eq!(
|
||||||
|
is_make_admin_success("Done: user is now an admin"),
|
||||||
|
Some(())
|
||||||
|
);
|
||||||
|
assert_eq!(is_make_admin_success("Made @x:y an admin"), Some(()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The control: an unrelated or failing reply must not read as
|
||||||
|
/// success, or a failed promotion returns Ok and the warning that
|
||||||
|
/// would have named it never fires.
|
||||||
|
#[test]
|
||||||
|
fn failures_and_noise_do_not_match() {
|
||||||
|
assert_eq!(is_make_admin_success("Command not recognised."), None);
|
||||||
|
assert_eq!(is_make_admin_success("User @x:y does not exist"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Promote a user to homeserver admin via the Matrix admin room
|
/// Promote a user to homeserver admin via the Matrix admin room
|
||||||
/// (`#admins:<server>`). Sends `!admin users make-user-admin @<localpart>:<server>` as
|
/// (`#admins:<server>`). Sends `!admin users make-user-admin @<localpart>:<server>` as
|
||||||
/// @hive, polls for the bot's "Done:" success response.
|
/// @hive, polls for the bot's success reply.
|
||||||
|
///
|
||||||
|
/// ⚠️ Requires the **sender** to be an admin already — tuwunel only
|
||||||
|
/// treats a message as a command when its sender is in the admin room.
|
||||||
|
/// So this promotes a *second* user; it cannot bootstrap the first one.
|
||||||
|
/// That is what the `admin_execute` entry in `hive-matrix.nix` is for.
|
||||||
///
|
///
|
||||||
/// Goes through the admin room rather than a direct HTTP call because
|
/// Goes through the admin room rather than a direct HTTP call because
|
||||||
/// tuwunel implements parts of the Synapse admin API but not user
|
/// tuwunel implements parts of the Synapse admin API but not user
|
||||||
|
|
@ -907,14 +1129,7 @@ pub async fn promote_user_to_admin(
|
||||||
server_name,
|
server_name,
|
||||||
&room_url,
|
&room_url,
|
||||||
&command,
|
&command,
|
||||||
|body| {
|
is_make_admin_success,
|
||||||
let lower = body.to_ascii_lowercase();
|
|
||||||
if lower.starts_with("done") || lower.contains("made") && lower.contains("admin") {
|
|
||||||
Some(())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.with_context(|| {
|
.with_context(|| {
|
||||||
|
|
@ -1637,10 +1852,14 @@ pub async fn ensure_all() -> bool {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let mut ok = true;
|
let mut ok = true;
|
||||||
let register_token = match ensure_register_token() {
|
// Loud and non-destructive: with no appservice token this sweep can
|
||||||
|
// create nothing, so it does nothing. Agents that already hold a
|
||||||
|
// token keep using it — their accounts and sessions are untouched by
|
||||||
|
// anything in here.
|
||||||
|
let as_token = match read_appservice_token() {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = ?e, "matrix: ensure_register_token failed");
|
tracing::warn!(error = ?e, "matrix: no appservice token; skipping the user sweep");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1656,10 +1875,12 @@ pub async fn ensure_all() -> bool {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Provision hive admin user FIRST so it's the first registered
|
// The hive admin first, because everything below provisions THROUGH
|
||||||
// account on a fresh homeserver (Conduit/tuwunel makes the first
|
// it (the Space, the chat room and every invite are sent with its
|
||||||
// registered user admin automatically).
|
// token). Not, any more, so that it wins a first-registered-user
|
||||||
if let Err(e) = ensure_admin_user(&client, ®ister_token).await {
|
// grant: it holds admin rights by explicit promotion, checked in
|
||||||
|
// `provision_space` once the server name is known.
|
||||||
|
if let Err(e) = ensure_admin_user(&client, &as_token).await {
|
||||||
tracing::warn!(error = ?e, "matrix: ensure_admin_user failed");
|
tracing::warn!(error = ?e, "matrix: ensure_admin_user failed");
|
||||||
ok = false;
|
ok = false;
|
||||||
}
|
}
|
||||||
|
|
@ -1672,7 +1893,7 @@ pub async fn ensure_all() -> bool {
|
||||||
let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else {
|
let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
sync_agent(&client, name, ®ister_token).await;
|
sync_agent(&client, name, &as_token).await;
|
||||||
agent_names.push(name.to_owned());
|
agent_names.push(name.to_owned());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1705,6 +1926,11 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Before the rooms: the one thing in this sweep that is about the
|
||||||
|
// admin account itself rather than about what it provisions.
|
||||||
|
if !ensure_admin_rights(client, &admin_token, &server_name).await {
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
let room_id = match ensure_hive_space(client, &admin_token).await {
|
let room_id = match ensure_hive_space(client, &admin_token).await {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
|
||||||
|
|
@ -253,14 +253,15 @@ pub fn gateway_agents_conf() -> PathBuf {
|
||||||
// `nix/host-modules/hive-c0re/default.nix` and `nix/host-modules/hive-ci.nix` — must match.
|
// `nix/host-modules/hive-c0re/default.nix` and `nix/host-modules/hive-ci.nix` — must match.
|
||||||
pub const FORGE_CORE_TOKEN: &str = "/var/lib/hyperhive/forge-core-token";
|
pub const FORGE_CORE_TOKEN: &str = "/var/lib/hyperhive/forge-core-token";
|
||||||
|
|
||||||
/// `matrix-register-token` — shared matrix registration token.
|
/// `matrix-appservice-token` — the `as_token` of the hive's appservice
|
||||||
// nix: bind-mounted into the tuwunel/matrix container (hive-matrix.nix) — must match.
|
/// registration, which authorises every account this daemon creates.
|
||||||
// Not operator-option-driven: `registrationTokenFile` is `internal` on the nix
|
// nix: minted by the `hive-matrix-appservice` activation script in
|
||||||
// side, and an `assertions` entry there rejects any attempt to move it, so this
|
// `nix/host-modules/hive-matrix.nix`, which renders it into the registration
|
||||||
// literal can never diverge from it.
|
// file the homeserver loads — must match. Read-only here on purpose: a token
|
||||||
|
// minted on this side would not be the one in that file.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn matrix_register_token() -> PathBuf {
|
pub fn matrix_appservice_token() -> PathBuf {
|
||||||
state_root().join("matrix-register-token")
|
state_root().join("matrix-appservice-token")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `/run/hyperhive` — the runtime root (host admin socket + per-agent dirs).
|
/// `/run/hyperhive` — the runtime root (host admin socket + per-agent dirs).
|
||||||
|
|
|
||||||
|
|
@ -558,8 +558,8 @@ async fn handle_matrix_create_user(
|
||||||
password: Option<&str>,
|
password: Option<&str>,
|
||||||
) -> Result<HostResponse> {
|
) -> Result<HostResponse> {
|
||||||
require_matrix_present()?;
|
require_matrix_present()?;
|
||||||
let register_token =
|
let as_token =
|
||||||
crate::matrix::ensure_register_token().context("read matrix register token")?;
|
crate::matrix::read_appservice_token().context("read matrix appservice token")?;
|
||||||
let client = matrix_http_client()?;
|
let client = matrix_http_client()?;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
if agent_exists(name)? {
|
if agent_exists(name)? {
|
||||||
|
|
@ -571,7 +571,7 @@ async fn handle_matrix_create_user(
|
||||||
"matrix create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via access_token"
|
"matrix create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via access_token"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
crate::matrix::ensure_user_for(&client, name.as_str(), ®ister_token)
|
crate::matrix::ensure_user_for(&client, name.as_str(), &as_token)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("matrix create-user {name}"))?;
|
.with_context(|| format!("matrix create-user {name}"))?;
|
||||||
let path = Coordinator::agent_notes_dir(name).join("matrix-token");
|
let path = Coordinator::agent_notes_dir(name).join("matrix-token");
|
||||||
|
|
@ -585,7 +585,7 @@ async fn handle_matrix_create_user(
|
||||||
let token = crate::matrix::provision_user_token(
|
let token = crate::matrix::provision_user_token(
|
||||||
&client,
|
&client,
|
||||||
name.as_str(),
|
name.as_str(),
|
||||||
®ister_token,
|
&as_token,
|
||||||
&effective_password,
|
&effective_password,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
@ -770,10 +770,10 @@ async fn handle_push_snapshot(
|
||||||
|
|
||||||
async fn handle_matrix_sync_admin() -> Result<HostResponse> {
|
async fn handle_matrix_sync_admin() -> Result<HostResponse> {
|
||||||
require_matrix_present()?;
|
require_matrix_present()?;
|
||||||
let register_token =
|
let as_token =
|
||||||
crate::matrix::ensure_register_token().context("read matrix register token")?;
|
crate::matrix::read_appservice_token().context("read matrix appservice token")?;
|
||||||
let client = matrix_http_client()?;
|
let client = matrix_http_client()?;
|
||||||
crate::matrix::ensure_admin_user(&client, ®ister_token)
|
crate::matrix::ensure_admin_user(&client, &as_token)
|
||||||
.await
|
.await
|
||||||
.context("matrix sync-admin")?;
|
.context("matrix sync-admin")?;
|
||||||
let path = crate::matrix::admin_token_path();
|
let path = crate::matrix::admin_token_path();
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@
|
||||||
//! to start sync.
|
//! to start sync.
|
||||||
//!
|
//!
|
||||||
//! No OAuth dance / cross-signing setup (in contrast to damocles-daemon's
|
//! No OAuth dance / cross-signing setup (in contrast to damocles-daemon's
|
||||||
//! ccc.de connection): for the in-hive tuwunel the `registration_token`
|
//! ccc.de connection): for the in-hive tuwunel hive-c0re already minted
|
||||||
//! UIAA flow already minted the token + user/device, hive-c0re just
|
//! the token + user/device as the hive's appservice and handed us the
|
||||||
//! handed us the bearer in a file. matrix-sdk's `restore_session` with
|
//! bearer in a file. matrix-sdk's `restore_session` with a constructed
|
||||||
//! a constructed `MatrixSession` skips the login flow entirely.
|
//! `MatrixSession` skips the login flow entirely.
|
||||||
//!
|
//!
|
||||||
//! E2EE is enabled via `with_encryption_settings(EncryptionSettings::default())`.
|
//! E2EE is enabled via `with_encryption_settings(EncryptionSettings::default())`.
|
||||||
//! Crypto keys are persisted in the sqlite store under `state_dir`
|
//! Crypto keys are persisted in the sqlite store under `state_dir`
|
||||||
|
|
|
||||||
|
|
@ -247,10 +247,29 @@ in
|
||||||
[ "services" "hyperhive" "swarm" "matrix" "maxRequestSize" ]
|
[ "services" "hyperhive" "swarm" "matrix" "maxRequestSize" ]
|
||||||
[ "services" "hyperhive" "deploy" "matrix" "maxRequestSize" ]
|
[ "services" "hyperhive" "deploy" "matrix" "maxRequestSize" ]
|
||||||
)
|
)
|
||||||
(lib.mkRenamedOptionModule
|
# Both spellings of the registration token, removed rather than renamed:
|
||||||
[ "services" "hyperhive" "swarm" "matrix" "registrationTokenFile" ]
|
# the homeserver no longer accepts a shared registration secret at all,
|
||||||
[ "services" "hyperhive" "deploy" "matrix" "registrationTokenFile" ]
|
# 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
|
(lib.mkRenamedOptionModule
|
||||||
[ "services" "hyperhive" "swarm" "matrix" "gui" "enable" ]
|
[ "services" "hyperhive" "swarm" "matrix" "gui" "enable" ]
|
||||||
[ "services" "hyperhive" "deploy" "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
|
# 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
|
# 32-byte value and writes it where ./hive-matrix.nix already looks, then asks
|
||||||
# homeserver never learns the store exists, and its config is unchanged.
|
# 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
|
# ⚠️ 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
|
# 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
|
# 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
|
# `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
|
# 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.
|
# 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
|
# `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.
|
# 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
|
# A literal, not an option — ./hive-matrix.nix names its container
|
||||||
# `containers.hive-matrix` directly and declares no `machine` to derive it
|
# `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" ];
|
services.hyperhive.swarm.otel.journaldUnits = [ "swarm-bao-matrix-token" ];
|
||||||
|
|
||||||
systemd.services.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.
|
# 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
|
# `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
|
# conditional even though the read is not: off-host there is nothing local
|
||||||
|
|
@ -176,8 +177,20 @@ in
|
||||||
fi
|
fi
|
||||||
|
|
||||||
umask 077
|
umask 077
|
||||||
printf '%s\n' "$token" > ${lib.escapeShellArg (toString deployCfg.matrix.registrationTokenFile)}
|
printf '%s\n' "$token" > ${lib.escapeShellArg (toString deployCfg.matrix.appserviceTokenFile)}
|
||||||
chmod 0600 ${lib.escapeShellArg (toString deployCfg.matrix.registrationTokenFile)}
|
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
|
# 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
|
# 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:
|
# 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
|
# 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.
|
# into one that is refused, which reaches the agent as a timeout.
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -40,22 +40,155 @@ let
|
||||||
# login into an intermittent one.
|
# login into an intermittent one.
|
||||||
matrixSecretPath = "/var/lib/tuwunel-oidc/${cfg.sso.clientId}.secret";
|
matrixSecretPath = "/var/lib/tuwunel-oidc/${cfg.sso.clientId}.secret";
|
||||||
|
|
||||||
# Single source for the registration-token path: the option's `default`,
|
# ⚠️ tuwunel does NOT read the host path directly, and this indirection
|
||||||
# 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
|
|
||||||
# is not ceremony. Upstream's own words: "under systemd the path must be
|
# is not ceremony. Upstream's own words: "under systemd the path must be
|
||||||
# visible to the service after sandboxing (ReadWritePaths / ProtectHome),
|
# visible to the service after sandboxing (ReadWritePaths / ProtectHome),
|
||||||
# typically by placing the file under /etc/tuwunel/" — which this
|
# typically by placing the file under /etc/tuwunel/" — which this
|
||||||
# container has no writable etc for. `LoadCredential` is the answer
|
# 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
|
# same reason: it keeps `DynamicUser=true` + `PrivateUsers=true` intact
|
||||||
# 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 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";
|
||||||
|
|
||||||
|
# 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}$";
|
||||||
|
|
||||||
|
# Mint the tokens if they are absent, then render the registration from
|
||||||
|
# whatever they now hold. **One script with two callers** — the activation
|
||||||
|
# script below and `glue-matrix-bao-token.nix`, which overwrites the
|
||||||
|
# `as_token` with the swarm store's copy and has to re-render afterwards.
|
||||||
|
#
|
||||||
|
# ⚠️ The `as_token` mint here is the FIRST-BOOT FALLBACK, not the normal
|
||||||
|
# route. `swarm-secret-publisher.nix` mints one per hive swarm-side and
|
||||||
|
# publishes it to the store; the reader above overwrites this file before
|
||||||
|
# the homeserver's container starts, so what a hive actually runs on is the
|
||||||
|
# swarm's value. This keeps a hive that has never reached the store working
|
||||||
|
# — and it fires only when the file is absent, so it never clobbers one the
|
||||||
|
# store delivered. (`hs_token` has no swarm half: it is how the homeserver
|
||||||
|
# authenticates to *us*, so it stays hive-local and is minted here for real.)
|
||||||
|
# A second copy of the registration's shape would be a second source of
|
||||||
|
# truth for a format whose mismatch is silent: the homeserver would load a
|
||||||
|
# registration naming a token nobody holds, and every request hive-c0re
|
||||||
|
# makes would come back 401 naming nothing.
|
||||||
|
#
|
||||||
|
# Re-rendering unconditionally is the point rather than thoroughness: "the
|
||||||
|
# token file exists" does not mean "the registration carries what is in
|
||||||
|
# it".
|
||||||
|
appserviceRegistrationScript = pkgs.writeShellApplication {
|
||||||
|
name = "hive-matrix-appservice-registration";
|
||||||
|
runtimeInputs = [ pkgs.coreutils ];
|
||||||
|
text = ''
|
||||||
|
# Both the tokens and the rendered registration are secrets; 077
|
||||||
|
# covers every file this script creates rather than each one
|
||||||
|
# separately.
|
||||||
|
umask 077
|
||||||
|
mkdir -p ${lib.escapeShellArg appserviceDir}
|
||||||
|
for f in ${lib.escapeShellArg appserviceTokenPath} ${lib.escapeShellArg appserviceHsTokenPath}; do
|
||||||
|
if [ ! -s "$f" ]; then
|
||||||
|
head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$f"
|
||||||
|
echo >> "$f"
|
||||||
|
echo "hive-matrix: generated appservice token at $f"
|
||||||
|
fi
|
||||||
|
chmod 0600 "$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Read into shell variables and emitted with `printf`, a shell
|
||||||
|
# builtin: a token passed as an argument to a real command would land
|
||||||
|
# in that process's argv, which is world-readable for its lifetime.
|
||||||
|
asToken="$(cat ${lib.escapeShellArg appserviceTokenPath})"
|
||||||
|
hsToken="$(cat ${lib.escapeShellArg appserviceHsTokenPath})"
|
||||||
|
|
||||||
|
# The quoted heredoc keeps the regex's own `$` and `\` out of the
|
||||||
|
# shell's hands; the YAML single quotes keep them out of YAML's.
|
||||||
|
{
|
||||||
|
cat <<'REGISTRATION'
|
||||||
|
id: ${appserviceId}
|
||||||
|
url: null
|
||||||
|
sender_localpart: ${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
|
# 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
|
||||||
|
|
@ -443,40 +576,59 @@ in
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
registrationTokenFile = lib.mkOption {
|
appserviceTokenFile = lib.mkOption {
|
||||||
type = lib.types.path;
|
type = lib.types.path;
|
||||||
internal = true;
|
internal = true;
|
||||||
default = registrationTokenPath;
|
default = appserviceTokenPath;
|
||||||
description = ''
|
description = ''
|
||||||
Host path to a file containing the matrix registration token
|
Host path to a file containing this hive's matrix appservice
|
||||||
tuwunel reads to authorise new-account creation. The token is
|
token (`as_token`) — the identity `hive-c0re` creates and logs
|
||||||
generated automatically by `hive-c0re` on first boot (32-byte
|
into accounts with. Minted automatically on first activation
|
||||||
random hex, mode 0600) and is bind-mounted read-only into the
|
(32-byte random hex, mode 0600) and rendered into the
|
||||||
tuwunel container at the same path. Agents never see this
|
appservice registration the homeserver loads at boot. Agents
|
||||||
token — hive-c0re uses it to provision per-agent accounts
|
never see it; an agent only ever receives its own
|
||||||
and the agent only receives the resulting `access_token`.
|
`access_token`.
|
||||||
|
|
||||||
Not operator-settable — `hive-c0re`'s Rust side derives this
|
Not operator-settable — `hive-c0re`'s Rust side derives this
|
||||||
same path independently (`paths::matrix_register_token()`) with
|
same path independently (`paths::matrix_appservice_token()`)
|
||||||
nothing wiring an override across, so a moved path used to
|
with nothing wiring an override across, so a moved path desyncs
|
||||||
desync the two silently. An externally-managed token is
|
the two silently. An externally-managed token is delivered by
|
||||||
delivered by writing into *this* fixed path instead of moving it —
|
writing into *this* fixed path instead of moving it — see
|
||||||
see `glue-matrix-bao-token.nix`, which fetches from the swarm
|
`glue-matrix-bao-token.nix`, which fetches from the swarm secret
|
||||||
secret store and overwrites this file in place.
|
store, overwrites this file in place, and re-renders the
|
||||||
|
registration that names it.
|
||||||
|
|
||||||
Enforced by an `assertions` entry below rather than `readOnly`:
|
Enforced by an `assertions` entry below rather than `readOnly`:
|
||||||
the `config` block gives this option its own `mkDefault`
|
the `config` block gives this option its own `mkDefault`
|
||||||
definition (lowest priority) so any real override — an operator's,
|
definition (lowest priority) so a real override still resolves
|
||||||
or the pre-rename `swarm.matrix.registrationTokenFile` shim's —
|
cleanly instead of crashing eval with nixpkgs' generic
|
||||||
still resolves cleanly instead of crashing eval with nixpkgs'
|
"read-only, set multiple times" message; the assertion then
|
||||||
generic "read-only, set multiple times" message; the assertion
|
names the actual problem. (`readOnly` was the first attempt on
|
||||||
then names the actual problem. (`readOnly` was the first attempt —
|
this option's predecessor — atlas caught that it only rejects a
|
||||||
atlas caught that it only rejects a *second* definition,
|
*second* definition, so with nothing else defining the option a
|
||||||
so with nothing else defining the option a lone override sailed
|
lone override sailed through silently, same desync as before
|
||||||
through silently, same desync as before with a lock that wasn't
|
with a lock that wasn't locked. Fixed by defining the value here
|
||||||
locked. Fixed by defining the value here instead of leaving it on
|
instead of leaving it on `default` alone, but a proper
|
||||||
`default` alone, but a proper `assertions` message beat re-adding
|
`assertions` message beat re-adding `readOnly` on top once the
|
||||||
`readOnly` on top once the option had a real definition either way.)
|
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.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -538,11 +690,11 @@ in
|
||||||
|
|
||||||
config = lib.mkIf deployCfg.matrix.enable {
|
config = lib.mkIf deployCfg.matrix.enable {
|
||||||
# The option's own value, defined explicitly rather than left on its
|
# The option's own value, defined explicitly rather than left on its
|
||||||
# bare `default` — `mkDefault` so a real override (an operator's, or
|
# bare `default` — `mkDefault` so a real override still resolves
|
||||||
# the pre-rename shim's) still resolves cleanly rather than crashing
|
# cleanly rather than crashing eval; the `assertions` entry below is
|
||||||
# eval; the `assertions` entry below is what actually rejects it, with
|
# what actually rejects it, with a message naming the reason. See that
|
||||||
# a message naming the reason. See that option's own comment above.
|
# option's own comment above.
|
||||||
services.hyperhive.deploy.matrix.registrationTokenFile = lib.mkDefault registrationTokenPath;
|
services.hyperhive.deploy.matrix.appserviceTokenFile = lib.mkDefault appserviceTokenPath;
|
||||||
|
|
||||||
# Matrix's own gateway surface: the sub-domain vhost, the name the
|
# Matrix's own gateway surface: the sub-domain vhost, the name the
|
||||||
# hive resolver answers for, and the Accept-header map that vhost's
|
# hive resolver answers for, and the Accept-header map that vhost's
|
||||||
|
|
@ -723,19 +875,17 @@ in
|
||||||
# mkDefault above lets an override resolve instead of crashing
|
# mkDefault above lets an override resolve instead of crashing
|
||||||
# eval — this is the actual rejection, with a message that names
|
# eval — this is the actual rejection, with a message that names
|
||||||
# the real fix instead of nixpkgs' generic conflicting-definition
|
# the real fix instead of nixpkgs' generic conflicting-definition
|
||||||
# text. Covers both the current path and the pre-rename
|
# text.
|
||||||
# `swarm.matrix.registrationTokenFile` shim in one check, since
|
assertion = deployCfg.matrix.appserviceTokenFile == appserviceTokenPath;
|
||||||
# both land on the same merged option.
|
|
||||||
assertion = deployCfg.matrix.registrationTokenFile == registrationTokenPath;
|
|
||||||
message = ''
|
message = ''
|
||||||
services.hyperhive.deploy.matrix.registrationTokenFile is fixed at
|
services.hyperhive.deploy.matrix.appserviceTokenFile is fixed at
|
||||||
${registrationTokenPath} and cannot be moved — hive-c0re's Rust
|
${appserviceTokenPath} and cannot be moved — hive-c0re's Rust
|
||||||
side derives this same path independently and has no way to learn
|
side derives this same path independently and has no way to learn
|
||||||
an override, so moving it desyncs the two silently instead of
|
an override, so moving it desyncs the two silently instead of
|
||||||
loudly.
|
loudly.
|
||||||
|
|
||||||
Integrating an externally-managed registration token? Deliver it
|
Integrating an externally-managed appservice token? Deliver it
|
||||||
by writing into ${registrationTokenPath} instead of pointing this
|
by writing into ${appserviceTokenPath} instead of pointing this
|
||||||
option elsewhere — see glue-matrix-bao-token.nix, which does
|
option elsewhere — see glue-matrix-bao-token.nix, which does
|
||||||
exactly that from the swarm secret store.
|
exactly that from the swarm secret store.
|
||||||
'';
|
'';
|
||||||
|
|
@ -781,8 +931,8 @@ in
|
||||||
# permanent stall presenting as "matrix is broken", several layers from
|
# permanent stall presenting as "matrix is broken", several layers from
|
||||||
# its cause.
|
# its cause.
|
||||||
#
|
#
|
||||||
# The registration token above dodges that with an activation script
|
# The appservice registration dodges that with an activation script
|
||||||
# that pre-creates the file. ⚠️ That dodge is NOT available here:
|
# that renders the file first. ⚠️ That dodge is NOT available here:
|
||||||
# tuwunel requires the secret file to exist *and be non-empty*, so a
|
# 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
|
# zero-byte placeholder would satisfy the bind mount and then stop the
|
||||||
# homeserver from starting.
|
# homeserver from starting.
|
||||||
|
|
@ -890,16 +1040,21 @@ in
|
||||||
''
|
''
|
||||||
);
|
);
|
||||||
|
|
||||||
system.activationScripts.hive-matrix-register-token = lib.stringAfter [ "var" ] ''
|
# Mint the appservice tokens and render the registration, before any
|
||||||
tokenFile=${lib.escapeShellArg (toString deployCfg.matrix.registrationTokenFile)}
|
# container start.
|
||||||
if [ ! -s "$tokenFile" ]; then
|
#
|
||||||
mkdir -p "$(dirname "$tokenFile")"
|
# An activation script rather than a unit: the directory below is
|
||||||
head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$tokenFile"
|
# bind-mounted into the container and nixos-container refuses to start
|
||||||
echo >> "$tokenFile"
|
# when a bind source is missing, so this has to have run first.
|
||||||
echo "hive-matrix: generated registration token at $tokenFile"
|
# Activation is what runs before the container both on a switch and on
|
||||||
fi
|
# every boot. (The registration token this replaced used an activation
|
||||||
# Re-apply 0600 (normalises any pre-LoadCredential carry-over).
|
# script for the same reason, and additionally to dodge nspawn creating
|
||||||
chmod 0600 "$tokenFile"
|
# an empty file at a missing bind target — which tuwunel then read as
|
||||||
|
# "no token", refusing every registration until the next restart. A
|
||||||
|
# missing registration file is not silent in that way: the homeserver
|
||||||
|
# fails its appservice load loudly.)
|
||||||
|
system.activationScripts.hive-matrix-appservice = lib.stringAfter [ "var" ] ''
|
||||||
|
${deployCfg.matrix.appserviceRegistrationScript}
|
||||||
'';
|
'';
|
||||||
|
|
||||||
containers.hive-matrix = {
|
containers.hive-matrix = {
|
||||||
|
|
@ -910,11 +1065,15 @@ in
|
||||||
extraFlags = [ "--link-journal=host" ];
|
extraFlags = [ "--link-journal=host" ];
|
||||||
# Shared host netns — agents reach tuwunel at localhost:<port>.
|
# Shared host netns — agents reach tuwunel at localhost:<port>.
|
||||||
privateNetwork = false;
|
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).
|
# 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 = {
|
bindMounts = {
|
||||||
${deployCfg.matrix.registrationTokenFile} = {
|
${appserviceDir} = {
|
||||||
hostPath = deployCfg.matrix.registrationTokenFile;
|
hostPath = appserviceDir;
|
||||||
isReadOnly = true;
|
isReadOnly = true;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1008,13 +1167,50 @@ in
|
||||||
# trustedServers keeps it effectively closed.
|
# trustedServers keeps it effectively closed.
|
||||||
allow_federation = true;
|
allow_federation = true;
|
||||||
trusted_servers = deployCfg.matrix.trustedServers;
|
trusted_servers = deployCfg.matrix.trustedServers;
|
||||||
# Token-gated registration. The absent
|
# Nobody registers themselves here. Accounts are created by
|
||||||
# `yes_i_am_very_very_sure_…_open_registration_…` flag
|
# the hive's appservice, which this flag does not gate —
|
||||||
# keeps the server closed to anyone without the token.
|
# tuwunel checks it only for requests that arrive WITHOUT an
|
||||||
allow_registration = true;
|
# appservice token, so hive-c0re provisions exactly as before
|
||||||
# LoadCredential below copies the host file into a
|
# and everyone else is refused outright.
|
||||||
# 0400 dynamic-user-owned path; tuwunel reads from there.
|
#
|
||||||
registration_token_file = "/run/credentials/tuwunel.service/registration_token";
|
# ⚠️ Not a hardening afterthought: `allow_registration = true`
|
||||||
|
# with no registration token configured makes tuwunel REFUSE
|
||||||
|
# TO START (it demands
|
||||||
|
# `yes_i_am_very_very_sure_…_open_registration_…` instead). So
|
||||||
|
# dropping the token and leaving this true is not a lax
|
||||||
|
# homeserver, it is a homeserver that does not boot.
|
||||||
|
allow_registration = false;
|
||||||
|
|
||||||
|
# Where the hive's appservice registration is read from — the
|
||||||
|
# credentials directory, for the reasons at
|
||||||
|
# `appserviceCredentialDir`'s own definition. Loaded inside
|
||||||
|
# `Services::start()`, before the listener accepts anything.
|
||||||
|
appservice_dir = appserviceCredentialDir;
|
||||||
|
|
||||||
|
# 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;
|
||||||
|
|
@ -1075,7 +1271,10 @@ in
|
||||||
# host-side chown :tuwunel / GID-pin gymnastics needed.
|
# host-side chown :tuwunel / GID-pin gymnastics needed.
|
||||||
# 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}"
|
# 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
|
# 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.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
# The unit that copies authelia's minted OIDC client secrets into the swarm's
|
# The unit that puts swarm-level secrets into the swarm's secret store, so
|
||||||
# secret store, so whoever needs one can read it there: a hive its agents'
|
# whoever needs one can read it there: a hive its agents' credential, a swarm
|
||||||
# credential, a swarm service its own.
|
# service its own, a hive its matrix appservice token. The OIDC secrets it only
|
||||||
|
# copies; the appservice token it MINTS, having had no swarm-side producer.
|
||||||
#
|
#
|
||||||
# ⚠️ "Whoever", including a reader on THIS host. A swarm service's secret goes
|
# ⚠️ "Whoever", including a reader on THIS host. A swarm service's secret goes
|
||||||
# into the store even when the service runs beside authelia, because its
|
# into the store even when the service runs beside authelia, because its
|
||||||
|
|
@ -14,19 +15,19 @@
|
||||||
# why: the plaintext's other reader lives in a **different container**, and
|
# why: the plaintext's other reader lives in a **different container**, and
|
||||||
# containers that share this host's network namespace still have separate
|
# containers that share this host's network namespace still have separate
|
||||||
# filesystem roots, so "the host is the only place both trees are addressable".
|
# filesystem roots, so "the host is the only place both trees are addressable".
|
||||||
# A delivery step therefore runs on the host that mints — not on the store's
|
# A delivery step therefore runs on the minting host, not the store's or the
|
||||||
# host, and not on the reading hive's.
|
# reading hive's.
|
||||||
#
|
#
|
||||||
# ⚠️ ITS OWN STORE IDENTITY, not swarm-controller's. That principal may rewrite
|
# ⚠️ ITS OWN STORE IDENTITY, not swarm-controller's. That principal may rewrite
|
||||||
# every hive's policy and login role; a unit whose entire job is copying one
|
# every hive's policy and login role; a unit that writes a handful of secrets
|
||||||
# file has no business holding it. ./swarm-bao.nix grants this one
|
# has no business holding it. ./swarm-bao.nix grants this one `create`/`update`
|
||||||
# `create`/`update` under the hive prefix and nothing else.
|
# under the hive prefix and nothing else — no `read`, which is why the mint
|
||||||
|
# below keeps its own record of what it published.
|
||||||
#
|
#
|
||||||
# ⚠️ THE SECRET NEVER REACHES `argv`. `bao` is an external binary, so every
|
# ⚠️ THE SECRET NEVER REACHES `argv`. `bao` is an external binary, so every
|
||||||
# argument is world-readable in /proc for the life of the call — the value is
|
# argument is world-readable in /proc for the life of the call — the value is
|
||||||
# passed as `@<path>` and read by bao itself. (`/knowledge/secret-hygiene.md`:
|
# passed as `@<path>` and read by bao itself. Same rule governs the mint: it
|
||||||
# "a path keeps the secret out of the store, and reading it into a shell
|
# redirects into a file and never holds the token in a shell variable.
|
||||||
# variable puts it straight into argv".)
|
|
||||||
{
|
{
|
||||||
pkgs,
|
pkgs,
|
||||||
lib,
|
lib,
|
||||||
|
|
@ -71,6 +72,21 @@ let
|
||||||
hyperhiveCfg.swarm.grafana.oidc.clientId
|
hyperhiveCfg.swarm.grafana.oidc.clientId
|
||||||
hyperhiveCfg.swarm.otel.clientId
|
hyperhiveCfg.swarm.otel.clientId
|
||||||
];
|
];
|
||||||
|
|
||||||
|
# Where this unit keeps the appservice tokens it minted, and the whole reason
|
||||||
|
# a re-publish is idempotent. The store cannot be that record: ./swarm-bao.nix
|
||||||
|
# grants this principal `create`/`update` and deliberately no `read`, so "does
|
||||||
|
# this hive already have a token?" is a question it can only ask locally.
|
||||||
|
# Without the file every run would mint a fresh value and rotate the swarm's
|
||||||
|
# token for no reason — and a rotation is not free, because the homeserver
|
||||||
|
# only reloads the registration naming it when its container restarts.
|
||||||
|
#
|
||||||
|
# `StateDirectory=` below is what creates it, so the name there is relative
|
||||||
|
# and this unit owns the tree. Losing it mints once more and republishes;
|
||||||
|
# readers pick that up on their next start, and nothing that already
|
||||||
|
# registered breaks — the token authenticates the appservice, it does not
|
||||||
|
# live inside any account it created.
|
||||||
|
matrixTokenDir = "/var/lib/swarm-secret-publish/matrix-appservice-token";
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.services.hyperhive.deploy.swarm-secret-publisher = {
|
options.services.hyperhive.deploy.swarm-secret-publisher = {
|
||||||
|
|
@ -82,10 +98,12 @@ in
|
||||||
Publish the OIDC client secrets this host mints into the swarm's
|
Publish the OIDC client secrets this host mints into the swarm's
|
||||||
secret store, so a hive that does not run authelia can read its
|
secret store, so a hive that does not run authelia can read its
|
||||||
agents' credential and a swarm service can read its own — from
|
agents' credential and a swarm service can read its own — from
|
||||||
wherever it runs, this host included.
|
wherever it runs, this host included. Also mints each hive's matrix
|
||||||
|
appservice token, which has no other swarm-side producer, and
|
||||||
|
publishes it the same way.
|
||||||
|
|
||||||
Defaults to whether this host mints them, which is the only half of
|
Defaults to whether this host mints the OIDC secrets, which is the
|
||||||
the question that is a property of *this* host.
|
only half of the question that is a property of *this* host.
|
||||||
|
|
||||||
⚠️ Deliberately **not** `deploy.bao.enable`. That asks whether the
|
⚠️ Deliberately **not** `deploy.bao.enable`. That asks whether the
|
||||||
store stands here, and a publisher beside a remote store, holding a
|
store stands here, and a publisher beside a remote store, holding a
|
||||||
|
|
@ -95,11 +113,12 @@ in
|
||||||
identity's job (see `baoClientCertFile`), not this option's.
|
identity's job (see `baoClientCertFile`), not this option's.
|
||||||
|
|
||||||
Turning it off leaves every hive but this one without its agents'
|
Turning it off leaves every hive but this one without its agents'
|
||||||
credential, the swarm's Grafana without any login at all, and its
|
credential, the swarm's Grafana without any login at all, its
|
||||||
collector pushing unauthenticated — each secret has exactly one route
|
collector pushing unauthenticated, and every hive falling back to the
|
||||||
and this is the producer's end of it. So the honest reason to set it
|
appservice token its own first boot minted — so two hives never agree
|
||||||
false is a deployment delivering those secrets by some other
|
on one. Each secret has exactly one route and this is the producer's
|
||||||
mechanism it owns.
|
end of it, so the honest reason to set it false is a deployment
|
||||||
|
delivering those secrets by some other mechanism it owns.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -143,7 +162,7 @@ in
|
||||||
};
|
};
|
||||||
|
|
||||||
systemd.services.swarm-secret-publish = {
|
systemd.services.swarm-secret-publish = {
|
||||||
description = "publish minted OIDC client secrets to the swarm secret store";
|
description = "publish the swarm's minted secrets to the swarm secret store";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
after = [ "network.target" ];
|
after = [ "network.target" ];
|
||||||
path = [
|
path = [
|
||||||
|
|
@ -159,6 +178,12 @@ in
|
||||||
TimeoutStartSec = 60;
|
TimeoutStartSec = 60;
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
RestartSec = 30;
|
RestartSec = 30;
|
||||||
|
# The minted appservice tokens, see `matrixTokenDir` above. Named
|
||||||
|
# relative to /var/lib because that is the form systemd creates; the
|
||||||
|
# mode is narrowed from systemd's 0755 default because the directory
|
||||||
|
# listing is the roster of hives and every file in it is a secret.
|
||||||
|
StateDirectory = "swarm-secret-publish/matrix-appservice-token";
|
||||||
|
StateDirectoryMode = "0700";
|
||||||
};
|
};
|
||||||
environment = {
|
environment = {
|
||||||
BAO_ADDR = "https://${hyperhiveCfg.swarm.bao.domain}:${toString hyperhiveCfg.swarm.bao.port}";
|
BAO_ADDR = "https://${hyperhiveCfg.swarm.bao.domain}:${toString hyperhiveCfg.swarm.bao.port}";
|
||||||
|
|
@ -185,6 +210,7 @@ in
|
||||||
|
|
||||||
published=0
|
published=0
|
||||||
skipped=0
|
skipped=0
|
||||||
|
tokens=0
|
||||||
|
|
||||||
${lib.concatMapStringsSep "\n" (hive: ''
|
${lib.concatMapStringsSep "\n" (hive: ''
|
||||||
src=${lib.escapeShellArg "${deployCfg.authelia.hostClientSecretDir}/${agentClientId hive}.secret"}
|
src=${lib.escapeShellArg "${deployCfg.authelia.hostClientSecretDir}/${agentClientId hive}.secret"}
|
||||||
|
|
@ -228,7 +254,43 @@ in
|
||||||
fi
|
fi
|
||||||
'') serviceClientIds}
|
'') serviceClientIds}
|
||||||
|
|
||||||
echo "published $published client secret(s), skipped $skipped"
|
# The matrix appservice token, per hive. Unlike the two loops above
|
||||||
|
# there is nothing to copy: authelia never minted this one, and each
|
||||||
|
# homeserver's own host minted its own — which is exactly why two hives
|
||||||
|
# never converged on a value the swarm has to agree on. The mint moves
|
||||||
|
# here because this unit already holds a store identity and already
|
||||||
|
# writes under the hive prefix; ../host-modules/hive-matrix.nix keeps
|
||||||
|
# its activation mint as the first-boot fallback for a hive that has
|
||||||
|
# never reached the store.
|
||||||
|
#
|
||||||
|
# 32 bytes of urandom as lowercase hex, the shape that file has
|
||||||
|
# generated all along. Written with no trailing newline, unlike that
|
||||||
|
# file: what goes into the store is the value, and
|
||||||
|
# ./glue-matrix-bao-token.nix adds the newline the hive's token file
|
||||||
|
# carries.
|
||||||
|
${lib.concatMapStringsSep "\n" (hive: ''
|
||||||
|
src=${lib.escapeShellArg "${matrixTokenDir}/${hive}"}
|
||||||
|
if [ ! -s "$src" ]; then
|
||||||
|
# The subshell keeps the umask local — the loops above rely on
|
||||||
|
# bao's own file handling and should not inherit one.
|
||||||
|
( umask 077; head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$src" )
|
||||||
|
echo "minted a matrix appservice token for ${hive}" >&2
|
||||||
|
fi
|
||||||
|
# `value=@$src` for the same reason as both loops above: bao opens
|
||||||
|
# the file itself, so the token is never an argument of this process.
|
||||||
|
#
|
||||||
|
# Put on every run, not only on the run that minted — a put of the
|
||||||
|
# value already there changes nothing for any reader, while a mint
|
||||||
|
# whose publish failed must not be left as a token this host holds
|
||||||
|
# and no hive can reach. The `-s` test above is what keeps the VALUE
|
||||||
|
# stable across those runs: rotating on every boot would hand the
|
||||||
|
# homeserver a registration its running container does not carry.
|
||||||
|
bao kv put ${lib.escapeShellArg "secret/swarm/hives/${hive}/matrix/appservice-token"} \
|
||||||
|
value=@"$src"
|
||||||
|
tokens=$((tokens + 1))
|
||||||
|
'') hiveNames}
|
||||||
|
|
||||||
|
echo "published $published client secret(s), skipped $skipped, and $tokens matrix appservice token(s)"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -116,24 +116,45 @@ let
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
# The homeserver's turn to split. All eight host-side options are set through
|
# The homeserver's turn to split. All seven remaining host-side options are
|
||||||
# their pre-rename paths — including `gui.enable`, whose value is deliberately
|
# set through their pre-rename paths — including `gui.enable`, whose value is
|
||||||
# the opposite of its default so the definition has to actually land, and both
|
# deliberately the opposite of its default so the definition has to actually
|
||||||
# packages, stubbed to a derivation neither option defaults to for the same
|
# land, and both packages, stubbed to a derivation neither option defaults to
|
||||||
# reason. All eight so that dropping any single shim entry fails the eval, not
|
# for the same reason. All seven so that dropping any single shim entry fails
|
||||||
# just the ones the assertions read.
|
# 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 {
|
matrixOldPath = hive {
|
||||||
deploy.matrix.enable = true;
|
deploy.matrix.enable = true;
|
||||||
swarm.matrix.openFirewall = true;
|
swarm.matrix.openFirewall = true;
|
||||||
swarm.matrix.trustedServers = [ "matrix.example.invalid" ];
|
swarm.matrix.trustedServers = [ "matrix.example.invalid" ];
|
||||||
swarm.matrix.maxRequestSize = 31457280;
|
swarm.matrix.maxRequestSize = 31457280;
|
||||||
swarm.matrix.registrationTokenFile = "/etc/matrix/register.token";
|
|
||||||
swarm.matrix.gui.enable = false;
|
swarm.matrix.gui.enable = false;
|
||||||
swarm.matrix.package = pkgs.emptyDirectory;
|
swarm.matrix.package = pkgs.emptyDirectory;
|
||||||
swarm.matrix.gui.package = pkgs.emptyDirectory;
|
swarm.matrix.gui.package = pkgs.emptyDirectory;
|
||||||
swarm.matrix.sso.clientSecretFile = "/etc/matrix/oidc.secret";
|
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
|
# The queue's callout identity, fourth split slice. `autoGenerateCallout` is
|
||||||
# left FALSE on purpose: that is what makes the seed paths the thing deciding
|
# left FALSE on purpose: that is what makes the seed paths the thing deciding
|
||||||
# `responderConfigured`, so the assertion below is about the seeds rather
|
# `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
|
# 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
|
# 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
|
# effect — the host firewall and the container's bind-mount table — so a
|
||||||
# rename that resolves but stops reaching the module still fails.
|
# 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 =
|
ok =
|
||||||
let
|
let
|
||||||
ports = matrixOldPath.networking.firewall.allowedTCPPorts;
|
ports = matrixOldPath.networking.firewall.allowedTCPPorts;
|
||||||
httpPort = matrixOldPath.services.hyperhive.swarm.matrix.httpPort;
|
httpPort = matrixOldPath.services.hyperhive.swarm.matrix.httpPort;
|
||||||
in
|
in
|
||||||
builtins.elem httpPort ports
|
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
|
# appserviceTokenFile has no override capability (a store-delivered
|
||||||
# (bao-delivered secrets don't need one — glue-matrix-bao-token.nix
|
# secret doesn't need one — glue-matrix-bao-token.nix writes into the
|
||||||
# already writes into the fixed path instead of moving it), unlike its
|
# fixed path instead of moving it), unlike its siblings in the same
|
||||||
# five siblings in the same rename. `matrixOldPath` above proves the
|
# rename. The fixture proves the override still *resolves* (mkDefault,
|
||||||
# override still *resolves* (mkDefault, not a crash) — this proves it
|
# not a crash); this proves it also gets *rejected*, by a named
|
||||||
# also gets *rejected*, by a named assertion rather than nixpkgs' generic
|
# assertion rather than nixpkgs' generic conflicting-definition text.
|
||||||
# conflicting-definition text. Reads `.assertions` directly (cheap: a
|
# Reads `.assertions` directly (cheap: a list of `{assertion;
|
||||||
# list of `{assertion; message;}`, not `system.build.toplevel`) rather
|
# message;}`, not `system.build.toplevel`) rather than forcing a real
|
||||||
# than forcing a real build just to observe a boolean.
|
# build just to observe a boolean.
|
||||||
name = "overriding registrationTokenFile (even via the pre-rename shim) trips a named assertion, not a silent desync";
|
name = "moving appserviceTokenFile trips a named assertion, not a silent desync";
|
||||||
ok = lib.any (
|
ok = lib.any (
|
||||||
a: !a.assertion && lib.hasInfix "registrationTokenFile" a.message
|
a: !a.assertion && lib.hasInfix "appserviceTokenFile" a.message
|
||||||
) matrixOldPath.assertions;
|
) 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
|
# Reads the DELIVERY UNIT, not the options: `responderConfigured` gates
|
||||||
|
|
@ -1448,6 +1477,41 @@ let
|
||||||
lib.hasInfix "secret/swarm/hives/h1/queue/agent" s
|
lib.hasInfix "secret/swarm/hives/h1/queue/agent" s
|
||||||
&& lib.hasInfix "secret/swarm/hives/h2/queue/agent" s;
|
&& lib.hasInfix "secret/swarm/hives/h2/queue/agent" s;
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
# The producer's end of the read `glue-matrix-bao-token.nix` already did.
|
||||||
|
# Both hives are asserted for the reason the queue case above gives: a
|
||||||
|
# publisher that knew one name rather than the roster would pass on a
|
||||||
|
# single-hive fixture and strand the second hive ever added — which is
|
||||||
|
# the two-hives-never-converge shape this slice exists to close.
|
||||||
|
name = "the publisher mints an appservice token for every hive and writes it to that hive's matrix path";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
s = secretPublisherHere.systemd.services.swarm-secret-publish.script;
|
||||||
|
in
|
||||||
|
lib.hasInfix "secret/swarm/hives/h1/matrix/appservice-token" s
|
||||||
|
&& lib.hasInfix "secret/swarm/hives/h2/matrix/appservice-token" s
|
||||||
|
&& lib.hasInfix "/dev/urandom" s;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
# What makes a re-publish idempotent. This principal is granted
|
||||||
|
# `create`/`update` and no `read`, so it cannot ask the store whether a
|
||||||
|
# hive already has a token — with nowhere to keep one, every run would
|
||||||
|
# mint a fresh value and rotate the swarm's token. A state directory is
|
||||||
|
# that somewhere, and nothing else in this unit needs one, so its absence
|
||||||
|
# means exactly this.
|
||||||
|
#
|
||||||
|
# The second arm is the mint's own guard: the state file is only written
|
||||||
|
# when it is missing or empty. Dropping that test leaves a unit that
|
||||||
|
# still has a state directory and still rotates on every boot.
|
||||||
|
name = "the publisher keeps the tokens it minted, and mints only when it holds none";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
u = secretPublisherHere.systemd.services.swarm-secret-publish;
|
||||||
|
in
|
||||||
|
lib.hasInfix "matrix-appservice-token" (u.serviceConfig.StateDirectory or "")
|
||||||
|
&& u.serviceConfig.StateDirectoryMode or null == "0700"
|
||||||
|
&& lib.hasInfix "if [ ! -s \"$src\" ]" u.script;
|
||||||
|
}
|
||||||
{
|
{
|
||||||
# A property of the SET, not of one unit: both of these authenticate by
|
# A property of the SET, not of one unit: both of these authenticate by
|
||||||
# certificate, and `BAO_CLIENT_CERT` is transport rather than identity, so
|
# certificate, and `BAO_CLIENT_CERT` is transport rather than identity, so
|
||||||
|
|
@ -1518,7 +1582,7 @@ let
|
||||||
s = baoWithMatrix.systemd.services.swarm-bao-matrix-token.script;
|
s = baoWithMatrix.systemd.services.swarm-bao-matrix-token.script;
|
||||||
in
|
in
|
||||||
lib.hasInfix "secret/swarm/hives/" s
|
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,
|
# The shape it used to have: `matrix` where a principal kind belongs,
|
||||||
# which no grant covers.
|
# which no grant covers.
|
||||||
&& !(lib.hasInfix "secret/swarm/matrix/" s);
|
&& !(lib.hasInfix "secret/swarm/matrix/" s);
|
||||||
|
|
@ -2471,7 +2535,7 @@ let
|
||||||
# The store's first reader. Its unit belongs to the pairing, not to
|
# 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
|
# either service: matrix must not learn the store exists, and the store
|
||||||
# must not know who reads it.
|
# 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;
|
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}"))
|
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`]
|
/// Keyed per **hive**, not per agent, like [`crate::queue::agent_client_path`]
|
||||||
/// and unlike [`account_path`] above: one homeserver admits one hive's
|
/// 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
|
/// # Errors
|
||||||
/// [`Error::PathSegment`] when `hive` contains anything but `[A-Za-z0-9_-]`,
|
/// [`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.
|
/// 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)?;
|
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.
|
/// What an account's path holds: the token, plus the homeserver it belongs to.
|
||||||
|
|
@ -80,28 +87,29 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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:
|
// 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
|
// the read policy grants `secret/data/swarm/hives/<hive>/*`, so this
|
||||||
// string is what makes the path reachable at all.
|
// string is what makes the path reachable at all.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
registration_token_path("pr1ma").expect("a plain name is legal"),
|
appservice_token_path("pr1ma").expect("a plain name is legal"),
|
||||||
"swarm/hives/pr1ma/matrix/registration-token"
|
"swarm/hives/pr1ma/matrix/appservice-token"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_registration_token_is_not_a_top_level_namespace() {
|
fn the_appservice_token_is_not_a_top_level_namespace() {
|
||||||
// The path it used to hold. `Kind` is a closed set and `matrix` is not
|
// The path its predecessor used to hold. `Kind` is a closed set and
|
||||||
// one of its members, so a path with `matrix` as the second segment is
|
// `matrix` is not one of its members, so a path with `matrix` as the
|
||||||
// outside every grant — which is how it came to 403 on every read.
|
// second segment is outside every grant — which is how it came to 403
|
||||||
let p = registration_token_path("pr1ma").expect("legal");
|
// on every read.
|
||||||
|
let p = appservice_token_path("pr1ma").expect("legal");
|
||||||
assert!(!p.starts_with("swarm/matrix/"), "{p}");
|
assert!(!p.starts_with("swarm/matrix/"), "{p}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_traversal_in_the_hive_name_is_refused() {
|
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}");
|
assert!(matches!(e, Error::PathSegment { kind: "hive", .. }), "{e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue