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