Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-09-29 23:49:57 +02:00 • committed by mara
commit ddb7d7196d
22 changed files with 187 additions and 1162 deletions

1
Cargo.lock generated
View file

@ -4840,7 +4840,6 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
"serde_json",
"swarm-matrix-client", "swarm-matrix-client",
"swarm-secret-client", "swarm-secret-client",
"tokio", "tokio",

View file

@ -280,9 +280,6 @@ control: [`swarm/ui.md`](../swarm/ui.md).
### 6 · Matrix ### 6 · Matrix
```bash ```bash
# Ensure the appservice's sender account exists first
hivectl matrix sync-admin
# Invite the operator to the hive Space (and optionally to rooms) # Invite the operator to the hive Space (and optionally to rooms)
hivectl matrix invite mara hivectl matrix invite mara
hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver' hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver'

View file

@ -93,9 +93,8 @@ delegation (the latter lives in `gateway.md::Discovery flow`).
## Provisioning flow (appservice) ## Provisioning flow (appservice)
<!-- vale write-good.Passive = NO --> <!-- vale write-good.Passive = NO -->
Registration is closed. The hive's own **appservice** creates accounts: Registration is closed. **Appservices** create accounts: agents never see
hive-c0re holds the appservice token, agents never see an appservice token, and an agent only ever receives its own `access_token`.
it, and an agent only ever receives its own `access_token`.
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->
The appservice has no URL (`url: null` in its registration), so the The appservice has no URL (`url: null` in its registration), so the
@ -110,7 +109,7 @@ a token.
spec-required `hs_token` sibling, mode `0600 root:root`, then renders spec-required `hs_token` sibling, mode `0600 root:root`, then renders
the registration to the registration to
`/var/lib/hyperhive/matrix-appservice/hyperhive.yaml` (also `0600`). `/var/lib/hyperhive/matrix-appservice/hyperhive.yaml` (also `0600`).
hive-c0re mints the tokens only when missing; the registration is The script mints the tokens only when missing; the registration is
re-rendered every time, because the token file can be overwritten in re-rendered every time, because the token file can be overwritten in
place by the swarm secret store and a registration naming a stale place by the swarm secret store and a registration naming a stale
token authenticates nobody. Runs at activation time, before any token authenticates nobody. Runs at activation time, before any
@ -129,10 +128,8 @@ a token.
the bind-mount path. It reads only `.yaml`/`.yml` entries from there, the bind-mount path. It reads only `.yaml`/`.yml` entries from there,
so the sibling credentials are invisible to it. The `.yaml` suffix on so the sibling credentials are invisible to it. The `.yaml` suffix on
the credential id is what makes this work. the credential id is what makes this work.
5. **hive-c0re** reads the appservice token and creates its own 5. **hive-c0re** doesn't read the appservice token and creates no
`@hive-<hive>:` account. It never mints the token itself: the value account. Its `@hive-<hive>:` token comes from the swarm store (below).
has to be the one the rendered registration names, and only the nix
side writes that.
6. **Agents' accounts aren't this hive's.** `swarm-controller` creates each 6. **Agents' accounts aren't this hive's.** `swarm-controller` creates each
one with the swarm's own appservice token (next section), stores its one with the swarm's own appservice token (next section), stores its
token at `swarm/agents/<agent>/matrix/main`, and the agent's token at `swarm/agents/<agent>/matrix/main`, and the agent's
@ -183,9 +180,8 @@ hive's standing leaves the others alone.
Its access token is the **sender token**, and it's the credential Its access token is the **sender token**, and it's the credential
hive-c0re presents for every homeserver call it makes on the hive's hive-c0re presents for every homeserver call it makes on the hive's
behalf. It's per hive for the same reason the account is: behalf. It's per hive for the same reason the account is:
`swarm-controller` mints it for every hive with the swarm's appservice token (and `swarm-controller`, its only minter, mints it for every hive with the swarm's
`swarm-matrix-ctl`, inside the `hive-matrix` container, for its own hive) and appservice token and publishes it to `swarm/hives/<hive>/matrix/sender-token`, and the hive
publishes it to `swarm/hives/<hive>/matrix/sender-token`, and the hive
reads it from there under its own certificate. That path sits inside the reads it from there under its own certificate. That path sits inside the
hive's own read grant (`swarm/hives/<hive>/*`), so a hive fetches its own hive's own read grant (`swarm/hives/<hive>/*`), so a hive fetches its own
token and gets a refusal on any other hive's. The name says what it token and gets a refusal on any other hive's. The name says what it
@ -212,34 +208,19 @@ Nothing to do, and no window where the hive is without an account.
<!-- vale write-good.Passive = NO --> <!-- vale write-good.Passive = NO -->
- **The old shared value at `swarm/services/matrix/sender-token` is read by - **The old shared value at `swarm/services/matrix/sender-token` is read by
nothing.** `hive-c0re` and `swarm-matrix-ctl` both build the path from the nothing.** `hive-c0re` and `swarm-controller` both build the path from the
same function, and it now carries the hive's name — so the old object same function, and it now carries the hive's name — so the old object
stays in the store, unread, until an operator deletes it. Delete it or stays in the store, unread, until an operator deletes it. Delete it or
leave it; the accounts it authenticates as keep their own standing either leave it; the accounts it authenticates as keep their own standing either
way, since an access token lives on the device that minted it. way, since an access token lives on the device that minted it.
- **The hive re-mints, per hive, on the next sweep.** `ensure_hive_user` - **The hive switches on the next sweep.** `ensure_hive_user` reads the
reads the new per-hive store path **first, on every sweep**, not just when per-hive store path **first, on every sweep**, not just when the file is
the file is missing (`sender_source`'s decision). If a per-hive token is missing (`sender_source`'s decision). `swarm-controller` mints a token
already there — `swarm-controller` mints one for every hive — the sweep there for every hive within five minutes; the sweep takes it and
takes it and overwrites the file, so the shared token stops being served overwrites the file, so the shared token stops being served as soon as
as soon as one exists in the store, no boot required. If the store has one exists in the store, no boot required. While the store has nothing
nothing yet, the file is left untouched (still the shared token, right yet, the file is left untouched (still the shared token, right after the
after the upgrade), so nothing breaks mid-sweep. Only when neither the upgrade), so nothing breaks mid-sweep.
store nor the file holds anything does the sweep fall through to the
register-or-appservice-login ladder against `@hive-<hive>:`, using only
the `as_token`, which is per hive and on local disk, so that step works
with or without a reachable store.
- **To move a hive onto its own account now**, clear both copies: the
sender-token file, and the store's `swarm/hives/<hive>/matrix/sender-token`
if `swarm-controller` or `swarm-matrix-ctl` has already published one for
it (otherwise the next sweep just re-adopts that value instead of minting
a new one). With both empty, the next sweep — or `hivectl matrix
sync-admin` — runs the ladder, registers `@hive-<hive>:`, and persists
that account's token to the file. The ladder never writes the store — on
a swarm that runs `swarm-controller`, its own mint pass will reach the
same account on its next tick and write a token there too, on the same
pinned device, which replaces whichever token was minted last. Only once
both copies agree does the hive stop presenting the shared one.
- **The rooms the shared account created don't follow the new account, and - **The rooms the shared account created don't follow the new account, and
this is the one step that needs a decision.** Membership is per account. this is the one step that needs a decision.** Membership is per account.
`ensure_hive_space` takes the stored room id first, so the sweep hands the `ensure_hive_space` takes the stored room id first, so the sweep hands the

View file

@ -59,20 +59,20 @@ of the cell says how.
<!-- vale write-good.Passive = NO --> <!-- vale write-good.Passive = NO -->
| store path | minter | reader — pulls at runtime, holds in memory | automatic re-mint | automatic re-pull | | store path | minter | reader — pulls at runtime, holds in memory | automatic re-mint | automatic re-pull |
| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `swarm/agents/<agent>/matrix/main` | `swarm-controller`, with the swarm's appservice token, at agent creation and in a five-minute pass | the agent container itself, under the certificate its hive passed in | ✅ the pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ `hive-matrix-daemon` exits when the homeserver rejects its token, and a five-minute timer restarts it, which reads the store again | | `swarm/agents/<agent>/matrix/main` | `swarm-controller`, with the swarm's appservice token, at agent creation and in a five-minute pass | the agent container itself, under the certificate its hive passed in | ✅ the pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ `hive-matrix-daemon` exits when the homeserver rejects its token, and a five-minute timer restarts it, which reads the store again |
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | must be stated | | `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | must be stated |
| `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl`, inside the `hive-matrix` container, once | `swarm-controller`, under its own certificate | ❌ `swarm-matrix-ctl` mints it once; the container keeps its copy and republishes it when the store's differs | ✅ the controller reads it on every five-minute matrix pass | | `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl`, inside the `hive-matrix` container, once | `swarm-controller`, under its own certificate | ❌ `swarm-matrix-ctl` mints it once; the container keeps its copy and republishes it when the store's differs | ✅ the controller reads it on every five-minute matrix pass |
| `swarm/controller/swarm-controller/oidc/client` | authelia, at its first boot, where the controller registers its client; `swarm-secret-publish` copies it in | `swarm-controller`, under its own certificate, once at start | ❌ authelia mints it once. A re-mint is republished by `swarm-secret-publish`'s path unit | ❌ read once at start; the controller holds the old value until it restarts | | `swarm/controller/swarm-controller/oidc/client` | authelia, at its first boot, where the controller registers its client; `swarm-secret-publish` copies it in | `swarm-controller`, under its own certificate, once at start | ❌ authelia mints it once. A re-mint is republished by `swarm-secret-publish`'s path unit | ❌ read once at start; the controller holds the old value until it restarts |
| `swarm/agents/<agent>/bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), which generates the key, at `swarm-controller`'s request at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | ✅ `swarm-controller`'s five-minute pass re-issues a live agent's leaf once it's past half its validity (45 of 90 days, read from the certificate itself) | ❌ `hive-c0re` reads it when it writes the container config, so the agent presents a new leaf from its next start; the old leaf stays valid until it expires | | `swarm/agents/<agent>/bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), which generates the key, at `swarm-controller`'s request at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | ✅ `swarm-controller`'s five-minute pass re-issues a live agent's leaf once it's past half its validity (45 of 90 days, read from the certificate itself) | ❌ `hive-c0re` reads it when it writes the container config, so the agent presents a new leaf from its next start; the old leaf stays valid until it expires |
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | `hive-agent` in the agent container, under the agent's own certificate, held in memory — the identity it presents to the swarm queue, naming that one agent rather than its hive | ✅ `swarm-controller`'s five-minute pass re-mints a live agent's secret once it's 45 days old by `minted_at` on the stored object; a secret with no `minted_at` gets one stamped, value unchanged. The pass skips agents declared `Destroyed` — declaring an agent destroyed deletes every version of the path instead, the undo of the mint rather than another one | ✅ `hive-agent` reads the path before its first connect and again on every reconnect attempt, so a reconnect after a re-mint presents the new secret. An open connection keeps the secret it connected with; after a revocation the agent keeps retrying under the queue client's backoff | | `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | `hive-agent` in the agent container, under the agent's own certificate, held in memory — the identity it presents to the swarm queue, naming that one agent rather than its hive | ✅ `swarm-controller`'s five-minute pass re-mints a live agent's secret once it's 45 days old by `minted_at` on the stored object; a secret with no `minted_at` gets one stamped, value unchanged. The pass skips agents declared `Destroyed` — declaring an agent destroyed deletes every version of the path instead, the undo of the mint rather than another one | ✅ `hive-agent` reads the path before its first connect and again on every reconnect attempt, so a reconnect after a re-mint presents the new secret. An open connection keeps the secret it connected with; after a revocation the agent keeps retrying under the queue client's backoff |
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | ✅ the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the agent re-fetches on a 10-minute timer | | `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | ✅ the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the agent re-fetches on a 10-minute timer |
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | must be stated | | `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | must be stated |
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, with the swarm's appservice token, for every hive in its directory in a five-minute pass; also `swarm-matrix-ctl` in the `hive-matrix` container, for its own hive, when the path is empty | `swarm-controller` and `swarm-matrix-ctl` under their own certificates, before they decide whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ hive-c0re's matrix sweep reads the store every run and overwrites its token file when the store's token differs | | `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, with the swarm's appservice token, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ hive-c0re's matrix sweep reads the store every run and overwrites its token file when the store's token differs |
| `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated | | `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated |
| `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | must be stated | | `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | must be stated |
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | must be stated | | _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | must be stated |
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->

