matrix: swarm-controller is the only minter
Every hive is in a swarm and every swarm runs matrix, so every swarm has a swarm-controller, and since #4810 its hive_sender pass mints each hive's @hive-<hive>: sender token into the store every five minutes. The two other minters of that token go: - swarm-matrix-ctl mint: the systemd.services.swarm-matrix-ctl unit in the hive-matrix container, Command::Mint and src/mint.rs. The binary, its appservice render/publish verbs, ctlPackage, ctlActive and the ctl cert role stay. bao-matrix-reader's checks on the deleted unit are removed; the leaf-identity and no-token-in-env checks now look at swarm-matrix-appservice-publish, which runs under the same identity. - the hive-side mint ladder in hive-c0re's ensure_hive_user (register/appservice-login/password-login with the local as_token), with read_appservice_token, paths::matrix_appservice_token and the helpers only it used. ensure_hive_user now takes the store's token, keeps the file when the store has none or can't be reached, and fails otherwise. - hivectl matrix sync-admin: the verb, HostRequest::MatrixSyncAdmin and handle_matrix_sync_admin. The periodic MatrixSweep (ensure_all) is unchanged apart from no longer reading the local as_token. This removes the double-mint race #4810's review flagged: two minters logging in on one pinned device could leave a dead token in the store until the next pass. Closes #4813 Closes #4814
This commit is contained in:
parent
91e47732a6
commit
ddb7d7196d
22 changed files with 187 additions and 1162 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -4840,7 +4840,6 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
"serde_json",
|
|
||||||
"swarm-matrix-client",
|
"swarm-matrix-client",
|
||||||
"swarm-secret-client",
|
"swarm-secret-client",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
|
||||||
|
|
@ -280,9 +280,6 @@ control: [`swarm/ui.md`](../swarm/ui.md).
|
||||||
### 6 · Matrix
|
### 6 · Matrix
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ensure the appservice's sender account exists first
|
|
||||||
hivectl matrix sync-admin
|
|
||||||
|
|
||||||
# Invite the operator to the hive Space (and optionally to rooms)
|
# Invite the operator to the hive Space (and optionally to rooms)
|
||||||
hivectl matrix invite mara
|
hivectl matrix invite mara
|
||||||
hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver'
|
hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver'
|
||||||
|
|
|
||||||
|
|
@ -93,9 +93,8 @@ delegation (the latter lives in `gateway.md::Discovery flow`).
|
||||||
## Provisioning flow (appservice)
|
## Provisioning flow (appservice)
|
||||||
|
|
||||||
<!-- vale write-good.Passive = NO -->
|
<!-- vale write-good.Passive = NO -->
|
||||||
Registration is closed. The hive's own **appservice** creates accounts:
|
Registration is closed. **Appservices** create accounts: agents never see
|
||||||
hive-c0re holds the appservice token, agents never see
|
an appservice token, and an agent only ever receives its own `access_token`.
|
||||||
it, and an agent only ever receives its own `access_token`.
|
|
||||||
<!-- vale write-good.Passive = YES -->
|
<!-- vale write-good.Passive = YES -->
|
||||||
|
|
||||||
The appservice has no URL (`url: null` in its registration), so the
|
The appservice has no URL (`url: null` in its registration), so the
|
||||||
|
|
@ -110,7 +109,7 @@ a token.
|
||||||
spec-required `hs_token` sibling, mode `0600 root:root`, then renders
|
spec-required `hs_token` sibling, mode `0600 root:root`, then renders
|
||||||
the registration to
|
the registration to
|
||||||
`/var/lib/hyperhive/matrix-appservice/hyperhive.yaml` (also `0600`).
|
`/var/lib/hyperhive/matrix-appservice/hyperhive.yaml` (also `0600`).
|
||||||
hive-c0re mints the tokens only when missing; the registration is
|
The script mints the tokens only when missing; the registration is
|
||||||
re-rendered every time, because the token file can be overwritten in
|
re-rendered every time, because the token file can be overwritten in
|
||||||
place by the swarm secret store and a registration naming a stale
|
place by the swarm secret store and a registration naming a stale
|
||||||
token authenticates nobody. Runs at activation time, before any
|
token authenticates nobody. Runs at activation time, before any
|
||||||
|
|
@ -129,10 +128,8 @@ a token.
|
||||||
the bind-mount path. It reads only `.yaml`/`.yml` entries from there,
|
the bind-mount path. It reads only `.yaml`/`.yml` entries from there,
|
||||||
so the sibling credentials are invisible to it. The `.yaml` suffix on
|
so the sibling credentials are invisible to it. The `.yaml` suffix on
|
||||||
the credential id is what makes this work.
|
the credential id is what makes this work.
|
||||||
5. **hive-c0re** reads the appservice token and creates its own
|
5. **hive-c0re** doesn't read the appservice token and creates no
|
||||||
`@hive-<hive>:` account. It never mints the token itself: the value
|
account. Its `@hive-<hive>:` token comes from the swarm store (below).
|
||||||
has to be the one the rendered registration names, and only the nix
|
|
||||||
side writes that.
|
|
||||||
6. **Agents' accounts aren't this hive's.** `swarm-controller` creates each
|
6. **Agents' accounts aren't this hive's.** `swarm-controller` creates each
|
||||||
one with the swarm's own appservice token (next section), stores its
|
one with the swarm's own appservice token (next section), stores its
|
||||||
token at `swarm/agents/<agent>/matrix/main`, and the agent's
|
token at `swarm/agents/<agent>/matrix/main`, and the agent's
|
||||||
|
|
@ -183,9 +180,8 @@ hive's standing leaves the others alone.
|
||||||
Its access token is the **sender token**, and it's the credential
|
Its access token is the **sender token**, and it's the credential
|
||||||
hive-c0re presents for every homeserver call it makes on the hive's
|
hive-c0re presents for every homeserver call it makes on the hive's
|
||||||
behalf. It's per hive for the same reason the account is:
|
behalf. It's per hive for the same reason the account is:
|
||||||
`swarm-controller` mints it for every hive with the swarm's appservice token (and
|
`swarm-controller`, its only minter, mints it for every hive with the swarm's
|
||||||
`swarm-matrix-ctl`, inside the `hive-matrix` container, for its own hive) and
|
appservice token and publishes it to `swarm/hives/<hive>/matrix/sender-token`, and the hive
|
||||||
publishes it to `swarm/hives/<hive>/matrix/sender-token`, and the hive
|
|
||||||
reads it from there under its own certificate. That path sits inside the
|
reads it from there under its own certificate. That path sits inside the
|
||||||
hive's own read grant (`swarm/hives/<hive>/*`), so a hive fetches its own
|
hive's own read grant (`swarm/hives/<hive>/*`), so a hive fetches its own
|
||||||
token and gets a refusal on any other hive's. The name says what it
|
token and gets a refusal on any other hive's. The name says what it
|
||||||
|
|
@ -212,34 +208,19 @@ Nothing to do, and no window where the hive is without an account.
|
||||||
|
|
||||||
<!-- vale write-good.Passive = NO -->
|
<!-- vale write-good.Passive = NO -->
|
||||||
- **The old shared value at `swarm/services/matrix/sender-token` is read by
|
- **The old shared value at `swarm/services/matrix/sender-token` is read by
|
||||||
nothing.** `hive-c0re` and `swarm-matrix-ctl` both build the path from the
|
nothing.** `hive-c0re` and `swarm-controller` both build the path from the
|
||||||
same function, and it now carries the hive's name — so the old object
|
same function, and it now carries the hive's name — so the old object
|
||||||
stays in the store, unread, until an operator deletes it. Delete it or
|
stays in the store, unread, until an operator deletes it. Delete it or
|
||||||
leave it; the accounts it authenticates as keep their own standing either
|
leave it; the accounts it authenticates as keep their own standing either
|
||||||
way, since an access token lives on the device that minted it.
|
way, since an access token lives on the device that minted it.
|
||||||
- **The hive re-mints, per hive, on the next sweep.** `ensure_hive_user`
|
- **The hive switches on the next sweep.** `ensure_hive_user` reads the
|
||||||
reads the new per-hive store path **first, on every sweep**, not just when
|
per-hive store path **first, on every sweep**, not just when the file is
|
||||||
the file is missing (`sender_source`'s decision). If a per-hive token is
|
missing (`sender_source`'s decision). `swarm-controller` mints a token
|
||||||
already there — `swarm-controller` mints one for every hive — the sweep
|
there for every hive within five minutes; the sweep takes it and
|
||||||
takes it and overwrites the file, so the shared token stops being served
|
overwrites the file, so the shared token stops being served as soon as
|
||||||
as soon as one exists in the store, no boot required. If the store has
|
one exists in the store, no boot required. While the store has nothing
|
||||||
nothing yet, the file is left untouched (still the shared token, right
|
yet, the file is left untouched (still the shared token, right after the
|
||||||
after the upgrade), so nothing breaks mid-sweep. Only when neither the
|
upgrade), so nothing breaks mid-sweep.
|
||||||
store nor the file holds anything does the sweep fall through to the
|
|
||||||
register-or-appservice-login ladder against `@hive-<hive>:`, using only
|
|
||||||
the `as_token`, which is per hive and on local disk, so that step works
|
|
||||||
with or without a reachable store.
|
|
||||||
- **To move a hive onto its own account now**, clear both copies: the
|
|
||||||
sender-token file, and the store's `swarm/hives/<hive>/matrix/sender-token`
|
|
||||||
if `swarm-controller` or `swarm-matrix-ctl` has already published one for
|
|
||||||
it (otherwise the next sweep just re-adopts that value instead of minting
|
|
||||||
a new one). With both empty, the next sweep — or `hivectl matrix
|
|
||||||
sync-admin` — runs the ladder, registers `@hive-<hive>:`, and persists
|
|
||||||
that account's token to the file. The ladder never writes the store — on
|
|
||||||
a swarm that runs `swarm-controller`, its own mint pass will reach the
|
|
||||||
same account on its next tick and write a token there too, on the same
|
|
||||||
pinned device, which replaces whichever token was minted last. Only once
|
|
||||||
both copies agree does the hive stop presenting the shared one.
|
|
||||||
- **The rooms the shared account created don't follow the new account, and
|
- **The rooms the shared account created don't follow the new account, and
|
||||||
this is the one step that needs a decision.** Membership is per account.
|
this is the one step that needs a decision.** Membership is per account.
|
||||||
`ensure_hive_space` takes the stored room id first, so the sweep hands the
|
`ensure_hive_space` takes the stored room id first, so the sweep hands the
|
||||||
|
|
|
||||||
|
|
@ -59,20 +59,20 @@ of the cell says how.
|
||||||
|
|
||||||
<!-- vale write-good.Passive = NO -->
|
<!-- vale write-good.Passive = NO -->
|
||||||
|
|
||||||
| store path | minter | reader — pulls at runtime, holds in memory | automatic re-mint | automatic re-pull |
|
| store path | minter | reader — pulls at runtime, holds in memory | automatic re-mint | automatic re-pull |
|
||||||
| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `swarm/agents/<agent>/matrix/main` | `swarm-controller`, with the swarm's appservice token, at agent creation and in a five-minute pass | the agent container itself, under the certificate its hive passed in | ✅ the pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ `hive-matrix-daemon` exits when the homeserver rejects its token, and a five-minute timer restarts it, which reads the store again |
|
| `swarm/agents/<agent>/matrix/main` | `swarm-controller`, with the swarm's appservice token, at agent creation and in a five-minute pass | the agent container itself, under the certificate its hive passed in | ✅ the pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ `hive-matrix-daemon` exits when the homeserver rejects its token, and a five-minute timer restarts it, which reads the store again |
|
||||||
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | must be stated |
|
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | must be stated |
|
||||||
| `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl`, inside the `hive-matrix` container, once | `swarm-controller`, under its own certificate | ❌ `swarm-matrix-ctl` mints it once; the container keeps its copy and republishes it when the store's differs | ✅ the controller reads it on every five-minute matrix pass |
|
| `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl`, inside the `hive-matrix` container, once | `swarm-controller`, under its own certificate | ❌ `swarm-matrix-ctl` mints it once; the container keeps its copy and republishes it when the store's differs | ✅ the controller reads it on every five-minute matrix pass |
|
||||||
| `swarm/controller/swarm-controller/oidc/client` | authelia, at its first boot, where the controller registers its client; `swarm-secret-publish` copies it in | `swarm-controller`, under its own certificate, once at start | ❌ authelia mints it once. A re-mint is republished by `swarm-secret-publish`'s path unit | ❌ read once at start; the controller holds the old value until it restarts |
|
| `swarm/controller/swarm-controller/oidc/client` | authelia, at its first boot, where the controller registers its client; `swarm-secret-publish` copies it in | `swarm-controller`, under its own certificate, once at start | ❌ authelia mints it once. A re-mint is republished by `swarm-secret-publish`'s path unit | ❌ read once at start; the controller holds the old value until it restarts |
|
||||||
| `swarm/agents/<agent>/bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), which generates the key, at `swarm-controller`'s request at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | ✅ `swarm-controller`'s five-minute pass re-issues a live agent's leaf once it's past half its validity (45 of 90 days, read from the certificate itself) | ❌ `hive-c0re` reads it when it writes the container config, so the agent presents a new leaf from its next start; the old leaf stays valid until it expires |
|
| `swarm/agents/<agent>/bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), which generates the key, at `swarm-controller`'s request at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | ✅ `swarm-controller`'s five-minute pass re-issues a live agent's leaf once it's past half its validity (45 of 90 days, read from the certificate itself) | ❌ `hive-c0re` reads it when it writes the container config, so the agent presents a new leaf from its next start; the old leaf stays valid until it expires |
|
||||||
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | `hive-agent` in the agent container, under the agent's own certificate, held in memory — the identity it presents to the swarm queue, naming that one agent rather than its hive | ✅ `swarm-controller`'s five-minute pass re-mints a live agent's secret once it's 45 days old by `minted_at` on the stored object; a secret with no `minted_at` gets one stamped, value unchanged. The pass skips agents declared `Destroyed` — declaring an agent destroyed deletes every version of the path instead, the undo of the mint rather than another one | ✅ `hive-agent` reads the path before its first connect and again on every reconnect attempt, so a reconnect after a re-mint presents the new secret. An open connection keeps the secret it connected with; after a revocation the agent keeps retrying under the queue client's backoff |
|
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | `hive-agent` in the agent container, under the agent's own certificate, held in memory — the identity it presents to the swarm queue, naming that one agent rather than its hive | ✅ `swarm-controller`'s five-minute pass re-mints a live agent's secret once it's 45 days old by `minted_at` on the stored object; a secret with no `minted_at` gets one stamped, value unchanged. The pass skips agents declared `Destroyed` — declaring an agent destroyed deletes every version of the path instead, the undo of the mint rather than another one | ✅ `hive-agent` reads the path before its first connect and again on every reconnect attempt, so a reconnect after a re-mint presents the new secret. An open connection keeps the secret it connected with; after a revocation the agent keeps retrying under the queue client's backoff |
|
||||||
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | ✅ the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the agent re-fetches on a 10-minute timer |
|
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | ✅ the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the agent re-fetches on a 10-minute timer |
|
||||||
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | must be stated |
|
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | must be stated |
|
||||||
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, with the swarm's appservice token, for every hive in its directory in a five-minute pass; also `swarm-matrix-ctl` in the `hive-matrix` container, for its own hive, when the path is empty | `swarm-controller` and `swarm-matrix-ctl` under their own certificates, before they decide whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ hive-c0re's matrix sweep reads the store every run and overwrites its token file when the store's token differs |
|
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, with the swarm's appservice token, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ hive-c0re's matrix sweep reads the store every run and overwrites its token file when the store's token differs |
|
||||||
| `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated |
|
| `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated |
|
||||||
| `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | must be stated |
|
| `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | must be stated |
|
||||||
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | must be stated |
|
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | must be stated |
|
||||||
|
|
||||||
<!-- vale write-good.Passive = YES -->
|
<!-- vale write-good.Passive = YES -->
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ This document contains the help content for the `hivectl` command-line program.
|
||||||
* [`hivectl forge`↴](#hivectl-forge)
|
* [`hivectl forge`↴](#hivectl-forge)
|
||||||
* [`hivectl forge reconcile-config`↴](#hivectl-forge-reconcile-config)
|
* [`hivectl forge reconcile-config`↴](#hivectl-forge-reconcile-config)
|
||||||
* [`hivectl matrix`↴](#hivectl-matrix)
|
* [`hivectl matrix`↴](#hivectl-matrix)
|
||||||
* [`hivectl matrix sync-admin`↴](#hivectl-matrix-sync-admin)
|
|
||||||
* [`hivectl matrix invite`↴](#hivectl-matrix-invite)
|
* [`hivectl matrix invite`↴](#hivectl-matrix-invite)
|
||||||
* [`hivectl github`↴](#hivectl-github)
|
* [`hivectl github`↴](#hivectl-github)
|
||||||
* [`hivectl github set-token`↴](#hivectl-github-set-token)
|
* [`hivectl github set-token`↴](#hivectl-github-set-token)
|
||||||
|
|
@ -64,7 +63,7 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that
|
||||||
###### **Subcommands:**
|
###### **Subcommands:**
|
||||||
|
|
||||||
* `forge` — Reconcile an agent's config between this hive and the forge
|
* `forge` — Reconcile an agent's config between this hive and the forge
|
||||||
* `matrix` — matrix-tuwunel user provisioning
|
* `matrix` — matrix-tuwunel invites
|
||||||
* `github` — GitHub account provisioning
|
* `github` — GitHub account provisioning
|
||||||
* `gateway` — Gateway htpasswd user management
|
* `gateway` — Gateway htpasswd user management
|
||||||
* `agent` — Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running
|
* `agent` — Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running
|
||||||
|
|
@ -129,29 +128,18 @@ Always prints the diff first. `--from forge` resets the local checkout to forge
|
||||||
|
|
||||||
## `hivectl matrix`
|
## `hivectl matrix`
|
||||||
|
|
||||||
matrix-tuwunel user provisioning.
|
matrix-tuwunel invites.
|
||||||
|
|
||||||
Manual entry point to the same idempotent provisioning c0re runs at boot — for re-registering an agent the boot sweep skipped, or after wiping a token file.
|
Manual invites into the hive Space and its rooms; c0re's periodic matrix sweep provisions the Space, the chat room and the agents' invites on its own.
|
||||||
|
|
||||||
**Usage:** `hivectl matrix <COMMAND>`
|
**Usage:** `hivectl matrix <COMMAND>`
|
||||||
|
|
||||||
###### **Subcommands:**
|
###### **Subcommands:**
|
||||||
|
|
||||||
* `sync-admin` — Provision (or re-provision) the matrix appservice's sender account
|
|
||||||
* `invite` — Invite a matrix user to the hive Space, or a specific room with `--room`. Idempotent
|
* `invite` — Invite a matrix user to the hive Space, or a specific room with `--room`. Idempotent
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## `hivectl matrix sync-admin`
|
|
||||||
|
|
||||||
Provision (or re-provision) the matrix appservice's sender account.
|
|
||||||
|
|
||||||
Runs automatically on startup; run manually to recover a missing access token.
|
|
||||||
|
|
||||||
**Usage:** `hivectl matrix sync-admin`
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## `hivectl matrix invite`
|
## `hivectl matrix invite`
|
||||||
|
|
||||||
Invite a matrix user to the hive Space, or a specific room with `--room`. Idempotent
|
Invite a matrix user to the hive Space, or a specific room with `--room`. Idempotent
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,6 @@ Manual entry to the same idempotent matrix provisioning flow
|
||||||
running (`services.hyperhive.deploy.matrix.enable = true`).
|
running (`services.hyperhive.deploy.matrix.enable = true`).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hivectl matrix sync-admin # provision / refresh the appservice's sender account
|
|
||||||
hivectl matrix invite mara # invite a user to the hive Space
|
hivectl matrix invite mara # invite a user to the hive Space
|
||||||
hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a specific room/alias
|
hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a specific room/alias
|
||||||
```
|
```
|
||||||
|
|
@ -59,10 +58,6 @@ Human matrix accounts come from SSO, not `hivectl`; matrix homeserver
|
||||||
admin should eventually come from membership in authelia's `admins`
|
admin should eventually come from membership in authelia's `admins`
|
||||||
group; nobody has built that sync yet.
|
group; nobody has built that sync yet.
|
||||||
|
|
||||||
- `sync-admin`: ensures this hive's appservice sender account
|
|
||||||
(`@hive-<hive>:<server_name>`, one per hive) exists
|
|
||||||
(the account `hive-c0re` provisions rooms with). Token persisted to the
|
|
||||||
access token path. Safe to run again — idempotent.
|
|
||||||
- `invite`: invites a matrix user (full `@user:server` or a bare
|
- `invite`: invites a matrix user (full `@user:server` or a bare
|
||||||
localpart, qualified with the homeserver's `server_name`) to the hive
|
localpart, qualified with the homeserver's `server_name`) to the hive
|
||||||
Space by default, or to a `--room` id / `#alias`. Uses the sender
|
Space by default, or to a `--room` id / `#alias`. Uses the sender
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,11 @@
|
||||||
//! Optional matrix-tuwunel wiring: the hive's appservice identity (host) +
|
//! Optional matrix-tuwunel wiring: the hive's `@hive-<hive>:` sender token,
|
||||||
//! per-agent account creation → `<agent-state>/matrix-token`. No-op
|
//! taken from the swarm secret store, and the hive Space, chat room and
|
||||||
//! when the `hive-matrix` container isn't running, so operators who
|
//! invites provisioned with it. No-op when no homeserver is configured, so
|
||||||
//! haven't flipped `services.hyperhive.deploy.matrix.enable = true` pay
|
//! operators who haven't flipped `services.hyperhive.deploy.matrix.enable =
|
||||||
//! nothing.
|
//! true` pay nothing.
|
||||||
//!
|
//!
|
||||||
//! Accounts are created **as the hive's appservice**, not by presenting a
|
//! This module creates no accounts: `swarm-controller` mints every hive's
|
||||||
//! shared registration token in a UIAA flow. The difference that matters
|
//! sender account and every agent's account with the swarm's appservice token.
|
||||||
//! 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;
|
||||||
|
|
||||||
|
|
@ -54,14 +46,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()",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/// HTTP timeout for registration round-trips. Account creation is one
|
/// HTTP timeout for the sweep's homeserver round-trips.
|
||||||
/// POST; 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
|
|
||||||
/// 32-byte hex — agents never log in with the password (they
|
|
||||||
/// authenticate by `access_token`), so it's protocol overhead. We
|
|
||||||
/// store it nowhere.
|
|
||||||
const PASSWORD_BYTES: usize = 32;
|
|
||||||
|
|
||||||
/// Matrix localpart this hive acts as. Not an agent; has no state dir.
|
/// Matrix localpart this hive acts as. Not an agent; has no state dir.
|
||||||
///
|
///
|
||||||
|
|
@ -124,14 +110,6 @@ pub fn sender_token_path() -> PathBuf {
|
||||||
crate::paths::matrix_sender_token()
|
crate::paths::matrix_sender_token()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Password file for the hive's own `@hive-<hive>:` account. Stored OUTSIDE
|
|
||||||
/// the purgeable `agent_state_root` tree so it survives `destroy --purge`.
|
|
||||||
///
|
|
||||||
/// Path: `/var/lib/hyperhive/matrix/creds/<name>-password`
|
|
||||||
fn password_path(name: &str) -> PathBuf {
|
|
||||||
crate::paths::matrix_creds_dir().join(format!("{name}-password"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Host path where the hive Matrix Space room ID is persisted.
|
/// Host path where the hive Matrix Space room ID is persisted.
|
||||||
/// Outside every purgeable path — not deleted by `destroy --purge`.
|
/// Outside every purgeable path — not deleted by `destroy --purge`.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|
@ -159,325 +137,39 @@ pub fn is_present() -> bool {
|
||||||
matrix_http().is_some()
|
matrix_http().is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read `n` cryptographic-quality bytes from `/dev/urandom` and return
|
|
||||||
/// them hex-encoded. Avoids pulling a workspace `rand` dep just for
|
|
||||||
/// 32 bytes of randomness; the kernel's CSPRNG is more than enough for
|
|
||||||
/// a long-lived shared secret on the same host.
|
|
||||||
fn random_hex(n: usize) -> Result<String> {
|
|
||||||
use std::io::Read;
|
|
||||||
let mut buf = vec![0_u8; n];
|
|
||||||
let mut f = std::fs::File::open("/dev/urandom").context("open /dev/urandom")?;
|
|
||||||
f.read_exact(&mut buf).context("read /dev/urandom")?;
|
|
||||||
let mut hex = String::with_capacity(n * 2);
|
|
||||||
for b in &buf {
|
|
||||||
use std::fmt::Write as _;
|
|
||||||
write!(hex, "{b:02x}").ok();
|
|
||||||
}
|
|
||||||
Ok(hex)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read the hive's appservice token — the `as_token` of the registration
|
|
||||||
/// the homeserver loaded at boot. Every account this module creates is
|
|
||||||
/// authorised by it.
|
|
||||||
///
|
|
||||||
/// **Reads, never mints**, unlike the registration token it replaced.
|
|
||||||
/// That token was the whole agreement, so whichever side wrote it first
|
|
||||||
/// was right; this one has a second half — the registration file naming
|
|
||||||
/// it, which only the nix side writes. A token minted here would be a
|
|
||||||
/// token the homeserver has never heard of, and the failure would surface
|
|
||||||
/// as every request being refused rather than as a missing file.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// 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.
|
|
||||||
pub fn read_appservice_token() -> Result<String> {
|
|
||||||
let path = crate::paths::matrix_appservice_token();
|
|
||||||
std::fs::read_to_string(&path)
|
|
||||||
.ok()
|
|
||||||
.map(|s| s.trim().to_owned())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.with_context(|| {
|
|
||||||
format!(
|
|
||||||
"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
|
|
||||||
/// usernames are 1-255 chars from the `[a-z0-9._=-/]` alphabet; agent
|
|
||||||
/// names already conform (hyperhive enforces a strict subset), so no
|
|
||||||
/// escaping is needed at the boundary.
|
|
||||||
fn user_localpart(agent: &str) -> &str {
|
|
||||||
agent
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send the registration POST as the appservice and parse the response.
|
|
||||||
/// Returns `Ok((status, body))` on any completed HTTP round-trip
|
|
||||||
/// (including the `M_USER_IN_USE` 400 the caller treats as "already
|
|
||||||
/// exists"); errors only on transport failure.
|
|
||||||
async fn register_post(
|
|
||||||
client: &reqwest::Client,
|
|
||||||
as_token: &str,
|
|
||||||
body: &serde_json::Value,
|
|
||||||
) -> Result<(StatusCode, serde_json::Value)> {
|
|
||||||
let base = matrix_base()?;
|
|
||||||
let url = format!("{base}/_matrix/client/v3/register");
|
|
||||||
let resp = client
|
|
||||||
.post(&url)
|
|
||||||
.bearer_auth(as_token)
|
|
||||||
.json(body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("matrix: POST /register")?;
|
|
||||||
let status = resp.status();
|
|
||||||
let json = resp
|
|
||||||
.json::<serde_json::Value>()
|
|
||||||
.await
|
|
||||||
.context("matrix: parse /register response")?;
|
|
||||||
Ok((status, json))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate a throwaway random password for matrix UIAA registration.
|
|
||||||
/// `PASSWORD_BYTES` raw bytes ⇒ 64-char hex string. The hive sender
|
|
||||||
/// account authenticates by `access_token`, so this password is
|
|
||||||
/// protocol overhead never used for login.
|
|
||||||
pub fn random_password() -> Result<String> {
|
|
||||||
random_hex(PASSWORD_BYTES)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create the matrix account for `agent` as the hive's appservice and
|
|
||||||
/// return an access token for it. One round-trip: an appservice-typed
|
|
||||||
/// registration needs no UIAA stage at all, so there is no session to
|
|
||||||
/// carry and no shared secret to present.
|
|
||||||
///
|
|
||||||
/// 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.
|
|
||||||
///
|
|
||||||
/// Its one caller, the hive sender account's provisioning, always passes
|
|
||||||
/// a [`random_password`] throwaway — the account authenticates by
|
|
||||||
/// `access_token`, never `m.login.password`.
|
|
||||||
///
|
|
||||||
/// # 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(
|
|
||||||
client: &reqwest::Client,
|
|
||||||
agent: &str,
|
|
||||||
as_token: &str,
|
|
||||||
password: &str,
|
|
||||||
) -> Result<String> {
|
|
||||||
let localpart = user_localpart(agent);
|
|
||||||
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,
|
|
||||||
"password": password,
|
|
||||||
// device_id stays stable across re-runs so a re-mint doesn't
|
|
||||||
// strand orphan devices in tuwunel.
|
|
||||||
"device_id": format!("hyperhive-{agent}"),
|
|
||||||
"initial_device_display_name": format!("hyperhive ({agent})"),
|
|
||||||
"inhibit_login": false,
|
|
||||||
});
|
|
||||||
let (status, body) = register_post(client, as_token, &body).await?;
|
|
||||||
if !status.is_success() {
|
|
||||||
anyhow::bail!("matrix: /register as appservice HTTP {status}, body: {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.
|
|
||||||
fn extract_access_token(body: &serde_json::Value) -> Result<String> {
|
|
||||||
body["access_token"]
|
|
||||||
.as_str()
|
|
||||||
.map(str::to_owned)
|
|
||||||
.with_context(|| format!("matrix: missing access_token in response: {body}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Login with `m.login.password` and return the access token. Fallback
|
|
||||||
/// for when registration fails with `M_USER_IN_USE` — the account
|
|
||||||
/// already exists in the homeserver but the token file was lost. Fails
|
|
||||||
/// if the stored password no longer matches (e.g. homeserver wiped);
|
|
||||||
/// the only caller is the hive sender account's own recovery path
|
|
||||||
/// (`hivectl matrix sync-admin`).
|
|
||||||
async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Result<String> {
|
|
||||||
let base = matrix_base()?;
|
|
||||||
let url = format!("{base}/_matrix/client/v3/login");
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"type": "m.login.password",
|
|
||||||
"identifier": {
|
|
||||||
"type": "m.id.user",
|
|
||||||
"user": user_localpart(agent),
|
|
||||||
},
|
|
||||||
"password": password,
|
|
||||||
"device_id": format!("hyperhive-{agent}"),
|
|
||||||
"initial_device_display_name": format!("hyperhive ({agent})"),
|
|
||||||
});
|
|
||||||
let resp = client
|
|
||||||
.post(&url)
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("matrix: POST /login")?;
|
|
||||||
let status = resp.status();
|
|
||||||
let json = resp
|
|
||||||
.json::<serde_json::Value>()
|
|
||||||
.await
|
|
||||||
.context("matrix: parse /login response")?;
|
|
||||||
if !status.is_success() {
|
|
||||||
anyhow::bail!("matrix: /login HTTP {status} for agent {agent}, body: {json}");
|
|
||||||
}
|
|
||||||
extract_access_token(&json)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Percent-encode a matrix room ID for use in a URL path segment.
|
/// Percent-encode a matrix room ID for use in a URL path segment.
|
||||||
/// Only `:` needs encoding; `!` and alphanumerics are path-safe.
|
/// Only `:` needs encoding; `!` and alphanumerics are path-safe.
|
||||||
fn encode_room_id_for_url(room_id: &str) -> String {
|
fn encode_room_id_for_url(room_id: &str) -> String {
|
||||||
room_id.replace(':', "%3A")
|
room_id.replace(':', "%3A")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure the hive's `@hive-<hive>:` matrix user exists and that its access token is
|
/// Bring the hive's `@hive-<hive>:` sender token at [`sender_token_path()`] in
|
||||||
/// persisted at [`sender_token_path()`].
|
/// line with the swarm secret store.
|
||||||
///
|
///
|
||||||
/// **Nothing here depends on registration order, and nothing here is
|
/// `swarm-controller` is the only minter: it creates the account with the
|
||||||
/// privileged.** The account used to have to be the first ever
|
/// swarm's appservice token, keeps the stored token while it is live and
|
||||||
/// registered, to win tuwunel's automatic first-user grant — a rule that
|
/// replaces it when it is not. The file follows the store, and is kept as it
|
||||||
/// cannot fire for an appservice-created account at all. It is now an
|
/// is when the store has nothing or cannot be reached, so a store outage does
|
||||||
/// ordinary account: the homeserver creates it because it is the
|
/// not take the hive's matrix provisioning down with it.
|
||||||
/// appservice registration's `sender_localpart`, and everything the hive
|
|
||||||
/// provisions with it, it provisions as the creator of those rooms.
|
|
||||||
///
|
|
||||||
/// Where the token comes from is [`sender_source`]'s decision. The **swarm
|
|
||||||
/// secret store** wins whenever it holds one: `swarm-controller` mints it
|
|
||||||
/// there (and `swarm-matrix-ctl` on the homeserver's own host), keeps it while
|
|
||||||
/// it is live and replaces it when it is not, so the file follows the store
|
|
||||||
/// rather than outliving it. That is also what lets a hive that holds no
|
|
||||||
/// `as_token` have an account at all. The file is kept when the store has
|
|
||||||
/// nothing or cannot be reached, and the mint ladder below is the fallback
|
|
||||||
/// when neither holds a token and this hive has an `as_token`.
|
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// When no token can be had — nothing in the store or the file, and no
|
/// When neither the store nor the file holds a token, or the file write fails.
|
||||||
/// `as_token` — or when a step of the mint ladder or the file write fails.
|
pub async fn ensure_hive_user() -> Result<()> {
|
||||||
pub async fn ensure_hive_user(client: &reqwest::Client, as_token: Option<&str>) -> Result<()> {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
let path = sender_token_path();
|
let path = sender_token_path();
|
||||||
let on_disk = std::fs::read_to_string(&path).ok();
|
let on_disk = std::fs::read_to_string(&path).ok();
|
||||||
let stored = stored_sender_token().await;
|
let stored = stored_sender_token().await;
|
||||||
let as_token = match sender_source(stored.as_deref(), on_disk.as_deref(), as_token) {
|
match sender_source(stored.as_deref(), on_disk.as_deref()) {
|
||||||
SenderSource::Keep => {
|
SenderSource::Keep => {
|
||||||
tracing::debug!("matrix: the sender token is already present");
|
tracing::debug!("matrix: the sender token is already present");
|
||||||
return Ok(());
|
Ok(())
|
||||||
}
|
}
|
||||||
SenderSource::Store(token) => return persist_sender_token(&path, token),
|
SenderSource::Store(token) => persist_sender_token(&path, token),
|
||||||
SenderSource::Mint(as_token) => as_token,
|
|
||||||
SenderSource::Unavailable => anyhow::bail!(
|
SenderSource::Unavailable => anyhow::bail!(
|
||||||
"matrix: no sender token in the swarm store or at {}, and no appservice \
|
"matrix: no sender token in the swarm store or at {}; swarm-controller \
|
||||||
token on this host to mint one with",
|
mints it into the store",
|
||||||
path.display()
|
path.display()
|
||||||
),
|
),
|
||||||
};
|
}
|
||||||
// Per hive, and fatal when it cannot be derived: the fallback ladder below
|
|
||||||
// must not mint under some other hive's name.
|
|
||||||
let localpart = hive_localpart()?;
|
|
||||||
let password = random_password()?;
|
|
||||||
let access_token = match register_user(client, &localpart, as_token, &password).await {
|
|
||||||
Ok(token) => {
|
|
||||||
let pw_path = password_path(&localpart);
|
|
||||||
if let Some(parent) = pw_path.parent() {
|
|
||||||
std::fs::create_dir_all(parent).ok();
|
|
||||||
}
|
|
||||||
if let Err(e) = std::fs::write(&pw_path, format!("{password}\n")) {
|
|
||||||
tracing::warn!(error = ?e, %localpart, "matrix: failed to persist the sender account password");
|
|
||||||
} else {
|
|
||||||
let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600));
|
|
||||||
}
|
|
||||||
token
|
|
||||||
}
|
|
||||||
Err(reg_err) if reg_err.to_string().contains("M_USER_IN_USE") => {
|
|
||||||
// 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!(%localpart, "matrix: the sender account already exists, logging in as the appservice");
|
|
||||||
match appservice_login(client, as_token, &localpart).await {
|
|
||||||
Ok(token) => token,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = ?e, %localpart, "matrix: appservice login for the sender account failed; falling back to the stored password");
|
|
||||||
let pw_path = password_path(&localpart);
|
|
||||||
let stored = std::fs::read_to_string(&pw_path)
|
|
||||||
.ok()
|
|
||||||
.map(|s| s.trim().to_owned())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.with_context(|| {
|
|
||||||
format!(
|
|
||||||
"matrix: @{localpart}: exists, appservice login failed, and no \
|
|
||||||
password is stored at {} — check that the registration file's \
|
|
||||||
namespace covers @{localpart} and that the homeserver \
|
|
||||||
loaded it",
|
|
||||||
pw_path.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
login_user(client, &localpart, &stored).await?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(other) => return Err(other),
|
|
||||||
};
|
|
||||||
persist_sender_token(&path, &access_token)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What [`ensure_hive_user`] does about the sender token this sweep.
|
/// What [`ensure_hive_user`] does about the sender token this sweep.
|
||||||
|
|
@ -487,39 +179,27 @@ enum SenderSource<'a> {
|
||||||
Keep,
|
Keep,
|
||||||
/// Write the store's token to the file.
|
/// Write the store's token to the file.
|
||||||
Store(&'a str),
|
Store(&'a str),
|
||||||
/// Mint one with this hive's own appservice token.
|
/// No token in the store or the file.
|
||||||
Mint(&'a str),
|
|
||||||
/// Nothing to take and nothing to mint with.
|
|
||||||
Unavailable,
|
Unavailable,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decide [`ensure_hive_user`]'s step from the store's token, the file's
|
/// Decide [`ensure_hive_user`]'s step from the store's token and the file's
|
||||||
/// content and this hive's `as_token`, each `None` when absent. Blank counts
|
/// content, each `None` when absent. Blank counts as absent.
|
||||||
/// as absent.
|
fn sender_source<'a>(stored: Option<&'a str>, on_disk: Option<&str>) -> SenderSource<'a> {
|
||||||
fn sender_source<'a>(
|
|
||||||
stored: Option<&'a str>,
|
|
||||||
on_disk: Option<&str>,
|
|
||||||
as_token: Option<&'a str>,
|
|
||||||
) -> SenderSource<'a> {
|
|
||||||
let present = |s: &&str| !s.trim().is_empty();
|
let present = |s: &&str| !s.trim().is_empty();
|
||||||
let stored = stored.map(str::trim).filter(present);
|
let stored = stored.map(str::trim).filter(present);
|
||||||
let on_disk = on_disk.map(str::trim).filter(present);
|
let on_disk = on_disk.map(str::trim).filter(present);
|
||||||
match (stored, on_disk, as_token.filter(present)) {
|
match (stored, on_disk) {
|
||||||
(Some(s), Some(d), _) if s == d => SenderSource::Keep,
|
(Some(s), Some(d)) if s == d => SenderSource::Keep,
|
||||||
(Some(s), _, _) => SenderSource::Store(s),
|
(Some(s), _) => SenderSource::Store(s),
|
||||||
(None, Some(_), _) => SenderSource::Keep,
|
(None, Some(_)) => SenderSource::Keep,
|
||||||
(None, None, Some(a)) => SenderSource::Mint(a),
|
(None, None) => SenderSource::Unavailable,
|
||||||
(None, None, None) => SenderSource::Unavailable,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write the appservice sender account's access token to `path`, 0600, creating the
|
/// Write the appservice sender account's access token to `path`, 0600, creating the
|
||||||
/// directory if it is not there.
|
/// directory if it is not there. The file's mode is the only thing keeping an
|
||||||
///
|
/// unprivileged reader off the hive's matrix credential.
|
||||||
/// Shared by both arms of [`ensure_hive_user`] rather than duplicated into
|
|
||||||
/// the store one: the file's mode is the only thing keeping an unprivileged
|
|
||||||
/// reader off the hive's matrix credential, and a second copy of that decision
|
|
||||||
/// is one that can be edited alone.
|
|
||||||
fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()> {
|
fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()> {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
|
@ -533,8 +213,8 @@ fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch the sender token `swarm-controller` (or `swarm-matrix-ctl`)
|
/// Fetch the sender token `swarm-controller` published, under this hive's own
|
||||||
/// published, under this hive's own store identity.
|
/// store identity.
|
||||||
///
|
///
|
||||||
/// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` —
|
/// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` —
|
||||||
/// the same role string `workers::credential` logs in with, and already in
|
/// the same role string `workers::credential` logs in with, and already in
|
||||||
|
|
@ -546,10 +226,10 @@ fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()
|
||||||
///
|
///
|
||||||
/// `None`, never an error, for every way this can come up empty — no hive
|
/// `None`, never an error, for every way this can come up empty — no hive
|
||||||
/// name, no `BAO_*` identity, an unreachable store, nothing at the path. All
|
/// name, no `BAO_*` identity, an unreachable store, nothing at the path. All
|
||||||
/// four mean the same thing to the caller ("keep the file, or mint it the old
|
/// four mean the same thing to the caller ("keep the file"), and three of
|
||||||
/// way"), and three of them are the ordinary state of a swarm with no store
|
/// them are the ordinary state of a swarm with no store token for this hive
|
||||||
/// token for this hive yet, so raising would turn a supported deployment into
|
/// yet, so raising would turn a supported deployment into a warning every
|
||||||
/// a warning every sweep.
|
/// sweep.
|
||||||
///
|
///
|
||||||
/// 🩸 Logs the store **path** and never the value.
|
/// 🩸 Logs the store **path** and never the value.
|
||||||
async fn stored_sender_token() -> Option<String> {
|
async fn stored_sender_token() -> Option<String> {
|
||||||
|
|
@ -1279,12 +959,6 @@ pub async fn ensure_all() -> bool {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let mut ok = true;
|
let mut ok = true;
|
||||||
// Absent on every hive whose homeserver runs elsewhere. Only the sender
|
|
||||||
// token's mint fallback needs it; `ensure_hive_user` fails, and says so,
|
|
||||||
// when the store has no token either.
|
|
||||||
let as_token = read_appservice_token()
|
|
||||||
.inspect_err(|e| tracing::debug!(error = ?e, "matrix: no local appservice token"))
|
|
||||||
.ok();
|
|
||||||
// One HTTP client for the whole sweep.
|
// One HTTP client for the whole sweep.
|
||||||
let client = match reqwest::Client::builder()
|
let client = match reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
|
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
|
||||||
|
|
@ -1300,7 +974,7 @@ pub async fn ensure_all() -> bool {
|
||||||
// THROUGH it (the Space, the chat room and every invite are sent with
|
// THROUGH it (the Space, the chat room and every invite are sent with
|
||||||
// its token) — as an ordinary user that created those rooms, not as a
|
// its token) — as an ordinary user that created those rooms, not as a
|
||||||
// homeserver admin.
|
// homeserver admin.
|
||||||
if let Err(e) = ensure_hive_user(&client, as_token.as_deref()).await {
|
if let Err(e) = ensure_hive_user().await {
|
||||||
tracing::warn!(error = ?e, "matrix: ensure_hive_user failed");
|
tracing::warn!(error = ?e, "matrix: ensure_hive_user failed");
|
||||||
ok = false;
|
ok = false;
|
||||||
}
|
}
|
||||||
|
|
@ -1413,18 +1087,15 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_remote_hive_takes_the_store_token_without_an_appservice_token() {
|
fn with_no_file_the_store_token_is_taken() {
|
||||||
assert_eq!(
|
assert_eq!(sender_source(Some("tok"), None), SenderSource::Store("tok"));
|
||||||
sender_source(Some("tok"), None, None),
|
|
||||||
SenderSource::Store("tok")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_store_token_replaces_a_different_file_token() {
|
fn a_store_token_replaces_a_different_file_token() {
|
||||||
// The swarm re-minted a dead token; the file must follow it.
|
// The swarm re-minted a dead token; the file must follow it.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
sender_source(Some("new\n"), Some("old\n"), Some("as")),
|
sender_source(Some("new\n"), Some("old\n")),
|
||||||
SenderSource::Store("new")
|
SenderSource::Store("new")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1433,40 +1104,23 @@ mod tests {
|
||||||
fn a_file_matching_the_store_is_kept() {
|
fn a_file_matching_the_store_is_kept() {
|
||||||
// Trailing newline on disk is how `persist_sender_token` writes it.
|
// Trailing newline on disk is how `persist_sender_token` writes it.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
sender_source(Some("tok"), Some("tok\n"), None),
|
sender_source(Some("tok"), Some("tok\n")),
|
||||||
SenderSource::Keep
|
SenderSource::Keep
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn with_nothing_in_the_store_the_file_is_kept() {
|
fn with_nothing_in_the_store_the_file_is_kept() {
|
||||||
assert_eq!(
|
assert_eq!(sender_source(None, Some("tok\n")), SenderSource::Keep);
|
||||||
sender_source(None, Some("tok\n"), Some("as")),
|
|
||||||
SenderSource::Keep
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn with_no_token_anywhere_the_appservice_token_mints() {
|
fn with_no_token_anywhere_nothing_is_available() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
sender_source(None, Some(" \n"), Some("as")),
|
sender_source(Some(""), Some(" \n")),
|
||||||
SenderSource::Mint("as")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn with_no_token_and_no_appservice_token_nothing_is_available() {
|
|
||||||
assert_eq!(
|
|
||||||
sender_source(Some(""), None, Some("")),
|
|
||||||
SenderSource::Unavailable
|
SenderSource::Unavailable
|
||||||
);
|
);
|
||||||
}
|
assert_eq!(sender_source(None, None), SenderSource::Unavailable);
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn random_hex_is_well_formed_and_correct_length() {
|
|
||||||
let h = random_hex(16).expect("/dev/urandom readable");
|
|
||||||
assert_eq!(h.len(), 32);
|
|
||||||
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The steady state. This is the whole point of the guard: the sweep
|
/// The steady state. This is the whole point of the guard: the sweep
|
||||||
|
|
@ -1506,25 +1160,6 @@ mod tests {
|
||||||
assert!(state_needs_write(None, &desired));
|
assert!(state_needs_write(None, &desired));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn random_hex_two_calls_differ() {
|
|
||||||
// Sanity check — not a statistical claim, just guards
|
|
||||||
// against ever accidentally returning a constant.
|
|
||||||
let a = random_hex(16).expect("/dev/urandom readable");
|
|
||||||
let b = random_hex(16).expect("/dev/urandom readable");
|
|
||||||
assert_ne!(a, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_access_token_pulls_from_success_body() {
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"user_id": "@alice:matrix.example.org",
|
|
||||||
"access_token": "syt_abc123",
|
|
||||||
"device_id": "ABC",
|
|
||||||
});
|
|
||||||
assert_eq!(extract_access_token(&body).unwrap(), "syt_abc123");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A homeserver response built in memory, so no client (and no TLS
|
/// A homeserver response built in memory, so no client (and no TLS
|
||||||
/// roots) is needed to drive the response-handling halves.
|
/// roots) is needed to drive the response-handling halves.
|
||||||
fn response(status: u16, body: &'static str) -> reqwest::Response {
|
fn response(status: u16, body: &'static str) -> reqwest::Response {
|
||||||
|
|
@ -1572,11 +1207,4 @@ mod tests {
|
||||||
let outcome = refused_invite(response(500, "{}"), Some("join"), "@a:x", "!r:x").await;
|
let outcome = refused_invite(response(500, "{}"), Some("join"), "@a:x", "!r:x").await;
|
||||||
assert!(outcome.is_err(), "a 500 is not excused by membership");
|
assert!(outcome.is_err(), "a 500 is not excused by membership");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_access_token_errors_on_missing_field() {
|
|
||||||
let body = serde_json::json!({"user_id": "@alice:matrix.example.org"});
|
|
||||||
let err = extract_access_token(&body).unwrap_err();
|
|
||||||
assert!(err.to_string().contains("missing access_token"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -160,14 +160,6 @@ pub fn matrix_chat_room_id() -> PathBuf {
|
||||||
matrix_dir().join("chat-room-id")
|
matrix_dir().join("chat-room-id")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `matrix/creds/` — the hive sender account's throwaway matrix password
|
|
||||||
/// (survives `destroy --purge`; it authenticates by token, this is
|
|
||||||
/// recovery only).
|
|
||||||
#[must_use]
|
|
||||||
pub fn matrix_creds_dir() -> PathBuf {
|
|
||||||
matrix_dir().join("creds")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `run/` — runtime maps hive-c0re regenerates on every meta sync.
|
/// `run/` — runtime maps hive-c0re regenerates on every meta sync.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn run_dir() -> PathBuf {
|
pub fn run_dir() -> PathBuf {
|
||||||
|
|
@ -284,17 +276,6 @@ 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-appservice-token` — the `as_token` of the hive's appservice
|
|
||||||
/// registration, which authorises every account this daemon creates.
|
|
||||||
// nix: minted by the `hive-matrix-appservice` activation script in
|
|
||||||
// `nix/host-modules/hive-matrix.nix`, which renders it into the registration
|
|
||||||
// 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]
|
|
||||||
pub fn matrix_appservice_token() -> PathBuf {
|
|
||||||
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).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn runtime_root() -> PathBuf {
|
pub fn runtime_root() -> PathBuf {
|
||||||
|
|
@ -324,13 +305,12 @@ pub fn agent_runtime_dir(name: &str) -> PathBuf {
|
||||||
/// within the same filesystem is atomic.
|
/// within the same filesystem is atomic.
|
||||||
pub fn relocate_legacy_state() {
|
pub fn relocate_legacy_state() {
|
||||||
let root = state_root();
|
let root = state_root();
|
||||||
let moves: [(&str, PathBuf); 7] = [
|
let moves: [(&str, PathBuf); 6] = [
|
||||||
("broker.sqlite", db_dir().join("broker.sqlite")),
|
("broker.sqlite", db_dir().join("broker.sqlite")),
|
||||||
("build_logs.sqlite", db_dir().join("build_logs.sqlite")),
|
("build_logs.sqlite", db_dir().join("build_logs.sqlite")),
|
||||||
("forge-core-avatar-set", forge_core_avatar_marker()),
|
("forge-core-avatar-set", forge_core_avatar_marker()),
|
||||||
("matrix-sender-token", matrix_sender_token()),
|
("matrix-sender-token", matrix_sender_token()),
|
||||||
("matrix-space-room-id", matrix_space_room_id()),
|
("matrix-space-room-id", matrix_space_room_id()),
|
||||||
("matrix-creds", matrix_creds_dir()),
|
|
||||||
("agent-sockets.json", agent_sockets_file()),
|
("agent-sockets.json", agent_sockets_file()),
|
||||||
];
|
];
|
||||||
for (old_rel, new) in &moves {
|
for (old_rel, new) in &moves {
|
||||||
|
|
|
||||||
|
|
@ -206,7 +206,6 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
HostRequest::MatrixSyncAdmin => handle_matrix_sync_admin().await?,
|
|
||||||
HostRequest::MatrixInvite { user, room } => {
|
HostRequest::MatrixInvite { user, room } => {
|
||||||
handle_matrix_invite(user, room.as_deref()).await?
|
handle_matrix_invite(user, room.as_deref()).await?
|
||||||
}
|
}
|
||||||
|
|
@ -366,10 +365,9 @@ async fn stream_agent_status(
|
||||||
//
|
//
|
||||||
// The `hivectl matrix` subcommands used to run these in-process, which forced
|
// The `hivectl matrix` subcommands used to run these in-process, which forced
|
||||||
// the standalone CLI to link the whole daemon crate (matrix-sdk, reqwest, …).
|
// the standalone CLI to link the whole daemon crate (matrix-sdk, reqwest, …).
|
||||||
// They now run daemon-side over the host socket: the daemon already holds the
|
// They now run daemon-side over the host socket, where the daemon already holds
|
||||||
// register + sender tokens and the matrix creds dir. Each op returns the
|
// the sender token. Each op returns the operator-facing lines hivectl used to
|
||||||
// operator-facing lines hivectl used to `println!` in `HostResponse::messages`
|
// `println!` in `HostResponse::messages` for the client to print verbatim.
|
||||||
// for the client to print verbatim.
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Shared reqwest client for the matrix admin HTTP calls (30s timeout,
|
/// Shared reqwest client for the matrix admin HTTP calls (30s timeout,
|
||||||
|
|
@ -588,24 +586,6 @@ async fn handle_push_snapshot(
|
||||||
Ok(HostResponse::success())
|
Ok(HostResponse::success())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_matrix_sync_admin() -> Result<HostResponse> {
|
|
||||||
require_matrix_present()?;
|
|
||||||
let as_token =
|
|
||||||
crate::matrix::read_appservice_token().context("read matrix appservice token")?;
|
|
||||||
let client = matrix_http_client()?;
|
|
||||||
crate::matrix::ensure_hive_user(&client, Some(&as_token))
|
|
||||||
.await
|
|
||||||
.context("matrix sync-admin")?;
|
|
||||||
let path = crate::matrix::sender_token_path();
|
|
||||||
Ok(HostResponse::messages(vec![
|
|
||||||
format!(
|
|
||||||
"matrix: the @{}: user is provisioned",
|
|
||||||
crate::matrix::hive_localpart()?
|
|
||||||
),
|
|
||||||
format!("token persisted at: {}", path.display()),
|
|
||||||
]))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResponse> {
|
async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResponse> {
|
||||||
require_matrix_present()?;
|
require_matrix_present()?;
|
||||||
let sender_token = crate::matrix::read_sender_token()?;
|
let sender_token = crate::matrix::read_sender_token()?;
|
||||||
|
|
|
||||||
|
|
@ -270,9 +270,6 @@ pub enum HostRequest {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
scope: LifecycleScope,
|
scope: LifecycleScope,
|
||||||
},
|
},
|
||||||
/// Provision (or re-provision) the hive system admin matrix account.
|
|
||||||
/// Daemon-side equivalent of `hivectl matrix sync-admin`.
|
|
||||||
MatrixSyncAdmin,
|
|
||||||
/// Invite a matrix user to the hive Space (default) or a specific
|
/// Invite a matrix user to the hive Space (default) or a specific
|
||||||
/// `room`. Uses the daemon's admin token; idempotent
|
/// `room`. Uses the daemon's admin token; idempotent
|
||||||
/// (already-member / already-invited is a no-op).
|
/// (already-member / already-invited is a no-op).
|
||||||
|
|
@ -539,8 +536,7 @@ pub struct HostResponse {
|
||||||
pub nodes: Option<Vec<hive_jobq_wire::GraphNode>>,
|
pub nodes: Option<Vec<hive_jobq_wire::GraphNode>>,
|
||||||
/// Free-form operator-facing output lines the client prints verbatim
|
/// Free-form operator-facing output lines the client prints verbatim
|
||||||
/// (one per line). Carries results a request produced daemon-side that
|
/// (one per line). Carries results a request produced daemon-side that
|
||||||
/// have no structured home — e.g. the sender token path from
|
/// have no structured home — e.g. the invited room id from `MatrixInvite`.
|
||||||
/// `MatrixSyncAdmin`, or the invited room id from `MatrixInvite`.
|
|
||||||
/// Empty for requests that produce no such output.
|
/// Empty for requests that produce no such output.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub messages: Vec<String>,
|
pub messages: Vec<String>,
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,11 @@ pub enum Cmd {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
cmd: ForgeCmd,
|
cmd: ForgeCmd,
|
||||||
},
|
},
|
||||||
/// matrix-tuwunel user provisioning.
|
/// matrix-tuwunel invites.
|
||||||
///
|
///
|
||||||
/// Manual entry point to the same idempotent provisioning c0re runs at
|
/// Manual invites into the hive Space and its rooms; c0re's periodic
|
||||||
/// boot — for re-registering an agent the boot sweep skipped, or after
|
/// matrix sweep provisions the Space, the chat room and the agents'
|
||||||
/// wiping a token file.
|
/// invites on its own.
|
||||||
Matrix {
|
Matrix {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
cmd: MatrixCmd,
|
cmd: MatrixCmd,
|
||||||
|
|
@ -286,11 +286,6 @@ impl From<ReconcileFrom> for hive_host_sock::ReconcileDirection {
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
pub enum MatrixCmd {
|
pub enum MatrixCmd {
|
||||||
/// Provision (or re-provision) the matrix appservice's sender account.
|
|
||||||
///
|
|
||||||
/// Runs automatically on startup; run manually to recover a missing
|
|
||||||
/// access token.
|
|
||||||
SyncAdmin,
|
|
||||||
/// Invite a matrix user to the hive Space, or a specific room with
|
/// Invite a matrix user to the hive Space, or a specific room with
|
||||||
/// `--room`. Idempotent.
|
/// `--room`. Idempotent.
|
||||||
Invite {
|
Invite {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
//! `hivectl matrix` — matrix account provisioning verbs. hivectl forwards
|
//! `hivectl matrix` — matrix provisioning verbs. hivectl forwards
|
||||||
//! each request to the daemon (which owns the register + sender tokens and
|
//! each request to the daemon (which owns the sender token) and renders the
|
||||||
//! the matrix creds dir) and renders the reply; it no longer links the
|
//! reply; it no longer links the matrix machinery itself.
|
||||||
//! matrix machinery itself.
|
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
|
@ -13,15 +12,14 @@ use crate::cli::MatrixCmd;
|
||||||
/// dispatch match so the top-level router stays small.
|
/// dispatch match so the top-level router stays small.
|
||||||
pub(crate) async fn run_matrix_cmd(socket: &Path, cmd: MatrixCmd) -> Result<()> {
|
pub(crate) async fn run_matrix_cmd(socket: &Path, cmd: MatrixCmd) -> Result<()> {
|
||||||
match cmd {
|
match cmd {
|
||||||
MatrixCmd::SyncAdmin => matrix_sync_admin(socket).await,
|
|
||||||
MatrixCmd::Invite { user, room } => matrix_invite(socket, &user, room.as_deref()).await,
|
MatrixCmd::Invite { user, room } => matrix_invite(socket, &user, room.as_deref()).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a matrix provisioning request to the daemon and print the
|
/// Send a matrix provisioning request to the daemon and print the
|
||||||
/// operator-facing result lines it returns. The daemon owns the register +
|
/// operator-facing result lines it returns. The daemon owns the sender token,
|
||||||
/// sender tokens and the matrix creds dir, so hivectl no longer links the
|
/// so hivectl no longer links the matrix machinery — it just forwards the
|
||||||
/// matrix machinery — it just forwards the request and renders the reply.
|
/// request and renders the reply.
|
||||||
async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Result<()> {
|
async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Result<()> {
|
||||||
let resp = crate::client::request(socket, req)
|
let resp = crate::client::request(socket, req)
|
||||||
.await
|
.await
|
||||||
|
|
@ -38,10 +36,6 @@ async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Resu
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn matrix_sync_admin(socket: &Path) -> Result<()> {
|
|
||||||
matrix_request(socket, hive_host_sock::HostRequest::MatrixSyncAdmin).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn matrix_invite(socket: &Path, user: &str, room: Option<&str>) -> Result<()> {
|
async fn matrix_invite(socket: &Path, user: &str, room: Option<&str>) -> Result<()> {
|
||||||
matrix_request(
|
matrix_request(
|
||||||
socket,
|
socket,
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ let
|
||||||
# which already admits it.
|
# which already admits it.
|
||||||
#
|
#
|
||||||
# ⚠️ Must equal `swarm_secret_client::matrix::hive_localpart`, which
|
# ⚠️ Must equal `swarm_secret_client::matrix::hive_localpart`, which
|
||||||
# hive-c0re and swarm-matrix-ctl both derive from independently with
|
# hive-c0re and swarm-controller both derive from independently with
|
||||||
# nothing wiring an override across — same agreement, and same reason for
|
# nothing wiring an override across — same agreement, and same reason for
|
||||||
# saying so, as the token path below.
|
# saying so, as the token path below.
|
||||||
#
|
#
|
||||||
|
|
@ -92,7 +92,7 @@ let
|
||||||
|
|
||||||
# The `as_token`, and the `hs_token` the spec requires alongside it. Both
|
# 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
|
# 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
|
# the swarm secret store overwrites (see
|
||||||
# `glue-matrix-bao-token.nix`). The `hs_token` authenticates the homeserver
|
# `glue-matrix-bao-token.nix`). The `hs_token` authenticates the homeserver
|
||||||
# TO the appservice, which with `url = null` is nobody — it exists because
|
# TO the appservice, which with `url = null` is nobody — it exists because
|
||||||
# the registration format requires it.
|
# the registration format requires it.
|
||||||
|
|
@ -127,18 +127,12 @@ let
|
||||||
|
|
||||||
# ── swarm-matrix-ctl ────────────────────────────────────────────────
|
# ── swarm-matrix-ctl ────────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# The oneshot that publishes the appservice sender account's access token to
|
# The container's own store identity, which the swarm appservice units below
|
||||||
# the swarm's secret store. It runs INSIDE the container, beside tuwunel,
|
# run under. Gated on the identity, not on `deploy.bao.enable`: a swarm's ONE
|
||||||
# because the appservice token that authorises the mint is already in here —
|
# homeserver is the host least likely to also be the host running the store,
|
||||||
# `appserviceDir` below is bound read-only precisely so the homeserver can
|
# so "co-located with bao" would leave the intended deployment silently
|
||||||
# load it — and minting anywhere else would create a second holder of that
|
# publishing nothing. Same rule ./swarm-secret-publisher.nix's
|
||||||
# secret, which is the thing this whole arrangement exists to stop.
|
# `haveClientIdentity` states, for a sharper reason.
|
||||||
#
|
|
||||||
# Gated on the identity, not on `deploy.bao.enable`: a swarm's ONE homeserver
|
|
||||||
# is the host least likely to also be the host running the store, so
|
|
||||||
# "co-located with bao" would leave the intended deployment silently minting
|
|
||||||
# nothing. Same rule ./swarm-secret-publisher.nix's `haveClientIdentity`
|
|
||||||
# states, for a sharper reason.
|
|
||||||
ctlActive =
|
ctlActive =
|
||||||
deployCfg.matrix.ctlBaoClientCertFile != null && deployCfg.matrix.ctlBaoClientKeyFile != null;
|
deployCfg.matrix.ctlBaoClientCertFile != null && deployCfg.matrix.ctlBaoClientKeyFile != null;
|
||||||
|
|
||||||
|
|
@ -179,9 +173,9 @@ let
|
||||||
# identity on this homeserver: `swarm-controller` creates every agent's
|
# identity on this homeserver: `swarm-controller` creates every agent's
|
||||||
# account with its token. Minted INSIDE this container by
|
# account with its token. Minted INSIDE this container by
|
||||||
# `swarm-matrix-ctl appservice render` and published to the store by
|
# `swarm-matrix-ctl appservice render` and published to the store by
|
||||||
# `appservice publish`, which is why it is gated on the same identity as
|
# `appservice publish`, which is why it is gated on matrix-ctl's store
|
||||||
# the sender mint: with nobody to publish it, a registration here would be
|
# identity: with nobody to publish it, a registration here would be an
|
||||||
# an admin credential nobody reads.
|
# admin credential nobody reads.
|
||||||
#
|
#
|
||||||
# Its sender is promoted to homeserver admin at boot (`admin_execute`
|
# Its sender is promoted to homeserver admin at boot (`admin_execute`
|
||||||
# below), so its token goes only to a store path no hive's policy reaches.
|
# below), so its token goes only to a store path no hive's policy reaches.
|
||||||
|
|
@ -193,13 +187,6 @@ let
|
||||||
swarmAppserviceDir = "/var/lib/swarm-matrix-appservice";
|
swarmAppserviceDir = "/var/lib/swarm-matrix-appservice";
|
||||||
swarmAppserviceCredentialId = "swarm-appservice.yaml";
|
swarmAppserviceCredentialId = "swarm-appservice.yaml";
|
||||||
|
|
||||||
# Where a reader of the published credential is told the token is good for.
|
|
||||||
# Empty when this hive serves no vhost: `matrix::Credential.homeserver` is an
|
|
||||||
# `Option`, and matrix-ctl reads an empty variable as absent rather than as
|
|
||||||
# the string "null" — which is what a hive with no gateway host actually
|
|
||||||
# knows about itself.
|
|
||||||
ctlHomeserverUrl = if cfg.gatewayHost == null then "" else "https://${toString cfg.gatewayHost}";
|
|
||||||
|
|
||||||
# Every local user this hive may provision — agents and `@hive-<hive>:`
|
# Every local user this hive may provision — agents and `@hive-<hive>:`
|
||||||
# itself — which is the whole matrix localpart charset.
|
# itself — which is the whole matrix localpart charset.
|
||||||
#
|
#
|
||||||
|
|
@ -677,15 +664,14 @@ in
|
||||||
default = appserviceTokenPath;
|
default = appserviceTokenPath;
|
||||||
description = ''
|
description = ''
|
||||||
Host path to a file containing this hive's matrix appservice
|
Host path to a file containing this hive's matrix appservice
|
||||||
token (`as_token`) — the identity `hive-c0re` creates and logs
|
token (`as_token`). Minted automatically on first activation
|
||||||
into accounts with. Minted automatically on first activation
|
|
||||||
(32-byte random hex, mode 0600) and rendered into the
|
(32-byte random hex, mode 0600) and rendered into the
|
||||||
appservice registration the homeserver loads at boot. Agents
|
appservice registration the homeserver loads at boot. Agents
|
||||||
never see it; an agent only ever receives its own
|
never see it; an agent only ever receives its own
|
||||||
`access_token`.
|
`access_token`.
|
||||||
|
|
||||||
Not operator-settable — `hive-c0re`'s Rust side derives this
|
Not operator-settable — the module's registration renderer reads
|
||||||
same path independently (`paths::matrix_appservice_token()`)
|
this same path as a literal, not through the option,
|
||||||
with nothing wiring an override across, so a moved path desyncs
|
with nothing wiring an override across, so a moved path desyncs
|
||||||
the two silently. An externally-managed token is delivered by
|
the two silently. An externally-managed token is delivered by
|
||||||
writing into *this* fixed path instead of moving it — see
|
writing into *this* fixed path instead of moving it — see
|
||||||
|
|
@ -1015,8 +1001,8 @@ in
|
||||||
assertion = deployCfg.matrix.appserviceTokenFile == appserviceTokenPath;
|
assertion = deployCfg.matrix.appserviceTokenFile == appserviceTokenPath;
|
||||||
message = ''
|
message = ''
|
||||||
services.hyperhive.deploy.matrix.appserviceTokenFile is fixed at
|
services.hyperhive.deploy.matrix.appserviceTokenFile is fixed at
|
||||||
${appserviceTokenPath} and cannot be moved — hive-c0re's Rust
|
${appserviceTokenPath} and cannot be moved — the registration
|
||||||
side derives this same path independently and has no way to learn
|
renderer reads this same path as a literal 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.
|
||||||
|
|
||||||
|
|
@ -1415,66 +1401,6 @@ in
|
||||||
# is why the render below is `requiredBy` it and local only.
|
# is why the render below is `requiredBy` it and local only.
|
||||||
++ lib.optional ctlActive "${swarmAppserviceCredentialId}:${swarmAppserviceDir}/swarm.yaml";
|
++ lib.optional ctlActive "${swarmAppserviceCredentialId}:${swarmAppserviceDir}/swarm.yaml";
|
||||||
|
|
||||||
# Publish the appservice sender account's access token to the swarm
|
|
||||||
# store, once, under an identity that belongs to this container and
|
|
||||||
# not to the hive. See `ctlActive` above for why it runs here.
|
|
||||||
#
|
|
||||||
# A `oneshot` with no timer and no retry loop of its own: the whole
|
|
||||||
# of "and only once" is the binary's first act, a read of the path it
|
|
||||||
# would write. `Restart=on-failure` covers a store that is sealed or
|
|
||||||
# a homeserver still starting; `RemainAfterExit` is deliberately NOT
|
|
||||||
# set, because the unit having succeeded is not the idempotency
|
|
||||||
# record — the store is, and it outlives this machine.
|
|
||||||
systemd.services.swarm-matrix-ctl = lib.mkIf ctlActive {
|
|
||||||
description = "publish the matrix sender token to the swarm secret store";
|
|
||||||
# Ordered after the homeserver because both of the ladder's arms
|
|
||||||
# are client-server API calls. `wants`, not `requires`: a run that
|
|
||||||
# finds the credential already published never touches tuwunel at
|
|
||||||
# all, so a homeserver that is slow to come up should delay this,
|
|
||||||
# not cancel it.
|
|
||||||
after = [ "tuwunel.service" ];
|
|
||||||
wants = [ "tuwunel.service" ];
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "oneshot";
|
|
||||||
# The verb is part of the contract: `swarm-matrix-ctl` is a
|
|
||||||
# subcommand binary and refuses a bare invocation, so dropping
|
|
||||||
# `mint` here fails the unit rather than doing something else.
|
|
||||||
ExecStart = "${deployCfg.matrix.ctlPackage}/bin/swarm-matrix-ctl mint";
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = 30;
|
|
||||||
# Bounded here rather than left to systemd's default, for the
|
|
||||||
# reason ./swarm-secret-publisher.nix states: a sealed store
|
|
||||||
# answers on the port and never answers the read.
|
|
||||||
TimeoutStartSec = 60;
|
|
||||||
SyslogIdentifier = "swarm-matrix-ctl";
|
|
||||||
};
|
|
||||||
environment = {
|
|
||||||
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
|
|
||||||
BAO_CLIENT_CERT = deployCfg.matrix.ctlBaoClientCertFile;
|
|
||||||
BAO_CLIENT_KEY = deployCfg.matrix.ctlBaoClientKeyFile;
|
|
||||||
MATRIX_MINT_CERT_ROLE = ctlCertRole;
|
|
||||||
# Loopback: this container shares the host netns, so the
|
|
||||||
# homeserver it must talk to is the one in this very unit's
|
|
||||||
# netns and needs no name, no vhost and no TLS.
|
|
||||||
MATRIX_MINT_API_URL = "http://127.0.0.1:${toString cfg.httpPort}";
|
|
||||||
# The bind-mounted registration, which IS the as_token. A path,
|
|
||||||
# never a value.
|
|
||||||
MATRIX_MINT_REGISTRATION = appserviceRegistrationPath;
|
|
||||||
MATRIX_MINT_LOCALPART = hiveLocalpart;
|
|
||||||
# The hive segment of the store path the token is published
|
|
||||||
# under, and so the thing that keeps this hive's token out of
|
|
||||||
# every other hive's reach: the grant that reaches it is the
|
|
||||||
# hive's own `swarm/hives/<name>/*` stanza. ./swarm-bao.nix
|
|
||||||
# spells the same name into matrix-ctl's write grant.
|
|
||||||
MATRIX_MINT_HIVE = toString config.services.hyperhive.hiveName;
|
|
||||||
MATRIX_MINT_HOMESERVER = ctlHomeserverUrl;
|
|
||||||
}
|
|
||||||
// lib.optionalAttrs (deployCfg.bao.serverCaFile != null) {
|
|
||||||
BAO_CACERT = deployCfg.bao.serverCaFile;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
# The swarm registration, minted and rendered before the homeserver
|
# The swarm registration, minted and rendered before the homeserver
|
||||||
# loads it. No network and no store: this is on tuwunel's start
|
# loads it. No network and no store: this is on tuwunel's start
|
||||||
# path, and a store outage must not keep the homeserver down.
|
# path, and a store outage must not keep the homeserver down.
|
||||||
|
|
@ -1502,9 +1428,12 @@ in
|
||||||
};
|
};
|
||||||
|
|
||||||
# Hand the swarm registration's token to swarm-controller, through
|
# Hand the swarm registration's token to swarm-controller, through
|
||||||
# the store. Same identity and retry shape as `swarm-matrix-ctl`
|
# the store, under matrix-ctl's identity; the write lands at a path
|
||||||
# above; the write lands at a path only matrix-ctl and the
|
# only matrix-ctl and the controller are granted (./swarm-bao.nix).
|
||||||
# controller are granted (./swarm-bao.nix).
|
# `Restart=on-failure` covers a sealed store or one still starting;
|
||||||
|
# the start timeout is bounded for the reason
|
||||||
|
# ./swarm-secret-publisher.nix states: a sealed store answers on the
|
||||||
|
# port and never answers the read.
|
||||||
systemd.services.swarm-matrix-appservice-publish = lib.mkIf ctlActive {
|
systemd.services.swarm-matrix-appservice-publish = lib.mkIf ctlActive {
|
||||||
description = "publish the swarm's appservice token to the swarm secret store";
|
description = "publish the swarm's appservice token to the swarm secret store";
|
||||||
after = [ "swarm-matrix-appservice-render.service" ];
|
after = [ "swarm-matrix-appservice-render.service" ];
|
||||||
|
|
|
||||||
|
|
@ -471,10 +471,9 @@ let
|
||||||
# `secret/data/` is KV v2's ACL prefix, inserted by the engine rather than
|
# `secret/data/` is KV v2's ACL prefix, inserted by the engine rather than
|
||||||
# written by the caller — the same trap as the two grants above.
|
# written by the caller — the same trap as the two grants above.
|
||||||
#
|
#
|
||||||
# Not `swarm/services/*` like the publisher's: this principal produces
|
# Not `swarm/services/*` like the publisher's: a homeserver is not entitled
|
||||||
# exactly one secret, its own hive's matrix sender account access token, and
|
# to overwrite Grafana's OIDC client. Each path is spelled to the leaf for
|
||||||
# a homeserver is not entitled to overwrite Grafana's OIDC client. The path
|
# that reason, not for tidiness.
|
||||||
# is spelled to the leaf for that reason, not for tidiness.
|
|
||||||
#
|
#
|
||||||
# 🩸 And the leaf now carries a HIVE segment, which is the narrowing that
|
# 🩸 And the leaf now carries a HIVE segment, which is the narrowing that
|
||||||
# matters: the credential used to live at `swarm/services/matrix/sender-token`
|
# matters: the credential used to live at `swarm/services/matrix/sender-token`
|
||||||
|
|
@ -484,16 +483,12 @@ let
|
||||||
# another's. `matrixCtlHive` below is the name this principal may write, and
|
# another's. `matrixCtlHive` below is the name this principal may write, and
|
||||||
# it is one hive rather than a `hives/*` wildcard for the same reason.
|
# it is one hive rather than a `hives/*` wildcard for the same reason.
|
||||||
#
|
#
|
||||||
# `read` as well as write, unlike either sibling, and it is what makes "and
|
# ⚠️ Nothing presenting this identity writes the first stanza's path:
|
||||||
# only once" mechanical: matrix-ctl's first act is to read this path back and
|
# `swarm-controller` is the sender token's only minter. The stanza is unused.
|
||||||
# stop if something is there, so without the capability every container
|
|
||||||
# restart would mint a second access token and invalidate the hive's. A read
|
|
||||||
# here recovers one secret this principal itself wrote, which is a much
|
|
||||||
# narrower grant than the publisher's would have been.
|
|
||||||
#
|
#
|
||||||
# The second stanza is the swarm appservice's token, which matrix-ctl mints
|
# The second stanza is the swarm appservice's token, which matrix-ctl mints
|
||||||
# inside the container and publishes here for the controller. `read` for the
|
# inside the container and publishes here for the controller. `read` as well
|
||||||
# same reason: publish compares before it writes.
|
# as write, unlike either sibling, because publish compares before it writes.
|
||||||
matrixCtlPolicyText = ''
|
matrixCtlPolicyText = ''
|
||||||
path "${credentialMountPath}/data/swarm/hives/${matrixCtlHive}/matrix/sender-token" {
|
path "${credentialMountPath}/data/swarm/hives/${matrixCtlHive}/matrix/sender-token" {
|
||||||
capabilities = ["create", "update", "read"]
|
capabilities = ["create", "update", "read"]
|
||||||
|
|
@ -504,15 +499,7 @@ let
|
||||||
}
|
}
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# Which hive matrix-ctl mints for. This host's own by default, which is right
|
# The hive the unused first stanza above names.
|
||||||
# whenever the store and the homeserver are co-located and is the shape the
|
|
||||||
# `mkDefault` deployments produce; an operator running them apart names the
|
|
||||||
# homeserver's hive here, because the policy is written where the store is
|
|
||||||
# and the container runs where the homeserver is.
|
|
||||||
#
|
|
||||||
# A wrong value is loud rather than silent: matrix-ctl's write comes back 403
|
|
||||||
# with the store's own message and the hive falls back to minting its account
|
|
||||||
# locally, which is the same degrade a store that was never deployed gives.
|
|
||||||
matrixCtlHive = baoDeploy.matrixCtlHiveName;
|
matrixCtlHive = baoDeploy.matrixCtlHiveName;
|
||||||
|
|
||||||
# The swarm appservice token's leaf, the nix half of
|
# The swarm appservice token's leaf, the nix half of
|
||||||
|
|
@ -1460,8 +1447,8 @@ in
|
||||||
example = "swarm-matrix-ctl.svc";
|
example = "swarm-matrix-ctl.svc";
|
||||||
description = ''
|
description = ''
|
||||||
Subject the store's matrix-ctl cert-auth role accepts — the
|
Subject the store's matrix-ctl cert-auth role accepts — the
|
||||||
identity the oneshot inside the matrix container presents when it
|
identity the matrix container's `swarm-matrix-appservice-publish`
|
||||||
publishes the appservice sender account's access token.
|
unit presents when it publishes the swarm appservice's token.
|
||||||
|
|
||||||
A **third** identity rather than reuse of either sibling above, and
|
A **third** identity rather than reuse of either sibling above, and
|
||||||
the narrowest of the three: its grant is one path, that
|
the narrowest of the three: its grant is one path, that
|
||||||
|
|
@ -1632,20 +1619,8 @@ in
|
||||||
description = ''
|
description = ''
|
||||||
Hive whose matrix sender token the store's matrix-ctl role may write.
|
Hive whose matrix sender token the store's matrix-ctl role may write.
|
||||||
|
|
||||||
The sender account's access token is **per hive**: it lives at
|
Unused: nothing presenting that identity writes the sender token;
|
||||||
`swarm/hives/<name>/matrix/sender-token`, and the only read grant that
|
`swarm-controller` is its only minter.
|
||||||
reaches it is that hive's own. So matrix-ctl's write grant names one
|
|
||||||
hive too — the hive whose homeserver container it runs in.
|
|
||||||
|
|
||||||
Defaults to this host's own {option}`services.hyperhive.hiveName`,
|
|
||||||
which is correct whenever the store and the homeserver are co-located.
|
|
||||||
Set it when they are not: the policy is written where the store runs,
|
|
||||||
and the oneshot runs where the homeserver does.
|
|
||||||
|
|
||||||
A wrong value degrades rather than breaks — matrix-ctl's write is
|
|
||||||
refused with the store's own message and the hive mints its account
|
|
||||||
locally instead, the same fallback a swarm that never deployed the
|
|
||||||
store already uses.
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -451,14 +451,10 @@ let
|
||||||
&& !(lib.hasInfix "sys/policies/acl" s);
|
&& !(lib.hasInfix "sys/policies/acl" s);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
# 🩸 `read` is load-bearing here, and the publisher — the one sibling
|
# 🩸 `read` is load-bearing here, unlike on the publisher: matrix-ctl's
|
||||||
# that still has no `read` — shows what its absence costs. matrix-ctl's
|
# `appservice publish` reads the swarm appservice token back and writes
|
||||||
# first act is to read this path back and stop if something is there —
|
# only when the store's copy differs.
|
||||||
# that read IS "and only once", so without the capability every container
|
name = "matrix-ctl may read back what it writes";
|
||||||
# restart would mint a second access token and invalidate the hive's.
|
|
||||||
# (The controller holds `read` for the same idempotency reason, on the
|
|
||||||
# agent prefix.)
|
|
||||||
name = "matrix-ctl may read back the one path it writes";
|
|
||||||
ok =
|
ok =
|
||||||
let
|
let
|
||||||
s = baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.script;
|
s = baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.script;
|
||||||
|
|
|
||||||
|
|
@ -292,7 +292,8 @@ let
|
||||||
name = "matrix-ctl presents its own leaf, never the hive's store-wide one";
|
name = "matrix-ctl presents its own leaf, never the hive's store-wide one";
|
||||||
ok =
|
ok =
|
||||||
let
|
let
|
||||||
env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl.environment;
|
env =
|
||||||
|
baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-appservice-publish.environment;
|
||||||
hiveLeaf = baoWithMatrix.services.hyperhive.deploy.bao.clientCertFile;
|
hiveLeaf = baoWithMatrix.services.hyperhive.deploy.bao.clientCertFile;
|
||||||
in
|
in
|
||||||
env.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/matrix-ctl.pem" && env.BAO_CLIENT_CERT != hiveLeaf;
|
env.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/matrix-ctl.pem" && env.BAO_CLIENT_CERT != hiveLeaf;
|
||||||
|
|
@ -316,81 +317,37 @@ let
|
||||||
&& mounts ? "/var/lib/hyperhive/matrix-appservice";
|
&& mounts ? "/var/lib/hyperhive/matrix-appservice";
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
# What the unit is for, read as the two agreements it cannot get wrong:
|
# The cert role ./host-modules/swarm-bao.nix writes, which is the one
|
||||||
# the cert role ./host-modules/swarm-bao.nix writes, and a homeserver
|
# agreement the unit cannot get wrong, and the verb: a bare invocation
|
||||||
# address that is loopback because the container shares the host netns. A
|
# exits non-zero with clap's usage, a deploy-time failure with no local
|
||||||
# vhost here would be a request out through the gateway and back.
|
# signal.
|
||||||
name = "matrix-ctl is handed the store role and the loopback homeserver";
|
name = "the publish unit is handed the store role and invokes its verb";
|
||||||
ok =
|
ok =
|
||||||
let
|
let
|
||||||
m = baoWithMatrix;
|
u = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-appservice-publish;
|
||||||
u = m.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl;
|
|
||||||
port = m.services.hyperhive.swarm.matrix.httpPort;
|
|
||||||
in
|
in
|
||||||
u.environment.MATRIX_MINT_CERT_ROLE == "swarm-matrix-ctl"
|
u.environment.MATRIX_APPSERVICE_CERT_ROLE == "swarm-matrix-ctl"
|
||||||
&& u.environment.MATRIX_MINT_API_URL == "http://127.0.0.1:${toString port}"
|
&& lib.hasSuffix "/bin/swarm-matrix-ctl appservice publish" u.serviceConfig.ExecStart;
|
||||||
&& u.environment.MATRIX_MINT_REGISTRATION == "/var/lib/hyperhive/matrix-appservice/hyperhive.yaml"
|
|
||||||
&& u.serviceConfig.Type == "oneshot";
|
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
# 🩸 The per-hive minting identity, read off the rendered unit rather than
|
# 🩸 A secret is a path, never a value. Every variable the unit is given
|
||||||
# off the option: the binary builds its store path out of
|
# names a file or an address; the token itself is read out of the state
|
||||||
# `MATRIX_MINT_HIVE` and logs in as `MATRIX_MINT_LOCALPART`, so a unit
|
# dir at runtime, so nothing here can be a token and an environment block
|
||||||
# that passed the old bare `hive` would publish one identity for the
|
# is world-readable through `systemctl show`.
|
||||||
# whole swarm again and nothing in the Rust tests could see it. Both
|
name = "the publish unit's environment carries paths and addresses, never a token";
|
||||||
# spellings are pinned, and the localpart is pinned as *derived from* the
|
|
||||||
# hive name rather than as a literal, which is the agreement
|
|
||||||
# `swarm_secret_client::matrix::hive_localpart` owns.
|
|
||||||
name = "matrix-ctl is told which hive it mints for, and acts as that hive's account";
|
|
||||||
ok =
|
ok =
|
||||||
let
|
let
|
||||||
m = baoWithMatrix;
|
env =
|
||||||
u = m.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl;
|
baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-appservice-publish.environment;
|
||||||
hive = m.services.hyperhive.hiveName;
|
|
||||||
in
|
|
||||||
u.environment.MATRIX_MINT_HIVE == hive
|
|
||||||
&& u.environment.MATRIX_MINT_LOCALPART == "hive-${hive}"
|
|
||||||
&& u.environment.MATRIX_MINT_LOCALPART != "hive";
|
|
||||||
}
|
|
||||||
{
|
|
||||||
# 🩸 The crate is a `*ctl` with subcommands, so the unit has to name a
|
|
||||||
# VERB. This is the one end of that contract nix owns: the binary's own
|
|
||||||
# test pins how `mint` is spelled, but only a rendered `ExecStart` can
|
|
||||||
# say the unit actually passes it. A bare invocation exits non-zero with
|
|
||||||
# clap's usage — which is a deploy-time failure with no local signal, and
|
|
||||||
# exactly what the next verb added here is most likely to disturb.
|
|
||||||
name = "the unit invokes a verb rather than the bare binary";
|
|
||||||
ok =
|
|
||||||
let
|
|
||||||
exec =
|
|
||||||
baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl.serviceConfig.ExecStart;
|
|
||||||
in
|
|
||||||
lib.hasSuffix "/bin/swarm-matrix-ctl mint" exec;
|
|
||||||
}
|
|
||||||
{
|
|
||||||
# 🩸 A secret is a path, never a value — checked on the one unit in this
|
|
||||||
# tree whose whole job is an `as_token`. Every variable it is given names
|
|
||||||
# a file or an address; the token itself is read out of the bind-mounted
|
|
||||||
# registration at runtime, so nothing here can be a token and an
|
|
||||||
# environment block is world-readable through `systemctl show`.
|
|
||||||
name = "matrix-ctl's environment carries paths and addresses, never a token";
|
|
||||||
ok =
|
|
||||||
let
|
|
||||||
env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl.environment;
|
|
||||||
in
|
in
|
||||||
!(lib.any (v: lib.hasInfix "as_token" v || lib.hasInfix "syt_" v) (lib.attrValues env));
|
!(lib.any (v: lib.hasInfix "as_token" v || lib.hasInfix "syt_" v) (lib.attrValues env));
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
# The absence arm, and the deployment it protects: a homeserver on a hive
|
# The absence arm, and the deployment it protects: a homeserver on a hive
|
||||||
# with no store identity at all. Without it the unit would exist naming
|
# with no store identity at all. Without it the mount would name `null`
|
||||||
# `null` as its certificate, which nixos renders as the literal string.
|
# as its source, which nixos renders as the literal string.
|
||||||
name = "a matrix container with no store identity runs no matrix-ctl and binds no PKI";
|
name = "a matrix container with no store identity binds no PKI";
|
||||||
ok =
|
ok = !(matrixNoBaoIdentity.containers.hive-matrix.bindMounts ? "/var/lib/swarm-bao-pki");
|
||||||
let
|
|
||||||
units = matrixNoBaoIdentity.containers.hive-matrix.config.systemd.services;
|
|
||||||
in
|
|
||||||
!(units ? swarm-matrix-ctl)
|
|
||||||
&& !(matrixNoBaoIdentity.containers.hive-matrix.bindMounts ? "/var/lib/swarm-bao-pki");
|
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
# 🩸 The privilege arm: exactly one account is a homeserver admin, the
|
# 🩸 The privilege arm: exactly one account is a homeserver admin, the
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,15 @@
|
||||||
//! Each hive's sender account, `@hive-<hive>:`, on the swarm's homeserver:
|
//! Each hive's sender account, `@hive-<hive>:`, on the swarm's homeserver:
|
||||||
//! created here with the **swarm's** appservice token and stored at
|
//! created here with the **swarm's** appservice token and stored at
|
||||||
//! `swarm/hives/<hive>/matrix/sender-token`, where hive-c0re's matrix sweep
|
//! `swarm/hives/<hive>/matrix/sender-token`, where hive-c0re's matrix sweep
|
||||||
//! reads it under the hive's own store identity. A hive whose homeserver runs
|
//! reads it under the hive's own store identity.
|
||||||
//! elsewhere holds no appservice token, so this is its only sender token.
|
|
||||||
//!
|
//!
|
||||||
//! Runs for every hive in the directory, local or remote, and decides the
|
//! Runs for every hive in the directory, local or remote, and decides the
|
||||||
//! same way [`super::agent_token`] does: a stored token that `whoami`
|
//! same way [`super::agent_token`] does: a stored token that `whoami`
|
||||||
//! confirms as `@hive-<hive>:` is kept, so this writes only when the path is
|
//! confirms as `@hive-<hive>:` is kept, so this writes only when the path is
|
||||||
//! empty or its token is dead.
|
//! empty or its token is dead.
|
||||||
//!
|
//!
|
||||||
//! ⚠️ `swarm-matrix-ctl mint` also writes this path for the hive whose host
|
//! This is the only writer of that path: hive-c0re never mints, it takes
|
||||||
//! runs the homeserver, and skips when it is non-empty. Both log in on the
|
//! whatever the store holds.
|
||||||
//! same pinned device, so if both find it empty at once, one of the two
|
|
||||||
//! tokens is dead on arrival. Whichever of them lands in the store, the next
|
|
||||||
//! [`RECONCILE_INTERVAL`] pass either keeps it (live) or re-mints it
|
|
||||||
//! (`Revoked`), and matrix-ctl never writes a non-empty path — so the store
|
|
||||||
//! converges on one live token, and the hive's sweep takes whatever it holds.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|
@ -158,7 +152,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_dead_token_mints() {
|
fn a_dead_token_mints() {
|
||||||
// What the loser of a simultaneous mint with matrix-ctl leaves behind.
|
// A token the homeserver revoked, or a device a manual login replaced.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify_hive("pr1ma", &Probe::Whoami(Whoami::UnknownToken)),
|
classify_hive("pr1ma", &Probe::Whoami(Whoami::UnknownToken)),
|
||||||
Decision::Mint(MintReason::Revoked)
|
Decision::Mint(MintReason::Revoked)
|
||||||
|
|
@ -191,8 +185,8 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_published_path_is_the_one_the_hive_reads() {
|
fn the_published_path_is_the_one_the_hive_reads() {
|
||||||
// hive-c0re's `stored_sender_token` and `swarm-matrix-ctl mint` both
|
// hive-c0re's `stored_sender_token` resolves this path; the literal is
|
||||||
// resolve this path; the literal is what the bao grant names.
|
// what the bao grant names.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
matrix::sender_token_path("pr1ma").expect("a plain name is legal"),
|
matrix::sender_token_path("pr1ma").expect("a plain name is legal"),
|
||||||
"swarm/hives/pr1ma/matrix/sender-token"
|
"swarm/hives/pr1ma/matrix/sender-token"
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,11 @@ path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
# One verb today (`mint`), and the reason this crate is a `*ctl` rather than a
|
# The reason this crate is a `*ctl` rather than a single-purpose binary: the
|
||||||
# single-purpose binary: the next thing that has to run in the matrix container
|
# next thing that has to run in the matrix container is a subcommand here, not a
|
||||||
# is a subcommand here, not a new crate.
|
# new crate.
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
serde_json.workspace = true
|
# The token generator, shared with `swarm-controller`.
|
||||||
# The appservice calls, shared with `swarm-controller`, which mints agents'
|
|
||||||
# accounts through the same device id.
|
|
||||||
swarm-matrix-client.workspace = true
|
swarm-matrix-client.workspace = true
|
||||||
# The agreement this binary is one end of: where the credential lives, what the
|
# The agreement this binary is one end of: where the credential lives, what the
|
||||||
# object at that path holds, and the `BAO_*` spellings the unit sets.
|
# object at that path holds, and the `BAO_*` spellings the unit sets.
|
||||||
|
|
|
||||||
|
|
@ -11,52 +11,21 @@ crate.
|
||||||
|
|
||||||
## Verbs
|
## Verbs
|
||||||
|
|
||||||
### `mint`
|
### `appservice render`
|
||||||
|
|
||||||
Puts the appservice sender account's access token into the swarm's secret store,
|
Mints the swarm appservice registration's tokens when absent and renders the
|
||||||
under an identity of its own. A boot-time oneshot.
|
registration tuwunel loads. Runs before the homeserver and needs no network.
|
||||||
|
|
||||||
Configured entirely by the `MATRIX_MINT_*` environment the unit sets — no flags.
|
### `appservice publish`
|
||||||
A systemd `Environment=` block is what a nix module can render; a command line
|
|
||||||
full of paths is not. The prefix is scoped to the verb rather than to the binary
|
|
||||||
so the next verb brings its own, instead of widening a shared one nobody can
|
|
||||||
then narrow.
|
|
||||||
|
|
||||||
## Why this lives in the matrix container
|
Writes the rendered `as_token` to the swarm secret store for `swarm-controller`,
|
||||||
|
when the store's copy differs.
|
||||||
|
|
||||||
The credential `mint` writes is authorised by the appservice `as_token`, and the
|
Both are configured entirely by the `MATRIX_APPSERVICE_*` environment the units
|
||||||
container already holds that: `nix/host-modules/hive-matrix.nix` bind-mounts the
|
set — no flags. A systemd `Environment=` block is what a nix module can render; a
|
||||||
rendered appservice registration into it read-only, because that is how tuwunel
|
command line full of paths is not.
|
||||||
itself is handed the registration. Minting anywhere else would mean copying the
|
|
||||||
`as_token` to a second holder — and the point of this component is that the hive
|
|
||||||
stops being one.
|
|
||||||
|
|
||||||
It is not the swarm controller for the same reason, plus a structural one: a
|
|
||||||
homeserver has exactly **one** appservice registration and so one sender
|
|
||||||
account, and a swarm runs one homeserver, so "mint it once" needs no lock, no
|
|
||||||
lease and no trigger surface — it is a property of the thing being minted.
|
|
||||||
|
|
||||||
## Idempotency
|
|
||||||
|
|
||||||
The **store** is the key, not the homeserver. A `mint` run reads
|
|
||||||
`swarm/services/matrix/sender-token` first and returns without touching the
|
|
||||||
homeserver when something is already there. Only an empty path reaches the mint
|
|
||||||
ladder:
|
|
||||||
|
|
||||||
1. `POST /_matrix/client/v3/register` with `"type": "m.login.application_service"`
|
|
||||||
— one round trip, no UIAA.
|
|
||||||
2. `M_USER_IN_USE` (the expected arm on a homeserver that has already loaded the
|
|
||||||
registration, since the account is the appservice's own `sender_localpart`) →
|
|
||||||
`POST /_matrix/client/v3/login` as the appservice, same pinned `device_id`, so
|
|
||||||
the old device is replaced rather than duplicated.
|
|
||||||
3. Write the result to the store.
|
|
||||||
|
|
||||||
A crash between the homeserver call and the store write is recoverable: the next
|
|
||||||
run takes arm 2.
|
|
||||||
|
|
||||||
## 🩸 A secret is a path, never a value
|
## 🩸 A secret is a path, never a value
|
||||||
|
|
||||||
Nothing here logs, prints or interpolates a token. The mint ladder's errors are
|
Nothing here logs, prints or interpolates a token. The one identifier this
|
||||||
built from the homeserver's _status_ and its `errcode`, never its body, because a
|
binary logs is the store path it wrote.
|
||||||
`/login` response body is an access token. The one identifier this binary logs is
|
|
||||||
the store path it wrote.
|
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,14 @@
|
||||||
//! identity plumbing to add one action, so the next thing that has to run in
|
//! identity plumbing to add one action, so the next thing that has to run in
|
||||||
//! here is a verb below, not a new crate.
|
//! here is a verb below, not a new crate.
|
||||||
//!
|
//!
|
||||||
//! [`mint`] publishes a hive's appservice sender token to the swarm's secret
|
//! [`appservice`] mints the **swarm's** own appservice registration and
|
||||||
//! store, once. [`appservice`] mints the **swarm's** own appservice
|
//! publishes its token for `swarm-controller`.
|
||||||
//! registration and publishes its token for `swarm-controller`.
|
|
||||||
//!
|
|
||||||
//! It lives in the container because the appservice `as_token` that authorises
|
|
||||||
//! the mint is *already* there — the registration tuwunel loads is bind-mounted
|
|
||||||
//! in — so no second holder of that secret is created.
|
|
||||||
//!
|
//!
|
||||||
//! 🩸 **A secret is a path, never a value.** The only identifier any verb here
|
//! 🩸 **A secret is a path, never a value.** The only identifier any verb here
|
||||||
//! logs is the store path; see `swarm_matrix_client`'s module doc for the same rule
|
//! logs is the store path; see `swarm_matrix_client`'s module doc for the same rule
|
||||||
//! applied to error messages.
|
//! applied to error messages.
|
||||||
|
|
||||||
mod appservice;
|
mod appservice;
|
||||||
mod mint;
|
|
||||||
mod registration;
|
mod registration;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
@ -38,13 +32,6 @@ struct Cli {
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
enum Command {
|
enum Command {
|
||||||
/// Publish the appservice sender account's access token to the swarm
|
|
||||||
/// secret store, once.
|
|
||||||
///
|
|
||||||
/// Configured entirely by the `MATRIX_MINT_*` environment the unit sets —
|
|
||||||
/// no flags, because a systemd `Environment=` block is what a nix module
|
|
||||||
/// can render and a command line full of paths is not.
|
|
||||||
Mint,
|
|
||||||
/// The swarm's own appservice registration, whose sender is the
|
/// The swarm's own appservice registration, whose sender is the
|
||||||
/// homeserver's admin account. Configured by `MATRIX_APPSERVICE_*`.
|
/// homeserver's admin account. Configured by `MATRIX_APPSERVICE_*`.
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
|
|
@ -71,7 +58,6 @@ async fn main() -> Result<()> {
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
match Cli::parse().command {
|
match Cli::parse().command {
|
||||||
Command::Mint => mint::run().await,
|
|
||||||
Command::Appservice(Appservice::Render) => appservice::render(),
|
Command::Appservice(Appservice::Render) => appservice::render(),
|
||||||
Command::Appservice(Appservice::Publish) => appservice::publish().await,
|
Command::Appservice(Appservice::Publish) => appservice::publish().await,
|
||||||
}
|
}
|
||||||
|
|
@ -87,17 +73,8 @@ mod tests {
|
||||||
Cli::command().debug_assert();
|
Cli::command().debug_assert();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The unit's `ExecStart` names a verb, so a rename of it is a deploy-time
|
/// The two units name these verbs in `ExecStart`, so a rename of one is a
|
||||||
/// failure with no local signal. This is that signal.
|
/// deploy-time failure with no local signal. This is that signal.
|
||||||
#[test]
|
|
||||||
fn mint_is_spelled_the_way_the_unit_invokes_it() {
|
|
||||||
let cli = Cli::try_parse_from(["swarm-matrix-ctl", "mint"]).expect("`mint` is a verb");
|
|
||||||
assert!(matches!(cli.command, Command::Mint));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The control: without it the case above passes on a parser that accepts
|
|
||||||
/// anything.
|
|
||||||
/// The two units name these verbs, same reason as the test above.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_appservice_verbs_are_spelled_the_way_the_units_invoke_them() {
|
fn the_appservice_verbs_are_spelled_the_way_the_units_invoke_them() {
|
||||||
let cli =
|
let cli =
|
||||||
|
|
@ -122,9 +99,9 @@ mod tests {
|
||||||
.expect_err("only declared verbs are accepted");
|
.expect_err("only declared verbs are accepted");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A bare invocation must not silently do something. `mint` writes a
|
/// A bare invocation must not silently do something. `appservice publish`
|
||||||
/// credential, so "no verb" defaulting to it would make a typo in the unit
|
/// writes a credential, so "no verb" defaulting to it would make a typo in
|
||||||
/// mint rather than fail.
|
/// the unit write rather than fail.
|
||||||
#[test]
|
#[test]
|
||||||
fn no_verb_at_all_is_refused() {
|
fn no_verb_at_all_is_refused() {
|
||||||
Cli::try_parse_from(["swarm-matrix-ctl"]).expect_err("a verb is required");
|
Cli::try_parse_from(["swarm-matrix-ctl"]).expect_err("a verb is required");
|
||||||
|
|
|
||||||
|
|
@ -1,303 +0,0 @@
|
||||||
//! `swarm-matrix-ctl mint` — publish the appservice sender account's
|
|
||||||
//! homeserver access token to the swarm's secret store, once.
|
|
||||||
//!
|
|
||||||
//! A oneshot inside `containers.hive-matrix`, not a daemon and not part of the
|
|
||||||
//! swarm controller. It mints for **one hive** — the hive this container runs
|
|
||||||
//! on, named by [`ENV_HIVE`] — and publishes to that hive's own path, so a
|
|
||||||
//! swarm whose hives share a homeserver gets one account and one token per
|
|
||||||
//! hive rather than one shared between all of them. "Only once" is therefore
|
|
||||||
//! once per hive, and it is still a property of what is being minted rather
|
|
||||||
//! than of a lock: nothing else writes that path.
|
|
||||||
//!
|
|
||||||
//! The store, not the homeserver, is the idempotency key — see
|
|
||||||
//! [`already_published`]. On the hive side `hive-c0re`'s
|
|
||||||
//! `matrix::ensure_hive_user` reads exactly the path written here, which is how
|
|
||||||
//! a hive that holds no `as_token` still gets its matrix account.
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use swarm_secret_client::{
|
|
||||||
SecretStore,
|
|
||||||
client::{DEFAULT_CERT_MOUNT, Settings},
|
|
||||||
matrix,
|
|
||||||
};
|
|
||||||
|
|
||||||
use swarm_matrix_client as homeserver;
|
|
||||||
|
|
||||||
use crate::registration;
|
|
||||||
|
|
||||||
/// Role on the store's `cert` auth mount to log in with. Its policy is what
|
|
||||||
/// allows the write below; the certificate the `BAO_*` variables name has to
|
|
||||||
/// carry the CN that role accepts.
|
|
||||||
const ENV_CERT_ROLE: &str = "MATRIX_MINT_CERT_ROLE";
|
|
||||||
/// Client-server API base of the homeserver beside us — loopback, since the
|
|
||||||
/// container shares the host netns.
|
|
||||||
const ENV_API_URL: &str = "MATRIX_MINT_API_URL";
|
|
||||||
/// The bind-mounted appservice registration, which is where the `as_token`
|
|
||||||
/// comes from. A path, never a value.
|
|
||||||
const ENV_REGISTRATION: &str = "MATRIX_MINT_REGISTRATION";
|
|
||||||
/// Localpart of this hive's sender account. The registration's own
|
|
||||||
/// `sender_localpart`, rendered by `hive-matrix.nix` from the hive name — the
|
|
||||||
/// same string `swarm_secret_client::matrix::hive_localpart` builds, which is
|
|
||||||
/// what `hive-c0re` derives its own copy with.
|
|
||||||
const ENV_LOCALPART: &str = "MATRIX_MINT_LOCALPART";
|
|
||||||
/// Name of the hive this container belongs to, and so the segment of the store
|
|
||||||
/// path the token is published under. It is what keeps one hive's token out of
|
|
||||||
/// another hive's reach — see `swarm_secret_client::matrix::sender_token_path`.
|
|
||||||
const ENV_HIVE: &str = "MATRIX_MINT_HIVE";
|
|
||||||
/// Public base URL of the homeserver, stored beside the token so a reader can
|
|
||||||
/// reconstruct where it is good for. Optional: a swarm with no gateway vhost
|
|
||||||
/// has no such URL, and `matrix::Credential` types the field to say so.
|
|
||||||
const ENV_HOMESERVER: &str = "MATRIX_MINT_HOMESERVER";
|
|
||||||
|
|
||||||
/// Everything the unit tells this verb, checked before anything is opened.
|
|
||||||
///
|
|
||||||
/// Separate from the work for the reason `swarm_secret_client::client::Settings`
|
|
||||||
/// is: every arm is a misconfiguration an operator reads an error about, and
|
|
||||||
/// none of them needs a reachable homeserver or store to happen.
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
|
||||||
struct Config {
|
|
||||||
cert_role: String,
|
|
||||||
api_url: String,
|
|
||||||
registration: String,
|
|
||||||
localpart: String,
|
|
||||||
hive: String,
|
|
||||||
homeserver: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Config {
|
|
||||||
/// Read the `MATRIX_MINT_*` variables from the process environment.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// Naming the first variable that is unset or empty.
|
|
||||||
fn from_env() -> Result<Self> {
|
|
||||||
Self::from_lookup(|k| std::env::var(k).ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`Config::from_env`] against an arbitrary lookup.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// Naming the first variable that is unset or empty.
|
|
||||||
fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
|
|
||||||
let required = |var: &'static str| -> Result<String> {
|
|
||||||
get(var)
|
|
||||||
.filter(|v| !v.is_empty())
|
|
||||||
.with_context(|| format!("{var} is unset or empty"))
|
|
||||||
};
|
|
||||||
Ok(Self {
|
|
||||||
cert_role: required(ENV_CERT_ROLE)?,
|
|
||||||
api_url: required(ENV_API_URL)?,
|
|
||||||
registration: required(ENV_REGISTRATION)?,
|
|
||||||
localpart: required(ENV_LOCALPART)?,
|
|
||||||
hive: required(ENV_HIVE)?,
|
|
||||||
// Empty is absent: systemd renders an unset nix option as
|
|
||||||
// `Environment=VAR=`, so that is the shape this arrives in.
|
|
||||||
homeserver: get(ENV_HOMESERVER).filter(|v| !v.is_empty()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Is the credential already in the store?
|
|
||||||
///
|
|
||||||
/// **This read is the "and only once".** The homeserver is not asked — a
|
|
||||||
/// re-run of the container, or of this unit, costs one store read and stops.
|
|
||||||
/// It is also the read-back of what a previous run wrote, so the path published
|
|
||||||
/// and the path consulted cannot drift apart: they are one function call.
|
|
||||||
///
|
|
||||||
/// A failure to read is reported and treated as absent rather than raised. The
|
|
||||||
/// two cases that reach it are a path that has never been written (the first
|
|
||||||
/// run, which must go on to mint) and a token whose policy does not cover the
|
|
||||||
/// path — and the second fails again, loudly and with the store's own message,
|
|
||||||
/// at the write below.
|
|
||||||
async fn already_published(store: &SecretStore, path: &str) -> bool {
|
|
||||||
match store.read::<matrix::Credential>(path).await {
|
|
||||||
Ok(credential) => !credential.value.trim().is_empty(),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::info!(%path, error = %e, "nothing readable in the store yet");
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run the verb.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// If the environment is incomplete, the store refuses the login or the write,
|
|
||||||
/// the registration cannot be read, or the homeserver refuses both the
|
|
||||||
/// registration and the appservice login.
|
|
||||||
pub async fn run() -> Result<()> {
|
|
||||||
let config = Config::from_env()?;
|
|
||||||
// Explicitly, rather than through `SecretStore::from_env`: a missing or
|
|
||||||
// misspelled `BAO_*` variable is the most likely thing to be wrong with a
|
|
||||||
// freshly deployed unit, and this reports it before the homeserver is
|
|
||||||
// touched at all.
|
|
||||||
let settings = Settings::from_env().context("reading the store's BAO_* environment")?;
|
|
||||||
let store = SecretStore::connect(&settings, &config.cert_role, DEFAULT_CERT_MOUNT)
|
|
||||||
.await
|
|
||||||
.with_context(|| {
|
|
||||||
format!(
|
|
||||||
"logging in to the swarm secret store as cert role {}",
|
|
||||||
config.cert_role
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let path = matrix::sender_token_path(&config.hive)
|
|
||||||
.with_context(|| format!("building the store path for hive {}", config.hive))?;
|
|
||||||
if already_published(&store, &path).await {
|
|
||||||
tracing::info!(%path, "the sender token is already published; not minting");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let as_token = registration::as_token(&config.registration)?;
|
|
||||||
let http = homeserver::client()?;
|
|
||||||
let token =
|
|
||||||
match homeserver::register(&http, &config.api_url, &config.localpart, &as_token).await? {
|
|
||||||
homeserver::Registered::Token(token) => token,
|
|
||||||
homeserver::Registered::AlreadyExists => {
|
|
||||||
// The expected arm, not an edge case: this account is the
|
|
||||||
// appservice's own `sender_localpart`, so the homeserver creates it
|
|
||||||
// when it loads the registration — before anything gets to ask.
|
|
||||||
tracing::info!("the sender account exists; logging in as the appservice instead");
|
|
||||||
homeserver::appservice_login(&http, &config.api_url, &config.localpart, &as_token)
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
store
|
|
||||||
.write(
|
|
||||||
&path,
|
|
||||||
&matrix::Credential {
|
|
||||||
value: token,
|
|
||||||
homeserver: config.homeserver,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("writing the sender token to {path}"))?;
|
|
||||||
tracing::info!(%path, "published the sender token");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// A lookup standing in for a fully-configured unit's environment.
|
|
||||||
fn full(k: &str) -> Option<String> {
|
|
||||||
match k {
|
|
||||||
ENV_CERT_ROLE => Some("swarm-matrix-ctl".to_owned()),
|
|
||||||
ENV_API_URL => Some("http://127.0.0.1:8008".to_owned()),
|
|
||||||
ENV_REGISTRATION => {
|
|
||||||
Some("/var/lib/hyperhive/matrix-appservice/hyperhive.yaml".to_owned())
|
|
||||||
}
|
|
||||||
ENV_LOCALPART => Some("hive-pr1ma".to_owned()),
|
|
||||||
ENV_HIVE => Some("pr1ma".to_owned()),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_complete_environment_is_accepted() {
|
|
||||||
// The control: without it every assertion below could be passing
|
|
||||||
// because `from_lookup` rejects everything.
|
|
||||||
let c = Config::from_lookup(full).expect("every required variable is set");
|
|
||||||
assert_eq!(c.localpart, "hive-pr1ma");
|
|
||||||
assert_eq!(c.hive, "pr1ma");
|
|
||||||
assert_eq!(c.homeserver, None, "an absent public URL is not an error");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn each_required_variable_is_named_when_it_is_the_missing_one() {
|
|
||||||
for var in [
|
|
||||||
ENV_CERT_ROLE,
|
|
||||||
ENV_API_URL,
|
|
||||||
ENV_REGISTRATION,
|
|
||||||
ENV_LOCALPART,
|
|
||||||
ENV_HIVE,
|
|
||||||
] {
|
|
||||||
let e = Config::from_lookup(|k| if k == var { None } else { full(k) })
|
|
||||||
.expect_err("one required variable is absent");
|
|
||||||
assert!(
|
|
||||||
format!("{e}").contains(var),
|
|
||||||
"dropping {var} should name {var}, got {e}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_empty_variable_is_as_absent_as_an_unset_one() {
|
|
||||||
// systemd writes `Environment=VAR=` for an unset nix option, so empty
|
|
||||||
// is the shape these actually arrive in.
|
|
||||||
let e = Config::from_lookup(|k| {
|
|
||||||
if k == ENV_CERT_ROLE {
|
|
||||||
Some(String::new())
|
|
||||||
} else {
|
|
||||||
full(k)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.expect_err("an empty role is not a role");
|
|
||||||
assert!(format!("{e}").contains(ENV_CERT_ROLE), "{e}");
|
|
||||||
|
|
||||||
let c = Config::from_lookup(|k| {
|
|
||||||
if k == ENV_HOMESERVER {
|
|
||||||
Some(String::new())
|
|
||||||
} else {
|
|
||||||
full(k)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.expect("an empty public URL is optional, not fatal");
|
|
||||||
assert_eq!(c.homeserver, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The environment prefix is a contract with the nix unit, and the crate
|
|
||||||
/// rename that produced it moved every one of these. A verb-scoped prefix
|
|
||||||
/// is the point: the next verb brings its own, instead of widening a
|
|
||||||
/// binary-scoped one nobody can then narrow.
|
|
||||||
#[test]
|
|
||||||
fn every_variable_is_scoped_to_the_verb() {
|
|
||||||
for var in [
|
|
||||||
ENV_CERT_ROLE,
|
|
||||||
ENV_API_URL,
|
|
||||||
ENV_REGISTRATION,
|
|
||||||
ENV_LOCALPART,
|
|
||||||
ENV_HIVE,
|
|
||||||
ENV_HOMESERVER,
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
var.starts_with("MATRIX_MINT_"),
|
|
||||||
"{var} is not scoped to the mint verb"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_published_path_is_the_one_the_hive_reads() {
|
|
||||||
// Both ends of this slice's loop resolve the same function, so there is
|
|
||||||
// no second spelling to drift — this pins that the loop exists at all,
|
|
||||||
// and names the literal so a move of the path is a deliberate edit on
|
|
||||||
// both sides rather than a silent 404 on the reading one.
|
|
||||||
assert_eq!(
|
|
||||||
matrix::sender_token_path("pr1ma").expect("a plain name is legal"),
|
|
||||||
"swarm/hives/pr1ma/matrix/sender-token"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn two_hives_are_published_to_two_paths() {
|
|
||||||
// What the hive segment is FOR: this binary runs beside a homeserver
|
|
||||||
// several hives share, so a path without the hive name in it would
|
|
||||||
// have each run overwrite the last and leave every hive holding one
|
|
||||||
// identity — which is the shape this change exists to end.
|
|
||||||
let a = matrix::sender_token_path("alpha").expect("legal");
|
|
||||||
let b = matrix::sender_token_path("beta").expect("legal");
|
|
||||||
assert_ne!(a, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_localpart_the_unit_hands_over_is_the_one_derived_from_the_hive() {
|
|
||||||
// The nix unit renders both variables independently; this pins that
|
|
||||||
// the pair it is expected to render agrees with the shared derivation,
|
|
||||||
// so a unit still passing the old bare `hive` fails here rather than
|
|
||||||
// silently logging in as another hive's account.
|
|
||||||
let c = Config::from_lookup(full).expect("every required variable is set");
|
|
||||||
assert_eq!(c.localpart, matrix::hive_localpart(&c.hive));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -197,9 +197,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn the_localpart_is_derived_from_the_hive_name() {
|
fn the_localpart_is_derived_from_the_hive_name() {
|
||||||
// The literal is the point: `nix/host-modules/hive-matrix.nix` renders
|
// The literal is the point: `nix/host-modules/hive-matrix.nix` renders
|
||||||
// the same string into the registration's `sender_localpart` and into
|
// the same string into the registration's `sender_localpart`, and
|
||||||
// `MATRIX_MINT_LOCALPART`, and nothing wires an override across — so a
|
// nothing wires an override across — so a change here is a change
|
||||||
// change here is a change there.
|
// there.
|
||||||
assert_eq!(hive_localpart("pr1ma"), "hive-pr1ma");
|
assert_eq!(hive_localpart("pr1ma"), "hive-pr1ma");
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
hive_localpart("pr1ma"),
|
hive_localpart("pr1ma"),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue