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 = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"serde_json",
|
||||
"swarm-matrix-client",
|
||||
"swarm-secret-client",
|
||||
"tokio",
|
||||
|
|
|
|||
|
|
@ -280,9 +280,6 @@ control: [`swarm/ui.md`](../swarm/ui.md).
|
|||
### 6 · Matrix
|
||||
|
||||
```bash
|
||||
# Ensure the appservice's sender account exists first
|
||||
hivectl matrix sync-admin
|
||||
|
||||
# Invite the operator to the hive Space (and optionally to rooms)
|
||||
hivectl matrix invite mara
|
||||
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)
|
||||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
Registration is closed. The hive's own **appservice** creates accounts:
|
||||
hive-c0re holds the appservice token, agents never see
|
||||
it, and an agent only ever receives its own `access_token`.
|
||||
Registration is closed. **Appservices** create accounts: agents never see
|
||||
an appservice token, and an agent only ever receives its own `access_token`.
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
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
|
||||
the registration to
|
||||
`/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
|
||||
place by the swarm secret store and a registration naming a stale
|
||||
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,
|
||||
so the sibling credentials are invisible to it. The `.yaml` suffix on
|
||||
the credential id is what makes this work.
|
||||
5. **hive-c0re** reads the appservice token and creates its own
|
||||
`@hive-<hive>:` account. It never mints the token itself: the value
|
||||
has to be the one the rendered registration names, and only the nix
|
||||
side writes that.
|
||||
5. **hive-c0re** doesn't read the appservice token and creates no
|
||||
account. Its `@hive-<hive>:` token comes from the swarm store (below).
|
||||
6. **Agents' accounts aren't this hive's.** `swarm-controller` creates each
|
||||
one with the swarm's own appservice token (next section), stores its
|
||||
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
|
||||
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:
|
||||
`swarm-controller` mints it for every hive with the swarm's appservice token (and
|
||||
`swarm-matrix-ctl`, inside the `hive-matrix` container, for its own hive) and
|
||||
publishes it to `swarm/hives/<hive>/matrix/sender-token`, and the hive
|
||||
`swarm-controller`, its only minter, mints it for every hive with the swarm's
|
||||
appservice token and 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
|
||||
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
|
||||
|
|
@ -212,34 +208,19 @@ Nothing to do, and no window where the hive is without an account.
|
|||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
- **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
|
||||
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
|
||||
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`
|
||||
reads the new per-hive store path **first, on every sweep**, not just when
|
||||
the file is missing (`sender_source`'s decision). If a per-hive token is
|
||||
already there — `swarm-controller` mints one for every hive — the sweep
|
||||
takes it and overwrites the file, so the shared token stops being served
|
||||
as soon as one exists in the store, no boot required. If the store has
|
||||
nothing yet, the file is left untouched (still the shared token, right
|
||||
after the upgrade), so nothing breaks mid-sweep. Only when neither the
|
||||
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 hive switches on the next sweep.** `ensure_hive_user` reads the
|
||||
per-hive store path **first, on every sweep**, not just when the file is
|
||||
missing (`sender_source`'s decision). `swarm-controller` mints a token
|
||||
there for every hive within five minutes; the sweep takes it and
|
||||
overwrites the file, so the shared token stops being served as soon as
|
||||
one exists in the store, no boot required. While the store has nothing
|
||||
yet, the file is left untouched (still the shared token, right after the
|
||||
upgrade), so nothing breaks mid-sweep.
|
||||
- **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.
|
||||
`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 -->
|
||||
|
||||
| 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/<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/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>/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/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>/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 |
|
||||
| _(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 |
|
||||
| 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/<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/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>/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/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 | `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/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 |
|
||||
|
||||
<!-- 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 reconcile-config`↴](#hivectl-forge-reconcile-config)
|
||||
* [`hivectl matrix`↴](#hivectl-matrix)
|
||||
* [`hivectl matrix sync-admin`↴](#hivectl-matrix-sync-admin)
|
||||
* [`hivectl matrix invite`↴](#hivectl-matrix-invite)
|
||||
* [`hivectl github`↴](#hivectl-github)
|
||||
* [`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:**
|
||||
|
||||
* `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
|
||||
* `gateway` — Gateway htpasswd user management
|
||||
* `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`
|
||||
|
||||
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>`
|
||||
|
||||
###### **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
|
||||
|
||||
|
||||
|
||||
## `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`
|
||||
|
||||
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`).
|
||||
|
||||
```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: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`
|
||||
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
|
||||
localpart, qualified with the homeserver's `server_name`) to the hive
|
||||
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) +
|
||||
//! per-agent account creation → `<agent-state>/matrix-token`. No-op
|
||||
//! when the `hive-matrix` container isn't running, so operators who
|
||||
//! haven't flipped `services.hyperhive.deploy.matrix.enable = true` pay
|
||||
//! nothing.
|
||||
//! Optional matrix-tuwunel wiring: the hive's `@hive-<hive>:` sender token,
|
||||
//! taken from the swarm secret store, and the hive Space, chat room and
|
||||
//! invites provisioned with it. No-op when no homeserver is configured, so
|
||||
//! operators who haven't flipped `services.hyperhive.deploy.matrix.enable =
|
||||
//! true` pay nothing.
|
||||
//!
|
||||
//! Accounts are created **as the hive's appservice**, not by presenting a
|
||||
//! shared registration token in a UIAA flow. The difference that matters
|
||||
//! 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.
|
||||
//! This module creates no accounts: `swarm-controller` mints every hive's
|
||||
//! sender account and every agent's account with the swarm's appservice token.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
|
@ -54,14 +46,8 @@ fn matrix_base() -> Result<&'static str> {
|
|||
this path should have been gated on matrix::is_present()",
|
||||
)
|
||||
}
|
||||
/// HTTP timeout for registration round-trips. Account creation is one
|
||||
/// POST; even the slow path should finish well inside this budget.
|
||||
/// HTTP timeout for the sweep's homeserver round-trips.
|
||||
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.
|
||||
///
|
||||
|
|
@ -124,14 +110,6 @@ pub fn sender_token_path() -> PathBuf {
|
|||
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.
|
||||
/// Outside every purgeable path — not deleted by `destroy --purge`.
|
||||
#[must_use]
|
||||
|
|
@ -159,325 +137,39 @@ pub fn is_present() -> bool {
|
|||
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.
|
||||
/// Only `:` needs encoding; `!` and alphanumerics are path-safe.
|
||||
fn encode_room_id_for_url(room_id: &str) -> String {
|
||||
room_id.replace(':', "%3A")
|
||||
}
|
||||
|
||||
/// Ensure the hive's `@hive-<hive>:` matrix user exists and that its access token is
|
||||
/// persisted at [`sender_token_path()`].
|
||||
/// Bring the hive's `@hive-<hive>:` sender token at [`sender_token_path()`] in
|
||||
/// line with the swarm secret store.
|
||||
///
|
||||
/// **Nothing here depends on registration order, and nothing here is
|
||||
/// privileged.** The account used to have to be the first ever
|
||||
/// registered, to win tuwunel's automatic first-user grant — a rule that
|
||||
/// cannot fire for an appservice-created account at all. It is now an
|
||||
/// ordinary account: the homeserver creates it because it is the
|
||||
/// 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`.
|
||||
/// `swarm-controller` is the only minter: it creates the account with the
|
||||
/// swarm's appservice token, keeps the stored token while it is live and
|
||||
/// replaces it when it is not. The file follows the store, and is kept as it
|
||||
/// is when the store has nothing or cannot be reached, so a store outage does
|
||||
/// not take the hive's matrix provisioning down with it.
|
||||
///
|
||||
/// # Errors
|
||||
/// When no token can be had — nothing in the store or the file, and no
|
||||
/// `as_token` — or when a step of the mint ladder or the file write fails.
|
||||
pub async fn ensure_hive_user(client: &reqwest::Client, as_token: Option<&str>) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
/// When neither the store nor the file holds a token, or the file write fails.
|
||||
pub async fn ensure_hive_user() -> Result<()> {
|
||||
let path = sender_token_path();
|
||||
let on_disk = std::fs::read_to_string(&path).ok();
|
||||
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 => {
|
||||
tracing::debug!("matrix: the sender token is already present");
|
||||
return Ok(());
|
||||
Ok(())
|
||||
}
|
||||
SenderSource::Store(token) => return persist_sender_token(&path, token),
|
||||
SenderSource::Mint(as_token) => as_token,
|
||||
SenderSource::Store(token) => persist_sender_token(&path, token),
|
||||
SenderSource::Unavailable => anyhow::bail!(
|
||||
"matrix: no sender token in the swarm store or at {}, and no appservice \
|
||||
token on this host to mint one with",
|
||||
"matrix: no sender token in the swarm store or at {}; swarm-controller \
|
||||
mints it into the store",
|
||||
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.
|
||||
|
|
@ -487,39 +179,27 @@ enum SenderSource<'a> {
|
|||
Keep,
|
||||
/// Write the store's token to the file.
|
||||
Store(&'a str),
|
||||
/// Mint one with this hive's own appservice token.
|
||||
Mint(&'a str),
|
||||
/// Nothing to take and nothing to mint with.
|
||||
/// No token in the store or the file.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Decide [`ensure_hive_user`]'s step from the store's token, the file's
|
||||
/// content and this hive's `as_token`, each `None` when absent. Blank counts
|
||||
/// as absent.
|
||||
fn sender_source<'a>(
|
||||
stored: Option<&'a str>,
|
||||
on_disk: Option<&str>,
|
||||
as_token: Option<&'a str>,
|
||||
) -> SenderSource<'a> {
|
||||
/// Decide [`ensure_hive_user`]'s step from the store's token and the file's
|
||||
/// content, each `None` when absent. Blank counts as absent.
|
||||
fn sender_source<'a>(stored: Option<&'a str>, on_disk: Option<&str>) -> SenderSource<'a> {
|
||||
let present = |s: &&str| !s.trim().is_empty();
|
||||
let stored = stored.map(str::trim).filter(present);
|
||||
let on_disk = on_disk.map(str::trim).filter(present);
|
||||
match (stored, on_disk, as_token.filter(present)) {
|
||||
(Some(s), Some(d), _) if s == d => SenderSource::Keep,
|
||||
(Some(s), _, _) => SenderSource::Store(s),
|
||||
(None, Some(_), _) => SenderSource::Keep,
|
||||
(None, None, Some(a)) => SenderSource::Mint(a),
|
||||
(None, None, None) => SenderSource::Unavailable,
|
||||
match (stored, on_disk) {
|
||||
(Some(s), Some(d)) if s == d => SenderSource::Keep,
|
||||
(Some(s), _) => SenderSource::Store(s),
|
||||
(None, Some(_)) => SenderSource::Keep,
|
||||
(None, None) => SenderSource::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the appservice sender account's access token to `path`, 0600, creating the
|
||||
/// directory if it is not there.
|
||||
///
|
||||
/// 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.
|
||||
/// directory if it is not there. The file's mode is the only thing keeping an
|
||||
/// unprivileged reader off the hive's matrix credential.
|
||||
fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
|
|
@ -533,8 +213,8 @@ fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch the sender token `swarm-controller` (or `swarm-matrix-ctl`)
|
||||
/// published, under this hive's own store identity.
|
||||
/// Fetch the sender token `swarm-controller` published, under this hive's own
|
||||
/// store identity.
|
||||
///
|
||||
/// 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
|
||||
|
|
@ -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
|
||||
/// 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
|
||||
/// way"), and three of them are the ordinary state of a swarm with no store
|
||||
/// token for this hive yet, so raising would turn a supported deployment into
|
||||
/// a warning every sweep.
|
||||
/// four mean the same thing to the caller ("keep the file"), and three of
|
||||
/// them are the ordinary state of a swarm with no store token for this hive
|
||||
/// yet, so raising would turn a supported deployment into a warning every
|
||||
/// sweep.
|
||||
///
|
||||
/// 🩸 Logs the store **path** and never the value.
|
||||
async fn stored_sender_token() -> Option<String> {
|
||||
|
|
@ -1279,12 +959,6 @@ pub async fn ensure_all() -> bool {
|
|||
return 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.
|
||||
let client = match reqwest::Client::builder()
|
||||
.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
|
||||
// its token) — as an ordinary user that created those rooms, not as a
|
||||
// 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");
|
||||
ok = false;
|
||||
}
|
||||
|
|
@ -1413,18 +1087,15 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_remote_hive_takes_the_store_token_without_an_appservice_token() {
|
||||
assert_eq!(
|
||||
sender_source(Some("tok"), None, None),
|
||||
SenderSource::Store("tok")
|
||||
);
|
||||
fn with_no_file_the_store_token_is_taken() {
|
||||
assert_eq!(sender_source(Some("tok"), None), SenderSource::Store("tok"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_store_token_replaces_a_different_file_token() {
|
||||
// The swarm re-minted a dead token; the file must follow it.
|
||||
assert_eq!(
|
||||
sender_source(Some("new\n"), Some("old\n"), Some("as")),
|
||||
sender_source(Some("new\n"), Some("old\n")),
|
||||
SenderSource::Store("new")
|
||||
);
|
||||
}
|
||||
|
|
@ -1433,40 +1104,23 @@ mod tests {
|
|||
fn a_file_matching_the_store_is_kept() {
|
||||
// Trailing newline on disk is how `persist_sender_token` writes it.
|
||||
assert_eq!(
|
||||
sender_source(Some("tok"), Some("tok\n"), None),
|
||||
sender_source(Some("tok"), Some("tok\n")),
|
||||
SenderSource::Keep
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_nothing_in_the_store_the_file_is_kept() {
|
||||
assert_eq!(
|
||||
sender_source(None, Some("tok\n"), Some("as")),
|
||||
SenderSource::Keep
|
||||
);
|
||||
assert_eq!(sender_source(None, Some("tok\n")), SenderSource::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_no_token_anywhere_the_appservice_token_mints() {
|
||||
fn with_no_token_anywhere_nothing_is_available() {
|
||||
assert_eq!(
|
||||
sender_source(None, Some(" \n"), Some("as")),
|
||||
SenderSource::Mint("as")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_no_token_and_no_appservice_token_nothing_is_available() {
|
||||
assert_eq!(
|
||||
sender_source(Some(""), None, Some("")),
|
||||
sender_source(Some(""), Some(" \n")),
|
||||
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()));
|
||||
assert_eq!(sender_source(None, None), SenderSource::Unavailable);
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
|
||||
#[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
|
||||
/// roots) is needed to drive the response-handling halves.
|
||||
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;
|
||||
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/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.
|
||||
#[must_use]
|
||||
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.
|
||||
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).
|
||||
#[must_use]
|
||||
pub fn runtime_root() -> PathBuf {
|
||||
|
|
@ -324,13 +305,12 @@ pub fn agent_runtime_dir(name: &str) -> PathBuf {
|
|||
/// within the same filesystem is atomic.
|
||||
pub fn relocate_legacy_state() {
|
||||
let root = state_root();
|
||||
let moves: [(&str, PathBuf); 7] = [
|
||||
let moves: [(&str, PathBuf); 6] = [
|
||||
("broker.sqlite", db_dir().join("broker.sqlite")),
|
||||
("build_logs.sqlite", db_dir().join("build_logs.sqlite")),
|
||||
("forge-core-avatar-set", forge_core_avatar_marker()),
|
||||
("matrix-sender-token", matrix_sender_token()),
|
||||
("matrix-space-room-id", matrix_space_room_id()),
|
||||
("matrix-creds", matrix_creds_dir()),
|
||||
("agent-sockets.json", agent_sockets_file()),
|
||||
];
|
||||
for (old_rel, new) in &moves {
|
||||
|
|
|
|||
|
|
@ -206,7 +206,6 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
|||
)
|
||||
.await?
|
||||
}
|
||||
HostRequest::MatrixSyncAdmin => handle_matrix_sync_admin().await?,
|
||||
HostRequest::MatrixInvite { user, room } => {
|
||||
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 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
|
||||
// register + sender tokens and the matrix creds dir. Each op returns the
|
||||
// operator-facing lines hivectl used to `println!` in `HostResponse::messages`
|
||||
// for the client to print verbatim.
|
||||
// They now run daemon-side over the host socket, where the daemon already holds
|
||||
// the sender token. Each op returns the operator-facing lines hivectl used to
|
||||
// `println!` in `HostResponse::messages` for the client to print verbatim.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Shared reqwest client for the matrix admin HTTP calls (30s timeout,
|
||||
|
|
@ -588,24 +586,6 @@ async fn handle_push_snapshot(
|
|||
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> {
|
||||
require_matrix_present()?;
|
||||
let sender_token = crate::matrix::read_sender_token()?;
|
||||
|
|
|
|||
|
|
@ -270,9 +270,6 @@ pub enum HostRequest {
|
|||
#[serde(default)]
|
||||
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
|
||||
/// `room`. Uses the daemon's admin token; idempotent
|
||||
/// (already-member / already-invited is a no-op).
|
||||
|
|
@ -539,8 +536,7 @@ pub struct HostResponse {
|
|||
pub nodes: Option<Vec<hive_jobq_wire::GraphNode>>,
|
||||
/// Free-form operator-facing output lines the client prints verbatim
|
||||
/// (one per line). Carries results a request produced daemon-side that
|
||||
/// have no structured home — e.g. the sender token path from
|
||||
/// `MatrixSyncAdmin`, or the invited room id from `MatrixInvite`.
|
||||
/// have no structured home — e.g. the invited room id from `MatrixInvite`.
|
||||
/// Empty for requests that produce no such output.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub messages: Vec<String>,
|
||||
|
|
|
|||
|
|
@ -36,11 +36,11 @@ pub enum Cmd {
|
|||
#[command(subcommand)]
|
||||
cmd: ForgeCmd,
|
||||
},
|
||||
/// 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.
|
||||
Matrix {
|
||||
#[command(subcommand)]
|
||||
cmd: MatrixCmd,
|
||||
|
|
@ -286,11 +286,6 @@ impl From<ReconcileFrom> for hive_host_sock::ReconcileDirection {
|
|||
|
||||
#[derive(Subcommand)]
|
||||
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
|
||||
/// `--room`. Idempotent.
|
||||
Invite {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
//! `hivectl matrix` — matrix account provisioning verbs. hivectl forwards
|
||||
//! each request to the daemon (which owns the register + sender tokens and
|
||||
//! the matrix creds dir) and renders the reply; it no longer links the
|
||||
//! matrix machinery itself.
|
||||
//! `hivectl matrix` — matrix provisioning verbs. hivectl forwards
|
||||
//! each request to the daemon (which owns the sender token) and renders the
|
||||
//! reply; it no longer links the matrix machinery itself.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
|
|
@ -13,15 +12,14 @@ use crate::cli::MatrixCmd;
|
|||
/// dispatch match so the top-level router stays small.
|
||||
pub(crate) async fn run_matrix_cmd(socket: &Path, cmd: MatrixCmd) -> Result<()> {
|
||||
match cmd {
|
||||
MatrixCmd::SyncAdmin => matrix_sync_admin(socket).await,
|
||||
MatrixCmd::Invite { user, room } => matrix_invite(socket, &user, room.as_deref()).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a matrix provisioning request to the daemon and print the
|
||||
/// operator-facing result lines it returns. The daemon owns the register +
|
||||
/// sender tokens and the matrix creds dir, so hivectl no longer links the
|
||||
/// matrix machinery — it just forwards the request and renders the reply.
|
||||
/// operator-facing result lines it returns. The daemon owns the sender token,
|
||||
/// so hivectl no longer links the matrix machinery — it just forwards the
|
||||
/// request and renders the reply.
|
||||
async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Result<()> {
|
||||
let resp = crate::client::request(socket, req)
|
||||
.await
|
||||
|
|
@ -38,10 +36,6 @@ async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Resu
|
|||
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<()> {
|
||||
matrix_request(
|
||||
socket,
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ let
|
|||
# which already admits it.
|
||||
#
|
||||
# ⚠️ 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
|
||||
# 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
|
||||
# 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
|
||||
# TO the appservice, which with `url = null` is nobody — it exists because
|
||||
# the registration format requires it.
|
||||
|
|
@ -127,18 +127,12 @@ let
|
|||
|
||||
# ── swarm-matrix-ctl ────────────────────────────────────────────────
|
||||
#
|
||||
# The oneshot that publishes the appservice sender account's access token to
|
||||
# the swarm's secret store. It runs INSIDE the container, beside tuwunel,
|
||||
# because the appservice token that authorises the mint is already in here —
|
||||
# `appserviceDir` below is bound read-only precisely so the homeserver can
|
||||
# load it — and minting anywhere else would create a second holder of that
|
||||
# secret, which is the thing this whole arrangement exists to stop.
|
||||
#
|
||||
# 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.
|
||||
# The container's own store identity, which the swarm appservice units below
|
||||
# run under. 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
|
||||
# publishing nothing. Same rule ./swarm-secret-publisher.nix's
|
||||
# `haveClientIdentity` states, for a sharper reason.
|
||||
ctlActive =
|
||||
deployCfg.matrix.ctlBaoClientCertFile != null && deployCfg.matrix.ctlBaoClientKeyFile != null;
|
||||
|
||||
|
|
@ -179,9 +173,9 @@ let
|
|||
# identity on this homeserver: `swarm-controller` creates every agent's
|
||||
# account with its token. Minted INSIDE this container 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
|
||||
# the sender mint: with nobody to publish it, a registration here would be
|
||||
# an admin credential nobody reads.
|
||||
# `appservice publish`, which is why it is gated on matrix-ctl's store
|
||||
# identity: with nobody to publish it, a registration here would be an
|
||||
# admin credential nobody reads.
|
||||
#
|
||||
# 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.
|
||||
|
|
@ -193,13 +187,6 @@ let
|
|||
swarmAppserviceDir = "/var/lib/swarm-matrix-appservice";
|
||||
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>:`
|
||||
# itself — which is the whole matrix localpart charset.
|
||||
#
|
||||
|
|
@ -677,15 +664,14 @@ in
|
|||
default = appserviceTokenPath;
|
||||
description = ''
|
||||
Host path to a file containing this hive's matrix appservice
|
||||
token (`as_token`) — the identity `hive-c0re` creates and logs
|
||||
into accounts with. Minted automatically on first activation
|
||||
token (`as_token`). Minted automatically on first activation
|
||||
(32-byte random hex, mode 0600) and rendered into the
|
||||
appservice registration the homeserver loads at boot. Agents
|
||||
never see it; an agent only ever receives its own
|
||||
`access_token`.
|
||||
|
||||
Not operator-settable — `hive-c0re`'s Rust side derives this
|
||||
same path independently (`paths::matrix_appservice_token()`)
|
||||
Not operator-settable — the module's registration renderer reads
|
||||
this same path as a literal, not through the option,
|
||||
with nothing wiring an override across, so a moved path desyncs
|
||||
the two silently. An externally-managed token is delivered by
|
||||
writing into *this* fixed path instead of moving it — see
|
||||
|
|
@ -1015,8 +1001,8 @@ in
|
|||
assertion = deployCfg.matrix.appserviceTokenFile == appserviceTokenPath;
|
||||
message = ''
|
||||
services.hyperhive.deploy.matrix.appserviceTokenFile is fixed at
|
||||
${appserviceTokenPath} and cannot be moved — hive-c0re's Rust
|
||||
side derives this same path independently and has no way to learn
|
||||
${appserviceTokenPath} and cannot be moved — the registration
|
||||
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
|
||||
loudly.
|
||||
|
||||
|
|
@ -1415,66 +1401,6 @@ in
|
|||
# is why the render below is `requiredBy` it and local only.
|
||||
++ 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
|
||||
# loads it. No network and no store: this is on tuwunel's start
|
||||
# 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
|
||||
# the store. Same identity and retry shape as `swarm-matrix-ctl`
|
||||
# above; the write lands at a path only matrix-ctl and the
|
||||
# controller are granted (./swarm-bao.nix).
|
||||
# the store, under matrix-ctl's identity; the write lands at a path
|
||||
# only matrix-ctl and the 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 {
|
||||
description = "publish the swarm's appservice token to the swarm secret store";
|
||||
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
|
||||
# written by the caller — the same trap as the two grants above.
|
||||
#
|
||||
# Not `swarm/services/*` like the publisher's: this principal produces
|
||||
# exactly one secret, its own hive's matrix sender account access token, and
|
||||
# a homeserver is not entitled to overwrite Grafana's OIDC client. The path
|
||||
# is spelled to the leaf for that reason, not for tidiness.
|
||||
# Not `swarm/services/*` like the publisher's: a homeserver is not entitled
|
||||
# to overwrite Grafana's OIDC client. Each path is spelled to the leaf for
|
||||
# that reason, not for tidiness.
|
||||
#
|
||||
# 🩸 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`
|
||||
|
|
@ -484,16 +483,12 @@ let
|
|||
# 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.
|
||||
#
|
||||
# `read` as well as write, unlike either sibling, and it is what makes "and
|
||||
# only once" mechanical: matrix-ctl's first act is to read this path back and
|
||||
# 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.
|
||||
# ⚠️ Nothing presenting this identity writes the first stanza's path:
|
||||
# `swarm-controller` is the sender token's only minter. The stanza is unused.
|
||||
#
|
||||
# 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
|
||||
# same reason: publish compares before it writes.
|
||||
# inside the container and publishes here for the controller. `read` as well
|
||||
# as write, unlike either sibling, because publish compares before it writes.
|
||||
matrixCtlPolicyText = ''
|
||||
path "${credentialMountPath}/data/swarm/hives/${matrixCtlHive}/matrix/sender-token" {
|
||||
capabilities = ["create", "update", "read"]
|
||||
|
|
@ -504,15 +499,7 @@ let
|
|||
}
|
||||
'';
|
||||
|
||||
# Which hive matrix-ctl mints for. This host's own by default, which is right
|
||||
# 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.
|
||||
# The hive the unused first stanza above names.
|
||||
matrixCtlHive = baoDeploy.matrixCtlHiveName;
|
||||
|
||||
# The swarm appservice token's leaf, the nix half of
|
||||
|
|
@ -1460,8 +1447,8 @@ in
|
|||
example = "swarm-matrix-ctl.svc";
|
||||
description = ''
|
||||
Subject the store's matrix-ctl cert-auth role accepts — the
|
||||
identity the oneshot inside the matrix container presents when it
|
||||
publishes the appservice sender account's access token.
|
||||
identity the matrix container's `swarm-matrix-appservice-publish`
|
||||
unit presents when it publishes the swarm appservice's token.
|
||||
|
||||
A **third** identity rather than reuse of either sibling above, and
|
||||
the narrowest of the three: its grant is one path, that
|
||||
|
|
@ -1632,20 +1619,8 @@ in
|
|||
description = ''
|
||||
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
|
||||
`swarm/hives/<name>/matrix/sender-token`, and the only read grant that
|
||||
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.
|
||||
Unused: nothing presenting that identity writes the sender token;
|
||||
`swarm-controller` is its only minter.
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -451,14 +451,10 @@ let
|
|||
&& !(lib.hasInfix "sys/policies/acl" s);
|
||||
}
|
||||
{
|
||||
# 🩸 `read` is load-bearing here, and the publisher — the one sibling
|
||||
# that still has no `read` — shows what its absence costs. matrix-ctl's
|
||||
# first act is to read this path back and stop if something is there —
|
||||
# that read IS "and only once", so without the capability every container
|
||||
# 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";
|
||||
# 🩸 `read` is load-bearing here, unlike on the publisher: matrix-ctl's
|
||||
# `appservice publish` reads the swarm appservice token back and writes
|
||||
# only when the store's copy differs.
|
||||
name = "matrix-ctl may read back what it writes";
|
||||
ok =
|
||||
let
|
||||
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";
|
||||
ok =
|
||||
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;
|
||||
in
|
||||
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";
|
||||
}
|
||||
{
|
||||
# What the unit is for, read as the two agreements it cannot get wrong:
|
||||
# the cert role ./host-modules/swarm-bao.nix writes, and a homeserver
|
||||
# address that is loopback because the container shares the host netns. A
|
||||
# vhost here would be a request out through the gateway and back.
|
||||
name = "matrix-ctl is handed the store role and the loopback homeserver";
|
||||
# The cert role ./host-modules/swarm-bao.nix writes, which is the one
|
||||
# agreement the unit cannot get wrong, and the verb: a bare invocation
|
||||
# exits non-zero with clap's usage, a deploy-time failure with no local
|
||||
# signal.
|
||||
name = "the publish unit is handed the store role and invokes its verb";
|
||||
ok =
|
||||
let
|
||||
m = baoWithMatrix;
|
||||
u = m.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl;
|
||||
port = m.services.hyperhive.swarm.matrix.httpPort;
|
||||
u = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-appservice-publish;
|
||||
in
|
||||
u.environment.MATRIX_MINT_CERT_ROLE == "swarm-matrix-ctl"
|
||||
&& u.environment.MATRIX_MINT_API_URL == "http://127.0.0.1:${toString port}"
|
||||
&& u.environment.MATRIX_MINT_REGISTRATION == "/var/lib/hyperhive/matrix-appservice/hyperhive.yaml"
|
||||
&& u.serviceConfig.Type == "oneshot";
|
||||
u.environment.MATRIX_APPSERVICE_CERT_ROLE == "swarm-matrix-ctl"
|
||||
&& lib.hasSuffix "/bin/swarm-matrix-ctl appservice publish" u.serviceConfig.ExecStart;
|
||||
}
|
||||
{
|
||||
# 🩸 The per-hive minting identity, read off the rendered unit rather than
|
||||
# off the option: the binary builds its store path out of
|
||||
# `MATRIX_MINT_HIVE` and logs in as `MATRIX_MINT_LOCALPART`, so a unit
|
||||
# that passed the old bare `hive` would publish one identity for the
|
||||
# whole swarm again and nothing in the Rust tests could see it. Both
|
||||
# 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";
|
||||
# 🩸 A secret is a path, never a value. Every variable the unit is given
|
||||
# names a file or an address; the token itself is read out of the state
|
||||
# dir at runtime, so nothing here can be a token and an environment block
|
||||
# is world-readable through `systemctl show`.
|
||||
name = "the publish unit's environment carries paths and addresses, never a token";
|
||||
ok =
|
||||
let
|
||||
m = baoWithMatrix;
|
||||
u = m.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl;
|
||||
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;
|
||||
env =
|
||||
baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-appservice-publish.environment;
|
||||
in
|
||||
!(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
|
||||
# with no store identity at all. Without it the unit would exist naming
|
||||
# `null` as its certificate, which nixos renders as the literal string.
|
||||
name = "a matrix container with no store identity runs no matrix-ctl and binds no PKI";
|
||||
ok =
|
||||
let
|
||||
units = matrixNoBaoIdentity.containers.hive-matrix.config.systemd.services;
|
||||
in
|
||||
!(units ? swarm-matrix-ctl)
|
||||
&& !(matrixNoBaoIdentity.containers.hive-matrix.bindMounts ? "/var/lib/swarm-bao-pki");
|
||||
# with no store identity at all. Without it the mount would name `null`
|
||||
# as its source, which nixos renders as the literal string.
|
||||
name = "a matrix container with no store identity binds no PKI";
|
||||
ok = !(matrixNoBaoIdentity.containers.hive-matrix.bindMounts ? "/var/lib/swarm-bao-pki");
|
||||
}
|
||||
{
|
||||
# 🩸 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:
|
||||
//! created here with the **swarm's** appservice token and stored at
|
||||
//! `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
|
||||
//! elsewhere holds no appservice token, so this is its only sender token.
|
||||
//! reads it under the hive's own store identity.
|
||||
//!
|
||||
//! Runs for every hive in the directory, local or remote, and decides the
|
||||
//! 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
|
||||
//! empty or its token is dead.
|
||||
//!
|
||||
//! ⚠️ `swarm-matrix-ctl mint` also writes this path for the hive whose host
|
||||
//! runs the homeserver, and skips when it is non-empty. Both log in on the
|
||||
//! 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.
|
||||
//! This is the only writer of that path: hive-c0re never mints, it takes
|
||||
//! whatever the store holds.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -158,7 +152,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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!(
|
||||
classify_hive("pr1ma", &Probe::Whoami(Whoami::UnknownToken)),
|
||||
Decision::Mint(MintReason::Revoked)
|
||||
|
|
@ -191,8 +185,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn the_published_path_is_the_one_the_hive_reads() {
|
||||
// hive-c0re's `stored_sender_token` and `swarm-matrix-ctl mint` both
|
||||
// resolve this path; the literal is what the bao grant names.
|
||||
// hive-c0re's `stored_sender_token` resolves this path; the literal is
|
||||
// what the bao grant names.
|
||||
assert_eq!(
|
||||
matrix::sender_token_path("pr1ma").expect("a plain name is legal"),
|
||||
"swarm/hives/pr1ma/matrix/sender-token"
|
||||
|
|
|
|||
|
|
@ -10,13 +10,11 @@ path = "src/main.rs"
|
|||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
# One verb today (`mint`), and the reason this crate is a `*ctl` rather than a
|
||||
# single-purpose binary: the next thing that has to run in the matrix container
|
||||
# is a subcommand here, not a new crate.
|
||||
# The reason this crate is a `*ctl` rather than a single-purpose binary: the
|
||||
# next thing that has to run in the matrix container is a subcommand here, not a
|
||||
# new crate.
|
||||
clap.workspace = true
|
||||
serde_json.workspace = true
|
||||
# The appservice calls, shared with `swarm-controller`, which mints agents'
|
||||
# accounts through the same device id.
|
||||
# The token generator, shared with `swarm-controller`.
|
||||
swarm-matrix-client.workspace = true
|
||||
# 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.
|
||||
|
|
|
|||
|
|
@ -11,52 +11,21 @@ crate.
|
|||
|
||||
## Verbs
|
||||
|
||||
### `mint`
|
||||
### `appservice render`
|
||||
|
||||
Puts the appservice sender account's access token into the swarm's secret store,
|
||||
under an identity of its own. A boot-time oneshot.
|
||||
Mints the swarm appservice registration's tokens when absent and renders the
|
||||
registration tuwunel loads. Runs before the homeserver and needs no network.
|
||||
|
||||
Configured entirely by the `MATRIX_MINT_*` environment the unit sets — no flags.
|
||||
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.
|
||||
### `appservice publish`
|
||||
|
||||
## 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
|
||||
container already holds that: `nix/host-modules/hive-matrix.nix` bind-mounts the
|
||||
rendered appservice registration into it read-only, because that is how tuwunel
|
||||
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.
|
||||
Both are configured entirely by the `MATRIX_APPSERVICE_*` environment the units
|
||||
set — no flags. A systemd `Environment=` block is what a nix module can render; a
|
||||
command line full of paths is not.
|
||||
|
||||
## 🩸 A secret is a path, never a value
|
||||
|
||||
Nothing here logs, prints or interpolates a token. The mint ladder's errors are
|
||||
built from the homeserver's _status_ and its `errcode`, never its body, because a
|
||||
`/login` response body is an access token. The one identifier this binary logs is
|
||||
the store path it wrote.
|
||||
Nothing here logs, prints or interpolates a 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
|
||||
//! here is a verb below, not a new crate.
|
||||
//!
|
||||
//! [`mint`] publishes a hive's appservice sender token to the swarm's secret
|
||||
//! store, once. [`appservice`] mints the **swarm's** own appservice
|
||||
//! 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.
|
||||
//! [`appservice`] mints the **swarm's** own appservice registration and
|
||||
//! publishes its token for `swarm-controller`.
|
||||
//!
|
||||
//! 🩸 **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
|
||||
//! applied to error messages.
|
||||
|
||||
mod appservice;
|
||||
mod mint;
|
||||
mod registration;
|
||||
|
||||
use anyhow::Result;
|
||||
|
|
@ -38,13 +32,6 @@ struct Cli {
|
|||
|
||||
#[derive(Debug, Subcommand)]
|
||||
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
|
||||
/// homeserver's admin account. Configured by `MATRIX_APPSERVICE_*`.
|
||||
#[command(subcommand)]
|
||||
|
|
@ -71,7 +58,6 @@ async fn main() -> Result<()> {
|
|||
.init();
|
||||
|
||||
match Cli::parse().command {
|
||||
Command::Mint => mint::run().await,
|
||||
Command::Appservice(Appservice::Render) => appservice::render(),
|
||||
Command::Appservice(Appservice::Publish) => appservice::publish().await,
|
||||
}
|
||||
|
|
@ -87,17 +73,8 @@ mod tests {
|
|||
Cli::command().debug_assert();
|
||||
}
|
||||
|
||||
/// The unit's `ExecStart` names a verb, so a rename of it is a 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.
|
||||
/// The two units name these verbs in `ExecStart`, so a rename of one is a
|
||||
/// deploy-time failure with no local signal. This is that signal.
|
||||
#[test]
|
||||
fn the_appservice_verbs_are_spelled_the_way_the_units_invoke_them() {
|
||||
let cli =
|
||||
|
|
@ -122,9 +99,9 @@ mod tests {
|
|||
.expect_err("only declared verbs are accepted");
|
||||
}
|
||||
|
||||
/// A bare invocation must not silently do something. `mint` writes a
|
||||
/// credential, so "no verb" defaulting to it would make a typo in the unit
|
||||
/// mint rather than fail.
|
||||
/// A bare invocation must not silently do something. `appservice publish`
|
||||
/// writes a credential, so "no verb" defaulting to it would make a typo in
|
||||
/// the unit write rather than fail.
|
||||
#[test]
|
||||
fn no_verb_at_all_is_refused() {
|
||||
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]
|
||||
fn the_localpart_is_derived_from_the_hive_name() {
|
||||
// The literal is the point: `nix/host-modules/hive-matrix.nix` renders
|
||||
// the same string into the registration's `sender_localpart` and into
|
||||
// `MATRIX_MINT_LOCALPART`, and nothing wires an override across — so a
|
||||
// change here is a change there.
|
||||
// the same string into the registration's `sender_localpart`, and
|
||||
// nothing wires an override across — so a change here is a change
|
||||
// there.
|
||||
assert_eq!(hive_localpart("pr1ma"), "hive-pr1ma");
|
||||
assert_ne!(
|
||||
hive_localpart("pr1ma"),
|
||||
|
|
|
|||
Loading…
Reference in a new issue