View file

@ -8,7 +8,6 @@ This document contains the help content for the `hivectl` command-line program.
* [`hivectl forge`↴](#hivectl-forge) * [`hivectl forge`↴](#hivectl-forge)
* [`hivectl forge reconcile-config`↴](#hivectl-forge-reconcile-config) * [`hivectl forge reconcile-config`↴](#hivectl-forge-reconcile-config)
* [`hivectl matrix`↴](#hivectl-matrix) * [`hivectl matrix`↴](#hivectl-matrix)
* [`hivectl matrix sync-admin`↴](#hivectl-matrix-sync-admin)
* [`hivectl matrix invite`↴](#hivectl-matrix-invite) * [`hivectl matrix invite`↴](#hivectl-matrix-invite)
* [`hivectl github`↴](#hivectl-github) * [`hivectl github`↴](#hivectl-github)
* [`hivectl github set-token`↴](#hivectl-github-set-token) * [`hivectl github set-token`↴](#hivectl-github-set-token)
@ -64,7 +63,7 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that
###### **Subcommands:** ###### **Subcommands:**
* `forge` — Reconcile an agent's config between this hive and the forge * `forge` — Reconcile an agent's config between this hive and the forge
* `matrix` — matrix-tuwunel user provisioning * `matrix` — matrix-tuwunel invites
* `github` — GitHub account provisioning * `github` — GitHub account provisioning
* `gateway` — Gateway htpasswd user management * `gateway` — Gateway htpasswd user management
* `agent` — Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running * `agent` — Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running
@ -129,29 +128,18 @@ Always prints the diff first. `--from forge` resets the local checkout to forge
## `hivectl matrix` ## `hivectl matrix`
matrix-tuwunel user provisioning. matrix-tuwunel invites.
Manual entry point to the same idempotent provisioning c0re runs at boot — for re-registering an agent the boot sweep skipped, or after wiping a token file. Manual invites into the hive Space and its rooms; c0re's periodic matrix sweep provisions the Space, the chat room and the agents' invites on its own.
**Usage:** `hivectl matrix <COMMAND>` **Usage:** `hivectl matrix <COMMAND>`
###### **Subcommands:** ###### **Subcommands:**
* `sync-admin` — Provision (or re-provision) the matrix appservice's sender account
* `invite` — Invite a matrix user to the hive Space, or a specific room with `--room`. Idempotent * `invite` — Invite a matrix user to the hive Space, or a specific room with `--room`. Idempotent
## `hivectl matrix sync-admin`
Provision (or re-provision) the matrix appservice's sender account.
Runs automatically on startup; run manually to recover a missing access token.
**Usage:** `hivectl matrix sync-admin`
## `hivectl matrix invite` ## `hivectl matrix invite`
Invite a matrix user to the hive Space, or a specific room with `--room`. Idempotent Invite a matrix user to the hive Space, or a specific room with `--room`. Idempotent

View file

@ -50,7 +50,6 @@ Manual entry to the same idempotent matrix provisioning flow
running (`services.hyperhive.deploy.matrix.enable = true`). running (`services.hyperhive.deploy.matrix.enable = true`).
```bash ```bash
hivectl matrix sync-admin # provision / refresh the appservice's sender account
hivectl matrix invite mara # invite a user to the hive Space hivectl matrix invite mara # invite a user to the hive Space
hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a specific room/alias hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a specific room/alias
``` ```
@ -59,10 +58,6 @@ Human matrix accounts come from SSO, not `hivectl`; matrix homeserver
admin should eventually come from membership in authelia's `admins` admin should eventually come from membership in authelia's `admins`
group; nobody has built that sync yet. group; nobody has built that sync yet.
- `sync-admin`: ensures this hive's appservice sender account
(`@hive-<hive>:<server_name>`, one per hive) exists
(the account `hive-c0re` provisions rooms with). Token persisted to the
access token path. Safe to run again — idempotent.
- `invite`: invites a matrix user (full `@user:server` or a bare - `invite`: invites a matrix user (full `@user:server` or a bare
localpart, qualified with the homeserver's `server_name`) to the hive localpart, qualified with the homeserver's `server_name`) to the hive
Space by default, or to a `--room` id / `#alias`. Uses the sender Space by default, or to a `--room` id / `#alias`. Uses the sender

View file

@ -1,19 +1,11 @@
//! Optional matrix-tuwunel wiring: the hive's appservice identity (host) + //! Optional matrix-tuwunel wiring: the hive's `@hive-<hive>:` sender token,
//! per-agent account creation → `<agent-state>/matrix-token`. No-op //! taken from the swarm secret store, and the hive Space, chat room and
//! when the `hive-matrix` container isn't running, so operators who //! invites provisioned with it. No-op when no homeserver is configured, so
//! haven't flipped `services.hyperhive.deploy.matrix.enable = true` pay //! operators who haven't flipped `services.hyperhive.deploy.matrix.enable =
//! nothing. //! true` pay nothing.
//! //!
//! Accounts are created **as the hive's appservice**, not by presenting a //! This module creates no accounts: `swarm-controller` mints every hive's
//! shared registration token in a UIAA flow. The difference that matters //! sender account and every agent's account with the swarm's appservice token.
//! here is not the round-trip count: an appservice token is an *identity*
//! the homeserver knows, so the secret never has to be the same on both
//! sides of the wire, and account creation does not depend on registration
//! being open to anyone who learns a token.
//!
//! See `docs/integrations/matrix.md::Provisioning flow (appservice)` for the
//! registration file's shape, how its token reaches both halves, and the
//! host/container bind-mount layout.
use std::path::PathBuf; use std::path::PathBuf;
@ -54,14 +46,8 @@ fn matrix_base() -> Result<&'static str> {
this path should have been gated on matrix::is_present()", this path should have been gated on matrix::is_present()",
) )
} }
/// HTTP timeout for registration round-trips. Account creation is one /// HTTP timeout for the sweep's homeserver round-trips.
/// POST; even the slow path should finish well inside this budget.
const HTTP_TIMEOUT_SECS: u64 = 10; const HTTP_TIMEOUT_SECS: u64 = 10;
/// Length (bytes) of the throwaway per-agent matrix password. Random
/// 32-byte hex — agents never log in with the password (they
/// authenticate by `access_token`), so it's protocol overhead. We
/// store it nowhere.
const PASSWORD_BYTES: usize = 32;
/// Matrix localpart this hive acts as. Not an agent; has no state dir. /// Matrix localpart this hive acts as. Not an agent; has no state dir.
/// ///
@ -124,14 +110,6 @@ pub fn sender_token_path() -> PathBuf {
crate::paths::matrix_sender_token() crate::paths::matrix_sender_token()
} }
/// Password file for the hive's own `@hive-<hive>:` account. Stored OUTSIDE
/// the purgeable `agent_state_root` tree so it survives `destroy --purge`.
///
/// Path: `/var/lib/hyperhive/matrix/creds/<name>-password`
fn password_path(name: &str) -> PathBuf {
crate::paths::matrix_creds_dir().join(format!("{name}-password"))
}
/// Host path where the hive Matrix Space room ID is persisted. /// Host path where the hive Matrix Space room ID is persisted.
/// Outside every purgeable path — not deleted by `destroy --purge`. /// Outside every purgeable path — not deleted by `destroy --purge`.
#[must_use] #[must_use]
@ -159,325 +137,39 @@ pub fn is_present() -> bool {
matrix_http().is_some() matrix_http().is_some()
} }
/// Read `n` cryptographic-quality bytes from `/dev/urandom` and return
/// them hex-encoded. Avoids pulling a workspace `rand` dep just for
/// 32 bytes of randomness; the kernel's CSPRNG is more than enough for
/// a long-lived shared secret on the same host.
fn random_hex(n: usize) -> Result<String> {
use std::io::Read;
let mut buf = vec![0_u8; n];
let mut f = std::fs::File::open("/dev/urandom").context("open /dev/urandom")?;
f.read_exact(&mut buf).context("read /dev/urandom")?;
let mut hex = String::with_capacity(n * 2);
for b in &buf {
use std::fmt::Write as _;
write!(hex, "{b:02x}").ok();
}
Ok(hex)
}
/// Read the hive's appservice token — the `as_token` of the registration
/// the homeserver loaded at boot. Every account this module creates is
/// authorised by it.
///
/// **Reads, never mints**, unlike the registration token it replaced.
/// That token was the whole agreement, so whichever side wrote it first
/// was right; this one has a second half — the registration file naming
/// it, which only the nix side writes. A token minted here would be a
/// token the homeserver has never heard of, and the failure would surface
/// as every request being refused rather than as a missing file.
///
/// # Errors
/// When the file is absent or empty. That means the host activation
/// script has not run on this generation yet; callers log it and leave
/// existing accounts alone rather than trying to proceed.
pub fn read_appservice_token() -> Result<String> {
let path = crate::paths::matrix_appservice_token();
std::fs::read_to_string(&path)
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.with_context(|| {
format!(
"matrix appservice token not found at {} — it is minted by the \
hive-matrix activation script, which also renders the registration \
file naming it; deploy the hive-matrix module (or re-run \
`nixos-rebuild switch`) before provisioning matrix users",
path.display()
)
})
}
/// Build the localpart of a matrix user id for `agent`. Matrix
/// usernames are 1-255 chars from the `[a-z0-9._=-/]` alphabet; agent
/// names already conform (hyperhive enforces a strict subset), so no
/// escaping is needed at the boundary.
fn user_localpart(agent: &str) -> &str {
agent
}
/// Send the registration POST as the appservice and parse the response.
/// Returns `Ok((status, body))` on any completed HTTP round-trip
/// (including the `M_USER_IN_USE` 400 the caller treats as "already
/// exists"); errors only on transport failure.
async fn register_post(
client: &reqwest::Client,
as_token: &str,
body: &serde_json::Value,
) -> Result<(StatusCode, serde_json::Value)> {
let base = matrix_base()?;
let url = format!("{base}/_matrix/client/v3/register");
let resp = client
.post(&url)
.bearer_auth(as_token)
.json(body)
.send()
.await
.context("matrix: POST /register")?;
let status = resp.status();
let json = resp
.json::<serde_json::Value>()
.await
.context("matrix: parse /register response")?;
Ok((status, json))
}
/// Generate a throwaway random password for matrix UIAA registration.
/// `PASSWORD_BYTES` raw bytes ⇒ 64-char hex string. The hive sender
/// account authenticates by `access_token`, so this password is
/// protocol overhead never used for login.
pub fn random_password() -> Result<String> {
random_hex(PASSWORD_BYTES)
}
/// Create the matrix account for `agent` as the hive's appservice and
/// return an access token for it. One round-trip: an appservice-typed
/// registration needs no UIAA stage at all, so there is no session to
/// carry and no shared secret to present.
///
/// The account is created **by** the appservice but is an ordinary user
/// afterwards — it gets its own device and its own access token, and the
/// agent authenticates with that rather than with anything the hive
/// holds. The `as_token` never leaves the host.
///
/// Its one caller, the hive sender account's provisioning, always passes
/// a [`random_password`] throwaway — the account authenticates by
/// `access_token`, never `m.login.password`.
///
/// # Errors
/// Propagates the homeserver's own body, which is what the
/// `M_USER_IN_USE` callers match on. A `M_EXCLUSIVE` body means the
/// localpart falls outside the appservice's namespace — the registration
/// file's `namespaces.users` regex is the place to look, not this call.
async fn register_user(
client: &reqwest::Client,
agent: &str,
as_token: &str,
password: &str,
) -> Result<String> {
let localpart = user_localpart(agent);
let body = serde_json::json!({
// What makes this an appservice registration rather than an
// ordinary one. Without it the homeserver treats the request as a
// normal client's and asks for a UIAA flow — even holding the
// as_token.
"type": "m.login.application_service",
"username": localpart,
"password": password,
// device_id stays stable across re-runs so a re-mint doesn't
// strand orphan devices in tuwunel.
"device_id": format!("hyperhive-{agent}"),
"initial_device_display_name": format!("hyperhive ({agent})"),
"inhibit_login": false,
});
let (status, body) = register_post(client, as_token, &body).await?;
if !status.is_success() {
anyhow::bail!("matrix: /register as appservice HTTP {status}, body: {body}");
}
extract_access_token(&body)
}
/// Log in as an **existing** account using the hive's appservice token,
/// and return a fresh access token for it. No password involved: the
/// appservice is authorised for every localpart in its namespace, so it
/// can mint a session for one without knowing anything about the account.
///
/// This is the recovery path that used to need a stored password or an
/// admin-room password reset — an account whose token file was lost is
/// re-tokened from the hive's own identity instead. The device id matches
/// [`register_user`]'s, so a re-login replaces that device's token rather
/// than accumulating devices.
async fn appservice_login(client: &reqwest::Client, as_token: &str, agent: &str) -> Result<String> {
let base = matrix_base()?;
let url = format!("{base}/_matrix/client/v3/login");
let body = serde_json::json!({
"type": "m.login.application_service",
"identifier": {
"type": "m.id.user",
"user": user_localpart(agent),
},
"device_id": format!("hyperhive-{agent}"),
"initial_device_display_name": format!("hyperhive ({agent})"),
});
let resp = client
.post(&url)
.bearer_auth(as_token)
.json(&body)
.send()
.await
.context("matrix: POST /login as appservice")?;
let status = resp.status();
let json = resp
.json::<serde_json::Value>()
.await
.context("matrix: parse appservice /login response")?;
if !status.is_success() {
anyhow::bail!("matrix: appservice /login HTTP {status} for {agent}, body: {json}");
}
extract_access_token(&json)
}
/// Pull `access_token` out of a successful /register response.
fn extract_access_token(body: &serde_json::Value) -> Result<String> {
body["access_token"]
.as_str()
.map(str::to_owned)
.with_context(|| format!("matrix: missing access_token in response: {body}"))
}
/// Login with `m.login.password` and return the access token. Fallback
/// for when registration fails with `M_USER_IN_USE` — the account
/// already exists in the homeserver but the token file was lost. Fails
/// if the stored password no longer matches (e.g. homeserver wiped);
/// the only caller is the hive sender account's own recovery path
/// (`hivectl matrix sync-admin`).
async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Result<String> {
let base = matrix_base()?;
let url = format!("{base}/_matrix/client/v3/login");
let body = serde_json::json!({
"type": "m.login.password",
"identifier": {
"type": "m.id.user",
"user": user_localpart(agent),
},
"password": password,
"device_id": format!("hyperhive-{agent}"),
"initial_device_display_name": format!("hyperhive ({agent})"),
});
let resp = client
.post(&url)
.json(&body)
.send()
.await
.context("matrix: POST /login")?;
let status = resp.status();
let json = resp
.json::<serde_json::Value>()
.await
.context("matrix: parse /login response")?;
if !status.is_success() {
anyhow::bail!("matrix: /login HTTP {status} for agent {agent}, body: {json}");
}
extract_access_token(&json)
}
/// Percent-encode a matrix room ID for use in a URL path segment. /// Percent-encode a matrix room ID for use in a URL path segment.
/// Only `:` needs encoding; `!` and alphanumerics are path-safe. /// Only `:` needs encoding; `!` and alphanumerics are path-safe.
fn encode_room_id_for_url(room_id: &str) -> String { fn encode_room_id_for_url(room_id: &str) -> String {
room_id.replace(':', "%3A") room_id.replace(':', "%3A")
} }
/// Ensure the hive's `@hive-<hive>:` matrix user exists and that its access token is /// Bring the hive's `@hive-<hive>:` sender token at [`sender_token_path()`] in
/// persisted at [`sender_token_path()`]. /// line with the swarm secret store.
/// ///
/// **Nothing here depends on registration order, and nothing here is /// `swarm-controller` is the only minter: it creates the account with the
/// privileged.** The account used to have to be the first ever /// swarm's appservice token, keeps the stored token while it is live and
/// registered, to win tuwunel's automatic first-user grant — a rule that /// replaces it when it is not. The file follows the store, and is kept as it
/// cannot fire for an appservice-created account at all. It is now an /// is when the store has nothing or cannot be reached, so a store outage does
/// ordinary account: the homeserver creates it because it is the /// not take the hive's matrix provisioning down with it.
/// appservice registration's `sender_localpart`, and everything the hive
/// provisions with it, it provisions as the creator of those rooms.
///
/// Where the token comes from is [`sender_source`]'s decision. The **swarm
/// secret store** wins whenever it holds one: `swarm-controller` mints it
/// there (and `swarm-matrix-ctl` on the homeserver's own host), keeps it while
/// it is live and replaces it when it is not, so the file follows the store
/// rather than outliving it. That is also what lets a hive that holds no
/// `as_token` have an account at all. The file is kept when the store has
/// nothing or cannot be reached, and the mint ladder below is the fallback
/// when neither holds a token and this hive has an `as_token`.
/// ///
/// # Errors /// # Errors
/// When no token can be had — nothing in the store or the file, and no /// When neither the store nor the file holds a token, or the file write fails.
/// `as_token` — or when a step of the mint ladder or the file write fails. pub async fn ensure_hive_user() -> Result<()> {
pub async fn ensure_hive_user(client: &reqwest::Client, as_token: Option<&str>) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let path = sender_token_path(); let path = sender_token_path();
let on_disk = std::fs::read_to_string(&path).ok(); let on_disk = std::fs::read_to_string(&path).ok();
let stored = stored_sender_token().await; let stored = stored_sender_token().await;
let as_token = match sender_source(stored.as_deref(), on_disk.as_deref(), as_token) { match sender_source(stored.as_deref(), on_disk.as_deref()) {
SenderSource::Keep => { SenderSource::Keep => {
tracing::debug!("matrix: the sender token is already present"); tracing::debug!("matrix: the sender token is already present");
return Ok(()); Ok(())
} }
SenderSource::Store(token) => return persist_sender_token(&path, token), SenderSource::Store(token) => persist_sender_token(&path, token),
SenderSource::Mint(as_token) => as_token,
SenderSource::Unavailable => anyhow::bail!( SenderSource::Unavailable => anyhow::bail!(
"matrix: no sender token in the swarm store or at {}, and no appservice \ "matrix: no sender token in the swarm store or at {}; swarm-controller \
token on this host to mint one with", mints it into the store",
path.display() path.display()
), ),
}; }
// Per hive, and fatal when it cannot be derived: the fallback ladder below
// must not mint under some other hive's name.
let localpart = hive_localpart()?;
let password = random_password()?;
let access_token = match register_user(client, &localpart, as_token, &password).await {
Ok(token) => {
let pw_path = password_path(&localpart);
if let Some(parent) = pw_path.parent() {
std::fs::create_dir_all(parent).ok();
}
if let Err(e) = std::fs::write(&pw_path, format!("{password}\n")) {
tracing::warn!(error = ?e, %localpart, "matrix: failed to persist the sender account password");
} else {
let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600));
}
token
}
Err(reg_err) if reg_err.to_string().contains("M_USER_IN_USE") => {
// The expected path, not an edge case: this account is the
// appservice's own `sender_localpart`, so the homeserver
// creates it when it loads the registration — before
// hive-c0re gets a chance to ask. An appservice login needs
// no password, which is just as well since an account the
// homeserver created has none.
tracing::info!(%localpart, "matrix: the sender account already exists, logging in as the appservice");
match appservice_login(client, as_token, &localpart).await {
Ok(token) => token,
Err(e) => {
tracing::warn!(error = ?e, %localpart, "matrix: appservice login for the sender account failed; falling back to the stored password");
let pw_path = password_path(&localpart);
let stored = std::fs::read_to_string(&pw_path)
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.with_context(|| {
format!(
"matrix: @{localpart}: exists, appservice login failed, and no \
password is stored at {} — check that the registration file's \
namespace covers @{localpart} and that the homeserver \
loaded it",
pw_path.display()
)
})?;
login_user(client, &localpart, &stored).await?
}
}
}
Err(other) => return Err(other),
};
persist_sender_token(&path, &access_token)
} }
/// What [`ensure_hive_user`] does about the sender token this sweep. /// What [`ensure_hive_user`] does about the sender token this sweep.
@ -487,39 +179,27 @@ enum SenderSource<'a> {
Keep, Keep,
/// Write the store's token to the file. /// Write the store's token to the file.
Store(&'a str), Store(&'a str),
/// Mint one with this hive's own appservice token. /// No token in the store or the file.
Mint(&'a str),
/// Nothing to take and nothing to mint with.
Unavailable, Unavailable,
} }
/// Decide [`ensure_hive_user`]'s step from the store's token, the file's /// Decide [`ensure_hive_user`]'s step from the store's token and the file's
/// content and this hive's `as_token`, each `None` when absent. Blank counts /// content, each `None` when absent. Blank counts as absent.
/// as absent. fn sender_source<'a>(stored: Option<&'a str>, on_disk: Option<&str>) -> SenderSource<'a> {
fn sender_source<'a>(
stored: Option<&'a str>,
on_disk: Option<&str>,
as_token: Option<&'a str>,
) -> SenderSource<'a> {
let present = |s: &&str| !s.trim().is_empty(); let present = |s: &&str| !s.trim().is_empty();
let stored = stored.map(str::trim).filter(present); let stored = stored.map(str::trim).filter(present);
let on_disk = on_disk.map(str::trim).filter(present); let on_disk = on_disk.map(str::trim).filter(present);
match (stored, on_disk, as_token.filter(present)) { match (stored, on_disk) {
(Some(s), Some(d), _) if s == d => SenderSource::Keep, (Some(s), Some(d)) if s == d => SenderSource::Keep,
(Some(s), _, _) => SenderSource::Store(s), (Some(s), _) => SenderSource::Store(s),
(None, Some(_), _) => SenderSource::Keep, (None, Some(_)) => SenderSource::Keep,
(None, None, Some(a)) => SenderSource::Mint(a), (None, None) => SenderSource::Unavailable,
(None, None, None) => SenderSource::Unavailable,
} }
} }
/// Write the appservice sender account's access token to `path`, 0600, creating the /// Write the appservice sender account's access token to `path`, 0600, creating the
/// directory if it is not there. /// directory if it is not there. The file's mode is the only thing keeping an
/// /// unprivileged reader off the hive's matrix credential.
/// Shared by both arms of [`ensure_hive_user`] rather than duplicated into
/// the store one: the file's mode is the only thing keeping an unprivileged
/// reader off the hive's matrix credential, and a second copy of that decision
/// is one that can be edited alone.
fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()> { fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()> {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
@ -533,8 +213,8 @@ fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()
Ok(()) Ok(())
} }
/// Fetch the sender token `swarm-controller` (or `swarm-matrix-ctl`) /// Fetch the sender token `swarm-controller` published, under this hive's own
/// published, under this hive's own store identity. /// store identity.
/// ///
/// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` — /// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` —
/// the same role string `workers::credential` logs in with, and already in /// the same role string `workers::credential` logs in with, and already in
@ -546,10 +226,10 @@ fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()
/// ///
/// `None`, never an error, for every way this can come up empty — no hive /// `None`, never an error, for every way this can come up empty — no hive
/// name, no `BAO_*` identity, an unreachable store, nothing at the path. All /// name, no `BAO_*` identity, an unreachable store, nothing at the path. All
/// four mean the same thing to the caller ("keep the file, or mint it the old /// four mean the same thing to the caller ("keep the file"), and three of
/// way"), and three of them are the ordinary state of a swarm with no store /// them are the ordinary state of a swarm with no store token for this hive
/// token for this hive yet, so raising would turn a supported deployment into /// yet, so raising would turn a supported deployment into a warning every
/// a warning every sweep. /// sweep.
/// ///
/// 🩸 Logs the store **path** and never the value. /// 🩸 Logs the store **path** and never the value.
async fn stored_sender_token() -> Option<String> { async fn stored_sender_token() -> Option<String> {
@ -1279,12 +959,6 @@ pub async fn ensure_all() -> bool {
return true; return true;
} }
let mut ok = true; let mut ok = true;
// Absent on every hive whose homeserver runs elsewhere. Only the sender
// token's mint fallback needs it; `ensure_hive_user` fails, and says so,
// when the store has no token either.
let as_token = read_appservice_token()
.inspect_err(|e| tracing::debug!(error = ?e, "matrix: no local appservice token"))
.ok();
// One HTTP client for the whole sweep. // One HTTP client for the whole sweep.
let client = match reqwest::Client::builder() let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
@ -1300,7 +974,7 @@ pub async fn ensure_all() -> bool {
// THROUGH it (the Space, the chat room and every invite are sent with // THROUGH it (the Space, the chat room and every invite are sent with
// its token) — as an ordinary user that created those rooms, not as a // its token) — as an ordinary user that created those rooms, not as a
// homeserver admin. // homeserver admin.
if let Err(e) = ensure_hive_user(&client, as_token.as_deref()).await { if let Err(e) = ensure_hive_user().await {
tracing::warn!(error = ?e, "matrix: ensure_hive_user failed"); tracing::warn!(error = ?e, "matrix: ensure_hive_user failed");
ok = false; ok = false;
} }
@ -1413,18 +1087,15 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn a_remote_hive_takes_the_store_token_without_an_appservice_token() { fn with_no_file_the_store_token_is_taken() {
assert_eq!( assert_eq!(sender_source(Some("tok"), None), SenderSource::Store("tok"));
sender_source(Some("tok"), None, None),
SenderSource::Store("tok")
);
} }
#[test] #[test]
fn a_store_token_replaces_a_different_file_token() { fn a_store_token_replaces_a_different_file_token() {
// The swarm re-minted a dead token; the file must follow it. // The swarm re-minted a dead token; the file must follow it.
assert_eq!( assert_eq!(
sender_source(Some("new\n"), Some("old\n"), Some("as")), sender_source(Some("new\n"), Some("old\n")),
SenderSource::Store("new") SenderSource::Store("new")
); );
} }
@ -1433,40 +1104,23 @@ mod tests {
fn a_file_matching_the_store_is_kept() { fn a_file_matching_the_store_is_kept() {
// Trailing newline on disk is how `persist_sender_token` writes it. // Trailing newline on disk is how `persist_sender_token` writes it.
assert_eq!( assert_eq!(
sender_source(Some("tok"), Some("tok\n"), None), sender_source(Some("tok"), Some("tok\n")),
SenderSource::Keep SenderSource::Keep
); );
} }
#[test] #[test]
fn with_nothing_in_the_store_the_file_is_kept() { fn with_nothing_in_the_store_the_file_is_kept() {
assert_eq!( assert_eq!(sender_source(None, Some("tok\n")), SenderSource::Keep);
sender_source(None, Some("tok\n"), Some("as")),
SenderSource::Keep
);
} }
#[test] #[test]
fn with_no_token_anywhere_the_appservice_token_mints() { fn with_no_token_anywhere_nothing_is_available() {
assert_eq!( assert_eq!(
sender_source(None, Some(" \n"), Some("as")), sender_source(Some(""), Some(" \n")),
SenderSource::Mint("as")
);
}
#[test]
fn with_no_token_and_no_appservice_token_nothing_is_available() {
assert_eq!(
sender_source(Some(""), None, Some("")),
SenderSource::Unavailable SenderSource::Unavailable
); );
} assert_eq!(sender_source(None, None), SenderSource::Unavailable);
#[test]
fn random_hex_is_well_formed_and_correct_length() {
let h = random_hex(16).expect("/dev/urandom readable");
assert_eq!(h.len(), 32);
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
} }
/// The steady state. This is the whole point of the guard: the sweep /// The steady state. This is the whole point of the guard: the sweep
@ -1506,25 +1160,6 @@ mod tests {
assert!(state_needs_write(None, &desired)); assert!(state_needs_write(None, &desired));
} }
#[test]
fn random_hex_two_calls_differ() {
// Sanity check — not a statistical claim, just guards
// against ever accidentally returning a constant.
let a = random_hex(16).expect("/dev/urandom readable");
let b = random_hex(16).expect("/dev/urandom readable");
assert_ne!(a, b);
}
#[test]
fn extract_access_token_pulls_from_success_body() {
let body = serde_json::json!({
"user_id": "@alice:matrix.example.org",
"access_token": "syt_abc123",
"device_id": "ABC",
});
assert_eq!(extract_access_token(&body).unwrap(), "syt_abc123");
}
/// A homeserver response built in memory, so no client (and no TLS /// A homeserver response built in memory, so no client (and no TLS
/// roots) is needed to drive the response-handling halves. /// roots) is needed to drive the response-handling halves.
fn response(status: u16, body: &'static str) -> reqwest::Response { fn response(status: u16, body: &'static str) -> reqwest::Response {
@ -1572,11 +1207,4 @@ mod tests {
let outcome = refused_invite(response(500, "{}"), Some("join"), "@a:x", "!r:x").await; let outcome = refused_invite(response(500, "{}"), Some("join"), "@a:x", "!r:x").await;
assert!(outcome.is_err(), "a 500 is not excused by membership"); assert!(outcome.is_err(), "a 500 is not excused by membership");
} }
#[test]
fn extract_access_token_errors_on_missing_field() {
let body = serde_json::json!({"user_id": "@alice:matrix.example.org"});
let err = extract_access_token(&body).unwrap_err();
assert!(err.to_string().contains("missing access_token"));
}
} }

