Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
32 changed files with 344 additions and 2105 deletions

14
Cargo.lock generated
View file

@ -4924,20 +4924,6 @@ dependencies = [
"swarm-queue-client", "swarm-queue-client",
] ]
[[package]]
name = "swarm-matrix-ctl"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"reqwest",
"serde_json",
"swarm-secret-client",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]] [[package]]
name = "swarm-nats-auth" name = "swarm-nats-auth"
version = "0.1.0" version = "0.1.0"

View file

@ -26,7 +26,6 @@ members = [
"swarm-authelia-bridge", "swarm-authelia-bridge",
"swarm-authelia-bridge-sock", "swarm-authelia-bridge-sock",
"swarm-controller", "swarm-controller",
"swarm-matrix-ctl",
"swarm-nats-auth", "swarm-nats-auth",
"swarm-queue-client", "swarm-queue-client",
"swarm-logs", "swarm-logs",

View file

@ -181,7 +181,7 @@ which. A hive that does **not** deploy the store names the reader's three
itself: the operator issues that leaf out of band, and it's the one credential itself: the operator issues that leaf out of band, and it's the one credential
the store can't hand you, being what opens it. ⚠️ Not the gateway's HTTPS certificates and not the hive CA — this is the store can't hand you, being what opens it. ⚠️ Not the gateway's HTTPS certificates and not the hive CA — this is
**mTLS between services and the store**, a separate trust domain, because a **mTLS between services and the store**, a separate trust domain, because a
store that took its identity from an authority it itself distributes could store that took its identity from an authority it will itself distribute could
never come up before that authority. never come up before that authority.
### 4 · Swarm SSO (only when `deploy.authelia`) ### 4 · Swarm SSO (only when `deploy.authelia`)
@ -203,8 +203,8 @@ surfaces (the swarm UI below) gate on that group, and an account
without it authenticates successfully and is then refused — which reads without it authenticates successfully and is then refused — which reads
like a broken login rather than a missing group. like a broken login rather than a missing group.
If an account already exists without it, `user add` refuses rather If an account already exists without it, `user add` will refuse rather
than amends — adding the group afterwards is `swarmctl user update mara than amend — adding the group afterwards is `swarmctl user update mara
--add-group admins`. --add-group admins`.
Detail, including what the password is and why this stays manual: Detail, including what the password is and why this stays manual:
@ -229,7 +229,7 @@ control: [`swarm/ui.md`](../swarm/ui.md).
### 6 · Matrix ### 6 · Matrix
```bash ```bash
# Ensure the appservice's sender account exists first # Ensure the hive-internal admin account exists first
hivectl matrix sync-admin hivectl matrix sync-admin
# Provision ruth's own matrix account — same bootstrap-bypass reasoning # Provision ruth's own matrix account — same bootstrap-bypass reasoning
@ -293,7 +293,7 @@ See [`tools/hivectl.md`](../tools/hivectl.md) for every `hivectl` verb.
- **No forge admin token is stored in any agent state dir.** Agents - **No forge admin token is stored in any agent state dir.** Agents
hold a regular agent token in their `forge-token` file; sensitive hold a regular agent token in their `forge-token` file; sensitive
creds (the core token) live on the host. creds (the core token, the matrix admin token) live on the host.
- All config changes (forge PRs on `agent-configs/<name>`) go through - All config changes (forge PRs on `agent-configs/<name>`) go through
operator approval — agents can't unilaterally rebuild containers, by design. operator approval — agents can't unilaterally rebuild containers, by design.
See [`boundary.md`](../trust-boundary/boundary.md) and [`security.md`](../trust-boundary/security.md). See [`boundary.md`](../trust-boundary/boundary.md) and [`security.md`](../trust-boundary/security.md).

View file

@ -147,86 +147,29 @@ a token.
(`hive-matrix-daemon.path` watching for `matrix-token` appearance) (`hive-matrix-daemon.path` watching for `matrix-token` appearance)
brings the daemon up on the same boot cycle anyway. brings the daemon up on the same boot cycle anyway.
### The appservice's sender account, and why it isn't an admin ### The admin account, and why it needs no first-user luck
`@hive-<hive>:<server_name>` is the appservice's own `sender_localpart`, `@hive:<server_name>` is the appservice's own `sender_localpart`, which
which the homeserver creates itself when it loads the registration — on a the homeserver creates itself when it loads the registration — on a
zero-user database, inside startup, before the HTTP listener accepts zero-user database, inside startup, before the HTTP listener accepts
anything. It's an **ordinary account**: nothing promotes it, and the anything. Its **admin rights** then come from an explicit
homeserver runs no `admin_execute` for it. `make_user_admin`, run by tuwunel's `admin_execute` in the same startup
and likewise before the listener — so a fresh hive has a joined,
power-level-100 admin on its first boot.
**One account per hive.** The localpart carries the hive's name, so a swarm This replaces a dependency on being the first account ever registered,
whose hives share a homeserver gives each of them its own identity: the which was fragile in both directions: the design excludes an
homeserver attributes an action to the hive that took it, and revoking one appservice-created account from that automatic grant, and on a homeserver that
hive's standing leaves the others alone. already had users the rule never fired at all.
Its access token is the **sender token**, and it's the credential Tuwunel doesn't support bootstrapping promotion over the API, and
hive-c0re presents for every homeserver call it makes on the hive's that's upstream's design rather than a gap: it only treats an admin-room message as a
behalf. It's per hive for the same reason the account is: command when its sender is already an admin. `admin_execute` is the one
`swarm-matrix-ctl` mints it inside the `hive-matrix` container and lever with no sender to check. hive-c0re re-checks the result on every
publishes it to `swarm/hives/<hive>/matrix/sender-token`, and the hive sweep by reading the admin account's own joined-rooms list; if the rights
reads it from there under its own certificate. That path sits inside the are missing it says so, names
hive's own read grant (`swarm/hives/<hive>/*`), so a hive fetches its own `systemctl restart container@hive-matrix` as the fix, and carries on —
token and gets a refusal on any other hive's. The name says what it agent accounts, the hive Space and the chat room need no admin.
authenticates as — an account the appservice registration brings into being
— rather than any privilege level, because it carries none.
It needs no promotion for what the hive does with it. Creating the hive
Space and the chat room, writing their hierarchy and join rules, and
inviting agents into them are all ordinary client calls that ride on
being the rooms' own creator at power level 100 — there is no homeserver
admin in any of it, and no Synapse admin API to reach for either, since
tuwunel has none.
Two operations need an admin **sender**: `hivectl matrix promote-user`
and `hivectl matrix reset-password`. Both are `!admin …` messages into
`#admins:<server_name>`, and tuwunel only treats a message as a command
when its sender is already an admin. They're swarm-level operations,
rehomed to the swarm tier rather than granted here; from the hive,
`@hive-<hive>:` has no admin sender to make that call with, so both get the
admin room's refusal rather than an over-privileged credential that
every other call site would also carry. The one hive-side path that
depends on them is the automatic password recovery for an agent that
has lost its stored password — the admin-sender limitation doesn't
touch the ordinary appservice re-login above.
<details><summary>Upgrading a hive that shared one sender account with every other hive</summary>
Nothing to do, and no window where the hive is without an account.
<!-- vale write-good.Passive = NO -->
- **The old shared value at `swarm/services/matrix/sender-token` is read by
nothing.** `hive-c0re` and `swarm-matrix-ctl` both build the path from the
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 boot.** `ensure_hive_user` runs
from the startup sweep. It short-circuits on the token file it already has
— so the first boot after the upgrade keeps running on the shared token
until that file goes — and on a hive with no such file it reads the new
per-hive store path, finds nothing on a store that has never held one, and
falls through to the register-or-appservice-login ladder against
`@hive-<hive>:`. That ladder needs only the `as_token`, which is per hive
and on local disk, so it works with or without a reachable store.
- **To move a hive onto its own account now**, delete its sender-token file
(`hivectl matrix sync-admin` re-provisions, or the next sweep does) — the
ladder then registers `@hive-<hive>:` and persists that account's token.
Only then does the hive stop presenting the shared one.
- **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
new account the old Space's id, the invite it then sends comes back
refused (a non-member can't invite), and the sweep logs it and carries
on — degraded, not crashed. Two ways out, both operator-chosen. Either invite
`@hive-<hive>:` into the existing Space and chat room from a client, which
keeps the history; or delete the hive's stored room-id files, after which
the next sweep creates a Space and chat room owned by the new account and
invites every agent into them. Do one of the two; leaving it gives a hive
that provisions no rooms.
<!-- vale write-good.Passive = YES -->
</details>
<details><summary>Upgrading a hive that used the registration token</summary> <details><summary>Upgrading a hive that used the registration token</summary>
@ -242,10 +185,10 @@ restarts, so the first boot after the switch already has both halves.
- **The per-agent sweep honours existing token files.** It skips any - **The per-agent sweep honours existing token files.** It skips any
agent that already has a `matrix-token`, so it re-registers no account agent that already has a `matrix-token`, so it re-registers no account
and displaces no session. and displaces no session.
- **The sender account may already be an admin** on such a hive (it won the - **The admin account is already admin** on such a hive (it won the
first-user grant when the hive was new). Nothing here demotes it; the first-user grant when the hive was new), so the startup promotion is a
homeserver no longer promotes it, so a hive built fresh has an no-op — upstream's `make_user_admin` short-circuits when the user is
ordinary account and an older one keeps whatever standing it acquired. already joined at power level 100.
- **`/var/lib/hyperhive/matrix-register-token` stays on disk**, read by - **`/var/lib/hyperhive/matrix-register-token` stays on disk**, read by
nothing. Delete it or leave it; neither does any harm. nothing. Delete it or leave it; neither does any harm.
- **`registrationTokenFile` is a removed option.** A config that still - **`registrationTokenFile` is a removed option.** A config that still
@ -294,8 +237,8 @@ Initial rollout settings:
## Hive Matrix Space ## Hive Matrix Space
On first boot, after hive-c0re provisions all agent accounts, it On first boot, after hive-c0re provisions all agent accounts, it
creates a private **Matrix Space** named `"hive"` using its own hive creates a private **Matrix Space** named `"hive"` using the admin
account (`@hive-<hive>:<server_name>`) and invites every provisioned agent account (`@hive:<server_name>`) and invites every provisioned agent
into it. This gives the operator a single Space in FluffyChat or any into it. This gives the operator a single Space in FluffyChat or any
Matrix client that groups all agent-to-agent + operator rooms in one Matrix client that groups all agent-to-agent + operator rooms in one
place. place.
@ -333,9 +276,9 @@ services.hyperhive.swarm.matrix = {
``` ```
**`trustedServers`** (default `[]`) — list of peer homeserver names **`trustedServers`** (default `[]`) — list of peer homeserver names
whose signing keys tuwunel fetches and trusts. tuwunel enables whose signing keys tuwunel will fetch and trust. tuwunel enables federation
federation at the protocol level from first boot (`allow_federation = at the protocol level from first boot (`allow_federation = true`) but
true`) but trusts no remote homeserver until you list it here. For a closed trusts no remote homeserver until you list it here. For a closed
single-hive deployment the default empty list is correct — add peer single-hive deployment the default empty list is correct — add peer
hive domains here when connecting hives into a swarm (see hive domains here when connecting hives into a swarm (see
[`docs/swarm/`](../swarm/README.md)). [`docs/swarm/`](../swarm/README.md)).

View file

@ -54,15 +54,14 @@ strategy for every credential, including the mTLS leaf.
<!-- vale write-good.Passive = NO --> <!-- vale write-good.Passive = NO -->
| store path | minter | reader — pulls at runtime, holds in memory | renewal | | store path | minter | reader — pulls at runtime, holds in memory | renewal |
| -------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------- | | -------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | -------------- |
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | | `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated |
| `swarm/agents/<agent>/bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated | | `swarm/agents/<agent>/bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated |
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | | `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated |
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated | | `swarm/hives/<hive>/queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated |
| `swarm/hives/<hive>/queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated | | `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated |
| `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | | _(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 |
| _(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 |
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->

View file

@ -165,7 +165,7 @@ Manual entry point to the same idempotent provisioning c0re runs at boot — for
###### **Subcommands:** ###### **Subcommands:**
* `create-user` — Create or refresh the matrix account + access token for `<name>` * `create-user` — Create or refresh the matrix account + access token for `<name>`
* `sync-admin` — Provision (or re-provision) the matrix appservice's sender account * `sync-admin` — Provision (or re-provision) the hive system admin matrix account
* `promote-user` — Promote a matrix user to homeserver admin * `promote-user` — Promote a matrix user to homeserver admin
* `reset-password` — Reset a matrix user's password via the admin API * `reset-password` — Reset a matrix user's password via the admin API
* `invite` — Invite a matrix user to the hive Space, or a specific room with `--room`. Idempotent * `invite` — Invite a matrix user to the hive Space, or a specific room with `--room`. Idempotent
@ -193,9 +193,9 @@ For an existing agent, persists the token to its state dir; for a human/other ac
## `hivectl matrix sync-admin` ## `hivectl matrix sync-admin`
Provision (or re-provision) the matrix appservice's sender account. Provision (or re-provision) the hive system admin matrix account.
Runs automatically on startup; run manually to recover a missing access token. Runs automatically on startup; run manually to recover a missing admin token.
**Usage:** `hivectl matrix sync-admin` **Usage:** `hivectl matrix sync-admin`

View file

@ -64,7 +64,7 @@ running (`services.hyperhive.deploy.matrix.enable = true`).
hivectl matrix create-user iris # provision (or re-provision) matrix account for agent `iris` hivectl matrix create-user iris # provision (or re-provision) matrix account for agent `iris`
hivectl matrix create-user mara # create matrix account for a human; prints access_token to stdout hivectl matrix create-user mara # create matrix account for a human; prints access_token to stdout
hivectl matrix create-user mara --password hunter2 # set a client-login password hivectl matrix create-user mara --password hunter2 # set a client-login password
hivectl matrix sync-admin # provision / refresh the appservice's sender account hivectl matrix sync-admin # provision / refresh the hive internal admin account
hivectl matrix promote-user mara # promote an existing matrix user to homeserver admin hivectl matrix promote-user mara # promote an existing matrix user to homeserver admin
hivectl matrix reset-password iris # generate and set a new random password for `iris`; prints it hivectl matrix reset-password iris # generate and set a new random password for `iris`; prints it
hivectl matrix invite mara # invite a user to the hive Space hivectl matrix invite mara # invite a user to the hive Space
@ -74,23 +74,19 @@ hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a spec
- `create-user`: for agents, persists the `access_token` to - `create-user`: for agents, persists the `access_token` to
`<state>/matrix-token`. Skips registration when the file already `<state>/matrix-token`. Skips registration when the file already
exists — delete it first to force re-registration. exists — delete it first to force re-registration.
- `sync-admin`: ensures this hive's appservice sender account - `sync-admin`: ensures the hive's internal admin matrix user exists
(`@hive-<hive>:<server_name>`, one per hive) exists (used by `hive-c0re` for admin-room commands). Token persisted to the
(the account `hive-c0re` provisions rooms with). Token persisted to the admin token path. Safe to run again — idempotent.
access token path. Safe to run again — idempotent.
- `promote-user`: promotes an already-registered user to homeserver - `promote-user`: promotes an already-registered user to homeserver
admin by an `!admin` command in `#admins`. ⚠️ Needs an admin **sender**, admin via the matrix admin API. Requires `sync-admin` to have run
which `@hive-<hive>:` isn't — promotion is a swarm-level operation, rehomed to first (needs a valid admin token).
the swarm tier rather than granted here, so it has no admin sender to - `reset-password`: calls the matrix admin API to set a new random
call it with from the hive. password and prints it to stdout. Useful if an agent or human lost
- `reset-password`: asks the admin room to set a new random credentials.
password and prints it to stdout. ⚠️ Same admin-**sender** requirement,
and the same swarm-level rehoming, so it's unavailable from the hive
too. Useful if an agent or human lost credentials.
- `invite`: invites a matrix user (full `@user:server` or a bare - `invite`: invites a matrix user (full `@user:server` or a bare
localpart, qualified with the homeserver's `server_name`) to the hive localpart, qualified with the homeserver's `server_name`) to the hive
Space by default, or to a `--room` id / `#alias`. Uses the sender Space by default, or to a `--room` id / `#alias`. Uses the hive admin
token; the account must be a member of the target room with token; the admin account must be a member of the target room with
invite power (it owns the hive Space, so that case always works). invite power (it owns the hive Space, so that case always works).
Idempotent — already-member / already-invited is a no-op. Idempotent — already-member / already-invited is a no-op.
@ -229,7 +225,7 @@ granting that group the one action `machinectl shell` needs); `choom`
checks this itself and refuses up front with a message naming the checks this itself and refuses up front with a message naming the
requirement rather than letting systemd reject the exec later with an requirement rather than letting systemd reject the exec later with an
opaque polkit prompt. Same caveat as the socket grant: a shell opened opaque polkit prompt. Same caveat as the socket grant: a shell opened
before the operator added you to `hive-admin` doesn't see it until you log before the operator added you to `hive-admin` won't see it until you log
back in — secondary group membership applies at login. back in — secondary group membership applies at login.
It also needs the daemon socket, unlike the other exec-into-a-container It also needs the daemon socket, unlike the other exec-into-a-container

View file

@ -94,20 +94,6 @@ it.
the grant per hive (and pay for the re-emission), or give each agent container the grant per hive (and pay for the re-emission), or give each agent container
its own store identity so credentials never pass through a hive at all. its own store identity so credentials never pass through a hive at all.
### Per-hive credentials live under the hive kind, not beside a service
`swarm_secret_client::policy` scopes the **hive** stanza narrowly (unlike the
agent and service stanzas above) because a hive's own path names it, so
scoping to the reader's own name costs nothing and drifts nowhere. That only
holds if a credential that must be one-per-hive is actually stored under the
hive kind — the matrix sender token was originally published to
`swarm/services/matrix/sender-token`, under the **service** kind, and so was
readable by every hive though it belonged to only one. Making it per-hive meant
moving its path under the hive kind, not narrowing the service stanza's grant.
**A credential that must be one-per-hive goes under `Kind::Hive`**; putting it
under `Kind::Service` and expecting the grant to scope it — that's the mistake
this note exists to stop.
### Threat model: prompt injection → confused deputy ### Threat model: prompt injection → confused deputy
The realistic adversary **never needs to breach the container**. They supply The realistic adversary **never needs to breach the container**. They supply

View file

@ -156,9 +156,6 @@
services.hyperhive.deploy.nats.authPackage = services.hyperhive.deploy.nats.authPackage =
lib.mkDefault lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-nats-auth; self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-nats-auth;
services.hyperhive.deploy.matrix.ctlPackage =
lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-matrix-ctl;
services.hyperhive.deploy.authelia.bridgePackage = services.hyperhive.deploy.authelia.bridgePackage =
lib.mkDefault lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-authelia-bridge; self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-authelia-bridge;

View file

@ -65,43 +65,18 @@ const HTTP_TIMEOUT_SECS: u64 = 10;
/// store it nowhere. /// store it nowhere.
const PASSWORD_BYTES: usize = 32; const PASSWORD_BYTES: usize = 32;
/// Matrix localpart this hive acts as. Not an agent; has no state dir. /// Matrix localpart for the hive system admin account. Not an agent; has
/// /// no state dir.
/// **Per hive, derived from the hive's name** — `hive-<name>`, built by
/// `swarm_secret_client::matrix::hive_localpart`, which is the one place the
/// derivation lives. It used to be the bare constant `hive`, and a swarm runs
/// one homeserver: every hive on it logged in as the same `@hive:`, so the
/// homeserver could not attribute an action to a hive and revoking one hive's
/// standing revoked all of them.
/// ///
/// Also the `sender_localpart` of the hive's appservice registration, /// Also the `sender_localpart` of the hive's appservice registration,
/// which is what creates this account on a homeserver that has never had /// which is what creates this account on a homeserver that has never had
/// one: the homeserver creates a registration's sender user itself, at /// one: the homeserver creates a registration's sender user itself, at
/// startup, before it accepts a request. See /// startup, before it accepts a request. The account's *admin rights*
/// `nix/host-modules/hive-matrix.nix`, where the same string is rendered as /// then come from the `admin_execute` promotion beside that registration
/// `hiveLocalpart`. **The two must match**; nothing wires an override /// — see `nix/host-modules/hive-matrix.nix`, where this same literal
/// across. /// appears as `adminLocalpart`. **The two must match**; nothing wires an
/// /// override across.
/// An ordinary account, with no homeserver-admin standing: what it pub const HIVE_ADMIN_LOCALPART: &str = "hive";
/// provisions — the Space, the chat room, the invites — it provisions as
/// the creator of those rooms.
///
/// # Errors
/// When `HYPERHIVE_HIVE_NAME` is unset or empty. That is the same variable
/// [`stored_sender_token`] logs in with and `hive-c0re`'s meta flake asserts
/// for every hive, so its absence is a broken deployment rather than a
/// supported one — and there is no safe default to fall back to: the only one
/// available is the shared account this replaced.
pub fn hive_localpart() -> Result<String> {
let hive = std::env::var("HYPERHIVE_HIVE_NAME")
.ok()
.filter(|h| !h.is_empty())
.context(
"matrix: HYPERHIVE_HIVE_NAME is unset, so this hive has no matrix account of its own \
— the localpart is derived from the hive name and has no swarm-wide fallback",
)?;
Ok(swarm_secret_client::matrix::hive_localpart(&hive))
}
/// Display name of the hive Space. Plain text, no special characters, so /// Display name of the hive Space. Plain text, no special characters, so
/// the Space stays rediscoverable by name (no room alias needed) even when /// the Space stays rediscoverable by name (no room alias needed) even when
@ -119,11 +94,11 @@ pub const HIVE_CHAT_ROOM_NAME: &str = "hive-chat";
const HIVE_CHAT_ROOM_TOPIC: &str = const HIVE_CHAT_ROOM_TOPIC: &str =
"Hive-wide chat for all agents and the operator. Auto-provisioned by hive-c0re."; "Hive-wide chat for all agents and the operator. Auto-provisioned by hive-c0re.";
/// Host path for the appservice sender account's matrix access token. Outside every /// Host path for the hive admin matrix access token. Outside every
/// purgeable path — not deleted by `destroy --purge` on any agent. /// purgeable path — not deleted by `destroy --purge` on any agent.
#[must_use] #[must_use]
pub fn sender_token_path() -> PathBuf { pub fn admin_token_path() -> PathBuf {
crate::paths::matrix_sender_token() crate::paths::matrix_admin_token()
} }
/// Token file inside the agent's bind-mounted state dir (visible as /// Token file inside the agent's bind-mounted state dir (visible as
@ -407,26 +382,21 @@ async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Re
extract_access_token(&json) extract_access_token(&json)
} }
/// Auto-recovery helper: reset a user's matrix password through the admin /// Auto-recovery helper: reset a user's matrix password via the admin API
/// room when the locally stored password is missing. Returns the new /// when the locally stored password is missing. Requires a valid hive admin
/// password (already persisted to [`password_path`]) on success. /// token at [`admin_token_path()`]. Returns the new password (already
/// /// persisted to [`password_path`]) on success.
/// ⚠️ Needs an **admin sender**, which `@hive-<hive>:` is not — the reset is a
/// `!admin` command and tuwunel only treats a message as a command when
/// its sender is an admin in that room. So this recovery path fails until
/// the two admin operations are rehomed at swarm level; the ordinary
/// route (the stored password, or an appservice login) is unaffected.
/// ///
/// Called by [`ensure_user_for`] when registration returns `M_USER_IN_USE` /// Called by [`ensure_user_for`] when registration returns `M_USER_IN_USE`
/// but the password file is absent — covers the case where agent state dirs /// but the password file is absent — covers the case where agent state dirs
/// were wiped but the homeserver still has the accounts. /// were wiped but the homeserver still has the accounts.
async fn auto_reset_password(client: &reqwest::Client, name: &str) -> anyhow::Result<String> { async fn auto_reset_password(client: &reqwest::Client, name: &str) -> anyhow::Result<String> {
let sender_token = read_sender_token() let admin_token = read_admin_token()
.context("matrix: the matrix sender token is unavailable for auto-recovery")?; .context("matrix: admin token unavailable for auto-recovery; provision hive admin first")?;
let server_name = discover_server_name(client) let server_name = discover_server_name(client)
.await .await
.context("matrix: discover_server_name for auto-recovery")?; .context("matrix: discover_server_name for auto-recovery")?;
let effective_password = reset_user_password(client, &sender_token, name, &server_name) let effective_password = reset_user_password(client, &admin_token, name, &server_name)
.await .await
.with_context(|| format!("matrix: admin-room password reset for {name} (auto-recovery)"))?; .with_context(|| format!("matrix: admin-room password reset for {name} (auto-recovery)"))?;
tracing::info!(%name, "matrix: auto-recovered password via admin-room reset"); tracing::info!(%name, "matrix: auto-recovered password via admin-room reset");
@ -446,7 +416,7 @@ fn encode_room_id_for_url(room_id: &str) -> String {
/// Look up the room ID for the `#admins:<server>` alias. /// Look up the room ID for the `#admins:<server>` alias.
async fn discover_admin_room_id( async fn discover_admin_room_id(
client: &reqwest::Client, client: &reqwest::Client,
sender_token: &str, admin_token: &str,
server_name: &str, server_name: &str,
) -> Result<String> { ) -> Result<String> {
let base = matrix_base()?; let base = matrix_base()?;
@ -455,7 +425,7 @@ async fn discover_admin_room_id(
let url = format!("{base}/_matrix/client/v3/directory/room/{encoded_alias}"); let url = format!("{base}/_matrix/client/v3/directory/room/{encoded_alias}");
let resp = client let resp = client
.get(&url) .get(&url)
.bearer_auth(sender_token) .bearer_auth(admin_token)
.send() .send()
.await .await
.context("matrix: GET admin room alias")?; .context("matrix: GET admin room alias")?;
@ -583,7 +553,7 @@ mod extract_new_password_tests {
/// Generic over `T` so both password-returning and `()` callers share the loop. /// Generic over `T` so both password-returning and `()` callers share the loop.
async fn admin_room_send_and_poll<T>( async fn admin_room_send_and_poll<T>(
client: &reqwest::Client, client: &reqwest::Client,
sender_token: &str, admin_token: &str,
server_name: &str, server_name: &str,
room_url: &str, room_url: &str,
command: &str, command: &str,
@ -596,7 +566,7 @@ async fn admin_room_send_and_poll<T>(
format!("{base}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}"); format!("{base}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}");
let send_resp = client let send_resp = client
.put(&send_url) .put(&send_url)
.bearer_auth(sender_token) .bearer_auth(admin_token)
.json(&serde_json::json!({"msgtype": "m.text", "body": command})) .json(&serde_json::json!({"msgtype": "m.text", "body": command}))
.send() .send()
.await .await
@ -617,13 +587,13 @@ async fn admin_room_send_and_poll<T>(
// Poll for bot response: fetch the 20 most recent events (newest-first) // Poll for bot response: fetch the 20 most recent events (newest-first)
// on each tick. Walk the list until we hit our own command event_id; // on each tick. Walk the list until we hit our own command event_id;
// everything *before* that marker arrived after our command. // everything *before* that marker arrived after our command.
let own_user_id = format!("@{}:{server_name}", hive_localpart()?); let own_user_id = format!("@{HIVE_ADMIN_LOCALPART}:{server_name}");
let poll_url = format!("{base}/_matrix/client/v3/rooms/{room_url}/messages?dir=b&limit=20"); let poll_url = format!("{base}/_matrix/client/v3/rooms/{room_url}/messages?dir=b&limit=20");
for _ in 0..15_u8 { for _ in 0..15_u8 {
tokio::time::sleep(std::time::Duration::from_secs(1)).await; tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let poll_json = client let poll_json = client
.get(&poll_url) .get(&poll_url)
.bearer_auth(sender_token) .bearer_auth(admin_token)
.send() .send()
.await .await
.context("matrix: admin room poll")? .context("matrix: admin room poll")?
@ -671,22 +641,22 @@ async fn admin_room_send_and_poll<T>(
} }
/// Reset a user's password via the Matrix admin room (`#admins:<server>`). /// Reset a user's password via the Matrix admin room (`#admins:<server>`).
/// Sends `!admin users reset-password @<localpart>:<server>` as `@hive-<hive>:`, polls for /// Sends `!admin users reset-password @<localpart>:<server>` as @hive, polls for the bot's
/// the bot's response containing the new password. /// response containing the new password.
/// ///
/// Returns the new password; caller is responsible for persisting it. /// Returns the new password; caller is responsible for persisting it.
async fn admin_room_reset_password( async fn admin_room_reset_password(
client: &reqwest::Client, client: &reqwest::Client,
sender_token: &str, admin_token: &str,
server_name: &str, server_name: &str,
localpart: &str, localpart: &str,
) -> Result<String> { ) -> Result<String> {
let room_id = discover_admin_room_id(client, sender_token, server_name).await?; let room_id = discover_admin_room_id(client, admin_token, server_name).await?;
let room_url = encode_room_id_for_url(&room_id); let room_url = encode_room_id_for_url(&room_id);
let command = format!("!admin users reset-password @{localpart}:{server_name}"); let command = format!("!admin users reset-password @{localpart}:{server_name}");
admin_room_send_and_poll( admin_room_send_and_poll(
client, client,
sender_token, admin_token,
server_name, server_name,
&room_url, &room_url,
&command, &command,
@ -791,15 +761,13 @@ pub async fn ensure_user_for(client: &reqwest::Client, name: &str, as_token: &st
{ {
pw pw
} else { } else {
// Password file missing — attempt auto-recovery through the // Password file missing — attempt auto-recovery via admin API.
// admin room.
// This covers the case where agent state dirs were wiped but the // This covers the case where agent state dirs were wiped but the
// homeserver still has the accounts. Requires the hive's sender // homeserver still has the accounts. Requires the hive admin
// token at /var/lib/hyperhive/matrix/access-token, and an admin // token at /var/lib/hyperhive/matrix/admin-token.
// sender, which `@hive-<hive>:` is not.
tracing::info!( tracing::info!(
%name, %name,
"matrix: stored password missing, attempting admin-room auto-recovery" "matrix: stored password missing, attempting admin-API auto-recovery"
); );
match auto_reset_password(client, name).await { match auto_reset_password(client, name).await {
Ok(new_pw) => new_pw, Ok(new_pw) => new_pw,
@ -915,52 +883,42 @@ pub async fn sync_agent_standalone(name: &str) {
sync_agent(&client, name, &as_token).await; sync_agent(&client, name, &as_token).await;
} }
/// Ensure the hive's `@hive-<hive>:` matrix user exists and that its access token is /// Ensure the hive system admin matrix user exists, that its token is
/// persisted at [`sender_token_path()`]. /// persisted at [`admin_token_path()`], and that it actually holds admin
/// rights.
/// ///
/// **Nothing here depends on registration order, and nothing here is /// **Nothing here depends on registration order any more.** The account
/// privileged.** The account used to have to be the first ever /// used to have to be the first ever registered, to win tuwunel's
/// registered, to win tuwunel's automatic first-user grant — a rule that /// automatic first-user grant — a rule that cannot fire for an
/// cannot fire for an appservice-created account at all. It is now an /// appservice-created account at all, and one that silently did nothing
/// ordinary account: the homeserver creates it because it is the /// on a homeserver that already had users. Admin rights now come from an
/// appservice registration's `sender_localpart`, and everything the hive /// explicit `make_user_admin`: the `admin_execute` entry beside the
/// provisions with it, it provisions as the creator of those rooms. /// appservice registration performs it at homeserver startup, and
/// [`ensure_admin_rights`] checks the result and says so when it is
/// missing.
/// ///
/// Idempotent — skips the account work when the token file already exists /// Idempotent — skips the account work when the token file already exists
/// and is non-empty. /// and is non-empty.
/// pub async fn ensure_admin_user(client: &reqwest::Client, as_token: &str) -> Result<()> {
/// The token is taken from the **swarm secret store** when it is there:
/// `swarm-matrix-ctl`, the oneshot inside the matrix container, publishes
/// it under an identity of its own, and taking it from there is what lets a
/// hive that holds no `as_token` have an admin at all. The mint ladder below
/// stays as the fallback for a store that is empty, unconfigured or
/// unreachable — which is every swarm whose matrix container predates that
/// binary.
pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Result<()> {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
let path = sender_token_path(); let path = admin_token_path();
if path.exists() if path.exists()
&& let Ok(existing) = std::fs::read_to_string(&path) && let Ok(existing) = std::fs::read_to_string(&path)
&& !existing.trim().is_empty() && !existing.trim().is_empty()
{ {
tracing::debug!("matrix: the sender token is already present"); tracing::debug!("matrix: hive admin token already present");
return Ok(()); return Ok(());
} }
if let Some(token) = stored_sender_token().await {
return persist_sender_token(&path, &token);
}
// 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 password = random_password()?;
let access_token = match register_user(client, &localpart, as_token, &password).await { let access_token = match register_user(client, HIVE_ADMIN_LOCALPART, as_token, &password).await
{
Ok(token) => { Ok(token) => {
let pw_path = password_path(&localpart); let pw_path = password_path(HIVE_ADMIN_LOCALPART);
if let Some(parent) = pw_path.parent() { if let Some(parent) = pw_path.parent() {
std::fs::create_dir_all(parent).ok(); std::fs::create_dir_all(parent).ok();
} }
if let Err(e) = std::fs::write(&pw_path, format!("{password}\n")) { 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"); tracing::warn!(error = ?e, "matrix: failed to persist hive admin password");
} else { } else {
let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600)); let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600));
} }
@ -973,108 +931,125 @@ pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Resul
// hive-c0re gets a chance to ask. An appservice login needs // hive-c0re gets a chance to ask. An appservice login needs
// no password, which is just as well since an account the // no password, which is just as well since an account the
// homeserver created has none. // homeserver created has none.
tracing::info!(%localpart, "matrix: the sender account already exists, logging in as the appservice"); tracing::info!("matrix: hive admin user already exists, logging in as the appservice");
match appservice_login(client, as_token, &localpart).await { match appservice_login(client, as_token, HIVE_ADMIN_LOCALPART).await {
Ok(token) => token, Ok(token) => token,
Err(e) => { Err(e) => {
tracing::warn!(error = ?e, %localpart, "matrix: appservice login for the sender account failed; falling back to the stored password"); tracing::warn!(error = ?e, "matrix: appservice login for the hive admin failed; falling back to the stored password");
let pw_path = password_path(&localpart); let pw_path = password_path(HIVE_ADMIN_LOCALPART);
let stored = std::fs::read_to_string(&pw_path) let stored = std::fs::read_to_string(&pw_path)
.ok() .ok()
.map(|s| s.trim().to_owned()) .map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.with_context(|| { .with_context(|| {
format!( format!(
"matrix: @{localpart}: exists, appservice login failed, and no \ "matrix: hive admin user exists, appservice login failed, and no \
password is stored at {} — check that the registration file's \ password is stored at {} — check that the registration file's \
namespace covers @{localpart} and that the homeserver \ namespace covers @{HIVE_ADMIN_LOCALPART} and that the homeserver \
loaded it", loaded it",
pw_path.display() pw_path.display()
) )
})?; })?;
login_user(client, &localpart, &stored).await? login_user(client, HIVE_ADMIN_LOCALPART, &stored).await?
} }
} }
} }
Err(other) => return Err(other), Err(other) => return Err(other),
}; };
persist_sender_token(&path, &access_token)
}
/// 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.
fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok(); std::fs::create_dir_all(parent).ok();
} }
std::fs::write(path, format!("{access_token}\n")) std::fs::write(&path, format!("{access_token}\n"))
.with_context(|| format!("matrix: write the sender token to {}", path.display()))?; .with_context(|| format!("matrix: write hive admin token to {}", path.display()))?;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
tracing::info!(path = %path.display(), "matrix: provisioned the sender token"); tracing::info!(path = %path.display(), "matrix: provisioned hive admin token");
Ok(()) Ok(())
} }
/// Fetch the sender token `swarm-matrix-ctl` published, under /// Check that the hive admin account holds admin rights, and repair it
/// this hive's own store identity. /// through the admin room when it does not.
/// ///
/// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` — /// Admin-ness in tuwunel is membership of the admin room, so that is what
/// the same role string `workers::credential` logs in with, and already in /// this reads: the account's own joined-rooms list, which needs no
/// this process's environment, so the store read costs no plumbing through /// privileges to fetch. When the admin room is in it there is nothing to
/// [`ensure_all`]. That name is also the path's own hive segment, which is /// do and nothing is sent — worth insisting on, because the repair is a
/// what makes this read reach **this** hive's token and 403 on any other's: /// message in a room and this runs on every sweep.
/// the grant behind it is the hive's own `swarm/hives/<name>/*` stanza, not
/// the swarm-wide `swarm/services/*` one the shared token used to sit under.
/// ///
/// `None`, never an error, for every way this can come up empty — no hive /// When it is absent, the repair is attempted anyway (a stale or failed
/// name, no `BAO_*` identity, an unreachable store, nothing at the path. All /// read is cheaper to retry than to reason about) and a failure is
/// four mean the same thing to the caller ("mint it the old way"), and three /// reported rather than raised: agent accounts, the hive Space and the
/// of them are the ordinary state of a swarm that has not deployed `swarm-matrix-ctl` /// chat room all work without an admin, so a hive with an unpromoted
/// yet, so raising would turn a supported deployment into a warning every /// admin is degraded, not broken. Only `hivectl matrix promote-user` /
/// sweep. /// `reset-password` need it.
/// ///
/// 🩸 Logs the store **path** and never the value. /// Returns whether the account holds admin rights now. A `false` has
async fn stored_sender_token() -> Option<String> { /// already been logged, with what to do about it.
let hive = std::env::var("HYPERHIVE_HIVE_NAME") async fn ensure_admin_rights(
.ok() client: &reqwest::Client,
.filter(|h| !h.is_empty())?; admin_token: &str,
let path = match swarm_secret_client::matrix::sender_token_path(&hive) { server_name: &str,
Ok(path) => path, ) -> bool {
let room_id = match discover_admin_room_id(client, admin_token, server_name).await {
Ok(id) => id,
Err(e) => { Err(e) => {
tracing::warn!(error = %e, "matrix: this hive's name does not form a store path"); tracing::warn!(error = ?e, "matrix: cannot resolve #admins — not checking the hive admin's rights");
return None; return false;
} }
}; };
let store = match swarm_secret_client::SecretStore::from_env(&hive).await { match joined_rooms(client, admin_token).await {
Ok(store) => store, Some(rooms) if rooms.iter().any(|r| r == &room_id) => {
Err(e) => { tracing::debug!("matrix: hive admin is in the admin room");
tracing::debug!(error = %e, "matrix: no swarm secret store to read the sender token from"); return true;
return None;
} }
}; Some(_) => {
match store tracing::warn!(
.read::<swarm_secret_client::matrix::Credential>(&path) "matrix: hive admin is not in the admin room — attempting to promote it"
.await );
{
Ok(credential) if !credential.value.trim().is_empty() => {
tracing::info!(%path, "matrix: taking the sender token from the swarm store");
Some(credential.value)
}
Ok(_) => {
tracing::warn!(%path, "matrix: the stored sender token is empty; minting instead");
None
}
Err(e) => {
tracing::debug!(%path, error = %e, "matrix: no sender token in the store; minting instead");
None
} }
None => tracing::debug!(
"matrix: could not read the hive admin's joined rooms; attempting to promote it"
),
} }
if let Err(e) =
promote_user_to_admin(client, admin_token, HIVE_ADMIN_LOCALPART, server_name).await
{
// The honest message. Promoting through the admin room requires
// being admin already, so the one lever that works on a
// homeserver with no admin at all is the `admin_execute` entry in
// the hive-matrix module — which runs at startup, which means a
// restart is the fix rather than another sweep.
tracing::warn!(
error = ?e,
"matrix: hive admin '@{HIVE_ADMIN_LOCALPART}' holds no admin rights. \
The homeserver promotes it at startup (admin_execute in hive-matrix.nix), \
so `systemctl restart container@hive-matrix` grants it. Agent accounts and \
room provisioning are unaffected; `hivectl matrix promote-user` and \
`reset-password` need it."
);
return false;
}
tracing::info!("matrix: promoted the hive admin through the admin room");
true
}
/// The rooms `token`'s account has joined, or `None` when the list could
/// not be read. `None` is deliberately not an empty list: "no rooms" and
/// "no answer" lead to different decisions in [`ensure_admin_rights`].
async fn joined_rooms(client: &reqwest::Client, token: &str) -> Option<Vec<String>> {
let base = matrix_http()?;
let url = format!("{base}/_matrix/client/v3/joined_rooms");
let resp = client.get(&url).bearer_auth(token).send().await.ok()?;
if !resp.status().is_success() {
return None;
}
let body = resp.json::<serde_json::Value>().await.ok()?;
Some(
body["joined_rooms"]
.as_array()?
.iter()
.filter_map(|r| r.as_str().map(ToOwned::to_owned))
.collect(),
)
} }
/// Whether an admin-room reply says a `make-user-admin` succeeded. /// Whether an admin-room reply says a `make-user-admin` succeeded.
@ -1128,16 +1103,12 @@ mod is_make_admin_success_tests {
/// Promote a user to homeserver admin via the Matrix admin room /// Promote a user to homeserver admin via the Matrix admin room
/// (`#admins:<server>`). Sends `!admin users make-user-admin @<localpart>:<server>` as /// (`#admins:<server>`). Sends `!admin users make-user-admin @<localpart>:<server>` as
/// `@hive-<hive>:`, polls for the bot's success reply. /// @hive, polls for the bot's success reply.
/// ///
/// ⚠️ Requires the **sender** to be an admin already — tuwunel only /// ⚠️ Requires the **sender** to be an admin already — tuwunel only
/// treats a message as a command when its sender is in the admin room. /// treats a message as a command when its sender is in the admin room.
/// `@hive-<hive>:` is an ordinary account (`hive-matrix.nix` grants it no /// So this promotes a *second* user; it cannot bootstrap the first one.
/// `admin_execute` promotion), so this call has no working sender from /// That is what the `admin_execute` entry in `hive-matrix.nix` is for.
/// the hive and fails with the admin room's refusal. Promotion is a
/// swarm-level operation and is being rehomed as such; this stays here,
/// failing loudly, rather than justifying an over-privileged token that
/// all 13 ordinary call sites would also carry.
/// ///
/// Goes through the admin room rather than a direct HTTP call because /// Goes through the admin room rather than a direct HTTP call because
/// tuwunel implements parts of the Synapse admin API but not user /// tuwunel implements parts of the Synapse admin API but not user
@ -1145,16 +1116,16 @@ mod is_make_admin_success_tests {
/// intended long-term mechanism, not a stopgap awaiting an upstream fix. /// intended long-term mechanism, not a stopgap awaiting an upstream fix.
pub async fn promote_user_to_admin( pub async fn promote_user_to_admin(
client: &reqwest::Client, client: &reqwest::Client,
sender_token: &str, admin_token: &str,
localpart: &str, localpart: &str,
server_name: &str, server_name: &str,
) -> Result<()> { ) -> Result<()> {
let room_id = discover_admin_room_id(client, sender_token, server_name).await?; let room_id = discover_admin_room_id(client, admin_token, server_name).await?;
let room_url = encode_room_id_for_url(&room_id); let room_url = encode_room_id_for_url(&room_id);
let command = format!("!admin users make-user-admin @{localpart}:{server_name}"); let command = format!("!admin users make-user-admin @{localpart}:{server_name}");
admin_room_send_and_poll( admin_room_send_and_poll(
client, client,
sender_token, admin_token,
server_name, server_name,
&room_url, &room_url,
&command, &command,
@ -1172,25 +1143,19 @@ pub async fn promote_user_to_admin(
/// Reset a user's password via the Matrix admin room (`#admins:<server>`). /// Reset a user's password via the Matrix admin room (`#admins:<server>`).
/// ///
/// Sends `!admin users reset-password @<localpart>:<server>` to the admin room as /// Sends `!admin users reset-password @<localpart>:<server>` to the admin room as @hive,
/// `@hive-<hive>:`, polls for the bot's response containing the new password, and persists /// polls for the bot's response containing the new password, and persists
/// it to the non-purgeable creds path so [`ensure_user_for`] can re-login /// it to the non-purgeable creds path so [`ensure_user_for`] can re-login
/// on the next provisioning sweep. /// on the next provisioning sweep.
/// ///
/// ⚠️ Same admin-**sender** requirement as [`promote_user_to_admin`], and
/// the same consequence: `@hive-<hive>:` is an ordinary account with no admin
/// sender, and reset, like promotion, is a swarm-level operation rehomed
/// to the swarm tier rather than granted here — so it has no working
/// sender from the hive either.
///
/// Returns the new password for use in subsequent `login_user` calls. /// Returns the new password for use in subsequent `login_user` calls.
pub async fn reset_user_password( pub async fn reset_user_password(
client: &reqwest::Client, client: &reqwest::Client,
sender_token: &str, admin_token: &str,
localpart: &str, localpart: &str,
server_name: &str, server_name: &str,
) -> Result<String> { ) -> Result<String> {
let pw = admin_room_reset_password(client, sender_token, server_name, localpart) let pw = admin_room_reset_password(client, admin_token, server_name, localpart)
.await .await
.with_context(|| { .with_context(|| {
format!("matrix: admin-room password reset for @{localpart}:{server_name}") format!("matrix: admin-room password reset for @{localpart}:{server_name}")
@ -1240,19 +1205,19 @@ pub async fn discover_server_name(client: &reqwest::Client) -> Result<String> {
}) })
} }
/// Read the sender token from disk. Returns an error if it /// Read the hive admin access token from disk. Returns an error if it
/// is absent — callers should gate their homeserver calls on this. /// is absent — callers should gate their admin-API calls on this.
pub fn read_sender_token() -> Result<String> { pub fn read_admin_token() -> Result<String> {
let path = sender_token_path(); let path = admin_token_path();
std::fs::read_to_string(&path) std::fs::read_to_string(&path)
.ok() .ok()
.map(|s| s.trim().to_owned()) .map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.with_context(|| { .with_context(|| {
format!( format!(
"the matrix sender token was not found at {} — \ "hive admin matrix token not found at {} — \
ensure hive-c0re has started at least once with matrix enabled \ ensure hive-c0re has started at least once with matrix enabled \
(it provisions the hive's @hive-<hive>: account on boot)", (it provisions the admin account on boot)",
path.display() path.display()
) )
}) })
@ -1279,12 +1244,12 @@ fn persist_space_room_id(room_id: &str) -> Result<()> {
/// ///
/// Name-based (not alias-based) rediscovery keeps the Space free of any /// Name-based (not alias-based) rediscovery keeps the Space free of any
/// special-char room alias — the hardcoded plain name is the anchor. /// special-char room alias — the hardcoded plain name is the anchor.
async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Option<String> { async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Option<String> {
let base = matrix_http()?; let base = matrix_http()?;
let joined_url = format!("{base}/_matrix/client/v3/joined_rooms"); let joined_url = format!("{base}/_matrix/client/v3/joined_rooms");
let joined: serde_json::Value = client let joined: serde_json::Value = client
.get(&joined_url) .get(&joined_url)
.bearer_auth(sender_token) .bearer_auth(admin_token)
.send() .send()
.await .await
.ok()? .ok()?
@ -1301,7 +1266,7 @@ async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Opt
let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/"); let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/");
let is_space = match client let is_space = match client
.get(&create_url) .get(&create_url)
.bearer_auth(sender_token) .bearer_auth(admin_token)
.send() .send()
.await .await
{ {
@ -1317,7 +1282,7 @@ async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Opt
} }
// …and named HIVE_SPACE_NAME (m.room.name `name`). // …and named HIVE_SPACE_NAME (m.room.name `name`).
let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/"); let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/");
let name_matches = match client.get(&name_url).bearer_auth(sender_token).send().await { let name_matches = match client.get(&name_url).bearer_auth(admin_token).send().await {
Ok(r) if r.status().is_success() => r Ok(r) if r.status().is_success() => r
.json::<serde_json::Value>() .json::<serde_json::Value>()
.await .await
@ -1334,7 +1299,7 @@ async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Opt
/// Create (or recover) the hive Matrix Space and persist its room ID to /// Create (or recover) the hive Matrix Space and persist its room ID to
/// [`hive_space_room_id_path()`]. The Space is a private `m.space` owned by /// [`hive_space_room_id_path()`]. The Space is a private `m.space` owned by
/// `@hive-<hive>:`, identified by its hardcoded name [`HIVE_SPACE_NAME`] (no alias). /// `@hive`, identified by its hardcoded name [`HIVE_SPACE_NAME`] (no alias).
/// ///
/// Dedup strategy (single canonical space): /// Dedup strategy (single canonical space):
/// 1. If the room-id file exists, reuse it. /// 1. If the room-id file exists, reuse it.
@ -1348,7 +1313,7 @@ async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Opt
/// ///
/// Returns an error if the homeserver is unreachable, `createRoom` fails, /// Returns an error if the homeserver is unreachable, `createRoom` fails,
/// or the room-ID file cannot be written. /// or the room-ID file cannot be written.
pub async fn ensure_hive_space(client: &reqwest::Client, sender_token: &str) -> Result<String> { pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> Result<String> {
let base = matrix_base()?; let base = matrix_base()?;
// 1. Stored room id wins (fast path). // 1. Stored room id wins (fast path).
if let Ok(existing) = std::fs::read_to_string(hive_space_room_id_path()) { if let Ok(existing) = std::fs::read_to_string(hive_space_room_id_path()) {
@ -1361,7 +1326,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, sender_token: &str) ->
// 2. No stored id — rediscover the existing space by its hardcoded name // 2. No stored id — rediscover the existing space by its hardcoded name
// before creating a new one (prevents duplicate spaces after a wipe). // before creating a new one (prevents duplicate spaces after a wipe).
if let Some(room_id) = find_space_by_name(client, sender_token).await { if let Some(room_id) = find_space_by_name(client, admin_token).await {
persist_space_room_id(&room_id)?; persist_space_room_id(&room_id)?;
tracing::info!(%room_id, "matrix: recovered hive space by name"); tracing::info!(%room_id, "matrix: recovered hive space by name");
return Ok(room_id); return Ok(room_id);
@ -1377,7 +1342,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, sender_token: &str) ->
}); });
let resp = client let resp = client
.post(&url) .post(&url)
.bearer_auth(sender_token) .bearer_auth(admin_token)
.json(&body) .json(&body)
.send() .send()
.await .await
@ -1417,12 +1382,12 @@ fn persist_chat_room_id(room_id: &str) -> Result<()> {
/// [`find_space_by_name`] so a lost room-id file recovers the existing chat /// [`find_space_by_name`] so a lost room-id file recovers the existing chat
/// room instead of spawning a duplicate. `None` if the homeserver is /// room instead of spawning a duplicate. `None` if the homeserver is
/// unreachable or no match exists. /// unreachable or no match exists.
async fn find_chat_room_by_name(client: &reqwest::Client, sender_token: &str) -> Option<String> { async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) -> Option<String> {
let base = matrix_http()?; let base = matrix_http()?;
let joined_url = format!("{base}/_matrix/client/v3/joined_rooms"); let joined_url = format!("{base}/_matrix/client/v3/joined_rooms");
let joined: serde_json::Value = client let joined: serde_json::Value = client
.get(&joined_url) .get(&joined_url)
.bearer_auth(sender_token) .bearer_auth(admin_token)
.send() .send()
.await .await
.ok()? .ok()?
@ -1439,7 +1404,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, sender_token: &str) ->
let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/"); let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/");
let is_space = match client let is_space = match client
.get(&create_url) .get(&create_url)
.bearer_auth(sender_token) .bearer_auth(admin_token)
.send() .send()
.await .await
{ {
@ -1455,7 +1420,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, sender_token: &str) ->
} }
// …and named HIVE_CHAT_ROOM_NAME (m.room.name `name`). // …and named HIVE_CHAT_ROOM_NAME (m.room.name `name`).
let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/"); let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/");
let name_matches = match client.get(&name_url).bearer_auth(sender_token).send().await { let name_matches = match client.get(&name_url).bearer_auth(admin_token).send().await {
Ok(r) if r.status().is_success() => r Ok(r) if r.status().is_success() => r
.json::<serde_json::Value>() .json::<serde_json::Value>()
.await .await
@ -1495,10 +1460,10 @@ fn state_needs_write(current: Option<&serde_json::Value>, desired: &serde_json::
/// too, loudly — and a 404 is the expected first-setup case. /// too, loudly — and a 404 is the expected first-setup case.
async fn current_room_state( async fn current_room_state(
client: &reqwest::Client, client: &reqwest::Client,
sender_token: &str, admin_token: &str,
url: &str, url: &str,
) -> Option<serde_json::Value> { ) -> Option<serde_json::Value> {
let resp = match client.get(url).bearer_auth(sender_token).send().await { let resp = match client.get(url).bearer_auth(admin_token).send().await {
Ok(resp) => resp, Ok(resp) => resp,
Err(e) => { Err(e) => {
tracing::debug!(error = ?e, url, "matrix: state read unreachable; writing"); tracing::debug!(error = ?e, url, "matrix: state read unreachable; writing");
@ -1526,7 +1491,7 @@ async fn current_room_state(
} }
} }
/// PUT a state event into `room_id` using the sender token, **skipping the /// PUT a state event into `room_id` using the admin token, **skipping the
/// write when the room already carries identical content**. /// write when the room already carries identical content**.
/// ///
/// The read is not an optimisation. A PUT of identical content is a no-op /// The read is not an optimisation. A PUT of identical content is a no-op
@ -1541,7 +1506,7 @@ async fn current_room_state(
/// missing or divergent link still gets written. /// missing or divergent link still gets written.
async fn set_room_state( async fn set_room_state(
client: &reqwest::Client, client: &reqwest::Client,
sender_token: &str, admin_token: &str,
room_id: &str, room_id: &str,
event_type: &str, event_type: &str,
state_key: &str, state_key: &str,
@ -1552,7 +1517,7 @@ async fn set_room_state(
let encoded_key = encode_room_id_for_url(state_key); let encoded_key = encode_room_id_for_url(state_key);
let url = let url =
format!("{base}/_matrix/client/v3/rooms/{encoded_room}/state/{event_type}/{encoded_key}"); format!("{base}/_matrix/client/v3/rooms/{encoded_room}/state/{event_type}/{encoded_key}");
let current = current_room_state(client, sender_token, &url).await; let current = current_room_state(client, admin_token, &url).await;
if !state_needs_write(current.as_ref(), content) { if !state_needs_write(current.as_ref(), content) {
tracing::debug!( tracing::debug!(
%room_id, %room_id,
@ -1563,7 +1528,7 @@ async fn set_room_state(
} }
let resp = client let resp = client
.put(&url) .put(&url)
.bearer_auth(sender_token) .bearer_auth(admin_token)
.json(content) .json(content)
.send() .send()
.await .await
@ -1600,7 +1565,7 @@ async fn set_room_state(
/// link is logged but not fatal (the room still exists + is joinable). /// link is logged but not fatal (the room still exists + is joinable).
pub async fn ensure_hive_chat_room( pub async fn ensure_hive_chat_room(
client: &reqwest::Client, client: &reqwest::Client,
sender_token: &str, admin_token: &str,
space_room_id: &str, space_room_id: &str,
server_name: &str, server_name: &str,
) -> Result<String> { ) -> Result<String> {
@ -1614,7 +1579,7 @@ pub async fn ensure_hive_chat_room(
{ {
tracing::debug!(room_id = %id, "matrix: hive chat room already provisioned"); tracing::debug!(room_id = %id, "matrix: hive chat room already provisioned");
id id
} else if let Some(id) = find_chat_room_by_name(client, sender_token).await { } else if let Some(id) = find_chat_room_by_name(client, admin_token).await {
persist_chat_room_id(&id)?; persist_chat_room_id(&id)?;
tracing::info!(room_id = %id, "matrix: recovered hive chat room by name"); tracing::info!(room_id = %id, "matrix: recovered hive chat room by name");
id id
@ -1654,7 +1619,7 @@ pub async fn ensure_hive_chat_room(
}); });
let resp = client let resp = client
.post(&url) .post(&url)
.bearer_auth(sender_token) .bearer_auth(admin_token)
.json(&body) .json(&body)
.send() .send()
.await .await
@ -1685,7 +1650,7 @@ pub async fn ensure_hive_chat_room(
}); });
if let Err(e) = set_room_state( if let Err(e) = set_room_state(
client, client,
sender_token, admin_token,
space_room_id, space_room_id,
"m.space.child", "m.space.child",
&room_id, &room_id,
@ -1703,21 +1668,21 @@ pub async fn ensure_hive_chat_room(
/// account. Idempotent — treats already-member responses as success. /// account. Idempotent — treats already-member responses as success.
async fn invite_to_room( async fn invite_to_room(
client: &reqwest::Client, client: &reqwest::Client,
sender_token: &str, admin_token: &str,
room_id: &str, room_id: &str,
localpart: &str, localpart: &str,
server_name: &str, server_name: &str,
) -> Result<()> { ) -> Result<()> {
let user_id = format!("@{localpart}:{server_name}"); let user_id = format!("@{localpart}:{server_name}");
invite_user_id(client, sender_token, room_id, &user_id).await invite_user_id(client, admin_token, room_id, &user_id).await
} }
/// Fetch a user's current membership in a room via the sender token, or /// Fetch a user's current membership in a room via the admin token, or
/// `None` if there is no membership event (never invited) or the lookup /// `None` if there is no membership event (never invited) or the lookup
/// fails. Returns the raw membership string (`invite`, `join`, `leave`, …). /// fails. Returns the raw membership string (`invite`, `join`, `leave`, …).
async fn room_membership( async fn room_membership(
client: &reqwest::Client, client: &reqwest::Client,
sender_token: &str, admin_token: &str,
encoded_room_id: &str, encoded_room_id: &str,
user_id: &str, user_id: &str,
) -> Option<String> { ) -> Option<String> {
@ -1730,7 +1695,7 @@ async fn room_membership(
); );
let resp = client let resp = client
.get(&url) .get(&url)
.bearer_auth(sender_token) .bearer_auth(admin_token)
.send() .send()
.await .await
.ok()?; .ok()?;
@ -1744,13 +1709,13 @@ async fn room_membership(
} }
/// Invite a fully-qualified Matrix user id (`@user:server`) to `room_id` /// Invite a fully-qualified Matrix user id (`@user:server`) to `room_id`
/// using the sender token. Idempotent: a user who is already a member or /// using the admin token. Idempotent: a user who is already a member or
/// already has a pending invite is left untouched (no fresh invite is sent, /// already has a pending invite is left untouched (no fresh invite is sent,
/// so they are not re-notified), and a 403 `M_FORBIDDEN` / `M_BAD_STATE` /// so they are not re-notified), and a 403 `M_FORBIDDEN` / `M_BAD_STATE`
/// from a racing invite is still treated as success. /// from a racing invite is still treated as success.
async fn invite_user_id( async fn invite_user_id(
client: &reqwest::Client, client: &reqwest::Client,
sender_token: &str, admin_token: &str,
room_id: &str, room_id: &str,
user_id: &str, user_id: &str,
) -> Result<()> { ) -> Result<()> {
@ -1762,7 +1727,7 @@ async fn invite_user_id(
// Skip the invite entirely when the user is already invited or joined. // Skip the invite entirely when the user is already invited or joined.
// Re-POSTing an invite to a pending member re-sends the invite event, // Re-POSTing an invite to a pending member re-sends the invite event,
// which re-notifies the agent on every provisioning sweep. // which re-notifies the agent on every provisioning sweep.
if let Some(membership) = room_membership(client, sender_token, &encoded_room_id, user_id).await if let Some(membership) = room_membership(client, admin_token, &encoded_room_id, user_id).await
&& matches!(membership.as_str(), "invite" | "join") && matches!(membership.as_str(), "invite" | "join")
{ {
tracing::debug!(%user_id, %room_id, %membership, "matrix: invite skipped (already a member/invited)"); tracing::debug!(%user_id, %room_id, %membership, "matrix: invite skipped (already a member/invited)");
@ -1772,7 +1737,7 @@ async fn invite_user_id(
let url = format!("{base}/_matrix/client/v3/rooms/{encoded_room_id}/invite"); let url = format!("{base}/_matrix/client/v3/rooms/{encoded_room_id}/invite");
let resp = client let resp = client
.post(&url) .post(&url)
.bearer_auth(sender_token) .bearer_auth(admin_token)
.json(&serde_json::json!({ "user_id": user_id })) .json(&serde_json::json!({ "user_id": user_id }))
.send() .send()
.await .await
@ -1805,13 +1770,13 @@ async fn invite_user_id(
/// ///
/// # Errors /// # Errors
/// ///
/// Returns an error if the sender token or `server_name` can't be read, /// Returns an error if the admin token or `server_name` can't be read,
/// the target room can't be resolved (no `--room` and no persisted hive /// the target room can't be resolved (no `--room` and no persisted hive
/// space), or the invite POST fails for a reason other than the user /// space), or the invite POST fails for a reason other than the user
/// already being a member / invited. /// already being a member / invited.
pub async fn invite_user( pub async fn invite_user(
client: &reqwest::Client, client: &reqwest::Client,
sender_token: &str, admin_token: &str,
user: &str, user: &str,
room_override: Option<&str>, room_override: Option<&str>,
server_name: &str, server_name: &str,
@ -1825,7 +1790,7 @@ pub async fn invite_user(
// Resolve the room: explicit override (id or #alias) wins; otherwise // Resolve the room: explicit override (id or #alias) wins; otherwise
// the persisted hive Space. // the persisted hive Space.
let room_id = match room_override { let room_id = match room_override {
Some(r) if r.starts_with('#') => resolve_room_alias(client, sender_token, r).await?, Some(r) if r.starts_with('#') => resolve_room_alias(client, admin_token, r).await?,
Some(r) if r.starts_with('!') => r.to_owned(), Some(r) if r.starts_with('!') => r.to_owned(),
Some(r) => anyhow::bail!( Some(r) => anyhow::bail!(
"matrix: --room {r:?} is neither a room id nor an alias; \ "matrix: --room {r:?} is neither a room id nor an alias; \
@ -1839,14 +1804,14 @@ pub async fn invite_user(
(run the hive-c0re matrix sweep first)", (run the hive-c0re matrix sweep first)",
)?, )?,
}; };
invite_user_id(client, sender_token, &room_id, &user_id).await?; invite_user_id(client, admin_token, &room_id, &user_id).await?;
Ok(room_id) Ok(room_id)
} }
/// Resolve a `#alias:server` to its room id via the directory API. /// Resolve a `#alias:server` to its room id via the directory API.
async fn resolve_room_alias( async fn resolve_room_alias(
client: &reqwest::Client, client: &reqwest::Client,
sender_token: &str, admin_token: &str,
alias: &str, alias: &str,
) -> Result<String> { ) -> Result<String> {
let base = matrix_base()?; let base = matrix_base()?;
@ -1854,7 +1819,7 @@ async fn resolve_room_alias(
let url = format!("{base}/_matrix/client/v3/directory/room/{encoded}"); let url = format!("{base}/_matrix/client/v3/directory/room/{encoded}");
let resp = client let resp = client
.get(&url) .get(&url)
.bearer_auth(sender_token) .bearer_auth(admin_token)
.send() .send()
.await .await
.with_context(|| format!("matrix: GET directory for {alias}"))?; .with_context(|| format!("matrix: GET directory for {alias}"))?;
@ -1910,12 +1875,13 @@ pub async fn ensure_all() -> bool {
return false; return false;
} }
}; };
// The hive's `@hive-<hive>:` account first, because everything below provisions // The hive admin first, because everything below provisions THROUGH
// THROUGH it (the Space, the chat room and every invite are sent with // it (the Space, the chat room and every invite are sent with its
// its token) — as an ordinary user that created those rooms, not as a // token). Not, any more, so that it wins a first-registered-user
// homeserver admin. // grant: it holds admin rights by explicit promotion, checked in
if let Err(e) = ensure_hive_user(&client, &as_token).await { // `provision_space` once the server name is known.
tracing::warn!(error = ?e, "matrix: ensure_hive_user failed"); if let Err(e) = ensure_admin_user(&client, &as_token).await {
tracing::warn!(error = ?e, "matrix: ensure_admin_user failed");
ok = false; ok = false;
} }
let Ok(containers) = crate::lifecycle::list().await else { let Ok(containers) = crate::lifecycle::list().await else {
@ -1945,10 +1911,10 @@ pub async fn ensure_all() -> bool {
async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bool { async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bool {
let mut ok = true; let mut ok = true;
// server_name is needed to form full Matrix user IDs for invites. // server_name is needed to form full Matrix user IDs for invites.
let sender_token = match read_sender_token() { let admin_token = match read_admin_token() {
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no matrix sender token)"); tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no admin token)");
return false; return false;
} }
}; };
@ -1960,55 +1926,60 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
return false; return false;
} }
}; };
let room_id = match ensure_hive_space(client, &sender_token).await { // Before the rooms: the one thing in this sweep that is about the
// admin account itself rather than about what it provisions.
if !ensure_admin_rights(client, &admin_token, &server_name).await {
ok = false;
}
let room_id = match ensure_hive_space(client, &admin_token).await {
Ok(id) => id, Ok(id) => id,
Err(e) => { Err(e) => {
tracing::warn!(error = ?e, "matrix: ensure_hive_space failed"); tracing::warn!(error = ?e, "matrix: ensure_hive_space failed");
return false; return false;
} }
}; };
// This hive's own account first, then all agents. // Invite @hive admin first, then all agents.
let localpart = match hive_localpart() { if let Err(e) = invite_to_room(
Ok(l) => l, client,
Err(e) => { &admin_token,
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning"); &room_id,
return false; HIVE_ADMIN_LOCALPART,
} &server_name,
}; )
if let Err(e) = invite_to_room(client, &sender_token, &room_id, &localpart, &server_name).await .await
{ {
tracing::warn!(error = ?e, %localpart, "matrix: invite the hive account to the space failed"); tracing::warn!(error = ?e, "matrix: invite @hive to space failed");
ok = false; ok = false;
} }
for name in agent_names { for name in agent_names {
if let Err(e) = invite_to_room(client, &sender_token, &room_id, name, &server_name).await { if let Err(e) = invite_to_room(client, &admin_token, &room_id, name, &server_name).await {
tracing::warn!(%name, error = ?e, "matrix: invite agent to space failed"); tracing::warn!(%name, error = ?e, "matrix: invite agent to space failed");
ok = false; ok = false;
} }
} }
// Provision the default hive chat room as an m.space.child of the Space // Provision the default hive chat room as an m.space.child of the Space
// and invite `@hive-<hive>:` + every agent. Joining a Space alone surfaces no // and invite @hive + every agent. Joining a Space alone surfaces no
// rooms to chat in (Matrix semantics — children aren't auto-joined), so // rooms to chat in (Matrix semantics — children aren't auto-joined), so
// without this the Space is empty. The restricted join rule additionally // without this the Space is empty. The restricted join rule additionally
// lets the operator (a Space member) join from the Space hierarchy. // lets the operator (a Space member) join from the Space hierarchy.
match ensure_hive_chat_room(client, &sender_token, &room_id, &server_name).await { match ensure_hive_chat_room(client, &admin_token, &room_id, &server_name).await {
Ok(chat_room_id) => { Ok(chat_room_id) => {
if let Err(e) = invite_to_room( if let Err(e) = invite_to_room(
client, client,
&sender_token, &admin_token,
&chat_room_id, &chat_room_id,
&localpart, HIVE_ADMIN_LOCALPART,
&server_name, &server_name,
) )
.await .await
{ {
tracing::warn!(error = ?e, %localpart, "matrix: invite the hive account to the chat room failed"); tracing::warn!(error = ?e, "matrix: invite @hive to chat room failed");
ok = false; ok = false;
} }
for name in agent_names { for name in agent_names {
if let Err(e) = if let Err(e) =
invite_to_room(client, &sender_token, &chat_room_id, name, &server_name).await invite_to_room(client, &admin_token, &chat_room_id, name, &server_name).await
{ {
tracing::warn!(%name, error = ?e, "matrix: invite agent to chat room failed"); tracing::warn!(%name, error = ?e, "matrix: invite agent to chat room failed");
ok = false; ok = false;

View file

@ -2,7 +2,7 @@
//! `/run/hyperhive` + `/run/hive-agent` runtime roots). //! `/run/hyperhive` + `/run/hive-agent` runtime roots).
//! //!
//! Historically these were flat string literals scattered across many //! Historically these were flat string literals scattered across many
//! modules (`broker.sqlite`, `matrix-sender-token`, `agent-sockets.json`, //! modules (`broker.sqlite`, `matrix-admin-token`, `agent-sockets.json`,
//! …). This module is the **single Rust-side source** for every host //! …). This module is the **single Rust-side source** for every host
//! path — the strictly host-side ones grouped into subdirs (`db/`, //! path — the strictly host-side ones grouped into subdirs (`db/`,
//! `forge/`, `matrix/`, `run/`), plus the **nix-coupled** roots //! `forge/`, `matrix/`, `run/`), plus the **nix-coupled** roots
@ -128,7 +128,7 @@ pub fn agent_identity_dir(name: &str) -> PathBuf {
agent_identity_root().join(name) agent_identity_root().join(name)
} }
/// `matrix/` — host-side matrix provisioning state (the appservice sender token, hive /// `matrix/` — host-side matrix provisioning state (admin token, hive
/// Space room id, per-agent password creds). The shared registration /// Space room id, per-agent password creds). The shared registration
/// token is bind-mounted into the tuwunel container via nix and stays /// token is bind-mounted into the tuwunel container via nix and stays
/// at its own path (tracked separately). /// at its own path (tracked separately).
@ -137,10 +137,10 @@ pub fn matrix_dir() -> PathBuf {
state_root().join("matrix") state_root().join("matrix")
} }
/// `matrix/access-token` — the matrix appservice sender account's access token. /// `matrix/admin-token` — hive system admin access token.
#[must_use] #[must_use]
pub fn matrix_sender_token() -> PathBuf { pub fn matrix_admin_token() -> PathBuf {
matrix_dir().join("access-token") matrix_dir().join("admin-token")
} }
/// `matrix/space-room-id` — persisted hive Space room id. /// `matrix/space-room-id` — persisted hive Space room id.
@ -327,7 +327,7 @@ pub fn relocate_legacy_state() {
"forge-agent-configs-avatar-set", "forge-agent-configs-avatar-set",
forge_config_org_avatar_marker(), forge_config_org_avatar_marker(),
), ),
("matrix-sender-token", matrix_sender_token()), ("matrix-admin-token", matrix_admin_token()),
("matrix-space-room-id", matrix_space_room_id()), ("matrix-space-room-id", matrix_space_room_id()),
("matrix-creds", matrix_creds_dir()), ("matrix-creds", matrix_creds_dir()),
("agent-sockets.json", agent_sockets_file()), ("agent-sockets.json", agent_sockets_file()),

View file

@ -455,7 +455,7 @@ async fn stream_agent_status(
// The `hivectl matrix` subcommands used to run these in-process, which forced // The `hivectl matrix` subcommands used to run these in-process, which forced
// the standalone CLI to link the whole daemon crate (matrix-sdk, reqwest, …). // the standalone CLI to link the whole daemon crate (matrix-sdk, reqwest, …).
// They now run daemon-side over the host socket: the daemon already holds the // They now run daemon-side over the host socket: the daemon already holds the
// register + sender tokens and the matrix creds dir. Each op returns the // register + admin tokens and the matrix creds dir. Each op returns the
// operator-facing lines hivectl used to `println!` in `HostResponse::messages` // operator-facing lines hivectl used to `println!` in `HostResponse::messages`
// for the client to print verbatim. // for the client to print verbatim.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -777,14 +777,14 @@ async fn handle_matrix_sync_admin() -> Result<HostResponse> {
let as_token = let as_token =
crate::matrix::read_appservice_token().context("read matrix appservice token")?; crate::matrix::read_appservice_token().context("read matrix appservice token")?;
let client = matrix_http_client()?; let client = matrix_http_client()?;
crate::matrix::ensure_hive_user(&client, &as_token) crate::matrix::ensure_admin_user(&client, &as_token)
.await .await
.context("matrix sync-admin")?; .context("matrix sync-admin")?;
let path = crate::matrix::sender_token_path(); let path = crate::matrix::admin_token_path();
Ok(HostResponse::messages(vec![ Ok(HostResponse::messages(vec![
format!( format!(
"matrix: the @{}: user is provisioned", "matrix: hive admin user '@{}' provisioned",
crate::matrix::hive_localpart()? crate::matrix::HIVE_ADMIN_LOCALPART
), ),
format!("token persisted at: {}", path.display()), format!("token persisted at: {}", path.display()),
])) ]))
@ -792,12 +792,12 @@ async fn handle_matrix_sync_admin() -> Result<HostResponse> {
async fn handle_matrix_promote_user(name: &str) -> Result<HostResponse> { async fn handle_matrix_promote_user(name: &str) -> Result<HostResponse> {
require_matrix_present()?; require_matrix_present()?;
let sender_token = crate::matrix::read_sender_token()?; let admin_token = crate::matrix::read_admin_token()?;
let client = matrix_http_client()?; let client = matrix_http_client()?;
let server_name = crate::matrix::discover_server_name(&client) let server_name = crate::matrix::discover_server_name(&client)
.await .await
.context("discover matrix server_name")?; .context("discover matrix server_name")?;
crate::matrix::promote_user_to_admin(&client, &sender_token, name, &server_name) crate::matrix::promote_user_to_admin(&client, &admin_token, name, &server_name)
.await .await
.with_context(|| format!("matrix promote-user {name}"))?; .with_context(|| format!("matrix promote-user {name}"))?;
Ok(HostResponse::messages(vec![format!( Ok(HostResponse::messages(vec![format!(
@ -807,12 +807,12 @@ async fn handle_matrix_promote_user(name: &str) -> Result<HostResponse> {
async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResponse> { async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResponse> {
require_matrix_present()?; require_matrix_present()?;
let sender_token = crate::matrix::read_sender_token()?; let admin_token = crate::matrix::read_admin_token()?;
let client = matrix_http_client()?; let client = matrix_http_client()?;
let server_name = crate::matrix::discover_server_name(&client) let server_name = crate::matrix::discover_server_name(&client)
.await .await
.context("discover matrix server_name")?; .context("discover matrix server_name")?;
let room_id = crate::matrix::invite_user(&client, &sender_token, user, room, &server_name) let room_id = crate::matrix::invite_user(&client, &admin_token, user, room, &server_name)
.await .await
.with_context(|| format!("matrix invite {user}"))?; .with_context(|| format!("matrix invite {user}"))?;
let target = if user.starts_with('@') { let target = if user.starts_with('@') {
@ -827,12 +827,12 @@ async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResp
async fn handle_matrix_reset_password(name: &str) -> Result<HostResponse> { async fn handle_matrix_reset_password(name: &str) -> Result<HostResponse> {
require_matrix_present()?; require_matrix_present()?;
let sender_token = crate::matrix::read_sender_token()?; let admin_token = crate::matrix::read_admin_token()?;
let client = matrix_http_client()?; let client = matrix_http_client()?;
let server_name = crate::matrix::discover_server_name(&client) let server_name = crate::matrix::discover_server_name(&client)
.await .await
.context("discover matrix server_name")?; .context("discover matrix server_name")?;
crate::matrix::reset_user_password(&client, &sender_token, name, &server_name) crate::matrix::reset_user_password(&client, &admin_token, name, &server_name)
.await .await
.with_context(|| format!("matrix reset-password {name}"))?; .with_context(|| format!("matrix reset-password {name}"))?;
// Password is persisted by reset_user_password. // Password is persisted by reset_user_password.

View file

@ -335,10 +335,10 @@ pub enum MatrixCmd {
#[arg(long, conflicts_with = "password")] #[arg(long, conflicts_with = "password")]
password_stdin: bool, password_stdin: bool,
}, },
/// Provision (or re-provision) the matrix appservice's sender account. /// Provision (or re-provision) the hive system admin matrix account.
/// ///
/// Runs automatically on startup; run manually to recover a missing /// Runs automatically on startup; run manually to recover a missing
/// access token. /// admin token.
SyncAdmin, SyncAdmin,
/// Promote a matrix user to homeserver admin. /// Promote a matrix user to homeserver admin.
PromoteUser { PromoteUser {

View file

@ -1,5 +1,5 @@
//! `hivectl matrix` — matrix account provisioning verbs. hivectl forwards //! `hivectl matrix` — matrix account provisioning verbs. hivectl forwards
//! each request to the daemon (which owns the register + sender tokens and //! each request to the daemon (which owns the register + admin tokens and
//! the matrix creds dir) and renders the reply; it no longer links the //! the matrix creds dir) and renders the reply; it no longer links the
//! matrix machinery itself. //! matrix machinery itself.
@ -28,7 +28,7 @@ pub(crate) async fn run_matrix_cmd(socket: &Path, cmd: MatrixCmd) -> Result<()>
/// Send a matrix provisioning request to the daemon and print the /// Send a matrix provisioning request to the daemon and print the
/// operator-facing result lines it returns. The daemon owns the register + /// operator-facing result lines it returns. The daemon owns the register +
/// sender tokens and the matrix creds dir, so hivectl no longer links the /// admin tokens and the matrix creds dir, so hivectl no longer links the
/// matrix machinery — it just forwards the request and renders the reply. /// matrix machinery — it just forwards the request and renders the reply.
async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Result<()> { async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Result<()> {
let resp = crate::client::request(socket, req) let resp = crate::client::request(socket, req)

View file

@ -27,7 +27,6 @@
./glue-controller-bao-identity.nix ./glue-controller-bao-identity.nix
./glue-grafana-oidc-client.nix ./glue-grafana-oidc-client.nix
./glue-matrix-bao-token.nix ./glue-matrix-bao-token.nix
./glue-matrix-ctl-bao-identity.nix
./glue-queue-agent-credential.nix ./glue-queue-agent-credential.nix
./glue-secret-publisher-bao-identity.nix ./glue-secret-publisher-bao-identity.nix
./glue-swarm-otel-oidc-client.nix ./glue-swarm-otel-oidc-client.nix

View file

@ -147,19 +147,6 @@ in
# to be this one whenever the store has a host of its own. # to be this one whenever the store has a host of its own.
[ -s ${pkiDir}/secret-publisher.pem ] || ${signLeaf} ${pkiDir} secret-publisher \ [ -s ${pkiDir}/secret-publisher.pem ] || ${signLeaf} ${pkiDir} secret-publisher \
${lib.escapeShellArg deployCfg.bao.secretPublisherCommonName} "" clientAuth ${lib.escapeShellArg deployCfg.bao.secretPublisherCommonName} "" clientAuth
# The matrix container's `swarm-matrix-ctl`. Minted unconditionally like the two
# above, and for the third variant of the same reason: the homeserver
# is a swarm singleton, so on every hive but the one running it this
# leaf is the file an operator copies rather than a file anything
# local reads.
#
# Its own CN, not the reader's: the reader's leaf carries this hive's
# name and its policy reads the whole store, while this principal may
# only write one path — which is the entire point of giving the
# container an identity instead of lending it the hive's.
[ -s ${pkiDir}/matrix-ctl.pem ] || ${signLeaf} ${pkiDir} matrix-ctl \
${lib.escapeShellArg deployCfg.bao.matrixCtlCommonName} "" clientAuth
''; '';
}; };
}; };

View file

@ -1,40 +0,0 @@
# Glue: point the matrix container's `swarm-matrix-ctl` at the bao leaf minted for it.
#
# ONE PAIRING PER FILE — matrix-ctl ← bao, and nothing else. Deleting this leaves a
# binary that takes operator-provided certificate paths, which is what any
# deployment not minting its own already does.
#
# ⚠️ The minting is NOT here. ./glue-bao-tls.nix holds the CA and signs the
# leaf, because the thing that owns a private key owns issuing from it. What
# belongs here is the pairing: which paths matrix-ctl presents.
#
# ⚠️ Gated on the leaf existing, not on the store being enabled — the same rule
# ./glue-secret-publisher-bao-identity.nix states, and it bites harder here: a
# swarm runs ONE homeserver, so the hive hosting it is the one least likely to
# also be the hive hosting the store.
#
# Everything is `mkDefault`. An operator naming their own paths wins.
{
lib,
config,
...
}:
let
hyperhiveCfg = config.services.hyperhive;
deployCfg = hyperhiveCfg.deploy;
baoDeploy = deployCfg.bao;
# Where ./glue-bao-tls.nix puts the leaves, derived from the reader's own path
# rather than repeating that file's directory literal: an operator who moves
# the PKI moves both, and the two cannot drift apart.
haveMintedPki = baoDeploy.clientCertFile != null;
pkiDir = if haveMintedPki then builtins.dirOf baoDeploy.clientCertFile else null;
in
{
config = lib.mkIf (hyperhiveCfg.enable && deployCfg.matrix.enable && haveMintedPki) {
services.hyperhive.deploy.matrix = {
ctlBaoClientCertFile = lib.mkDefault "${pkiDir}/matrix-ctl.pem";
ctlBaoClientKeyFile = lib.mkDefault "${pkiDir}/matrix-ctl-key.pem";
};
};
}

View file

@ -26,11 +26,6 @@ let
# `null/.well-known/…`. # `null/.well-known/…`.
autheliaCfg = config.services.hyperhive.swarm.authelia; autheliaCfg = config.services.hyperhive.swarm.authelia;
deployCfg = config.services.hyperhive.deploy; deployCfg = config.services.hyperhive.deploy;
# Where the swarm's secret store answers. Swarm-tier, identical on every
# host, which is what lets the container address it without knowing whether
# it stands here.
baoCfg = config.services.hyperhive.swarm.bao;
autheliaUrl = autheliaCfg.url; autheliaUrl = autheliaCfg.url;
# The all-local case: this host runs BOTH the homeserver and the swarm's # The all-local case: this host runs BOTH the homeserver and the swarm's
@ -64,30 +59,18 @@ let
# namespace below, with no shared registration secret in the picture. # namespace below, with no shared registration secret in the picture.
appserviceId = "hyperhive"; appserviceId = "hyperhive";
# The appservice's own user, and the account the hive acts as. An # The appservice's own user, and deliberately the hive admin account.
# ordinary user, not a homeserver admin: everything the hive does with # Loading a registration CREATES its `sender_localpart` user when absent
# it — the Space, the chat room, the invites — it does as the creator of # (tuwunel `src/service/appservice/mod.rs`), on a zero-user database,
# those rooms. Loading a registration CREATES its `sender_localpart` user # inside `Services::start()` — before the HTTP listener accepts anything.
# when absent (tuwunel `src/service/appservice/mod.rs`), on a zero-user # That is what lets the `admin_execute` promotion below land on the very
# database, inside `Services::start()` — before the HTTP listener accepts # first boot of a fresh homeserver, instead of depending on hive-c0re
# anything, so the account exists on the very first boot of a fresh # racing to register the first account and win the auto-admin grant.
# homeserver without hive-c0re racing to register it.
# #
# 🩸 **Derived from the hive name, so it is one account per hive.** It was # ⚠️ Must equal `matrix::HIVE_ADMIN_LOCALPART` in hive-c0re, which derives
# the bare `hive` and a swarm runs one homeserver: every hive on it acted # it independently with nothing wiring an override across — same
# as the same `@hive:`, which is one identity with no attribution and no # agreement, and same reason for saying so, as the token path below.
# way to revoke a single hive. The hives that do not run this container adminLocalpart = "hive";
# register their own `hive-<name>` through the appservice namespace below,
# which already admits it.
#
# ⚠️ Must equal `swarm_secret_client::matrix::hive_localpart`, which
# hive-c0re and swarm-matrix-ctl both derive from independently with
# nothing wiring an override across — same agreement, and same reason for
# saying so, as the token path below.
#
# `hiveName` has no fallback here for the reason ./glue-matrix-bao-token.nix
# gives at its own use of it: it is asserted set for every hyperhive host.
hiveLocalpart = "hive-${toString config.services.hyperhive.hiveName}";
# The `as_token`, and the `hs_token` the spec requires alongside it. Both # The `as_token`, and the `hs_token` the spec requires alongside it. Both
# minted by the render script below, mode 0600; the `as_token` is the one # minted by the render script below, mode 0600; the `as_token` is the one
@ -124,62 +107,7 @@ let
appserviceCredentialId = "${appserviceId}-appservice.yaml"; appserviceCredentialId = "${appserviceId}-appservice.yaml";
appserviceCredentialDir = "/run/credentials/tuwunel.service"; appserviceCredentialDir = "/run/credentials/tuwunel.service";
# ── swarm-matrix-ctl ──────────────────────────────────────────────── # Every local user this hive may provision — agents, the hive admin, and
#
# 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.
ctlActive =
deployCfg.matrix.ctlBaoClientCertFile != null && deployCfg.matrix.ctlBaoClientKeyFile != null;
# The role on the store's `cert` auth mount, and so the single source of the
# string both ends must agree on: ./swarm-bao.nix writes the role under
# `matrixCtlPolicyName` and this hands it to the binary in the
# environment, so the binary itself spells no role at all.
ctlCertRole = "swarm-matrix-ctl";
# Every host directory matrix-ctl's bao identity is spread across. Normally
# one — ./glue-bao-tls.nix puts all three files in ./glue's PKI dir — but
# derived rather than assumed, because an operator naming paths by hand is
# exactly the deployment the gate above is written for.
#
# Directories rather than the files, for the reason `appserviceDir`'s own
# comment gives: a re-issued leaf is a new inode, and binding the file would
# pin the one the container saw when it started.
ctlPkiDirs = lib.optionals ctlActive (
lib.unique (
map builtins.dirOf (
[
deployCfg.matrix.ctlBaoClientCertFile
deployCfg.matrix.ctlBaoClientKeyFile
]
++ lib.optional (deployCfg.bao.serverCaFile != null) deployCfg.bao.serverCaFile
)
)
);
ctlBindMounts = lib.genAttrs ctlPkiDirs (dir: {
hostPath = dir;
isReadOnly = true;
});
# 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, `@hive-<hive>:` itself, and
# the operator accounts `hivectl matrix create-user` makes, which is the # the operator accounts `hivectl matrix create-user` makes, which is the
# whole matrix localpart charset. # whole matrix localpart charset.
# #
@ -245,7 +173,7 @@ let
cat <<'REGISTRATION' cat <<'REGISTRATION'
id: ${appserviceId} id: ${appserviceId}
url: null url: null
sender_localpart: ${hiveLocalpart} sender_localpart: ${adminLocalpart}
rate_limited: false rate_limited: false
namespaces: namespaces:
users: users:
@ -758,56 +686,6 @@ in
match authelia's register. match authelia's register.
''; '';
}; };
ctlPackage = lib.mkOption {
type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-matrix-ctl";
description = ''
The `swarm-matrix-ctl` build run inside the matrix container.
⚠️ Named `ctlPackage`, not folded into `package` above: that one is
the homeserver, and this is a hyperhive binary that happens to run
beside it. Same split, and same reason, as
{option}`services.hyperhive.deploy.nats.authPackage`.
'';
};
ctlBaoClientCertFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Client certificate the matrix container's `swarm-matrix-ctl` presents to the
swarm's secret store. Its subject must be
{option}`services.hyperhive.deploy.bao.matrixCtlCommonName` — cert
auth matches on the CN, and the role accepts nothing else.
⚠️ **Not the hive's own leaf**, and that is the whole deliverable of
giving this container an identity: the hive's certificate reads every
secret in the store, while this one may write a single path. Pointing
this at `deploy.bao.clientCertFile` would evaluate, deploy and work —
and give away the separation in one line.
No default: a module that guessed would be holding the CA opinion
./swarm-bao.nix deliberately does not hold.
./glue-matrix-ctl-bao-identity.nix points it at the leaf
./glue-bao-tls.nix mints, where this host mints one.
The file and its key are bind-mounted into the container read-only.
Co-located in the hive's filesystem and therefore readable by hive
**root** — accepted: the boundary this buys is identity (no hive
*process* holds the appservice token), not physical isolation.
'';
};
ctlBaoClientKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Private key for
{option}`services.hyperhive.deploy.matrix.ctlBaoClientCertFile`.
Both or neither — the unit does not exist unless each is set.
'';
};
}; };
config = lib.mkIf deployCfg.matrix.enable { config = lib.mkIf deployCfg.matrix.enable {
@ -1204,11 +1082,7 @@ in
isReadOnly = true; isReadOnly = true;
}; };
} }
// caTrust.bindMount // caTrust.bindMount;
# matrix-ctl's bao client identity, and nothing else of the hive's. See
# `ctlPkiDirs` above for why it is a derived set of directories
# rather than one literal.
// ctlBindMounts;
config = config =
{ ... }: { ... }:
{ {
@ -1318,21 +1192,30 @@ in
# `Services::start()`, before the listener accepts anything. # `Services::start()`, before the listener accepts anything.
appservice_dir = appserviceCredentialDir; appservice_dir = appserviceCredentialDir;
# No `admin_execute` promotion for `@${hiveLocalpart}`. The # The zero-user bootstrap, and the only thing here that needs
# hive's account is an ordinary user: it creates the hive # no account to already exist. These run after startup and
# Space and chat room and invites agents into them, all of # BEFORE the HTTP listener, with no sender and no permission
# which ride on being the rooms' own creator at power level # check — which is what makes them the one lever that can
# 100, and none of which is a homeserver-admin capability. # promote the hive admin on a homeserver where nobody is admin
# Granting it server admin at boot would hand a credential # yet. The appservice registration above creates
# that every hive reads far more than the work needs. # `@${adminLocalpart}` as its sender user moments earlier;
# this joins it to the admin room at power level 100.
# #
# The two operations that do need an admin sender — # Idempotent by upstream's own guard: `make_user_admin`
# `!admin users make-user-admin` and # short-circuits when the user is already joined at 100, so a
# `!admin users reset-password`, both messages into # hive that has had an admin for months emits nothing.
# `#admins:${effectiveServerName}` — therefore have no admin_execute = [
# working sender here. They are swarm-level operations and "users make-user-admin @${adminLocalpart}:${effectiveServerName}"
# are being rehomed as such; until then they fail, loudly, ];
# rather than being served by an over-privileged token.
# ⚠️ Load-bearing, not tidiness. An `admin_execute` command
# that fails aborts startup outright when this is false — so
# the one boot where the promotion cannot work (a homeserver
# that has no `@${adminLocalpart}` and no appservice user yet,
# e.g. a registration file that arrived late) would take the
# homeserver down with it rather than converging on the next
# start.
admin_execute_errors_ignore = true;
# Server-side E2EE is opt-in (default off); the agent matrix # Server-side E2EE is opt-in (default off); the agent matrix
# client always supports decryption regardless. # client always supports decryption regardless.
allow_encryption = cfg.allowEncryption; allow_encryption = cfg.allowEncryption;
@ -1403,66 +1286,6 @@ in
"oidc_client_secret:${toString deployCfg.matrix.sso.clientSecretFile}" "oidc_client_secret:${toString deployCfg.matrix.sso.clientSecretFile}"
]; ];
# Publish the appservice sender account's access token to the swarm
# store, once, under an identity that belongs to this container and
# not to the hive. See `ctlActive` above for why it runs here.
#
# A `oneshot` with no timer and no retry loop of its own: the whole
# of "and only once" is the binary's first act, a read of the path it
# would write. `Restart=on-failure` covers a store that is sealed or
# a homeserver still starting; `RemainAfterExit` is deliberately NOT
# set, because the unit having succeeded is not the idempotency
# record — the store is, and it outlives this machine.
systemd.services.swarm-matrix-ctl = lib.mkIf ctlActive {
description = "publish the matrix sender token to the swarm secret store";
# Ordered after the homeserver because both of the ladder's arms
# are client-server API calls. `wants`, not `requires`: a run that
# finds the credential already published never touches tuwunel at
# all, so a homeserver that is slow to come up should delay this,
# not cancel it.
after = [ "tuwunel.service" ];
wants = [ "tuwunel.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
# The verb is part of the contract: `swarm-matrix-ctl` is a
# subcommand binary and refuses a bare invocation, so dropping
# `mint` here fails the unit rather than doing something else.
ExecStart = "${deployCfg.matrix.ctlPackage}/bin/swarm-matrix-ctl mint";
Restart = "on-failure";
RestartSec = 30;
# Bounded here rather than left to systemd's default, for the
# reason ./swarm-secret-publisher.nix states: a sealed store
# answers on the port and never answers the read.
TimeoutStartSec = 60;
SyslogIdentifier = "swarm-matrix-ctl";
};
environment = {
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
BAO_CLIENT_CERT = deployCfg.matrix.ctlBaoClientCertFile;
BAO_CLIENT_KEY = deployCfg.matrix.ctlBaoClientKeyFile;
MATRIX_MINT_CERT_ROLE = ctlCertRole;
# Loopback: this container shares the host netns, so the
# homeserver it must talk to is the one in this very unit's
# netns and needs no name, no vhost and no TLS.
MATRIX_MINT_API_URL = "http://127.0.0.1:${toString cfg.httpPort}";
# The bind-mounted registration, which IS the as_token. A path,
# never a value.
MATRIX_MINT_REGISTRATION = appserviceRegistrationPath;
MATRIX_MINT_LOCALPART = hiveLocalpart;
# The hive segment of the store path the token is published
# under, and so the thing that keeps this hive's token out of
# every other hive's reach: the grant that reaches it is the
# hive's own `swarm/hives/<name>/*` stanza. ./swarm-bao.nix
# spells the same name into matrix-ctl's write grant.
MATRIX_MINT_HIVE = toString config.services.hyperhive.hiveName;
MATRIX_MINT_HOMESERVER = ctlHomeserverUrl;
}
// lib.optionalAttrs (deployCfg.bao.serverCaFile != null) {
BAO_CACERT = deployCfg.bao.serverCaFile;
};
};
environment.systemPackages = [ deployCfg.matrix.package ]; environment.systemPackages = [ deployCfg.matrix.package ];
}; };
}; };

View file

@ -236,54 +236,6 @@ let
} }
''; '';
# The identity the matrix container's `swarm-matrix-ctl` presents. Named outside `hive-*`
# for the reason its two siblings above give — the controller may rewrite
# every policy under that prefix, and a policy it can rewrite constrains
# nothing.
matrixCtlPolicyName = "swarm-matrix-ctl";
matrixCtlCn = baoDeploy.matrixCtlCommonName;
# ONE path, and every narrowing in it is load-bearing.
#
# `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.
#
# 🩸 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`
# — one value for the whole swarm, under the `services/*` tree every hive's
# own policy grants read on. Under `swarm/hives/<name>/` the only read grant
# that reaches it is that hive's own stanza, so one hive cannot fetch
# 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.
matrixCtlPolicyText = ''
path "${credentialMountPath}/data/swarm/hives/${matrixCtlHive}/matrix/sender-token" {
capabilities = ["create", "update", "read"]
}
'';
# 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.
matrixCtlHive = baoDeploy.matrixCtlHiveName;
# The KV v2 engine the controller writes agent credentials through. Named # The KV v2 engine the controller writes agent credentials through. Named
# once because the grant above and the `secrets enable` in the bootstrap unit # once because the grant above and the `secrets enable` in the bootstrap unit
# have to agree: a policy pointing at a mount nobody created is precisely the # have to agree: a policy pointing at a mount nobody created is precisely the
@ -707,54 +659,6 @@ in
''; '';
}; };
matrixCtlCommonName = lib.mkOption {
type = lib.types.str;
default = "swarm-matrix-ctl";
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.
A **third** identity rather than reuse of either sibling above, and
the narrowest of the three: its grant is one path, that
credential itself. The point of the whole arrangement is that the
process holding the appservice token is not the hive, so handing it
the hive's own leaf — which reads every secret in the store — would
give the separation away in one line.
⚠️ Same collision as its siblings, and the same answer: ./swarm.nix
feeds this value into the guard on
{option}`services.hyperhive.swarm.hives`, so a hive named after it
fails evaluation rather than silently receiving matrix-ctl's grant.
'';
};
matrixCtlHiveName = lib.mkOption {
type = lib.types.str;
default = toString hyperhiveCfg.hiveName;
defaultText = lib.literalExpression "services.hyperhive.hiveName";
example = "pr1ma";
description = ''
Hive whose matrix sender token the store's matrix-ctl role may write.
The sender account's access token is **per hive**: it lives at
`swarm/hives/<name>/matrix/sender-token`, and the only read grant that
reaches it is that hive's own. So matrix-ctl's write grant names one
hive too — the hive whose homeserver container it runs in.
Defaults to this host's own {option}`services.hyperhive.hiveName`,
which is correct whenever the store and the homeserver are co-located.
Set it when they are not: the policy is written where the store runs,
and the oneshot runs where the homeserver does.
A wrong value degrades rather than breaks — matrix-ctl's write is
refused with the store's own message and the hive mints its account
locally instead, the same fallback a swarm that never deployed the
store already uses.
'';
};
serverCaFile = lib.mkOption { serverCaFile = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
default = null; default = null;
@ -1177,7 +1081,7 @@ in
# ⚠️ Deliberately EMPTY: nothing here generates an issuer into it, so # ⚠️ Deliberately EMPTY: nothing here generates an issuer into it, so
# the role below can be written but cannot yet issue. That is the # the role below can be written but cannot yet issue. That is the
# whole shape of this step — the mount, its narrowing and its grant # whole shape of this step — the mount, its narrowing and its grant
# exist, and the binary that uses them arrives separately. Undoing it # exist, and the minter that uses them arrives separately. Undoing it
# is `bao secrets disable ${servicesPkiMountPath}`. # is `bao secrets disable ${servicesPkiMountPath}`.
case "$mounts" in case "$mounts" in
*'"${servicesPkiMountPath}/"'*) ;; *'"${servicesPkiMountPath}/"'*) ;;
@ -1201,7 +1105,7 @@ in
# is a name type this issuer is unconstrained for. # is a name type this issuer is unconstrained for.
# `server_flag` alone because that is what today's leaf carries # `server_flag` alone because that is what today's leaf carries
# (`extendedKeyUsage=serverAuth`, ./hive-tls.nix:144), and rsa:4096 # (`extendedKeyUsage=serverAuth`, ./hive-tls.nix:144), and rsa:4096
# because that is the key the current implementation generates. # because that is the key the current minter generates.
# #
# An empty `swarm.serviceDomains` renders an empty `allowed_domains`, # An empty `swarm.serviceDomains` renders an empty `allowed_domains`,
# which with `allow_any_name=false` is a role that issues nothing — # which with `allow_any_name=false` is a role that issues nothing —
@ -1318,54 +1222,6 @@ in
''; '';
}; };
# A THIRD sibling, for the reason the second one's comment gives: these
# unit names are operator-facing strings, and the grant written here
# belongs to a principal neither of the other two names. Same `after`
# rather than `requires`, and for the same stated reason — the first unit
# creates the mounts this one writes into, but a failed oneshot still
# counts as finished, so only ordering plus this unit's own retry
# converges.
systemd.services.swarm-bao-matrix-ctl-policy = lib.mkIf haveBootstrapToken {
description = "write swarm-matrix-ctl's bao policy and cert-auth role";
after = [
"container@${cfg.machine}.service"
"swarm-bao-controller-policy.service"
];
wantedBy = [ "multi-user.target" ];
path = [
baoCli
pkgs.coreutils
];
unitConfig.ConditionPathExists = baoDeploy.bootstrapTokenFile;
# Same unseal wait as its two siblings above, for the reason stated
# there: under `seal = "shamir"` a human unseals by hand.
startLimitBurst = 2880;
startLimitIntervalSec = 90000;
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
Restart = "on-failure";
RestartSec = 30;
};
script = ''
set -euo pipefail
BAO_TOKEN="$(cat ${lib.escapeShellArg baoDeploy.bootstrapTokenFile})"
export BAO_TOKEN
printf '%s' ${lib.escapeShellArg matrixCtlPolicyText} |
bao policy write ${lib.escapeShellArg matrixCtlPolicyName} -
''
+ lib.optionalString (baoDeploy.clientCaFile != null) ''
bao write auth/cert/certs/${lib.escapeShellArg matrixCtlPolicyName} \
certificate=@${tlsDir}/client-ca.pem \
allowed_common_names=${lib.escapeShellArg matrixCtlCn} \
token_policies=${lib.escapeShellArg matrixCtlPolicyName} \
display_name=${lib.escapeShellArg matrixCtlCn}
'';
};
containers.${cfg.machine} = { containers.${cfg.machine} = {
autoStart = true; autoStart = true;
ephemeral = false; ephemeral = false;

View file

@ -46,7 +46,6 @@ let
certAuthCns = [ certAuthCns = [
deployCfg.bao.controllerCommonName deployCfg.bao.controllerCommonName
deployCfg.bao.secretPublisherCommonName deployCfg.bao.secretPublisherCommonName
deployCfg.bao.matrixCtlCommonName
]; ];
# Public hostnames of the swarm's own services, in declaration order. # Public hostnames of the swarm's own services, in declaration order.

Some files were not shown because too many files have changed in this diff Show more