View file

@ -160,14 +160,6 @@ pub fn matrix_chat_room_id() -> PathBuf {
matrix_dir().join("chat-room-id") matrix_dir().join("chat-room-id")
} }
/// `matrix/creds/` — the hive sender account's throwaway matrix password
/// (survives `destroy --purge`; it authenticates by token, this is
/// recovery only).
#[must_use]
pub fn matrix_creds_dir() -> PathBuf {
matrix_dir().join("creds")
}
/// `run/` — runtime maps hive-c0re regenerates on every meta sync. /// `run/` — runtime maps hive-c0re regenerates on every meta sync.
#[must_use] #[must_use]
pub fn run_dir() -> PathBuf { pub fn run_dir() -> PathBuf {
@ -284,17 +276,6 @@ pub fn gateway_agents_conf() -> PathBuf {
// `nix/host-modules/hive-c0re/default.nix` and `nix/host-modules/hive-ci.nix` — must match. // `nix/host-modules/hive-c0re/default.nix` and `nix/host-modules/hive-ci.nix` — must match.
pub const FORGE_CORE_TOKEN: &str = "/var/lib/hyperhive/forge-core-token"; pub const FORGE_CORE_TOKEN: &str = "/var/lib/hyperhive/forge-core-token";
/// `matrix-appservice-token` — the `as_token` of the hive's appservice
/// registration, which authorises every account this daemon creates.
// nix: minted by the `hive-matrix-appservice` activation script in
// `nix/host-modules/hive-matrix.nix`, which renders it into the registration
// file the homeserver loads — must match. Read-only here on purpose: a token
// minted on this side would not be the one in that file.
#[must_use]
pub fn matrix_appservice_token() -> PathBuf {
state_root().join("matrix-appservice-token")
}
/// `/run/hyperhive` — the runtime root (host admin socket + per-agent dirs). /// `/run/hyperhive` — the runtime root (host admin socket + per-agent dirs).
#[must_use] #[must_use]
pub fn runtime_root() -> PathBuf { pub fn runtime_root() -> PathBuf {
@ -324,13 +305,12 @@ pub fn agent_runtime_dir(name: &str) -> PathBuf {
/// within the same filesystem is atomic. /// within the same filesystem is atomic.
pub fn relocate_legacy_state() { pub fn relocate_legacy_state() {
let root = state_root(); let root = state_root();
let moves: [(&str, PathBuf); 7] = [ let moves: [(&str, PathBuf); 6] = [
("broker.sqlite", db_dir().join("broker.sqlite")), ("broker.sqlite", db_dir().join("broker.sqlite")),
("build_logs.sqlite", db_dir().join("build_logs.sqlite")), ("build_logs.sqlite", db_dir().join("build_logs.sqlite")),
("forge-core-avatar-set", forge_core_avatar_marker()), ("forge-core-avatar-set", forge_core_avatar_marker()),
("matrix-sender-token", matrix_sender_token()), ("matrix-sender-token", matrix_sender_token()),
("matrix-space-room-id", matrix_space_room_id()), ("matrix-space-room-id", matrix_space_room_id()),
("matrix-creds", matrix_creds_dir()),
("agent-sockets.json", agent_sockets_file()), ("agent-sockets.json", agent_sockets_file()),
]; ];
for (old_rel, new) in &moves { for (old_rel, new) in &moves {

View file

@ -206,7 +206,6 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
) )
.await? .await?
} }
HostRequest::MatrixSyncAdmin => handle_matrix_sync_admin().await?,
HostRequest::MatrixInvite { user, room } => { HostRequest::MatrixInvite { user, room } => {
handle_matrix_invite(user, room.as_deref()).await? handle_matrix_invite(user, room.as_deref()).await?
} }
@ -366,10 +365,9 @@ async fn stream_agent_status(
// //
// The `hivectl matrix` subcommands used to run these in-process, which forced // The `hivectl matrix` subcommands used to run these in-process, which forced
// the standalone CLI to link the whole daemon crate (matrix-sdk, reqwest, …). // the standalone CLI to link the whole daemon crate (matrix-sdk, reqwest, …).
// They now run daemon-side over the host socket: the daemon already holds the // They now run daemon-side over the host socket, where the daemon already holds
// register + sender tokens and the matrix creds dir. Each op returns the // the sender token. Each op returns the operator-facing lines hivectl used to
// operator-facing lines hivectl used to `println!` in `HostResponse::messages` // `println!` in `HostResponse::messages` for the client to print verbatim.
// for the client to print verbatim.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Shared reqwest client for the matrix admin HTTP calls (30s timeout, /// Shared reqwest client for the matrix admin HTTP calls (30s timeout,
@ -588,24 +586,6 @@ async fn handle_push_snapshot(
Ok(HostResponse::success()) Ok(HostResponse::success())
} }
async fn handle_matrix_sync_admin() -> Result<HostResponse> {
require_matrix_present()?;
let as_token =
crate::matrix::read_appservice_token().context("read matrix appservice token")?;
let client = matrix_http_client()?;
crate::matrix::ensure_hive_user(&client, Some(&as_token))
.await
.context("matrix sync-admin")?;
let path = crate::matrix::sender_token_path();
Ok(HostResponse::messages(vec![
format!(
"matrix: the @{}: user is provisioned",
crate::matrix::hive_localpart()?
),
format!("token persisted at: {}", path.display()),
]))
}
async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResponse> { async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResponse> {
require_matrix_present()?; require_matrix_present()?;
let sender_token = crate::matrix::read_sender_token()?; let sender_token = crate::matrix::read_sender_token()?;

View file

@ -270,9 +270,6 @@ pub enum HostRequest {
#[serde(default)] #[serde(default)]
scope: LifecycleScope, scope: LifecycleScope,
}, },
/// Provision (or re-provision) the hive system admin matrix account.
/// Daemon-side equivalent of `hivectl matrix sync-admin`.
MatrixSyncAdmin,
/// Invite a matrix user to the hive Space (default) or a specific /// Invite a matrix user to the hive Space (default) or a specific
/// `room`. Uses the daemon's admin token; idempotent /// `room`. Uses the daemon's admin token; idempotent
/// (already-member / already-invited is a no-op). /// (already-member / already-invited is a no-op).
@ -539,8 +536,7 @@ pub struct HostResponse {
pub nodes: Option<Vec<hive_jobq_wire::GraphNode>>, pub nodes: Option<Vec<hive_jobq_wire::GraphNode>>,
/// Free-form operator-facing output lines the client prints verbatim /// Free-form operator-facing output lines the client prints verbatim
/// (one per line). Carries results a request produced daemon-side that /// (one per line). Carries results a request produced daemon-side that
/// have no structured home — e.g. the sender token path from /// have no structured home — e.g. the invited room id from `MatrixInvite`.
/// `MatrixSyncAdmin`, or the invited room id from `MatrixInvite`.
/// Empty for requests that produce no such output. /// Empty for requests that produce no such output.
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub messages: Vec<String>, pub messages: Vec<String>,

View file

@ -36,11 +36,11 @@ pub enum Cmd {
#[command(subcommand)] #[command(subcommand)]
cmd: ForgeCmd, cmd: ForgeCmd,
}, },
/// matrix-tuwunel user provisioning. /// matrix-tuwunel invites.
/// ///
/// Manual entry point to the same idempotent provisioning c0re runs at /// Manual invites into the hive Space and its rooms; c0re's periodic
/// boot — for re-registering an agent the boot sweep skipped, or after /// matrix sweep provisions the Space, the chat room and the agents'
/// wiping a token file. /// invites on its own.
Matrix { Matrix {
#[command(subcommand)] #[command(subcommand)]
cmd: MatrixCmd, cmd: MatrixCmd,
@ -286,11 +286,6 @@ impl From<ReconcileFrom> for hive_host_sock::ReconcileDirection {
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum MatrixCmd { pub enum MatrixCmd {
/// Provision (or re-provision) the matrix appservice's sender account.
///
/// Runs automatically on startup; run manually to recover a missing
/// access token.
SyncAdmin,
/// Invite a matrix user to the hive Space, or a specific room with /// Invite a matrix user to the hive Space, or a specific room with
/// `--room`. Idempotent. /// `--room`. Idempotent.
Invite { Invite {

View file

@ -1,7 +1,6 @@
//! `hivectl matrix` — matrix account provisioning verbs. hivectl forwards //! `hivectl matrix` — matrix provisioning verbs. hivectl forwards
//! each request to the daemon (which owns the register + sender tokens and //! each request to the daemon (which owns the sender token) and renders the
//! the matrix creds dir) and renders the reply; it no longer links the //! reply; it no longer links the matrix machinery itself.
//! matrix machinery itself.
use std::path::Path; use std::path::Path;
@ -13,15 +12,14 @@ use crate::cli::MatrixCmd;
/// dispatch match so the top-level router stays small. /// dispatch match so the top-level router stays small.
pub(crate) async fn run_matrix_cmd(socket: &Path, cmd: MatrixCmd) -> Result<()> { pub(crate) async fn run_matrix_cmd(socket: &Path, cmd: MatrixCmd) -> Result<()> {
match cmd { match cmd {
MatrixCmd::SyncAdmin => matrix_sync_admin(socket).await,
MatrixCmd::Invite { user, room } => matrix_invite(socket, &user, room.as_deref()).await, MatrixCmd::Invite { user, room } => matrix_invite(socket, &user, room.as_deref()).await,
} }
} }
/// Send a matrix provisioning request to the daemon and print the /// Send a matrix provisioning request to the daemon and print the
/// operator-facing result lines it returns. The daemon owns the register + /// operator-facing result lines it returns. The daemon owns the sender token,
/// sender tokens and the matrix creds dir, so hivectl no longer links the /// so hivectl no longer links the matrix machinery — it just forwards the
/// matrix machinery — it just forwards the request and renders the reply. /// request and renders the reply.
async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Result<()> { async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Result<()> {
let resp = crate::client::request(socket, req) let resp = crate::client::request(socket, req)
.await .await
@ -38,10 +36,6 @@ async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Resu
Ok(()) Ok(())
} }
async fn matrix_sync_admin(socket: &Path) -> Result<()> {
matrix_request(socket, hive_host_sock::HostRequest::MatrixSyncAdmin).await
}
async fn matrix_invite(socket: &Path, user: &str, room: Option<&str>) -> Result<()> { async fn matrix_invite(socket: &Path, user: &str, room: Option<&str>) -> Result<()> {
matrix_request( matrix_request(
socket, socket,

View file

@ -81,7 +81,7 @@ let
# which already admits it. # which already admits it.
# #
# ⚠️ Must equal `swarm_secret_client::matrix::hive_localpart`, which # ⚠️ Must equal `swarm_secret_client::matrix::hive_localpart`, which
# hive-c0re and swarm-matrix-ctl both derive from independently with # hive-c0re and swarm-controller both derive from independently with
# nothing wiring an override across — same agreement, and same reason for # nothing wiring an override across — same agreement, and same reason for
# saying so, as the token path below. # saying so, as the token path below.
# #
@ -92,7 +92,7 @@ let
# The `as_token`, and the `hs_token` the spec requires alongside it. Both # The `as_token`, and the `hs_token` the spec requires alongside it. Both
# minted by the render script below, mode 0600; the `as_token` is the one # minted by the render script below, mode 0600; the `as_token` is the one
# hive-c0re reads and the one the swarm secret store overwrites (see # the swarm secret store overwrites (see
# `glue-matrix-bao-token.nix`). The `hs_token` authenticates the homeserver # `glue-matrix-bao-token.nix`). The `hs_token` authenticates the homeserver
# TO the appservice, which with `url = null` is nobody — it exists because # TO the appservice, which with `url = null` is nobody — it exists because
# the registration format requires it. # the registration format requires it.
@ -127,18 +127,12 @@ let
# ── swarm-matrix-ctl ──────────────────────────────────────────────── # ── swarm-matrix-ctl ────────────────────────────────────────────────
# #
# The oneshot that publishes the appservice sender account's access token to # The container's own store identity, which the swarm appservice units below
# the swarm's secret store. It runs INSIDE the container, beside tuwunel, # run under. Gated on the identity, not on `deploy.bao.enable`: a swarm's ONE
# because the appservice token that authorises the mint is already in here — # homeserver is the host least likely to also be the host running the store,
# `appserviceDir` below is bound read-only precisely so the homeserver can # so "co-located with bao" would leave the intended deployment silently
# load it — and minting anywhere else would create a second holder of that # publishing nothing. Same rule ./swarm-secret-publisher.nix's
# secret, which is the thing this whole arrangement exists to stop. # `haveClientIdentity` states, for a sharper reason.
#
# Gated on the identity, not on `deploy.bao.enable`: a swarm's ONE homeserver
# is the host least likely to also be the host running the store, so
# "co-located with bao" would leave the intended deployment silently minting
# nothing. Same rule ./swarm-secret-publisher.nix's `haveClientIdentity`
# states, for a sharper reason.
ctlActive = ctlActive =
deployCfg.matrix.ctlBaoClientCertFile != null && deployCfg.matrix.ctlBaoClientKeyFile != null; deployCfg.matrix.ctlBaoClientCertFile != null && deployCfg.matrix.ctlBaoClientKeyFile != null;
@ -179,9 +173,9 @@ let
# identity on this homeserver: `swarm-controller` creates every agent's # identity on this homeserver: `swarm-controller` creates every agent's
# account with its token. Minted INSIDE this container by # account with its token. Minted INSIDE this container by
# `swarm-matrix-ctl appservice render` and published to the store by # `swarm-matrix-ctl appservice render` and published to the store by
# `appservice publish`, which is why it is gated on the same identity as # `appservice publish`, which is why it is gated on matrix-ctl's store
# the sender mint: with nobody to publish it, a registration here would be # identity: with nobody to publish it, a registration here would be an
# an admin credential nobody reads. # admin credential nobody reads.
# #
# Its sender is promoted to homeserver admin at boot (`admin_execute` # Its sender is promoted to homeserver admin at boot (`admin_execute`
# below), so its token goes only to a store path no hive's policy reaches. # below), so its token goes only to a store path no hive's policy reaches.
@ -193,13 +187,6 @@ let
swarmAppserviceDir = "/var/lib/swarm-matrix-appservice"; swarmAppserviceDir = "/var/lib/swarm-matrix-appservice";
swarmAppserviceCredentialId = "swarm-appservice.yaml"; swarmAppserviceCredentialId = "swarm-appservice.yaml";
# Where a reader of the published credential is told the token is good for.
# Empty when this hive serves no vhost: `matrix::Credential.homeserver` is an
# `Option`, and matrix-ctl reads an empty variable as absent rather than as
# the string "null" — which is what a hive with no gateway host actually
# knows about itself.
ctlHomeserverUrl = if cfg.gatewayHost == null then "" else "https://${toString cfg.gatewayHost}";
# Every local user this hive may provision — agents and `@hive-<hive>:` # Every local user this hive may provision — agents and `@hive-<hive>:`
# itself — which is the whole matrix localpart charset. # itself — which is the whole matrix localpart charset.
# #
@ -677,15 +664,14 @@ in
default = appserviceTokenPath; default = appserviceTokenPath;
description = '' description = ''
Host path to a file containing this hive's matrix appservice Host path to a file containing this hive's matrix appservice
token (`as_token`) — the identity `hive-c0re` creates and logs token (`as_token`). Minted automatically on first activation
into accounts with. Minted automatically on first activation
(32-byte random hex, mode 0600) and rendered into the (32-byte random hex, mode 0600) and rendered into the
appservice registration the homeserver loads at boot. Agents appservice registration the homeserver loads at boot. Agents
never see it; an agent only ever receives its own never see it; an agent only ever receives its own
`access_token`. `access_token`.
Not operator-settable — `hive-c0re`'s Rust side derives this Not operator-settable — the module's registration renderer reads
same path independently (`paths::matrix_appservice_token()`) this same path as a literal, not through the option,
with nothing wiring an override across, so a moved path desyncs with nothing wiring an override across, so a moved path desyncs
the two silently. An externally-managed token is delivered by the two silently. An externally-managed token is delivered by
writing into *this* fixed path instead of moving it — see writing into *this* fixed path instead of moving it — see
@ -1015,8 +1001,8 @@ in
assertion = deployCfg.matrix.appserviceTokenFile == appserviceTokenPath; assertion = deployCfg.matrix.appserviceTokenFile == appserviceTokenPath;
message = '' message = ''
services.hyperhive.deploy.matrix.appserviceTokenFile is fixed at services.hyperhive.deploy.matrix.appserviceTokenFile is fixed at
${appserviceTokenPath} and cannot be moved — hive-c0re's Rust ${appserviceTokenPath} and cannot be moved — the registration
side derives this same path independently and has no way to learn renderer reads this same path as a literal and has no way to learn
an override, so moving it desyncs the two silently instead of an override, so moving it desyncs the two silently instead of
loudly. loudly.
@ -1415,66 +1401,6 @@ in
# is why the render below is `requiredBy` it and local only. # is why the render below is `requiredBy` it and local only.
++ lib.optional ctlActive "${swarmAppserviceCredentialId}:${swarmAppserviceDir}/swarm.yaml"; ++ lib.optional ctlActive "${swarmAppserviceCredentialId}:${swarmAppserviceDir}/swarm.yaml";
# Publish the appservice sender account's access token to the swarm
# store, once, under an identity that belongs to this container and
# not to the hive. See `ctlActive` above for why it runs here.
#
# A `oneshot` with no timer and no retry loop of its own: the whole
# of "and only once" is the binary's first act, a read of the path it
# would write. `Restart=on-failure` covers a store that is sealed or
# a homeserver still starting; `RemainAfterExit` is deliberately NOT
# set, because the unit having succeeded is not the idempotency
# record — the store is, and it outlives this machine.
systemd.services.swarm-matrix-ctl = lib.mkIf ctlActive {
description = "publish the matrix sender token to the swarm secret store";
# Ordered after the homeserver because both of the ladder's arms
# are client-server API calls. `wants`, not `requires`: a run that
# finds the credential already published never touches tuwunel at
# all, so a homeserver that is slow to come up should delay this,
# not cancel it.
after = [ "tuwunel.service" ];
wants = [ "tuwunel.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
# The verb is part of the contract: `swarm-matrix-ctl` is a
# subcommand binary and refuses a bare invocation, so dropping
# `mint` here fails the unit rather than doing something else.
ExecStart = "${deployCfg.matrix.ctlPackage}/bin/swarm-matrix-ctl mint";
Restart = "on-failure";
RestartSec = 30;
# Bounded here rather than left to systemd's default, for the
# reason ./swarm-secret-publisher.nix states: a sealed store
# answers on the port and never answers the read.
TimeoutStartSec = 60;
SyslogIdentifier = "swarm-matrix-ctl";
};
environment = {
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
BAO_CLIENT_CERT = deployCfg.matrix.ctlBaoClientCertFile;
BAO_CLIENT_KEY = deployCfg.matrix.ctlBaoClientKeyFile;
MATRIX_MINT_CERT_ROLE = ctlCertRole;
# Loopback: this container shares the host netns, so the
# homeserver it must talk to is the one in this very unit's
# netns and needs no name, no vhost and no TLS.
MATRIX_MINT_API_URL = "http://127.0.0.1:${toString cfg.httpPort}";
# The bind-mounted registration, which IS the as_token. A path,
# never a value.
MATRIX_MINT_REGISTRATION = appserviceRegistrationPath;
MATRIX_MINT_LOCALPART = hiveLocalpart;
# The hive segment of the store path the token is published
# under, and so the thing that keeps this hive's token out of
# every other hive's reach: the grant that reaches it is the
# hive's own `swarm/hives/<name>/*` stanza. ./swarm-bao.nix
# spells the same name into matrix-ctl's write grant.
MATRIX_MINT_HIVE = toString config.services.hyperhive.hiveName;
MATRIX_MINT_HOMESERVER = ctlHomeserverUrl;
}
// lib.optionalAttrs (deployCfg.bao.serverCaFile != null) {
BAO_CACERT = deployCfg.bao.serverCaFile;
};
};
# The swarm registration, minted and rendered before the homeserver # The swarm registration, minted and rendered before the homeserver
# loads it. No network and no store: this is on tuwunel's start # loads it. No network and no store: this is on tuwunel's start
# path, and a store outage must not keep the homeserver down. # path, and a store outage must not keep the homeserver down.
@ -1502,9 +1428,12 @@ in
}; };
# Hand the swarm registration's token to swarm-controller, through # Hand the swarm registration's token to swarm-controller, through
# the store. Same identity and retry shape as `swarm-matrix-ctl` # the store, under matrix-ctl's identity; the write lands at a path
# above; the write lands at a path only matrix-ctl and the # only matrix-ctl and the controller are granted (./swarm-bao.nix).
# controller are granted (./swarm-bao.nix). # `Restart=on-failure` covers a sealed store or one still starting;
# the start timeout is bounded for the reason
# ./swarm-secret-publisher.nix states: a sealed store answers on the
# port and never answers the read.
systemd.services.swarm-matrix-appservice-publish = lib.mkIf ctlActive { systemd.services.swarm-matrix-appservice-publish = lib.mkIf ctlActive {
description = "publish the swarm's appservice token to the swarm secret store"; description = "publish the swarm's appservice token to the swarm secret store";
after = [ "swarm-matrix-appservice-render.service" ]; after = [ "swarm-matrix-appservice-render.service" ];

View file

@ -471,10 +471,9 @@ let
# `secret/data/` is KV v2's ACL prefix, inserted by the engine rather than # `secret/data/` is KV v2's ACL prefix, inserted by the engine rather than
# written by the caller — the same trap as the two grants above. # written by the caller — the same trap as the two grants above.
# #
# Not `swarm/services/*` like the publisher's: this principal produces # Not `swarm/services/*` like the publisher's: a homeserver is not entitled
# exactly one secret, its own hive's matrix sender account access token, and # to overwrite Grafana's OIDC client. Each path is spelled to the leaf for
# a homeserver is not entitled to overwrite Grafana's OIDC client. The path # that reason, not for tidiness.
# is spelled to the leaf for that reason, not for tidiness.
# #
# 🩸 And the leaf now carries a HIVE segment, which is the narrowing that # 🩸 And the leaf now carries a HIVE segment, which is the narrowing that
# matters: the credential used to live at `swarm/services/matrix/sender-token` # matters: the credential used to live at `swarm/services/matrix/sender-token`
@ -484,16 +483,12 @@ let
# another's. `matrixCtlHive` below is the name this principal may write, and # another's. `matrixCtlHive` below is the name this principal may write, and
# it is one hive rather than a `hives/*` wildcard for the same reason. # it is one hive rather than a `hives/*` wildcard for the same reason.
# #
# `read` as well as write, unlike either sibling, and it is what makes "and # ⚠️ Nothing presenting this identity writes the first stanza's path:
# only once" mechanical: matrix-ctl's first act is to read this path back and # `swarm-controller` is the sender token's only minter. The stanza is unused.
# stop if something is there, so without the capability every container
# restart would mint a second access token and invalidate the hive's. A read
# here recovers one secret this principal itself wrote, which is a much
# narrower grant than the publisher's would have been.
# #
# The second stanza is the swarm appservice's token, which matrix-ctl mints # The second stanza is the swarm appservice's token, which matrix-ctl mints
# inside the container and publishes here for the controller. `read` for the # inside the container and publishes here for the controller. `read` as well
# same reason: publish compares before it writes. # as write, unlike either sibling, because publish compares before it writes.
matrixCtlPolicyText = '' matrixCtlPolicyText = ''
path "${credentialMountPath}/data/swarm/hives/${matrixCtlHive}/matrix/sender-token" { path "${credentialMountPath}/data/swarm/hives/${matrixCtlHive}/matrix/sender-token" {
capabilities = ["create", "update", "read"] capabilities = ["create", "update", "read"]
@ -504,15 +499,7 @@ let
} }
''; '';
# Which hive matrix-ctl mints for. This host's own by default, which is right # The hive the unused first stanza above names.
# whenever the store and the homeserver are co-located and is the shape the
# `mkDefault` deployments produce; an operator running them apart names the
# homeserver's hive here, because the policy is written where the store is
# and the container runs where the homeserver is.
#
# A wrong value is loud rather than silent: matrix-ctl's write comes back 403
# with the store's own message and the hive falls back to minting its account
# locally, which is the same degrade a store that was never deployed gives.
matrixCtlHive = baoDeploy.matrixCtlHiveName; matrixCtlHive = baoDeploy.matrixCtlHiveName;
# The swarm appservice token's leaf, the nix half of # The swarm appservice token's leaf, the nix half of
@ -1460,8 +1447,8 @@ in
example = "swarm-matrix-ctl.svc"; example = "swarm-matrix-ctl.svc";
description = '' description = ''
Subject the store's matrix-ctl cert-auth role accepts — the Subject the store's matrix-ctl cert-auth role accepts — the
identity the oneshot inside the matrix container presents when it identity the matrix container's `swarm-matrix-appservice-publish`
publishes the appservice sender account's access token. unit presents when it publishes the swarm appservice's token.
A **third** identity rather than reuse of either sibling above, and A **third** identity rather than reuse of either sibling above, and
the narrowest of the three: its grant is one path, that the narrowest of the three: its grant is one path, that
@ -1632,20 +1619,8 @@ in
description = '' description = ''
Hive whose matrix sender token the store's matrix-ctl role may write. Hive whose matrix sender token the store's matrix-ctl role may write.
The sender account's access token is **per hive**: it lives at Unused: nothing presenting that identity writes the sender token;
`swarm/hives/<name>/matrix/sender-token`, and the only read grant that `swarm-controller` is its only minter.
reaches it is that hive's own. So matrix-ctl's write grant names one
hive too — the hive whose homeserver container it runs in.
Defaults to this host's own {option}`services.hyperhive.hiveName`,
which is correct whenever the store and the homeserver are co-located.
Set it when they are not: the policy is written where the store runs,
and the oneshot runs where the homeserver does.
A wrong value degrades rather than breaks — matrix-ctl's write is
refused with the store's own message and the hive mints its account
locally instead, the same fallback a swarm that never deployed the
store already uses.
''; '';
}; };

View file

@ -451,14 +451,10 @@ let
&& !(lib.hasInfix "sys/policies/acl" s); && !(lib.hasInfix "sys/policies/acl" s);
} }
{ {
# 🩸 `read` is load-bearing here, and the publisher — the one sibling # 🩸 `read` is load-bearing here, unlike on the publisher: matrix-ctl's
# that still has no `read` — shows what its absence costs. matrix-ctl's # `appservice publish` reads the swarm appservice token back and writes
# first act is to read this path back and stop if something is there — # only when the store's copy differs.
# that read IS "and only once", so without the capability every container name = "matrix-ctl may read back what it writes";
# restart would mint a second access token and invalidate the hive's.
# (The controller holds `read` for the same idempotency reason, on the
# agent prefix.)
name = "matrix-ctl may read back the one path it writes";
ok = ok =
let let
s = baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.script; s = baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.script;

View file

@ -292,7 +292,8 @@ let
name = "matrix-ctl presents its own leaf, never the hive's store-wide one"; name = "matrix-ctl presents its own leaf, never the hive's store-wide one";
ok = ok =
let let
env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl.environment; env =
baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-appservice-publish.environment;
hiveLeaf = baoWithMatrix.services.hyperhive.deploy.bao.clientCertFile; hiveLeaf = baoWithMatrix.services.hyperhive.deploy.bao.clientCertFile;
in in
env.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/matrix-ctl.pem" && env.BAO_CLIENT_CERT != hiveLeaf; env.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/matrix-ctl.pem" && env.BAO_CLIENT_CERT != hiveLeaf;
@ -316,81 +317,37 @@ let
&& mounts ? "/var/lib/hyperhive/matrix-appservice"; && mounts ? "/var/lib/hyperhive/matrix-appservice";
} }
{ {
# What the unit is for, read as the two agreements it cannot get wrong: # The cert role ./host-modules/swarm-bao.nix writes, which is the one
# the cert role ./host-modules/swarm-bao.nix writes, and a homeserver # agreement the unit cannot get wrong, and the verb: a bare invocation
# address that is loopback because the container shares the host netns. A # exits non-zero with clap's usage, a deploy-time failure with no local
# vhost here would be a request out through the gateway and back. # signal.
name = "matrix-ctl is handed the store role and the loopback homeserver"; name = "the publish unit is handed the store role and invokes its verb";
ok = ok =
let let
m = baoWithMatrix; u = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-appservice-publish;
u = m.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl;
port = m.services.hyperhive.swarm.matrix.httpPort;
in in
u.environment.MATRIX_MINT_CERT_ROLE == "swarm-matrix-ctl" u.environment.MATRIX_APPSERVICE_CERT_ROLE == "swarm-matrix-ctl"
&& u.environment.MATRIX_MINT_API_URL == "http://127.0.0.1:${toString port}" && lib.hasSuffix "/bin/swarm-matrix-ctl appservice publish" u.serviceConfig.ExecStart;
&& u.environment.MATRIX_MINT_REGISTRATION == "/var/lib/hyperhive/matrix-appservice/hyperhive.yaml"
&& u.serviceConfig.Type == "oneshot";
} }
{ {
# 🩸 The per-hive minting identity, read off the rendered unit rather than # 🩸 A secret is a path, never a value. Every variable the unit is given
# off the option: the binary builds its store path out of # names a file or an address; the token itself is read out of the state
# `MATRIX_MINT_HIVE` and logs in as `MATRIX_MINT_LOCALPART`, so a unit # dir at runtime, so nothing here can be a token and an environment block
# that passed the old bare `hive` would publish one identity for the # is world-readable through `systemctl show`.
# whole swarm again and nothing in the Rust tests could see it. Both name = "the publish unit's environment carries paths and addresses, never a token";
# spellings are pinned, and the localpart is pinned as *derived from* the
# hive name rather than as a literal, which is the agreement
# `swarm_secret_client::matrix::hive_localpart` owns.
name = "matrix-ctl is told which hive it mints for, and acts as that hive's account";
ok = ok =
let let
m = baoWithMatrix; env =
u = m.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl; baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-appservice-publish.environment;
hive = m.services.hyperhive.hiveName;
in
u.environment.MATRIX_MINT_HIVE == hive
&& u.environment.MATRIX_MINT_LOCALPART == "hive-${hive}"
&& u.environment.MATRIX_MINT_LOCALPART != "hive";
}
{
# 🩸 The crate is a `*ctl` with subcommands, so the unit has to name a
# VERB. This is the one end of that contract nix owns: the binary's own
# test pins how `mint` is spelled, but only a rendered `ExecStart` can
# say the unit actually passes it. A bare invocation exits non-zero with
# clap's usage — which is a deploy-time failure with no local signal, and
# exactly what the next verb added here is most likely to disturb.
name = "the unit invokes a verb rather than the bare binary";
ok =
let
exec =
baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl.serviceConfig.ExecStart;
in
lib.hasSuffix "/bin/swarm-matrix-ctl mint" exec;
}
{
# 🩸 A secret is a path, never a value — checked on the one unit in this
# tree whose whole job is an `as_token`. Every variable it is given names
# a file or an address; the token itself is read out of the bind-mounted
# registration at runtime, so nothing here can be a token and an
# environment block is world-readable through `systemctl show`.
name = "matrix-ctl's environment carries paths and addresses, never a token";
ok =
let
env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl.environment;
in in
!(lib.any (v: lib.hasInfix "as_token" v || lib.hasInfix "syt_" v) (lib.attrValues env)); !(lib.any (v: lib.hasInfix "as_token" v || lib.hasInfix "syt_" v) (lib.attrValues env));
} }
{ {
# The absence arm, and the deployment it protects: a homeserver on a hive # The absence arm, and the deployment it protects: a homeserver on a hive
# with no store identity at all. Without it the unit would exist naming # with no store identity at all. Without it the mount would name `null`
# `null` as its certificate, which nixos renders as the literal string. # as its source, which nixos renders as the literal string.
name = "a matrix container with no store identity runs no matrix-ctl and binds no PKI"; name = "a matrix container with no store identity binds no PKI";
ok = ok = !(matrixNoBaoIdentity.containers.hive-matrix.bindMounts ? "/var/lib/swarm-bao-pki");
let
units = matrixNoBaoIdentity.containers.hive-matrix.config.systemd.services;
in
!(units ? swarm-matrix-ctl)
&& !(matrixNoBaoIdentity.containers.hive-matrix.bindMounts ? "/var/lib/swarm-bao-pki");
} }
{ {
# 🩸 The privilege arm: exactly one account is a homeserver admin, the # 🩸 The privilege arm: exactly one account is a homeserver admin, the

View file

@ -1,21 +1,15 @@
//! Each hive's sender account, `@hive-<hive>:`, on the swarm's homeserver: //! Each hive's sender account, `@hive-<hive>:`, on the swarm's homeserver:
//! created here with the **swarm's** appservice token and stored at //! created here with the **swarm's** appservice token and stored at
//! `swarm/hives/<hive>/matrix/sender-token`, where hive-c0re's matrix sweep //! `swarm/hives/<hive>/matrix/sender-token`, where hive-c0re's matrix sweep
//! reads it under the hive's own store identity. A hive whose homeserver runs //! reads it under the hive's own store identity.
//! elsewhere holds no appservice token, so this is its only sender token.
//! //!
//! Runs for every hive in the directory, local or remote, and decides the //! Runs for every hive in the directory, local or remote, and decides the
//! same way [`super::agent_token`] does: a stored token that `whoami` //! same way [`super::agent_token`] does: a stored token that `whoami`
//! confirms as `@hive-<hive>:` is kept, so this writes only when the path is //! confirms as `@hive-<hive>:` is kept, so this writes only when the path is
//! empty or its token is dead. //! empty or its token is dead.
//! //!
//! ⚠️ `swarm-matrix-ctl mint` also writes this path for the hive whose host //! This is the only writer of that path: hive-c0re never mints, it takes
//! runs the homeserver, and skips when it is non-empty. Both log in on the //! whatever the store holds.
//! same pinned device, so if both find it empty at once, one of the two
//! tokens is dead on arrival. Whichever of them lands in the store, the next
//! [`RECONCILE_INTERVAL`] pass either keeps it (live) or re-mints it
//! (`Revoked`), and matrix-ctl never writes a non-empty path — so the store
//! converges on one live token, and the hive's sweep takes whatever it holds.
use std::sync::Arc; use std::sync::Arc;
@ -158,7 +152,7 @@ mod tests {
#[test] #[test]
fn a_dead_token_mints() { fn a_dead_token_mints() {
// What the loser of a simultaneous mint with matrix-ctl leaves behind. // A token the homeserver revoked, or a device a manual login replaced.
assert_eq!( assert_eq!(
classify_hive("pr1ma", &Probe::Whoami(Whoami::UnknownToken)), classify_hive("pr1ma", &Probe::Whoami(Whoami::UnknownToken)),
Decision::Mint(MintReason::Revoked) Decision::Mint(MintReason::Revoked)
@ -191,8 +185,8 @@ mod tests {
#[test] #[test]
fn the_published_path_is_the_one_the_hive_reads() { fn the_published_path_is_the_one_the_hive_reads() {
// hive-c0re's `stored_sender_token` and `swarm-matrix-ctl mint` both // hive-c0re's `stored_sender_token` resolves this path; the literal is
// resolve this path; the literal is what the bao grant names. // what the bao grant names.
assert_eq!( assert_eq!(
matrix::sender_token_path("pr1ma").expect("a plain name is legal"), matrix::sender_token_path("pr1ma").expect("a plain name is legal"),
"swarm/hives/pr1ma/matrix/sender-token" "swarm/hives/pr1ma/matrix/sender-token"

View file

@ -10,13 +10,11 @@ path = "src/main.rs"
[dependencies] [dependencies]
anyhow.workspace = true anyhow.workspace = true
# One verb today (`mint`), and the reason this crate is a `*ctl` rather than a # The reason this crate is a `*ctl` rather than a single-purpose binary: the
# single-purpose binary: the next thing that has to run in the matrix container # next thing that has to run in the matrix container is a subcommand here, not a
# is a subcommand here, not a new crate. # new crate.
clap.workspace = true clap.workspace = true
serde_json.workspace = true # The token generator, shared with `swarm-controller`.
# The appservice calls, shared with `swarm-controller`, which mints agents'
# accounts through the same device id.
swarm-matrix-client.workspace = true swarm-matrix-client.workspace = true
# The agreement this binary is one end of: where the credential lives, what the # The agreement this binary is one end of: where the credential lives, what the
# object at that path holds, and the `BAO_*` spellings the unit sets. # object at that path holds, and the `BAO_*` spellings the unit sets.

View file

@ -11,52 +11,21 @@ crate.
## Verbs ## Verbs
### `mint` ### `appservice render`
Puts the appservice sender account's access token into the swarm's secret store, Mints the swarm appservice registration's tokens when absent and renders the
under an identity of its own. A boot-time oneshot. registration tuwunel loads. Runs before the homeserver and needs no network.
Configured entirely by the `MATRIX_MINT_*` environment the unit sets — no flags. ### `appservice publish`
A systemd `Environment=` block is what a nix module can render; a command line
full of paths is not. The prefix is scoped to the verb rather than to the binary
so the next verb brings its own, instead of widening a shared one nobody can
then narrow.
## Why this lives in the matrix container Writes the rendered `as_token` to the swarm secret store for `swarm-controller`,
when the store's copy differs.
The credential `mint` writes is authorised by the appservice `as_token`, and the Both are configured entirely by the `MATRIX_APPSERVICE_*` environment the units
container already holds that: `nix/host-modules/hive-matrix.nix` bind-mounts the set — no flags. A systemd `Environment=` block is what a nix module can render; a
rendered appservice registration into it read-only, because that is how tuwunel command line full of paths is not.
itself is handed the registration. Minting anywhere else would mean copying the
`as_token` to a second holder — and the point of this component is that the hive
stops being one.
It is not the swarm controller for the same reason, plus a structural one: a
homeserver has exactly **one** appservice registration and so one sender
account, and a swarm runs one homeserver, so "mint it once" needs no lock, no
lease and no trigger surface — it is a property of the thing being minted.
## Idempotency
The **store** is the key, not the homeserver. A `mint` run reads
`swarm/services/matrix/sender-token` first and returns without touching the
homeserver when something is already there. Only an empty path reaches the mint
ladder:
1. `POST /_matrix/client/v3/register` with `"type": "m.login.application_service"`
— one round trip, no UIAA.
2. `M_USER_IN_USE` (the expected arm on a homeserver that has already loaded the
registration, since the account is the appservice's own `sender_localpart`) →
`POST /_matrix/client/v3/login` as the appservice, same pinned `device_id`, so
the old device is replaced rather than duplicated.
3. Write the result to the store.
A crash between the homeserver call and the store write is recoverable: the next
run takes arm 2.
## 🩸 A secret is a path, never a value ## 🩸 A secret is a path, never a value
Nothing here logs, prints or interpolates a token. The mint ladder's errors are Nothing here logs, prints or interpolates a token. The one identifier this
built from the homeserver's _status_ and its `errcode`, never its body, because a binary logs is the store path it wrote.
`/login` response body is an access token. The one identifier this binary logs is
the store path it wrote.

View file

@ -7,20 +7,14 @@
//! identity plumbing to add one action, so the next thing that has to run in //! identity plumbing to add one action, so the next thing that has to run in
//! here is a verb below, not a new crate. //! here is a verb below, not a new crate.
//! //!
//! [`mint`] publishes a hive's appservice sender token to the swarm's secret //! [`appservice`] mints the **swarm's** own appservice registration and
//! store, once. [`appservice`] mints the **swarm's** own appservice //! publishes its token for `swarm-controller`.
//! registration and publishes its token for `swarm-controller`.
//!
//! It lives in the container because the appservice `as_token` that authorises
//! the mint is *already* there — the registration tuwunel loads is bind-mounted
//! in — so no second holder of that secret is created.
//! //!
//! 🩸 **A secret is a path, never a value.** The only identifier any verb here //! 🩸 **A secret is a path, never a value.** The only identifier any verb here
//! logs is the store path; see `swarm_matrix_client`'s module doc for the same rule //! logs is the store path; see `swarm_matrix_client`'s module doc for the same rule
//! applied to error messages. //! applied to error messages.
mod appservice; mod appservice;
mod mint;
mod registration; mod registration;
use anyhow::Result; use anyhow::Result;
@ -38,13 +32,6 @@ struct Cli {
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
enum Command { enum Command {
/// Publish the appservice sender account's access token to the swarm
/// secret store, once.
///
/// Configured entirely by the `MATRIX_MINT_*` environment the unit sets —
/// no flags, because a systemd `Environment=` block is what a nix module
/// can render and a command line full of paths is not.
Mint,
/// The swarm's own appservice registration, whose sender is the /// The swarm's own appservice registration, whose sender is the
/// homeserver's admin account. Configured by `MATRIX_APPSERVICE_*`. /// homeserver's admin account. Configured by `MATRIX_APPSERVICE_*`.
#[command(subcommand)] #[command(subcommand)]
@ -71,7 +58,6 @@ async fn main() -> Result<()> {
.init(); .init();
match Cli::parse().command { match Cli::parse().command {
Command::Mint => mint::run().await,
Command::Appservice(Appservice::Render) => appservice::render(), Command::Appservice(Appservice::Render) => appservice::render(),
Command::Appservice(Appservice::Publish) => appservice::publish().await, Command::Appservice(Appservice::Publish) => appservice::publish().await,
} }
@ -87,17 +73,8 @@ mod tests {
Cli::command().debug_assert(); Cli::command().debug_assert();
} }
/// The unit's `ExecStart` names a verb, so a rename of it is a deploy-time /// The two units name these verbs in `ExecStart`, so a rename of one is a
/// failure with no local signal. This is that signal. /// deploy-time failure with no local signal. This is that signal.
#[test]
fn mint_is_spelled_the_way_the_unit_invokes_it() {
let cli = Cli::try_parse_from(["swarm-matrix-ctl", "mint"]).expect("`mint` is a verb");
assert!(matches!(cli.command, Command::Mint));
}
/// The control: without it the case above passes on a parser that accepts
/// anything.
/// The two units name these verbs, same reason as the test above.
#[test] #[test]
fn the_appservice_verbs_are_spelled_the_way_the_units_invoke_them() { fn the_appservice_verbs_are_spelled_the_way_the_units_invoke_them() {
let cli = let cli =
@ -122,9 +99,9 @@ mod tests {
.expect_err("only declared verbs are accepted"); .expect_err("only declared verbs are accepted");
} }
/// A bare invocation must not silently do something. `mint` writes a /// A bare invocation must not silently do something. `appservice publish`
/// credential, so "no verb" defaulting to it would make a typo in the unit /// writes a credential, so "no verb" defaulting to it would make a typo in
/// mint rather than fail. /// the unit write rather than fail.
#[test] #[test]
fn no_verb_at_all_is_refused() { fn no_verb_at_all_is_refused() {
Cli::try_parse_from(["swarm-matrix-ctl"]).expect_err("a verb is required"); Cli::try_parse_from(["swarm-matrix-ctl"]).expect_err("a verb is required");

View file

@ -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));
}
}

View file

@ -197,9 +197,9 @@ mod tests {
#[test] #[test]
fn the_localpart_is_derived_from_the_hive_name() { fn the_localpart_is_derived_from_the_hive_name() {
// The literal is the point: `nix/host-modules/hive-matrix.nix` renders // The literal is the point: `nix/host-modules/hive-matrix.nix` renders
// the same string into the registration's `sender_localpart` and into // the same string into the registration's `sender_localpart`, and
// `MATRIX_MINT_LOCALPART`, and nothing wires an override across — so a // nothing wires an override across — so a change here is a change
// change here is a change there. // there.
assert_eq!(hive_localpart("pr1ma"), "hive-pr1ma"); assert_eq!(hive_localpart("pr1ma"), "hive-pr1ma");
assert_ne!( assert_ne!(
hive_localpart("pr1ma"), hive_localpart("pr1ma"),