Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1261b525d6 | ||
|
|
637b308d4d | ||
|
|
19cae964c8 | ||
|
|
67ba28448f | ||
|
|
fb9c6122df | ||
|
|
bbb4e471ea | ||
|
|
88b4a7841d | ||
|
|
f778122f5a |
32 changed files with 2105 additions and 344 deletions
14
Cargo.lock
generated
14
Cargo.lock
generated
|
|
@ -4924,6 +4924,20 @@ dependencies = [
|
|||
"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]]
|
||||
name = "swarm-nats-auth"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ members = [
|
|||
"swarm-authelia-bridge",
|
||||
"swarm-authelia-bridge-sock",
|
||||
"swarm-controller",
|
||||
"swarm-matrix-ctl",
|
||||
"swarm-nats-auth",
|
||||
"swarm-queue-client",
|
||||
"swarm-logs",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
store that took its identity from an authority it will itself distribute could
|
||||
store that took its identity from an authority it itself distributes could
|
||||
never come up before that authority.
|
||||
|
||||
### 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
|
||||
like a broken login rather than a missing group.
|
||||
|
||||
If an account already exists without it, `user add` will refuse rather
|
||||
than amend — adding the group afterwards is `swarmctl user update mara
|
||||
If an account already exists without it, `user add` refuses rather
|
||||
than amends — adding the group afterwards is `swarmctl user update mara
|
||||
--add-group admins`.
|
||||
|
||||
Detail, including what the password is and why this stays manual:
|
||||
|
|
@ -229,7 +229,7 @@ control: [`swarm/ui.md`](../swarm/ui.md).
|
|||
### 6 · Matrix
|
||||
|
||||
```bash
|
||||
# Ensure the hive-internal admin account exists first
|
||||
# Ensure the appservice's sender account exists first
|
||||
hivectl matrix sync-admin
|
||||
|
||||
# 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
|
||||
hold a regular agent token in their `forge-token` file; sensitive
|
||||
creds (the core token, the matrix admin token) live on the host.
|
||||
creds (the core token) live on the host.
|
||||
- All config changes (forge PRs on `agent-configs/<name>`) go through
|
||||
operator approval — agents can't unilaterally rebuild containers, by design.
|
||||
See [`boundary.md`](../trust-boundary/boundary.md) and [`security.md`](../trust-boundary/security.md).
|
||||
|
|
|
|||
|
|
@ -147,29 +147,86 @@ a token.
|
|||
(`hive-matrix-daemon.path` watching for `matrix-token` appearance)
|
||||
brings the daemon up on the same boot cycle anyway.
|
||||
|
||||
### The admin account, and why it needs no first-user luck
|
||||
### The appservice's sender account, and why it isn't an admin
|
||||
|
||||
`@hive:<server_name>` is the appservice's own `sender_localpart`, which
|
||||
the homeserver creates itself when it loads the registration — on a
|
||||
`@hive-<hive>:<server_name>` is the appservice's own `sender_localpart`,
|
||||
which the homeserver creates itself when it loads the registration — on a
|
||||
zero-user database, inside startup, before the HTTP listener accepts
|
||||
anything. Its **admin rights** then come from an explicit
|
||||
`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.
|
||||
anything. It's an **ordinary account**: nothing promotes it, and the
|
||||
homeserver runs no `admin_execute` for it.
|
||||
|
||||
This replaces a dependency on being the first account ever registered,
|
||||
which was fragile in both directions: the design excludes an
|
||||
appservice-created account from that automatic grant, and on a homeserver that
|
||||
already had users the rule never fired at all.
|
||||
**One account per hive.** The localpart carries the hive's name, so a swarm
|
||||
whose hives share a homeserver gives each of them its own identity: the
|
||||
homeserver attributes an action to the hive that took it, and revoking one
|
||||
hive's standing leaves the others alone.
|
||||
|
||||
Tuwunel doesn't support bootstrapping promotion over the API, and
|
||||
that's upstream's design rather than a gap: it only treats an admin-room message as a
|
||||
command when its sender is already an admin. `admin_execute` is the one
|
||||
lever with no sender to check. hive-c0re re-checks the result on every
|
||||
sweep by reading the admin account's own joined-rooms list; if the rights
|
||||
are missing it says so, names
|
||||
`systemctl restart container@hive-matrix` as the fix, and carries on —
|
||||
agent accounts, the hive Space and the chat room need no admin.
|
||||
Its access token is the **sender token**, and it's the credential
|
||||
hive-c0re presents for every homeserver call it makes on the hive's
|
||||
behalf. It's per hive for the same reason the account is:
|
||||
`swarm-matrix-ctl` mints it inside the `hive-matrix` container and
|
||||
publishes it to `swarm/hives/<hive>/matrix/sender-token`, and the hive
|
||||
reads it from there under its own certificate. That path sits inside the
|
||||
hive's own read grant (`swarm/hives/<hive>/*`), so a hive fetches its own
|
||||
token and gets a refusal on any other hive's. The name says what it
|
||||
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>
|
||||
|
||||
|
|
@ -185,10 +242,10 @@ restarts, so the first boot after the switch already has both halves.
|
|||
- **The per-agent sweep honours existing token files.** It skips any
|
||||
agent that already has a `matrix-token`, so it re-registers no account
|
||||
and displaces no session.
|
||||
- **The admin account is already admin** on such a hive (it won the
|
||||
first-user grant when the hive was new), so the startup promotion is a
|
||||
no-op — upstream's `make_user_admin` short-circuits when the user is
|
||||
already joined at power level 100.
|
||||
- **The sender account may already be an admin** on such a hive (it won the
|
||||
first-user grant when the hive was new). Nothing here demotes it; the
|
||||
homeserver no longer promotes it, so a hive built fresh has an
|
||||
ordinary account and an older one keeps whatever standing it acquired.
|
||||
- **`/var/lib/hyperhive/matrix-register-token` stays on disk**, read by
|
||||
nothing. Delete it or leave it; neither does any harm.
|
||||
- **`registrationTokenFile` is a removed option.** A config that still
|
||||
|
|
@ -237,8 +294,8 @@ Initial rollout settings:
|
|||
## Hive Matrix Space
|
||||
|
||||
On first boot, after hive-c0re provisions all agent accounts, it
|
||||
creates a private **Matrix Space** named `"hive"` using the admin
|
||||
account (`@hive:<server_name>`) and invites every provisioned agent
|
||||
creates a private **Matrix Space** named `"hive"` using its own hive
|
||||
account (`@hive-<hive>:<server_name>`) and invites every provisioned agent
|
||||
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
|
||||
place.
|
||||
|
|
@ -276,9 +333,9 @@ services.hyperhive.swarm.matrix = {
|
|||
```
|
||||
|
||||
**`trustedServers`** (default `[]`) — list of peer homeserver names
|
||||
whose signing keys tuwunel will fetch and trust. tuwunel enables federation
|
||||
at the protocol level from first boot (`allow_federation = true`) but
|
||||
trusts no remote homeserver until you list it here. For a closed
|
||||
whose signing keys tuwunel fetches and trusts. tuwunel enables
|
||||
federation at the protocol level from first boot (`allow_federation =
|
||||
true`) but trusts no remote homeserver until you list it here. For a closed
|
||||
single-hive deployment the default empty list is correct — add peer
|
||||
hive domains here when connecting hives into a swarm (see
|
||||
[`docs/swarm/`](../swarm/README.md)).
|
||||
|
|
|
|||
|
|
@ -54,14 +54,15 @@ strategy for every credential, including the mTLS leaf.
|
|||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
| 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>/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>/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 |
|
||||
| _(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 |
|
||||
| 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>/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/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/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 |
|
||||
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ Manual entry point to the same idempotent provisioning c0re runs at boot — for
|
|||
###### **Subcommands:**
|
||||
|
||||
* `create-user` — Create or refresh the matrix account + access token for `<name>`
|
||||
* `sync-admin` — Provision (or re-provision) the hive system admin matrix account
|
||||
* `sync-admin` — Provision (or re-provision) the matrix appservice's sender account
|
||||
* `promote-user` — Promote a matrix user to homeserver admin
|
||||
* `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
|
||||
|
|
@ -193,9 +193,9 @@ For an existing agent, persists the token to its state dir; for a human/other ac
|
|||
|
||||
## `hivectl matrix sync-admin`
|
||||
|
||||
Provision (or re-provision) the hive system admin matrix account.
|
||||
Provision (or re-provision) the matrix appservice's sender account.
|
||||
|
||||
Runs automatically on startup; run manually to recover a missing admin token.
|
||||
Runs automatically on startup; run manually to recover a missing access token.
|
||||
|
||||
**Usage:** `hivectl matrix sync-admin`
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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 sync-admin # provision / refresh the hive internal admin account
|
||||
hivectl matrix sync-admin # provision / refresh the appservice's sender account
|
||||
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 invite mara # invite a user to the hive Space
|
||||
|
|
@ -74,19 +74,23 @@ hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a spec
|
|||
- `create-user`: for agents, persists the `access_token` to
|
||||
`<state>/matrix-token`. Skips registration when the file already
|
||||
exists — delete it first to force re-registration.
|
||||
- `sync-admin`: ensures the hive's internal admin matrix user exists
|
||||
(used by `hive-c0re` for admin-room commands). Token persisted to the
|
||||
admin token path. Safe to run again — idempotent.
|
||||
- `sync-admin`: ensures this hive's appservice sender account
|
||||
(`@hive-<hive>:<server_name>`, one per hive) exists
|
||||
(the account `hive-c0re` provisions rooms with). Token persisted to the
|
||||
access token path. Safe to run again — idempotent.
|
||||
- `promote-user`: promotes an already-registered user to homeserver
|
||||
admin via the matrix admin API. Requires `sync-admin` to have run
|
||||
first (needs a valid admin token).
|
||||
- `reset-password`: calls the matrix admin API to set a new random
|
||||
password and prints it to stdout. Useful if an agent or human lost
|
||||
credentials.
|
||||
admin by an `!admin` command in `#admins`. ⚠️ Needs an admin **sender**,
|
||||
which `@hive-<hive>:` isn't — promotion is a swarm-level operation, rehomed to
|
||||
the swarm tier rather than granted here, so it has no admin sender to
|
||||
call it with from the hive.
|
||||
- `reset-password`: asks the admin room to set a new random
|
||||
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
|
||||
localpart, qualified with the homeserver's `server_name`) to the hive
|
||||
Space by default, or to a `--room` id / `#alias`. Uses the hive admin
|
||||
token; the admin account must be a member of the target room with
|
||||
Space by default, or to a `--room` id / `#alias`. Uses the sender
|
||||
token; the account must be a member of the target room with
|
||||
invite power (it owns the hive Space, so that case always works).
|
||||
Idempotent — already-member / already-invited is a no-op.
|
||||
|
||||
|
|
@ -225,7 +229,7 @@ granting that group the one action `machinectl shell` needs); `choom`
|
|||
checks this itself and refuses up front with a message naming the
|
||||
requirement rather than letting systemd reject the exec later with an
|
||||
opaque polkit prompt. Same caveat as the socket grant: a shell opened
|
||||
before the operator added you to `hive-admin` won't see it until you log
|
||||
before the operator added you to `hive-admin` doesn't see it until you log
|
||||
back in — secondary group membership applies at login.
|
||||
|
||||
It also needs the daemon socket, unlike the other exec-into-a-container
|
||||
|
|
|
|||
|
|
@ -94,6 +94,20 @@ it.
|
|||
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.
|
||||
|
||||
### 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
|
||||
|
||||
The realistic adversary **never needs to breach the container**. They supply
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@
|
|||
services.hyperhive.deploy.nats.authPackage =
|
||||
lib.mkDefault
|
||||
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 =
|
||||
lib.mkDefault
|
||||
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-authelia-bridge;
|
||||
|
|
|
|||
|
|
@ -65,18 +65,43 @@ const HTTP_TIMEOUT_SECS: u64 = 10;
|
|||
/// store it nowhere.
|
||||
const PASSWORD_BYTES: usize = 32;
|
||||
|
||||
/// Matrix localpart for the hive system admin account. Not an agent; has
|
||||
/// no state dir.
|
||||
/// Matrix localpart this hive acts as. 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,
|
||||
/// which is what creates this account on a homeserver that has never had
|
||||
/// one: the homeserver creates a registration's sender user itself, at
|
||||
/// startup, before it accepts a request. The account's *admin rights*
|
||||
/// then come from the `admin_execute` promotion beside that registration
|
||||
/// — see `nix/host-modules/hive-matrix.nix`, where this same literal
|
||||
/// appears as `adminLocalpart`. **The two must match**; nothing wires an
|
||||
/// override across.
|
||||
pub const HIVE_ADMIN_LOCALPART: &str = "hive";
|
||||
/// startup, before it accepts a request. See
|
||||
/// `nix/host-modules/hive-matrix.nix`, where the same string is rendered as
|
||||
/// `hiveLocalpart`. **The two must match**; nothing wires an override
|
||||
/// across.
|
||||
///
|
||||
/// An ordinary account, with no homeserver-admin standing: what it
|
||||
/// 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
|
||||
/// the Space stays rediscoverable by name (no room alias needed) even when
|
||||
|
|
@ -94,11 +119,11 @@ pub const HIVE_CHAT_ROOM_NAME: &str = "hive-chat";
|
|||
const HIVE_CHAT_ROOM_TOPIC: &str =
|
||||
"Hive-wide chat for all agents and the operator. Auto-provisioned by hive-c0re.";
|
||||
|
||||
/// Host path for the hive admin matrix access token. Outside every
|
||||
/// Host path for the appservice sender account's matrix access token. Outside every
|
||||
/// purgeable path — not deleted by `destroy --purge` on any agent.
|
||||
#[must_use]
|
||||
pub fn admin_token_path() -> PathBuf {
|
||||
crate::paths::matrix_admin_token()
|
||||
pub fn sender_token_path() -> PathBuf {
|
||||
crate::paths::matrix_sender_token()
|
||||
}
|
||||
|
||||
/// Token file inside the agent's bind-mounted state dir (visible as
|
||||
|
|
@ -382,21 +407,26 @@ async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Re
|
|||
extract_access_token(&json)
|
||||
}
|
||||
|
||||
/// Auto-recovery helper: reset a user's matrix password via the admin API
|
||||
/// when the locally stored password is missing. Requires a valid hive admin
|
||||
/// token at [`admin_token_path()`]. Returns the new password (already
|
||||
/// persisted to [`password_path`]) on success.
|
||||
/// Auto-recovery helper: reset a user's matrix password through the admin
|
||||
/// room when the locally stored password is missing. 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`
|
||||
/// but the password file is absent — covers the case where agent state dirs
|
||||
/// were wiped but the homeserver still has the accounts.
|
||||
async fn auto_reset_password(client: &reqwest::Client, name: &str) -> anyhow::Result<String> {
|
||||
let admin_token = read_admin_token()
|
||||
.context("matrix: admin token unavailable for auto-recovery; provision hive admin first")?;
|
||||
let sender_token = read_sender_token()
|
||||
.context("matrix: the matrix sender token is unavailable for auto-recovery")?;
|
||||
let server_name = discover_server_name(client)
|
||||
.await
|
||||
.context("matrix: discover_server_name for auto-recovery")?;
|
||||
let effective_password = reset_user_password(client, &admin_token, name, &server_name)
|
||||
let effective_password = reset_user_password(client, &sender_token, name, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix: admin-room password reset for {name} (auto-recovery)"))?;
|
||||
tracing::info!(%name, "matrix: auto-recovered password via admin-room reset");
|
||||
|
|
@ -416,7 +446,7 @@ fn encode_room_id_for_url(room_id: &str) -> String {
|
|||
/// Look up the room ID for the `#admins:<server>` alias.
|
||||
async fn discover_admin_room_id(
|
||||
client: &reqwest::Client,
|
||||
admin_token: &str,
|
||||
sender_token: &str,
|
||||
server_name: &str,
|
||||
) -> Result<String> {
|
||||
let base = matrix_base()?;
|
||||
|
|
@ -425,7 +455,7 @@ async fn discover_admin_room_id(
|
|||
let url = format!("{base}/_matrix/client/v3/directory/room/{encoded_alias}");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(admin_token)
|
||||
.bearer_auth(sender_token)
|
||||
.send()
|
||||
.await
|
||||
.context("matrix: GET admin room alias")?;
|
||||
|
|
@ -553,7 +583,7 @@ mod extract_new_password_tests {
|
|||
/// Generic over `T` so both password-returning and `()` callers share the loop.
|
||||
async fn admin_room_send_and_poll<T>(
|
||||
client: &reqwest::Client,
|
||||
admin_token: &str,
|
||||
sender_token: &str,
|
||||
server_name: &str,
|
||||
room_url: &str,
|
||||
command: &str,
|
||||
|
|
@ -566,7 +596,7 @@ async fn admin_room_send_and_poll<T>(
|
|||
format!("{base}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}");
|
||||
let send_resp = client
|
||||
.put(&send_url)
|
||||
.bearer_auth(admin_token)
|
||||
.bearer_auth(sender_token)
|
||||
.json(&serde_json::json!({"msgtype": "m.text", "body": command}))
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -587,13 +617,13 @@ async fn admin_room_send_and_poll<T>(
|
|||
// 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;
|
||||
// everything *before* that marker arrived after our command.
|
||||
let own_user_id = format!("@{HIVE_ADMIN_LOCALPART}:{server_name}");
|
||||
let own_user_id = format!("@{}:{server_name}", hive_localpart()?);
|
||||
let poll_url = format!("{base}/_matrix/client/v3/rooms/{room_url}/messages?dir=b&limit=20");
|
||||
for _ in 0..15_u8 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
let poll_json = client
|
||||
.get(&poll_url)
|
||||
.bearer_auth(admin_token)
|
||||
.bearer_auth(sender_token)
|
||||
.send()
|
||||
.await
|
||||
.context("matrix: admin room poll")?
|
||||
|
|
@ -641,22 +671,22 @@ async fn admin_room_send_and_poll<T>(
|
|||
}
|
||||
|
||||
/// Reset a user's password via the Matrix admin room (`#admins:<server>`).
|
||||
/// Sends `!admin users reset-password @<localpart>:<server>` as @hive, polls for the bot's
|
||||
/// response containing the new password.
|
||||
/// Sends `!admin users reset-password @<localpart>:<server>` as `@hive-<hive>:`, polls for
|
||||
/// the bot's response containing the new password.
|
||||
///
|
||||
/// Returns the new password; caller is responsible for persisting it.
|
||||
async fn admin_room_reset_password(
|
||||
client: &reqwest::Client,
|
||||
admin_token: &str,
|
||||
sender_token: &str,
|
||||
server_name: &str,
|
||||
localpart: &str,
|
||||
) -> Result<String> {
|
||||
let room_id = discover_admin_room_id(client, admin_token, server_name).await?;
|
||||
let room_id = discover_admin_room_id(client, sender_token, server_name).await?;
|
||||
let room_url = encode_room_id_for_url(&room_id);
|
||||
let command = format!("!admin users reset-password @{localpart}:{server_name}");
|
||||
admin_room_send_and_poll(
|
||||
client,
|
||||
admin_token,
|
||||
sender_token,
|
||||
server_name,
|
||||
&room_url,
|
||||
&command,
|
||||
|
|
@ -761,13 +791,15 @@ pub async fn ensure_user_for(client: &reqwest::Client, name: &str, as_token: &st
|
|||
{
|
||||
pw
|
||||
} else {
|
||||
// Password file missing — attempt auto-recovery via admin API.
|
||||
// Password file missing — attempt auto-recovery through the
|
||||
// admin room.
|
||||
// This covers the case where agent state dirs were wiped but the
|
||||
// homeserver still has the accounts. Requires the hive admin
|
||||
// token at /var/lib/hyperhive/matrix/admin-token.
|
||||
// homeserver still has the accounts. Requires the hive's sender
|
||||
// token at /var/lib/hyperhive/matrix/access-token, and an admin
|
||||
// sender, which `@hive-<hive>:` is not.
|
||||
tracing::info!(
|
||||
%name,
|
||||
"matrix: stored password missing, attempting admin-API auto-recovery"
|
||||
"matrix: stored password missing, attempting admin-room auto-recovery"
|
||||
);
|
||||
match auto_reset_password(client, name).await {
|
||||
Ok(new_pw) => new_pw,
|
||||
|
|
@ -883,42 +915,52 @@ pub async fn sync_agent_standalone(name: &str) {
|
|||
sync_agent(&client, name, &as_token).await;
|
||||
}
|
||||
|
||||
/// Ensure the hive system admin matrix user exists, that its token is
|
||||
/// persisted at [`admin_token_path()`], and that it actually holds admin
|
||||
/// rights.
|
||||
/// Ensure the hive's `@hive-<hive>:` matrix user exists and that its access token is
|
||||
/// persisted at [`sender_token_path()`].
|
||||
///
|
||||
/// **Nothing here depends on registration order any more.** The account
|
||||
/// used to have to be the first ever registered, to win tuwunel's
|
||||
/// automatic first-user grant — a rule that cannot fire for an
|
||||
/// appservice-created account at all, and one that silently did nothing
|
||||
/// on a homeserver that already had users. Admin rights now come from an
|
||||
/// explicit `make_user_admin`: the `admin_execute` entry beside the
|
||||
/// appservice registration performs it at homeserver startup, and
|
||||
/// [`ensure_admin_rights`] checks the result and says so when it is
|
||||
/// missing.
|
||||
/// **Nothing here depends on registration order, and nothing here is
|
||||
/// privileged.** The account used to have to be the first ever
|
||||
/// registered, to win tuwunel's automatic first-user grant — a rule that
|
||||
/// cannot fire for an appservice-created account at all. It is now an
|
||||
/// ordinary account: the homeserver creates it because it is the
|
||||
/// appservice registration's `sender_localpart`, and everything the hive
|
||||
/// provisions with it, it provisions as the creator of those rooms.
|
||||
///
|
||||
/// Idempotent — skips the account work when the token file already exists
|
||||
/// 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;
|
||||
let path = admin_token_path();
|
||||
let path = sender_token_path();
|
||||
if path.exists()
|
||||
&& let Ok(existing) = std::fs::read_to_string(&path)
|
||||
&& !existing.trim().is_empty()
|
||||
{
|
||||
tracing::debug!("matrix: hive admin token already present");
|
||||
tracing::debug!("matrix: the sender token is already present");
|
||||
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 access_token = match register_user(client, HIVE_ADMIN_LOCALPART, as_token, &password).await
|
||||
{
|
||||
let access_token = match register_user(client, &localpart, as_token, &password).await {
|
||||
Ok(token) => {
|
||||
let pw_path = password_path(HIVE_ADMIN_LOCALPART);
|
||||
let pw_path = password_path(&localpart);
|
||||
if let Some(parent) = pw_path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
if let Err(e) = std::fs::write(&pw_path, format!("{password}\n")) {
|
||||
tracing::warn!(error = ?e, "matrix: failed to persist hive admin password");
|
||||
tracing::warn!(error = ?e, %localpart, "matrix: failed to persist the sender account password");
|
||||
} else {
|
||||
let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
|
@ -931,125 +973,108 @@ pub async fn ensure_admin_user(client: &reqwest::Client, as_token: &str) -> Resu
|
|||
// hive-c0re gets a chance to ask. An appservice login needs
|
||||
// no password, which is just as well since an account the
|
||||
// homeserver created has none.
|
||||
tracing::info!("matrix: hive admin user already exists, logging in as the appservice");
|
||||
match appservice_login(client, as_token, HIVE_ADMIN_LOCALPART).await {
|
||||
tracing::info!(%localpart, "matrix: the sender account already exists, logging in as the appservice");
|
||||
match appservice_login(client, as_token, &localpart).await {
|
||||
Ok(token) => token,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "matrix: appservice login for the hive admin failed; falling back to the stored password");
|
||||
let pw_path = password_path(HIVE_ADMIN_LOCALPART);
|
||||
tracing::warn!(error = ?e, %localpart, "matrix: appservice login for the sender account failed; falling back to the stored password");
|
||||
let pw_path = password_path(&localpart);
|
||||
let stored = std::fs::read_to_string(&pw_path)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_owned())
|
||||
.filter(|s| !s.is_empty())
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"matrix: hive admin user exists, appservice login failed, and no \
|
||||
"matrix: @{localpart}: exists, appservice login failed, and no \
|
||||
password is stored at {} — check that the registration file's \
|
||||
namespace covers @{HIVE_ADMIN_LOCALPART} and that the homeserver \
|
||||
namespace covers @{localpart} and that the homeserver \
|
||||
loaded it",
|
||||
pw_path.display()
|
||||
)
|
||||
})?;
|
||||
login_user(client, HIVE_ADMIN_LOCALPART, &stored).await?
|
||||
login_user(client, &localpart, &stored).await?
|
||||
}
|
||||
}
|
||||
}
|
||||
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() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
std::fs::write(&path, format!("{access_token}\n"))
|
||||
.with_context(|| format!("matrix: write hive admin token to {}", path.display()))?;
|
||||
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
|
||||
tracing::info!(path = %path.display(), "matrix: provisioned hive admin token");
|
||||
std::fs::write(path, format!("{access_token}\n"))
|
||||
.with_context(|| format!("matrix: write the sender token to {}", path.display()))?;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||
tracing::info!(path = %path.display(), "matrix: provisioned the sender token");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check that the hive admin account holds admin rights, and repair it
|
||||
/// through the admin room when it does not.
|
||||
/// Fetch the sender token `swarm-matrix-ctl` published, under
|
||||
/// this hive's own store identity.
|
||||
///
|
||||
/// Admin-ness in tuwunel is membership of the admin room, so that is what
|
||||
/// this reads: the account's own joined-rooms list, which needs no
|
||||
/// privileges to fetch. When the admin room is in it there is nothing to
|
||||
/// do and nothing is sent — worth insisting on, because the repair is a
|
||||
/// message in a room and this runs on every sweep.
|
||||
/// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` —
|
||||
/// the same role string `workers::credential` logs in with, and already in
|
||||
/// this process's environment, so the store read costs no plumbing through
|
||||
/// [`ensure_all`]. That name is also the path's own hive segment, which is
|
||||
/// what makes this read reach **this** hive's token and 403 on any other's:
|
||||
/// 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.
|
||||
///
|
||||
/// When it is absent, the repair is attempted anyway (a stale or failed
|
||||
/// read is cheaper to retry than to reason about) and a failure is
|
||||
/// reported rather than raised: agent accounts, the hive Space and the
|
||||
/// chat room all work without an admin, so a hive with an unpromoted
|
||||
/// admin is degraded, not broken. Only `hivectl matrix promote-user` /
|
||||
/// `reset-password` need it.
|
||||
/// `None`, never an error, for every way this can come up empty — no hive
|
||||
/// name, no `BAO_*` identity, an unreachable store, nothing at the path. All
|
||||
/// four mean the same thing to the caller ("mint it the old way"), and three
|
||||
/// of them are the ordinary state of a swarm that has not deployed `swarm-matrix-ctl`
|
||||
/// yet, so raising would turn a supported deployment into a warning every
|
||||
/// sweep.
|
||||
///
|
||||
/// Returns whether the account holds admin rights now. A `false` has
|
||||
/// already been logged, with what to do about it.
|
||||
async fn ensure_admin_rights(
|
||||
client: &reqwest::Client,
|
||||
admin_token: &str,
|
||||
server_name: &str,
|
||||
) -> bool {
|
||||
let room_id = match discover_admin_room_id(client, admin_token, server_name).await {
|
||||
Ok(id) => id,
|
||||
/// 🩸 Logs the store **path** and never the value.
|
||||
async fn stored_sender_token() -> Option<String> {
|
||||
let hive = std::env::var("HYPERHIVE_HIVE_NAME")
|
||||
.ok()
|
||||
.filter(|h| !h.is_empty())?;
|
||||
let path = match swarm_secret_client::matrix::sender_token_path(&hive) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "matrix: cannot resolve #admins — not checking the hive admin's rights");
|
||||
return false;
|
||||
tracing::warn!(error = %e, "matrix: this hive's name does not form a store path");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match joined_rooms(client, admin_token).await {
|
||||
Some(rooms) if rooms.iter().any(|r| r == &room_id) => {
|
||||
tracing::debug!("matrix: hive admin is in the admin room");
|
||||
return true;
|
||||
let store = match swarm_secret_client::SecretStore::from_env(&hive).await {
|
||||
Ok(store) => store,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "matrix: no swarm secret store to read the sender token from");
|
||||
return None;
|
||||
}
|
||||
Some(_) => {
|
||||
tracing::warn!(
|
||||
"matrix: hive admin is not in the admin room — attempting to promote it"
|
||||
);
|
||||
}
|
||||
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
|
||||
};
|
||||
match store
|
||||
.read::<swarm_secret_client::matrix::Credential>(&path)
|
||||
.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;
|
||||
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
|
||||
}
|
||||
}
|
||||
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.
|
||||
|
|
@ -1103,12 +1128,16 @@ mod is_make_admin_success_tests {
|
|||
|
||||
/// Promote a user to homeserver admin via the Matrix admin room
|
||||
/// (`#admins:<server>`). Sends `!admin users make-user-admin @<localpart>:<server>` as
|
||||
/// @hive, polls for the bot's success reply.
|
||||
/// `@hive-<hive>:`, polls for the bot's success reply.
|
||||
///
|
||||
/// ⚠️ Requires the **sender** to be an admin already — tuwunel only
|
||||
/// treats a message as a command when its sender is in the admin room.
|
||||
/// So this promotes a *second* user; it cannot bootstrap the first one.
|
||||
/// That is what the `admin_execute` entry in `hive-matrix.nix` is for.
|
||||
/// `@hive-<hive>:` is an ordinary account (`hive-matrix.nix` grants it no
|
||||
/// `admin_execute` promotion), so this call has no working sender from
|
||||
/// 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
|
||||
/// tuwunel implements parts of the Synapse admin API but not user
|
||||
|
|
@ -1116,16 +1145,16 @@ mod is_make_admin_success_tests {
|
|||
/// intended long-term mechanism, not a stopgap awaiting an upstream fix.
|
||||
pub async fn promote_user_to_admin(
|
||||
client: &reqwest::Client,
|
||||
admin_token: &str,
|
||||
sender_token: &str,
|
||||
localpart: &str,
|
||||
server_name: &str,
|
||||
) -> Result<()> {
|
||||
let room_id = discover_admin_room_id(client, admin_token, server_name).await?;
|
||||
let room_id = discover_admin_room_id(client, sender_token, server_name).await?;
|
||||
let room_url = encode_room_id_for_url(&room_id);
|
||||
let command = format!("!admin users make-user-admin @{localpart}:{server_name}");
|
||||
admin_room_send_and_poll(
|
||||
client,
|
||||
admin_token,
|
||||
sender_token,
|
||||
server_name,
|
||||
&room_url,
|
||||
&command,
|
||||
|
|
@ -1143,19 +1172,25 @@ pub async fn promote_user_to_admin(
|
|||
|
||||
/// Reset a user's password via the Matrix admin room (`#admins:<server>`).
|
||||
///
|
||||
/// Sends `!admin users reset-password @<localpart>:<server>` to the admin room as @hive,
|
||||
/// polls for the bot's response containing the new password, and persists
|
||||
/// Sends `!admin users reset-password @<localpart>:<server>` to the admin room as
|
||||
/// `@hive-<hive>:`, 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
|
||||
/// 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.
|
||||
pub async fn reset_user_password(
|
||||
client: &reqwest::Client,
|
||||
admin_token: &str,
|
||||
sender_token: &str,
|
||||
localpart: &str,
|
||||
server_name: &str,
|
||||
) -> Result<String> {
|
||||
let pw = admin_room_reset_password(client, admin_token, server_name, localpart)
|
||||
let pw = admin_room_reset_password(client, sender_token, server_name, localpart)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("matrix: admin-room password reset for @{localpart}:{server_name}")
|
||||
|
|
@ -1205,19 +1240,19 @@ pub async fn discover_server_name(client: &reqwest::Client) -> Result<String> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Read the hive admin access token from disk. Returns an error if it
|
||||
/// is absent — callers should gate their admin-API calls on this.
|
||||
pub fn read_admin_token() -> Result<String> {
|
||||
let path = admin_token_path();
|
||||
/// Read the sender token from disk. Returns an error if it
|
||||
/// is absent — callers should gate their homeserver calls on this.
|
||||
pub fn read_sender_token() -> Result<String> {
|
||||
let path = sender_token_path();
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_owned())
|
||||
.filter(|s| !s.is_empty())
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"hive admin matrix token not found at {} — \
|
||||
"the matrix sender token was not found at {} — \
|
||||
ensure hive-c0re has started at least once with matrix enabled \
|
||||
(it provisions the admin account on boot)",
|
||||
(it provisions the hive's @hive-<hive>: account on boot)",
|
||||
path.display()
|
||||
)
|
||||
})
|
||||
|
|
@ -1244,12 +1279,12 @@ fn persist_space_room_id(room_id: &str) -> Result<()> {
|
|||
///
|
||||
/// Name-based (not alias-based) rediscovery keeps the Space free of any
|
||||
/// special-char room alias — the hardcoded plain name is the anchor.
|
||||
async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Option<String> {
|
||||
async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Option<String> {
|
||||
let base = matrix_http()?;
|
||||
let joined_url = format!("{base}/_matrix/client/v3/joined_rooms");
|
||||
let joined: serde_json::Value = client
|
||||
.get(&joined_url)
|
||||
.bearer_auth(admin_token)
|
||||
.bearer_auth(sender_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?
|
||||
|
|
@ -1266,7 +1301,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti
|
|||
let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/");
|
||||
let is_space = match client
|
||||
.get(&create_url)
|
||||
.bearer_auth(admin_token)
|
||||
.bearer_auth(sender_token)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
|
|
@ -1282,7 +1317,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti
|
|||
}
|
||||
// …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_matches = match client.get(&name_url).bearer_auth(admin_token).send().await {
|
||||
let name_matches = match client.get(&name_url).bearer_auth(sender_token).send().await {
|
||||
Ok(r) if r.status().is_success() => r
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
|
|
@ -1299,7 +1334,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti
|
|||
|
||||
/// 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`, identified by its hardcoded name [`HIVE_SPACE_NAME`] (no alias).
|
||||
/// `@hive-<hive>:`, identified by its hardcoded name [`HIVE_SPACE_NAME`] (no alias).
|
||||
///
|
||||
/// Dedup strategy (single canonical space):
|
||||
/// 1. If the room-id file exists, reuse it.
|
||||
|
|
@ -1313,7 +1348,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti
|
|||
///
|
||||
/// Returns an error if the homeserver is unreachable, `createRoom` fails,
|
||||
/// or the room-ID file cannot be written.
|
||||
pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> Result<String> {
|
||||
pub async fn ensure_hive_space(client: &reqwest::Client, sender_token: &str) -> Result<String> {
|
||||
let base = matrix_base()?;
|
||||
// 1. Stored room id wins (fast path).
|
||||
if let Ok(existing) = std::fs::read_to_string(hive_space_room_id_path()) {
|
||||
|
|
@ -1326,7 +1361,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> R
|
|||
|
||||
// 2. No stored id — rediscover the existing space by its hardcoded name
|
||||
// before creating a new one (prevents duplicate spaces after a wipe).
|
||||
if let Some(room_id) = find_space_by_name(client, admin_token).await {
|
||||
if let Some(room_id) = find_space_by_name(client, sender_token).await {
|
||||
persist_space_room_id(&room_id)?;
|
||||
tracing::info!(%room_id, "matrix: recovered hive space by name");
|
||||
return Ok(room_id);
|
||||
|
|
@ -1342,7 +1377,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> R
|
|||
});
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(admin_token)
|
||||
.bearer_auth(sender_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -1382,12 +1417,12 @@ fn persist_chat_room_id(room_id: &str) -> Result<()> {
|
|||
/// [`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
|
||||
/// unreachable or no match exists.
|
||||
async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) -> Option<String> {
|
||||
async fn find_chat_room_by_name(client: &reqwest::Client, sender_token: &str) -> Option<String> {
|
||||
let base = matrix_http()?;
|
||||
let joined_url = format!("{base}/_matrix/client/v3/joined_rooms");
|
||||
let joined: serde_json::Value = client
|
||||
.get(&joined_url)
|
||||
.bearer_auth(admin_token)
|
||||
.bearer_auth(sender_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?
|
||||
|
|
@ -1404,7 +1439,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) ->
|
|||
let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/");
|
||||
let is_space = match client
|
||||
.get(&create_url)
|
||||
.bearer_auth(admin_token)
|
||||
.bearer_auth(sender_token)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
|
|
@ -1420,7 +1455,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) ->
|
|||
}
|
||||
// …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_matches = match client.get(&name_url).bearer_auth(admin_token).send().await {
|
||||
let name_matches = match client.get(&name_url).bearer_auth(sender_token).send().await {
|
||||
Ok(r) if r.status().is_success() => r
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
|
|
@ -1460,10 +1495,10 @@ fn state_needs_write(current: Option<&serde_json::Value>, desired: &serde_json::
|
|||
/// too, loudly — and a 404 is the expected first-setup case.
|
||||
async fn current_room_state(
|
||||
client: &reqwest::Client,
|
||||
admin_token: &str,
|
||||
sender_token: &str,
|
||||
url: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
let resp = match client.get(url).bearer_auth(admin_token).send().await {
|
||||
let resp = match client.get(url).bearer_auth(sender_token).send().await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = ?e, url, "matrix: state read unreachable; writing");
|
||||
|
|
@ -1491,7 +1526,7 @@ async fn current_room_state(
|
|||
}
|
||||
}
|
||||
|
||||
/// PUT a state event into `room_id` using the admin token, **skipping the
|
||||
/// PUT a state event into `room_id` using the sender token, **skipping the
|
||||
/// write when the room already carries identical content**.
|
||||
///
|
||||
/// The read is not an optimisation. A PUT of identical content is a no-op
|
||||
|
|
@ -1506,7 +1541,7 @@ async fn current_room_state(
|
|||
/// missing or divergent link still gets written.
|
||||
async fn set_room_state(
|
||||
client: &reqwest::Client,
|
||||
admin_token: &str,
|
||||
sender_token: &str,
|
||||
room_id: &str,
|
||||
event_type: &str,
|
||||
state_key: &str,
|
||||
|
|
@ -1517,7 +1552,7 @@ async fn set_room_state(
|
|||
let encoded_key = encode_room_id_for_url(state_key);
|
||||
let url =
|
||||
format!("{base}/_matrix/client/v3/rooms/{encoded_room}/state/{event_type}/{encoded_key}");
|
||||
let current = current_room_state(client, admin_token, &url).await;
|
||||
let current = current_room_state(client, sender_token, &url).await;
|
||||
if !state_needs_write(current.as_ref(), content) {
|
||||
tracing::debug!(
|
||||
%room_id,
|
||||
|
|
@ -1528,7 +1563,7 @@ async fn set_room_state(
|
|||
}
|
||||
let resp = client
|
||||
.put(&url)
|
||||
.bearer_auth(admin_token)
|
||||
.bearer_auth(sender_token)
|
||||
.json(content)
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -1565,7 +1600,7 @@ async fn set_room_state(
|
|||
/// link is logged but not fatal (the room still exists + is joinable).
|
||||
pub async fn ensure_hive_chat_room(
|
||||
client: &reqwest::Client,
|
||||
admin_token: &str,
|
||||
sender_token: &str,
|
||||
space_room_id: &str,
|
||||
server_name: &str,
|
||||
) -> Result<String> {
|
||||
|
|
@ -1579,7 +1614,7 @@ pub async fn ensure_hive_chat_room(
|
|||
{
|
||||
tracing::debug!(room_id = %id, "matrix: hive chat room already provisioned");
|
||||
id
|
||||
} else if let Some(id) = find_chat_room_by_name(client, admin_token).await {
|
||||
} else if let Some(id) = find_chat_room_by_name(client, sender_token).await {
|
||||
persist_chat_room_id(&id)?;
|
||||
tracing::info!(room_id = %id, "matrix: recovered hive chat room by name");
|
||||
id
|
||||
|
|
@ -1619,7 +1654,7 @@ pub async fn ensure_hive_chat_room(
|
|||
});
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(admin_token)
|
||||
.bearer_auth(sender_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -1650,7 +1685,7 @@ pub async fn ensure_hive_chat_room(
|
|||
});
|
||||
if let Err(e) = set_room_state(
|
||||
client,
|
||||
admin_token,
|
||||
sender_token,
|
||||
space_room_id,
|
||||
"m.space.child",
|
||||
&room_id,
|
||||
|
|
@ -1668,21 +1703,21 @@ pub async fn ensure_hive_chat_room(
|
|||
/// account. Idempotent — treats already-member responses as success.
|
||||
async fn invite_to_room(
|
||||
client: &reqwest::Client,
|
||||
admin_token: &str,
|
||||
sender_token: &str,
|
||||
room_id: &str,
|
||||
localpart: &str,
|
||||
server_name: &str,
|
||||
) -> Result<()> {
|
||||
let user_id = format!("@{localpart}:{server_name}");
|
||||
invite_user_id(client, admin_token, room_id, &user_id).await
|
||||
invite_user_id(client, sender_token, room_id, &user_id).await
|
||||
}
|
||||
|
||||
/// Fetch a user's current membership in a room via the admin token, or
|
||||
/// Fetch a user's current membership in a room via the sender token, or
|
||||
/// `None` if there is no membership event (never invited) or the lookup
|
||||
/// fails. Returns the raw membership string (`invite`, `join`, `leave`, …).
|
||||
async fn room_membership(
|
||||
client: &reqwest::Client,
|
||||
admin_token: &str,
|
||||
sender_token: &str,
|
||||
encoded_room_id: &str,
|
||||
user_id: &str,
|
||||
) -> Option<String> {
|
||||
|
|
@ -1695,7 +1730,7 @@ async fn room_membership(
|
|||
);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(admin_token)
|
||||
.bearer_auth(sender_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
|
|
@ -1709,13 +1744,13 @@ async fn room_membership(
|
|||
}
|
||||
|
||||
/// Invite a fully-qualified Matrix user id (`@user:server`) to `room_id`
|
||||
/// using the admin token. Idempotent: a user who is already a member or
|
||||
/// using the sender token. Idempotent: a user who is already a member or
|
||||
/// 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`
|
||||
/// from a racing invite is still treated as success.
|
||||
async fn invite_user_id(
|
||||
client: &reqwest::Client,
|
||||
admin_token: &str,
|
||||
sender_token: &str,
|
||||
room_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<()> {
|
||||
|
|
@ -1727,7 +1762,7 @@ async fn invite_user_id(
|
|||
// 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,
|
||||
// which re-notifies the agent on every provisioning sweep.
|
||||
if let Some(membership) = room_membership(client, admin_token, &encoded_room_id, user_id).await
|
||||
if let Some(membership) = room_membership(client, sender_token, &encoded_room_id, user_id).await
|
||||
&& matches!(membership.as_str(), "invite" | "join")
|
||||
{
|
||||
tracing::debug!(%user_id, %room_id, %membership, "matrix: invite skipped (already a member/invited)");
|
||||
|
|
@ -1737,7 +1772,7 @@ async fn invite_user_id(
|
|||
let url = format!("{base}/_matrix/client/v3/rooms/{encoded_room_id}/invite");
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(admin_token)
|
||||
.bearer_auth(sender_token)
|
||||
.json(&serde_json::json!({ "user_id": user_id }))
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -1770,13 +1805,13 @@ async fn invite_user_id(
|
|||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the admin token or `server_name` can't be read,
|
||||
/// Returns an error if the sender token or `server_name` can't be read,
|
||||
/// 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
|
||||
/// already being a member / invited.
|
||||
pub async fn invite_user(
|
||||
client: &reqwest::Client,
|
||||
admin_token: &str,
|
||||
sender_token: &str,
|
||||
user: &str,
|
||||
room_override: Option<&str>,
|
||||
server_name: &str,
|
||||
|
|
@ -1790,7 +1825,7 @@ pub async fn invite_user(
|
|||
// Resolve the room: explicit override (id or #alias) wins; otherwise
|
||||
// the persisted hive Space.
|
||||
let room_id = match room_override {
|
||||
Some(r) if r.starts_with('#') => resolve_room_alias(client, admin_token, r).await?,
|
||||
Some(r) if r.starts_with('#') => resolve_room_alias(client, sender_token, r).await?,
|
||||
Some(r) if r.starts_with('!') => r.to_owned(),
|
||||
Some(r) => anyhow::bail!(
|
||||
"matrix: --room {r:?} is neither a room id nor an alias; \
|
||||
|
|
@ -1804,14 +1839,14 @@ pub async fn invite_user(
|
|||
(run the hive-c0re matrix sweep first)",
|
||||
)?,
|
||||
};
|
||||
invite_user_id(client, admin_token, &room_id, &user_id).await?;
|
||||
invite_user_id(client, sender_token, &room_id, &user_id).await?;
|
||||
Ok(room_id)
|
||||
}
|
||||
|
||||
/// Resolve a `#alias:server` to its room id via the directory API.
|
||||
async fn resolve_room_alias(
|
||||
client: &reqwest::Client,
|
||||
admin_token: &str,
|
||||
sender_token: &str,
|
||||
alias: &str,
|
||||
) -> Result<String> {
|
||||
let base = matrix_base()?;
|
||||
|
|
@ -1819,7 +1854,7 @@ async fn resolve_room_alias(
|
|||
let url = format!("{base}/_matrix/client/v3/directory/room/{encoded}");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(admin_token)
|
||||
.bearer_auth(sender_token)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("matrix: GET directory for {alias}"))?;
|
||||
|
|
@ -1875,13 +1910,12 @@ pub async fn ensure_all() -> bool {
|
|||
return false;
|
||||
}
|
||||
};
|
||||
// The hive admin first, because everything below provisions THROUGH
|
||||
// it (the Space, the chat room and every invite are sent with its
|
||||
// token). Not, any more, so that it wins a first-registered-user
|
||||
// grant: it holds admin rights by explicit promotion, checked in
|
||||
// `provision_space` once the server name is known.
|
||||
if let Err(e) = ensure_admin_user(&client, &as_token).await {
|
||||
tracing::warn!(error = ?e, "matrix: ensure_admin_user failed");
|
||||
// The hive's `@hive-<hive>:` account first, because everything below provisions
|
||||
// THROUGH it (the Space, the chat room and every invite are sent with
|
||||
// its token) — as an ordinary user that created those rooms, not as a
|
||||
// homeserver admin.
|
||||
if let Err(e) = ensure_hive_user(&client, &as_token).await {
|
||||
tracing::warn!(error = ?e, "matrix: ensure_hive_user failed");
|
||||
ok = false;
|
||||
}
|
||||
let Ok(containers) = crate::lifecycle::list().await else {
|
||||
|
|
@ -1911,10 +1945,10 @@ pub async fn ensure_all() -> bool {
|
|||
async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bool {
|
||||
let mut ok = true;
|
||||
// server_name is needed to form full Matrix user IDs for invites.
|
||||
let admin_token = match read_admin_token() {
|
||||
let sender_token = match read_sender_token() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no admin token)");
|
||||
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no matrix sender token)");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
@ -1926,60 +1960,55 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
|
|||
return false;
|
||||
}
|
||||
};
|
||||
// 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 {
|
||||
let room_id = match ensure_hive_space(client, &sender_token).await {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "matrix: ensure_hive_space failed");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// Invite @hive admin first, then all agents.
|
||||
if let Err(e) = invite_to_room(
|
||||
client,
|
||||
&admin_token,
|
||||
&room_id,
|
||||
HIVE_ADMIN_LOCALPART,
|
||||
&server_name,
|
||||
)
|
||||
.await
|
||||
// This hive's own account first, then all agents.
|
||||
let localpart = match hive_localpart() {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if let Err(e) = invite_to_room(client, &sender_token, &room_id, &localpart, &server_name).await
|
||||
{
|
||||
tracing::warn!(error = ?e, "matrix: invite @hive to space failed");
|
||||
tracing::warn!(error = ?e, %localpart, "matrix: invite the hive account to the space failed");
|
||||
ok = false;
|
||||
}
|
||||
for name in agent_names {
|
||||
if let Err(e) = invite_to_room(client, &admin_token, &room_id, name, &server_name).await {
|
||||
if let Err(e) = invite_to_room(client, &sender_token, &room_id, name, &server_name).await {
|
||||
tracing::warn!(%name, error = ?e, "matrix: invite agent to space failed");
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Provision the default hive chat room as an m.space.child of the Space
|
||||
// and invite @hive + every agent. Joining a Space alone surfaces no
|
||||
// and invite `@hive-<hive>:` + every agent. Joining a Space alone surfaces no
|
||||
// rooms to chat in (Matrix semantics — children aren't auto-joined), so
|
||||
// without this the Space is empty. The restricted join rule additionally
|
||||
// lets the operator (a Space member) join from the Space hierarchy.
|
||||
match ensure_hive_chat_room(client, &admin_token, &room_id, &server_name).await {
|
||||
match ensure_hive_chat_room(client, &sender_token, &room_id, &server_name).await {
|
||||
Ok(chat_room_id) => {
|
||||
if let Err(e) = invite_to_room(
|
||||
client,
|
||||
&admin_token,
|
||||
&sender_token,
|
||||
&chat_room_id,
|
||||
HIVE_ADMIN_LOCALPART,
|
||||
&localpart,
|
||||
&server_name,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = ?e, "matrix: invite @hive to chat room failed");
|
||||
tracing::warn!(error = ?e, %localpart, "matrix: invite the hive account to the chat room failed");
|
||||
ok = false;
|
||||
}
|
||||
for name in agent_names {
|
||||
if let Err(e) =
|
||||
invite_to_room(client, &admin_token, &chat_room_id, name, &server_name).await
|
||||
invite_to_room(client, &sender_token, &chat_room_id, name, &server_name).await
|
||||
{
|
||||
tracing::warn!(%name, error = ?e, "matrix: invite agent to chat room failed");
|
||||
ok = false;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//! `/run/hyperhive` + `/run/hive-agent` runtime roots).
|
||||
//!
|
||||
//! Historically these were flat string literals scattered across many
|
||||
//! modules (`broker.sqlite`, `matrix-admin-token`, `agent-sockets.json`,
|
||||
//! modules (`broker.sqlite`, `matrix-sender-token`, `agent-sockets.json`,
|
||||
//! …). This module is the **single Rust-side source** for every host
|
||||
//! path — the strictly host-side ones grouped into subdirs (`db/`,
|
||||
//! `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)
|
||||
}
|
||||
|
||||
/// `matrix/` — host-side matrix provisioning state (admin token, hive
|
||||
/// `matrix/` — host-side matrix provisioning state (the appservice sender token, hive
|
||||
/// Space room id, per-agent password creds). The shared registration
|
||||
/// token is bind-mounted into the tuwunel container via nix and stays
|
||||
/// at its own path (tracked separately).
|
||||
|
|
@ -137,10 +137,10 @@ pub fn matrix_dir() -> PathBuf {
|
|||
state_root().join("matrix")
|
||||
}
|
||||
|
||||
/// `matrix/admin-token` — hive system admin access token.
|
||||
/// `matrix/access-token` — the matrix appservice sender account's access token.
|
||||
#[must_use]
|
||||
pub fn matrix_admin_token() -> PathBuf {
|
||||
matrix_dir().join("admin-token")
|
||||
pub fn matrix_sender_token() -> PathBuf {
|
||||
matrix_dir().join("access-token")
|
||||
}
|
||||
|
||||
/// `matrix/space-room-id` — persisted hive Space room id.
|
||||
|
|
@ -327,7 +327,7 @@ pub fn relocate_legacy_state() {
|
|||
"forge-agent-configs-avatar-set",
|
||||
forge_config_org_avatar_marker(),
|
||||
),
|
||||
("matrix-admin-token", matrix_admin_token()),
|
||||
("matrix-sender-token", matrix_sender_token()),
|
||||
("matrix-space-room-id", matrix_space_room_id()),
|
||||
("matrix-creds", matrix_creds_dir()),
|
||||
("agent-sockets.json", agent_sockets_file()),
|
||||
|
|
|
|||
|
|
@ -455,7 +455,7 @@ async fn stream_agent_status(
|
|||
// The `hivectl matrix` subcommands used to run these in-process, which forced
|
||||
// the standalone CLI to link the whole daemon crate (matrix-sdk, reqwest, …).
|
||||
// They now run daemon-side over the host socket: the daemon already holds the
|
||||
// register + admin tokens and the matrix creds dir. Each op returns the
|
||||
// register + sender tokens and the matrix creds dir. Each op returns the
|
||||
// operator-facing lines hivectl used to `println!` in `HostResponse::messages`
|
||||
// for the client to print verbatim.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -777,14 +777,14 @@ async fn handle_matrix_sync_admin() -> Result<HostResponse> {
|
|||
let as_token =
|
||||
crate::matrix::read_appservice_token().context("read matrix appservice token")?;
|
||||
let client = matrix_http_client()?;
|
||||
crate::matrix::ensure_admin_user(&client, &as_token)
|
||||
crate::matrix::ensure_hive_user(&client, &as_token)
|
||||
.await
|
||||
.context("matrix sync-admin")?;
|
||||
let path = crate::matrix::admin_token_path();
|
||||
let path = crate::matrix::sender_token_path();
|
||||
Ok(HostResponse::messages(vec![
|
||||
format!(
|
||||
"matrix: hive admin user '@{}' provisioned",
|
||||
crate::matrix::HIVE_ADMIN_LOCALPART
|
||||
"matrix: the @{}: user is provisioned",
|
||||
crate::matrix::hive_localpart()?
|
||||
),
|
||||
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> {
|
||||
require_matrix_present()?;
|
||||
let admin_token = crate::matrix::read_admin_token()?;
|
||||
let sender_token = crate::matrix::read_sender_token()?;
|
||||
let client = matrix_http_client()?;
|
||||
let server_name = crate::matrix::discover_server_name(&client)
|
||||
.await
|
||||
.context("discover matrix server_name")?;
|
||||
crate::matrix::promote_user_to_admin(&client, &admin_token, name, &server_name)
|
||||
crate::matrix::promote_user_to_admin(&client, &sender_token, name, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix promote-user {name}"))?;
|
||||
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> {
|
||||
require_matrix_present()?;
|
||||
let admin_token = crate::matrix::read_admin_token()?;
|
||||
let sender_token = crate::matrix::read_sender_token()?;
|
||||
let client = matrix_http_client()?;
|
||||
let server_name = crate::matrix::discover_server_name(&client)
|
||||
.await
|
||||
.context("discover matrix server_name")?;
|
||||
let room_id = crate::matrix::invite_user(&client, &admin_token, user, room, &server_name)
|
||||
let room_id = crate::matrix::invite_user(&client, &sender_token, user, room, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix invite {user}"))?;
|
||||
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> {
|
||||
require_matrix_present()?;
|
||||
let admin_token = crate::matrix::read_admin_token()?;
|
||||
let sender_token = crate::matrix::read_sender_token()?;
|
||||
let client = matrix_http_client()?;
|
||||
let server_name = crate::matrix::discover_server_name(&client)
|
||||
.await
|
||||
.context("discover matrix server_name")?;
|
||||
crate::matrix::reset_user_password(&client, &admin_token, name, &server_name)
|
||||
crate::matrix::reset_user_password(&client, &sender_token, name, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix reset-password {name}"))?;
|
||||
// Password is persisted by reset_user_password.
|
||||
|
|
|
|||
|
|
@ -335,10 +335,10 @@ pub enum MatrixCmd {
|
|||
#[arg(long, conflicts_with = "password")]
|
||||
password_stdin: bool,
|
||||
},
|
||||
/// Provision (or re-provision) the hive system admin matrix account.
|
||||
/// Provision (or re-provision) the matrix appservice's sender account.
|
||||
///
|
||||
/// Runs automatically on startup; run manually to recover a missing
|
||||
/// admin token.
|
||||
/// access token.
|
||||
SyncAdmin,
|
||||
/// Promote a matrix user to homeserver admin.
|
||||
PromoteUser {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! `hivectl matrix` — matrix account provisioning verbs. hivectl forwards
|
||||
//! each request to the daemon (which owns the register + admin tokens and
|
||||
//! each request to the daemon (which owns the register + sender tokens and
|
||||
//! the matrix creds dir) and renders the reply; it no longer links the
|
||||
//! matrix machinery itself.
|
||||
|
||||
|
|
@ -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
|
||||
/// operator-facing result lines it returns. The daemon owns the register +
|
||||
/// admin tokens and the matrix creds dir, so hivectl no longer links the
|
||||
/// sender tokens and the matrix creds dir, so hivectl no longer links the
|
||||
/// matrix machinery — it just forwards the request and renders the reply.
|
||||
async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Result<()> {
|
||||
let resp = crate::client::request(socket, req)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
./glue-controller-bao-identity.nix
|
||||
./glue-grafana-oidc-client.nix
|
||||
./glue-matrix-bao-token.nix
|
||||
./glue-matrix-ctl-bao-identity.nix
|
||||
./glue-queue-agent-credential.nix
|
||||
./glue-secret-publisher-bao-identity.nix
|
||||
./glue-swarm-otel-oidc-client.nix
|
||||
|
|
|
|||
|
|
@ -147,6 +147,19 @@ in
|
|||
# to be this one whenever the store has a host of its own.
|
||||
[ -s ${pkiDir}/secret-publisher.pem ] || ${signLeaf} ${pkiDir} secret-publisher \
|
||||
${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
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
|
|
|||
40
nix/host-modules/glue-matrix-ctl-bao-identity.nix
Normal file
40
nix/host-modules/glue-matrix-ctl-bao-identity.nix
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# 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";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -26,6 +26,11 @@ let
|
|||
# `null/.well-known/…`.
|
||||
autheliaCfg = config.services.hyperhive.swarm.authelia;
|
||||
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;
|
||||
|
||||
# The all-local case: this host runs BOTH the homeserver and the swarm's
|
||||
|
|
@ -59,18 +64,30 @@ let
|
|||
# namespace below, with no shared registration secret in the picture.
|
||||
appserviceId = "hyperhive";
|
||||
|
||||
# The appservice's own user, and deliberately the hive admin account.
|
||||
# Loading a registration CREATES its `sender_localpart` user when absent
|
||||
# (tuwunel `src/service/appservice/mod.rs`), on a zero-user database,
|
||||
# inside `Services::start()` — before the HTTP listener accepts anything.
|
||||
# That is what lets the `admin_execute` promotion below land on the very
|
||||
# first boot of a fresh homeserver, instead of depending on hive-c0re
|
||||
# racing to register the first account and win the auto-admin grant.
|
||||
# The appservice's own user, and the account the hive acts as. An
|
||||
# ordinary user, not a homeserver admin: everything the hive does with
|
||||
# it — the Space, the chat room, the invites — it does as the creator of
|
||||
# those rooms. Loading a registration CREATES its `sender_localpart` user
|
||||
# when absent (tuwunel `src/service/appservice/mod.rs`), on a zero-user
|
||||
# database, inside `Services::start()` — before the HTTP listener accepts
|
||||
# anything, so the account exists on the very first boot of a fresh
|
||||
# homeserver without hive-c0re racing to register it.
|
||||
#
|
||||
# ⚠️ Must equal `matrix::HIVE_ADMIN_LOCALPART` in hive-c0re, which derives
|
||||
# it independently with nothing wiring an override across — same
|
||||
# agreement, and same reason for saying so, as the token path below.
|
||||
adminLocalpart = "hive";
|
||||
# 🩸 **Derived from the hive name, so it is one account per hive.** It was
|
||||
# the bare `hive` and a swarm runs one homeserver: every hive on it acted
|
||||
# as the same `@hive:`, which is one identity with no attribution and no
|
||||
# way to revoke a single hive. The hives that do not run this container
|
||||
# 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
|
||||
# minted by the render script below, mode 0600; the `as_token` is the one
|
||||
|
|
@ -107,7 +124,62 @@ let
|
|||
appserviceCredentialId = "${appserviceId}-appservice.yaml";
|
||||
appserviceCredentialDir = "/run/credentials/tuwunel.service";
|
||||
|
||||
# Every local user this hive may provision — agents, the hive admin, and
|
||||
# ── swarm-matrix-ctl ────────────────────────────────────────────────
|
||||
#
|
||||
# The oneshot that publishes the appservice sender account's access token to
|
||||
# the swarm's secret store. It runs INSIDE the container, beside tuwunel,
|
||||
# because the appservice token that authorises the mint is already in here —
|
||||
# `appserviceDir` below is bound read-only precisely so the homeserver can
|
||||
# load it — and minting anywhere else would create a second holder of that
|
||||
# secret, which is the thing this whole arrangement exists to stop.
|
||||
#
|
||||
# Gated on the identity, not on `deploy.bao.enable`: a swarm's ONE homeserver
|
||||
# is the host least likely to also be the host running the store, so
|
||||
# "co-located with bao" would leave the intended deployment silently minting
|
||||
# nothing. Same rule ./swarm-secret-publisher.nix's `haveClientIdentity`
|
||||
# states, for a sharper reason.
|
||||
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
|
||||
# whole matrix localpart charset.
|
||||
#
|
||||
|
|
@ -173,7 +245,7 @@ let
|
|||
cat <<'REGISTRATION'
|
||||
id: ${appserviceId}
|
||||
url: null
|
||||
sender_localpart: ${adminLocalpart}
|
||||
sender_localpart: ${hiveLocalpart}
|
||||
rate_limited: false
|
||||
namespaces:
|
||||
users:
|
||||
|
|
@ -686,6 +758,56 @@ in
|
|||
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 {
|
||||
|
|
@ -1082,7 +1204,11 @@ in
|
|||
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 =
|
||||
{ ... }:
|
||||
{
|
||||
|
|
@ -1192,30 +1318,21 @@ in
|
|||
# `Services::start()`, before the listener accepts anything.
|
||||
appservice_dir = appserviceCredentialDir;
|
||||
|
||||
# The zero-user bootstrap, and the only thing here that needs
|
||||
# no account to already exist. These run after startup and
|
||||
# BEFORE the HTTP listener, with no sender and no permission
|
||||
# check — which is what makes them the one lever that can
|
||||
# promote the hive admin on a homeserver where nobody is admin
|
||||
# yet. The appservice registration above creates
|
||||
# `@${adminLocalpart}` as its sender user moments earlier;
|
||||
# this joins it to the admin room at power level 100.
|
||||
# No `admin_execute` promotion for `@${hiveLocalpart}`. The
|
||||
# hive's account is an ordinary user: it creates the hive
|
||||
# Space and chat room and invites agents into them, all of
|
||||
# which ride on being the rooms' own creator at power level
|
||||
# 100, and none of which is a homeserver-admin capability.
|
||||
# Granting it server admin at boot would hand a credential
|
||||
# that every hive reads far more than the work needs.
|
||||
#
|
||||
# Idempotent by upstream's own guard: `make_user_admin`
|
||||
# short-circuits when the user is already joined at 100, so a
|
||||
# hive that has had an admin for months emits nothing.
|
||||
admin_execute = [
|
||||
"users make-user-admin @${adminLocalpart}:${effectiveServerName}"
|
||||
];
|
||||
|
||||
# ⚠️ 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;
|
||||
# The two operations that do need an admin sender —
|
||||
# `!admin users make-user-admin` and
|
||||
# `!admin users reset-password`, both messages into
|
||||
# `#admins:${effectiveServerName}` — therefore have no
|
||||
# working sender here. They are swarm-level operations and
|
||||
# are being rehomed as such; until then they fail, loudly,
|
||||
# rather than being served by an over-privileged token.
|
||||
# Server-side E2EE is opt-in (default off); the agent matrix
|
||||
# client always supports decryption regardless.
|
||||
allow_encryption = cfg.allowEncryption;
|
||||
|
|
@ -1286,6 +1403,66 @@ in
|
|||
"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 ];
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -236,6 +236,54 @@ 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
|
||||
# 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
|
||||
|
|
@ -659,6 +707,54 @@ 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 {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
|
|
@ -1081,7 +1177,7 @@ in
|
|||
# ⚠️ Deliberately EMPTY: nothing here generates an issuer into it, so
|
||||
# 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
|
||||
# exist, and the minter that uses them arrives separately. Undoing it
|
||||
# exist, and the binary that uses them arrives separately. Undoing it
|
||||
# is `bao secrets disable ${servicesPkiMountPath}`.
|
||||
case "$mounts" in
|
||||
*'"${servicesPkiMountPath}/"'*) ;;
|
||||
|
|
@ -1105,7 +1201,7 @@ in
|
|||
# is a name type this issuer is unconstrained for.
|
||||
# `server_flag` alone because that is what today's leaf carries
|
||||
# (`extendedKeyUsage=serverAuth`, ./hive-tls.nix:144), and rsa:4096
|
||||
# because that is the key the current minter generates.
|
||||
# because that is the key the current implementation generates.
|
||||
#
|
||||
# An empty `swarm.serviceDomains` renders an empty `allowed_domains`,
|
||||
# which with `allow_any_name=false` is a role that issues nothing —
|
||||
|
|
@ -1222,6 +1318,54 @@ 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} = {
|
||||
autoStart = true;
|
||||
ephemeral = false;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ let
|
|||
certAuthCns = [
|
||||
deployCfg.bao.controllerCommonName
|
||||
deployCfg.bao.secretPublisherCommonName
|
||||
deployCfg.bao.matrixCtlCommonName
|
||||
];
|
||||
|
||||
# Public hostnames of the swarm's own services, in declaration order.
|
||||
|
|
|
|||
|
|
@ -140,6 +140,62 @@ let
|
|||
baoGrantHere.systemd.services.swarm-bao-secret-publisher-policy.after
|
||||
);
|
||||
}
|
||||
{
|
||||
# The third principal's grant, and the narrowest of the three: ONE path,
|
||||
# spelled to the leaf. The negative arms are the property — a homeserver
|
||||
# is not entitled to overwrite Grafana's OIDC client, so widening this to
|
||||
# the `services/` prefix the publisher holds would be a real loss even
|
||||
# though it would read as tidier.
|
||||
#
|
||||
# ⚠️ `hives` is PLURAL, because the path segment comes from
|
||||
# `Kind::Hive`'s strum serialisation and not from `Kind::label`, which
|
||||
# renders the singular for error text. The singular spelling evaluates,
|
||||
# deploys, and 403s every read with "permission denied" and nothing else.
|
||||
#
|
||||
# 🩸 The hive NAME in the middle is the per-hive half of this credential:
|
||||
# the token used to be one swarm-wide value under `services/matrix/`,
|
||||
# which every hive's own policy granted read on. The negative arms below
|
||||
# are what keep it from drifting back — neither the `services/*` tree nor
|
||||
# a `hives/*` wildcard may appear, since either one hands matrix-ctl (or
|
||||
# a hive) reach beyond the single leaf it owns.
|
||||
name = "matrix-ctl's grant is one hive's sender token path and nothing else";
|
||||
ok =
|
||||
let
|
||||
s = baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.script;
|
||||
in
|
||||
lib.hasInfix "path \"secret/data/swarm/hives/h1/matrix/sender-token\" {" s
|
||||
&& !(lib.hasInfix "secret/data/swarm/services" s)
|
||||
&& !(lib.hasInfix "secret/data/swarm/agents" s)
|
||||
&& !(lib.hasInfix "secret/data/swarm/hives/*" s)
|
||||
&& !(lib.hasInfix "sys/policies/acl" s);
|
||||
}
|
||||
{
|
||||
# 🩸 `read` is load-bearing here and is the one capability neither
|
||||
# sibling has. matrix-ctl's first act is to read this path back and stop
|
||||
# if something is there — that read IS "and only once", so without the
|
||||
# capability every container restart would mint a second access token and
|
||||
# invalidate the hive's.
|
||||
name = "matrix-ctl may read back the one path it writes";
|
||||
ok =
|
||||
let
|
||||
s = baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.script;
|
||||
in
|
||||
lib.hasInfix "capabilities = [\"create\", \"update\", \"read\"]" s
|
||||
&& lib.hasInfix "auth/cert/certs/swarm-matrix-ctl" s
|
||||
&& lib.hasInfix "allowed_common_names=swarm-matrix-ctl" s;
|
||||
}
|
||||
{
|
||||
# Same two controls its siblings carry: ordered after the unit that makes
|
||||
# the mounts it writes into, and rendered on the HOST rather than inside
|
||||
# the store's container, where it would have neither an identity nor a
|
||||
# route to the store.
|
||||
name = "matrix-ctl's granting unit is ordered after the mounts and rendered on the host";
|
||||
ok =
|
||||
lib.elem "swarm-bao-controller-policy.service" (
|
||||
baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.after
|
||||
)
|
||||
&& !(baoGrantHere.containers.swarm-bao.config.systemd.services ? swarm-bao-matrix-ctl-policy);
|
||||
}
|
||||
{
|
||||
# The policy authorising this route lives in another file, and nothing
|
||||
# else relates the grants to the paths the code actually writes.
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ let
|
|||
deploy.bao.clientCertFile = "/etc/pki/bao-client.pem";
|
||||
deploy.bao.clientKeyFile = "/etc/pki/bao-client-key.pem";
|
||||
};
|
||||
|
||||
# A homeserver on a hive with NO store identity at all — neither a local
|
||||
# store nor a hand-placed leaf. The absence arm for the matrix-ctl cases below
|
||||
# needs it, and defining it here rather than importing keeps each group's
|
||||
# fixture set its own, as ./lib.nix asks.
|
||||
matrixNoBaoIdentity = hive { deploy.matrix.enable = true; };
|
||||
cases = [
|
||||
{
|
||||
# A login failure is the store being unreachable, sealed, or not yet
|
||||
|
|
@ -219,6 +225,144 @@ let
|
|||
&& (s.hive-c0re.environment.BAO_CACERT or null) == "%d/bao-ca.pem"
|
||||
&& lib.any (c: lib.hasPrefix "bao-ca.pem:" c) s.hive-c0re.serviceConfig.LoadCredential;
|
||||
}
|
||||
{
|
||||
# The same hole a third time, and the leaf whose absence is hardest to
|
||||
# see from outside: it is consumed by a unit INSIDE a container, so a
|
||||
# missing pairing renders as a container that comes up fine and publishes
|
||||
# nothing.
|
||||
name = "the store mints a leaf for matrix-ctl, and the container is pointed at it";
|
||||
ok =
|
||||
let
|
||||
m = baoWithMatrix;
|
||||
p = m.services.hyperhive.deploy.matrix;
|
||||
in
|
||||
lib.hasInfix "matrix-ctl.pem" m.systemd.services.swarm-bao-pki.script
|
||||
&& p.ctlBaoClientCertFile == "/var/lib/swarm-bao-pki/matrix-ctl.pem"
|
||||
&& p.ctlBaoClientKeyFile == "/var/lib/swarm-bao-pki/matrix-ctl-key.pem";
|
||||
}
|
||||
{
|
||||
# 🩸 The identity separation this whole arrangement buys, stated as the
|
||||
# one thing that would silently undo it. The container gets MATRIX-CTL's
|
||||
# leaf — whose grant is a single path — and not the hive's, which reads
|
||||
# every secret in the store. Both files exist in the same directory and
|
||||
# both would evaluate, deploy and work.
|
||||
name = "matrix-ctl presents its own leaf, never the hive's store-wide one";
|
||||
ok =
|
||||
let
|
||||
env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl.environment;
|
||||
hiveLeaf = baoWithMatrix.services.hyperhive.deploy.bao.clientCertFile;
|
||||
in
|
||||
env.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/matrix-ctl.pem" && env.BAO_CLIENT_CERT != hiveLeaf;
|
||||
}
|
||||
{
|
||||
# The bind mount is what makes the environment above resolvable: without
|
||||
# it the unit names two paths the container does not have, and fails at
|
||||
# the TLS handshake naming no cause. Read off the mount table rather than
|
||||
# the option, so a pairing that stops reaching `bindMounts` still fails.
|
||||
#
|
||||
# The second arm is the shape guard: `bindMounts` is one literal plus two
|
||||
# merges, and a rewrite that dropped the appservice registration would
|
||||
# take the homeserver's own credential with it.
|
||||
name = "the matrix container binds matrix-ctl's PKI read-only, without losing the appservice registration";
|
||||
ok =
|
||||
let
|
||||
mounts = baoWithMatrix.containers.hive-matrix.bindMounts;
|
||||
in
|
||||
mounts ? "/var/lib/swarm-bao-pki"
|
||||
&& mounts."/var/lib/swarm-bao-pki".isReadOnly
|
||||
&& mounts ? "/var/lib/hyperhive/matrix-appservice";
|
||||
}
|
||||
{
|
||||
# What the unit is for, read as the two agreements it cannot get wrong:
|
||||
# the cert role ./host-modules/swarm-bao.nix writes, and a homeserver
|
||||
# address that is loopback because the container shares the host netns. A
|
||||
# vhost here would be a request out through the gateway and back.
|
||||
name = "matrix-ctl is handed the store role and the loopback homeserver";
|
||||
ok =
|
||||
let
|
||||
m = baoWithMatrix;
|
||||
u = m.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl;
|
||||
port = m.services.hyperhive.swarm.matrix.httpPort;
|
||||
in
|
||||
u.environment.MATRIX_MINT_CERT_ROLE == "swarm-matrix-ctl"
|
||||
&& u.environment.MATRIX_MINT_API_URL == "http://127.0.0.1:${toString port}"
|
||||
&& u.environment.MATRIX_MINT_REGISTRATION == "/var/lib/hyperhive/matrix-appservice/hyperhive.yaml"
|
||||
&& u.serviceConfig.Type == "oneshot";
|
||||
}
|
||||
{
|
||||
# 🩸 The per-hive minting identity, read off the rendered unit rather than
|
||||
# off the option: the binary builds its store path out of
|
||||
# `MATRIX_MINT_HIVE` and logs in as `MATRIX_MINT_LOCALPART`, so a unit
|
||||
# that passed the old bare `hive` would publish one identity for the
|
||||
# whole swarm again and nothing in the Rust tests could see it. Both
|
||||
# spellings are pinned, and the localpart is pinned as *derived from* the
|
||||
# hive name rather than as a literal, which is the agreement
|
||||
# `swarm_secret_client::matrix::hive_localpart` owns.
|
||||
name = "matrix-ctl is told which hive it mints for, and acts as that hive's account";
|
||||
ok =
|
||||
let
|
||||
m = baoWithMatrix;
|
||||
u = m.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl;
|
||||
hive = m.services.hyperhive.hiveName;
|
||||
in
|
||||
u.environment.MATRIX_MINT_HIVE == hive
|
||||
&& u.environment.MATRIX_MINT_LOCALPART == "hive-${hive}"
|
||||
&& u.environment.MATRIX_MINT_LOCALPART != "hive";
|
||||
}
|
||||
{
|
||||
# 🩸 The crate is a `*ctl` with subcommands, so the unit has to name a
|
||||
# VERB. This is the one end of that contract nix owns: the binary's own
|
||||
# test pins how `mint` is spelled, but only a rendered `ExecStart` can
|
||||
# say the unit actually passes it. A bare invocation exits non-zero with
|
||||
# clap's usage — which is a deploy-time failure with no local signal, and
|
||||
# exactly what the next verb added here is most likely to disturb.
|
||||
name = "the unit invokes a verb rather than the bare binary";
|
||||
ok =
|
||||
let
|
||||
exec =
|
||||
baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl.serviceConfig.ExecStart;
|
||||
in
|
||||
lib.hasSuffix "/bin/swarm-matrix-ctl mint" exec;
|
||||
}
|
||||
{
|
||||
# 🩸 A secret is a path, never a value — checked on the one unit in this
|
||||
# tree whose whole job is an `as_token`. Every variable it is given names
|
||||
# a file or an address; the token itself is read out of the bind-mounted
|
||||
# registration at runtime, so nothing here can be a token and an
|
||||
# environment block is world-readable through `systemctl show`.
|
||||
name = "matrix-ctl's environment carries paths and addresses, never a token";
|
||||
ok =
|
||||
let
|
||||
env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl.environment;
|
||||
in
|
||||
!(lib.any (v: lib.hasInfix "as_token" v || lib.hasInfix "syt_" v) (lib.attrValues env));
|
||||
}
|
||||
{
|
||||
# The absence arm, and the deployment it protects: a homeserver on a hive
|
||||
# with no store identity at all. Without it the unit would exist naming
|
||||
# `null` as its certificate, which nixos renders as the literal string.
|
||||
name = "a matrix container with no store identity runs no matrix-ctl and binds no PKI";
|
||||
ok =
|
||||
let
|
||||
units = matrixNoBaoIdentity.containers.hive-matrix.config.systemd.services;
|
||||
in
|
||||
!(units ? swarm-matrix-ctl)
|
||||
&& !(matrixNoBaoIdentity.containers.hive-matrix.bindMounts ? "/var/lib/swarm-bao-pki");
|
||||
}
|
||||
{
|
||||
# 🩸 The privilege arm of the credential this slice publishes: the
|
||||
# account it belongs to must not be a homeserver admin. Read on the
|
||||
# rendered homeserver settings rather than on an option, because the
|
||||
# grant was never an option — it was a boot command in `admin_execute`,
|
||||
# and a command list is exactly the shape a later edit re-adds without
|
||||
# anything noticing.
|
||||
name = "the homeserver promotes no account to admin at boot";
|
||||
ok =
|
||||
let
|
||||
g = baoWithMatrix.containers.hive-matrix.config.services.matrix-tuwunel.settings.global;
|
||||
in
|
||||
!(g ? admin_execute) || g.admin_execute == [ ];
|
||||
}
|
||||
];
|
||||
in
|
||||
runGroup "bao-matrix-reader" cases
|
||||
|
|
|
|||
|
|
@ -52,6 +52,17 @@ let
|
|||
swarm.hives.pubctl.domain = "p.t.local";
|
||||
};
|
||||
|
||||
# The THIRD element of the same list, colliding on its own so neither of the
|
||||
# two above can carry it. matrix-ctl's grant is one path rather than a whole
|
||||
# prefix, which is exactly why a dead entry here would be easy to miss: a
|
||||
# hive that inherited it would not obviously break anything, it would
|
||||
# silently gain the ability to overwrite the swarm's matrix credential.
|
||||
hiveNamedAfterMatrixCtlSubject = hive {
|
||||
deploy.swarm-otel.enable = false;
|
||||
deploy.bao.matrixCtlCommonName = "mintctl";
|
||||
swarm.hives.mintctl.domain = "m.t.local";
|
||||
};
|
||||
|
||||
hiveNameWithComposedWord = hive {
|
||||
deploy.swarm-otel.enable = false;
|
||||
swarm.hives."h1-agent".domain = "a.t.local";
|
||||
|
|
@ -88,6 +99,18 @@ let
|
|||
a: !a.assertion && lib.hasInfix "'pubctl'" a.message
|
||||
) hiveNamedAfterPublisherSubject.assertions;
|
||||
}
|
||||
{
|
||||
# And the third, for the reason the second one's comment gives one list
|
||||
# element earlier. `certAuthCns` is where a role added beside the others
|
||||
# has to register itself, and nothing but a case per element notices when
|
||||
# one forgets.
|
||||
name = "a hive named after matrix-ctl's subject is refused too";
|
||||
ok =
|
||||
equalityGuardFired hiveNamedAfterMatrixCtlSubject
|
||||
&& lib.any (
|
||||
a: !a.assertion && lib.hasInfix "'mintctl'" a.message
|
||||
) hiveNamedAfterMatrixCtlSubject.assertions;
|
||||
}
|
||||
{
|
||||
# Without this the case above proves nothing: an arm that fires for every
|
||||
# roster is not a guard, and `hives` is non-empty in both fixtures.
|
||||
|
|
|
|||
|
|
@ -163,6 +163,13 @@ in
|
|||
# rather than every hive's.
|
||||
swarm-nats-auth = mkBinPackage "swarm-nats-auth" "hyperhive swarm queue auth-callout responder";
|
||||
|
||||
# The matrix container's control binary; `mint` publishes the sender token. Out of `daemonBins` for
|
||||
# "runs *inside* a container, not on the host" reason as the responder
|
||||
# above, and with a second one: putting it in the core bundle would place
|
||||
# the binary that reads the appservice token on every hive's filesystem,
|
||||
# which is the arrangement it exists to end.
|
||||
swarm-matrix-ctl = mkBinPackage "swarm-matrix-ctl" "hyperhive matrix container control binary";
|
||||
|
||||
# The only process allowed to write swarm-authelia's users database —
|
||||
# same "runs *inside* a container, not on the host" placement as
|
||||
# `swarm-nats-auth` above (this one lives in `swarm-authelia`'s
|
||||
|
|
|
|||
27
swarm-matrix-ctl/Cargo.toml
Normal file
27
swarm-matrix-ctl/Cargo.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
[package]
|
||||
name = "swarm-matrix-ctl"
|
||||
version.workspace = true
|
||||
readme = "README.md"
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "swarm-matrix-ctl"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
# One verb today (`mint`), and the reason this crate is a `*ctl` rather than a
|
||||
# single-purpose binary: the next thing that has to run in the matrix container
|
||||
# is a subcommand here, not a new crate.
|
||||
clap.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde_json.workspace = true
|
||||
# The agreement this binary is one end of: where the credential lives, what the
|
||||
# object at that path holds, and the `BAO_*` spellings the unit sets.
|
||||
swarm-secret-client.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
62
swarm-matrix-ctl/README.md
Normal file
62
swarm-matrix-ctl/README.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# swarm-matrix-ctl
|
||||
|
||||
The rust that runs **inside `containers.hive-matrix`**, beside the homeserver.
|
||||
|
||||
One binary with subcommands rather than one binary per job. Running code in that
|
||||
container is not free: it needs its own store identity, its own cert role and
|
||||
its own bind mounts, and every one of those is per-_container_, not per-task. A
|
||||
second single-purpose crate would have had to duplicate that plumbing to add one
|
||||
action, so the next thing that has to run in here is a **verb**, not a new
|
||||
crate.
|
||||
|
||||
## Verbs
|
||||
|
||||
### `mint`
|
||||
|
||||
Puts the appservice sender account's access token into the swarm's secret store,
|
||||
under an identity of its own. A boot-time oneshot.
|
||||
|
||||
Configured entirely by the `MATRIX_MINT_*` environment the unit sets — no flags.
|
||||
A systemd `Environment=` block is what a nix module can render; a command line
|
||||
full of paths is not. The prefix is scoped to the verb rather than to the binary
|
||||
so the next verb brings its own, instead of widening a shared one nobody can
|
||||
then narrow.
|
||||
|
||||
## Why this lives in the matrix container
|
||||
|
||||
The credential `mint` writes is authorised by the appservice `as_token`, and the
|
||||
container already holds that: `nix/host-modules/hive-matrix.nix` bind-mounts the
|
||||
rendered appservice registration into it read-only, because that is how tuwunel
|
||||
itself is handed the registration. Minting anywhere else would mean copying the
|
||||
`as_token` to a second holder — and the point of this component is that the hive
|
||||
stops being one.
|
||||
|
||||
It is not the swarm controller for the same reason, plus a structural one: a
|
||||
homeserver has exactly **one** appservice registration and so one sender
|
||||
account, and a swarm runs one homeserver, so "mint it once" needs no lock, no
|
||||
lease and no trigger surface — it is a property of the thing being minted.
|
||||
|
||||
## Idempotency
|
||||
|
||||
The **store** is the key, not the homeserver. A `mint` run reads
|
||||
`swarm/services/matrix/sender-token` first and returns without touching the
|
||||
homeserver when something is already there. Only an empty path reaches the mint
|
||||
ladder:
|
||||
|
||||
1. `POST /_matrix/client/v3/register` with `"type": "m.login.application_service"`
|
||||
— one round trip, no UIAA.
|
||||
2. `M_USER_IN_USE` (the expected arm on a homeserver that has already loaded the
|
||||
registration, since the account is the appservice's own `sender_localpart`) →
|
||||
`POST /_matrix/client/v3/login` as the appservice, same pinned `device_id`, so
|
||||
the old device is replaced rather than duplicated.
|
||||
3. Write the result to the store.
|
||||
|
||||
A crash between the homeserver call and the store write is recoverable: the next
|
||||
run takes arm 2.
|
||||
|
||||
## 🩸 A secret is a path, never a value
|
||||
|
||||
Nothing here logs, prints or interpolates a token. The mint ladder's errors are
|
||||
built from the homeserver's _status_ and its `errcode`, never its body, because a
|
||||
`/login` response body is an access token. The one identifier this binary logs is
|
||||
the store path it wrote.
|
||||
286
swarm-matrix-ctl/src/homeserver.rs
Normal file
286
swarm-matrix-ctl/src/homeserver.rs
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
//! The two calls the mint ladder is made of, against the homeserver next door.
|
||||
//!
|
||||
//! 🩸 **Every error in this module is built from the response's `status` and
|
||||
//! its `errcode`, never its body.** A successful `/register` or `/login` body
|
||||
//! *is* an access token, and an error body is one malformed response away from
|
||||
//! being the same bytes — so a `body: {json}` in a message here would put the
|
||||
//! sender token in the journal.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
/// Client-server API calls are one round trip each against a homeserver in the
|
||||
/// same netns; a slow one is a broken one.
|
||||
const TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
/// Bytes of the throwaway password `/register` is given.
|
||||
///
|
||||
/// Protocol overhead, and stored nowhere: this account authenticates by access
|
||||
/// token, and the recovery path when that token is lost is the appservice login
|
||||
/// below rather than anything a password could open.
|
||||
const PASSWORD_BYTES: usize = 32;
|
||||
|
||||
/// What the homeserver said about a registration attempt.
|
||||
///
|
||||
/// An enum rather than a string match on the error text: `M_USER_IN_USE` is the
|
||||
/// *expected* answer here — the hive's `@hive-<hive>:` account is the appservice
|
||||
/// registration's own `sender_localpart`, so the homeserver creates it at startup, before
|
||||
/// anything gets to ask — and an expected answer should not have to be
|
||||
/// recovered from a formatted message.
|
||||
pub enum Registered {
|
||||
/// A fresh account, and the access token minted with it.
|
||||
Token(String),
|
||||
/// The account is already there; it has to be logged into instead.
|
||||
AlreadyExists,
|
||||
}
|
||||
|
||||
/// An HTTP client with this module's timeout.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the TLS backend will not initialise, which is the only way building a
|
||||
/// client fails.
|
||||
pub fn client() -> Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(TIMEOUT_SECS))
|
||||
.build()
|
||||
.context("building the HTTP client for the homeserver")
|
||||
}
|
||||
|
||||
/// Create `localpart`'s account as the appservice and return its access token.
|
||||
///
|
||||
/// One round trip: an appservice-typed registration needs no UIAA stage, so
|
||||
/// there is no session to carry and no shared registration secret in the
|
||||
/// picture. The `device_id` is pinned so that a later [`appservice_login`]
|
||||
/// replaces this device rather than accumulating one per run.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the request cannot be sent, the response will not decode, or the
|
||||
/// homeserver refuses with anything other than `M_USER_IN_USE` — which is
|
||||
/// [`Registered::AlreadyExists`] rather than an error.
|
||||
pub async fn register(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
localpart: &str,
|
||||
as_token: &str,
|
||||
) -> Result<Registered> {
|
||||
let body = serde_json::json!({
|
||||
// What makes this an appservice registration rather than an ordinary
|
||||
// one: without it the homeserver asks for a UIAA flow even though the
|
||||
// request carries the as_token.
|
||||
"type": "m.login.application_service",
|
||||
"username": localpart,
|
||||
"password": random_password()?,
|
||||
"device_id": device_id(localpart),
|
||||
"initial_device_display_name": format!("hyperhive ({localpart})"),
|
||||
"inhibit_login": false,
|
||||
});
|
||||
let (status, json) = post(
|
||||
client,
|
||||
&format!("{base}/_matrix/client/v3/register?kind=user"),
|
||||
as_token,
|
||||
&body,
|
||||
)
|
||||
.await
|
||||
.context("POST /register as the appservice")?;
|
||||
|
||||
if status.is_success() {
|
||||
return Ok(Registered::Token(access_token(&json)?));
|
||||
}
|
||||
if errcode(&json) == Some("M_USER_IN_USE") {
|
||||
return Ok(Registered::AlreadyExists);
|
||||
}
|
||||
bail!(
|
||||
"the homeserver refused to register @{localpart}: {}",
|
||||
why(status, &json)
|
||||
);
|
||||
}
|
||||
|
||||
/// Log in as an **existing** account using the appservice's authority, and
|
||||
/// return a fresh access token for it.
|
||||
///
|
||||
/// No password: the appservice is authorised for every localpart in its
|
||||
/// namespace, so it mints a session for one without knowing anything about the
|
||||
/// account — which is just as well, since an account the homeserver created for
|
||||
/// its own registration has none.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the request cannot be sent, the response will not decode, or the
|
||||
/// homeserver refuses.
|
||||
pub async fn appservice_login(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
localpart: &str,
|
||||
as_token: &str,
|
||||
) -> Result<String> {
|
||||
let body = serde_json::json!({
|
||||
"type": "m.login.application_service",
|
||||
"identifier": {
|
||||
"type": "m.id.user",
|
||||
"user": localpart,
|
||||
},
|
||||
// Matching `register`'s, so a re-login REPLACES that device's token
|
||||
// rather than leaving a second live device behind.
|
||||
"device_id": device_id(localpart),
|
||||
"initial_device_display_name": format!("hyperhive ({localpart})"),
|
||||
});
|
||||
let (status, json) = post(
|
||||
client,
|
||||
&format!("{base}/_matrix/client/v3/login"),
|
||||
as_token,
|
||||
&body,
|
||||
)
|
||||
.await
|
||||
.context("POST /login as the appservice")?;
|
||||
|
||||
if !status.is_success() {
|
||||
bail!(
|
||||
"the homeserver refused to log in @{localpart}: {}",
|
||||
why(status, &json)
|
||||
);
|
||||
}
|
||||
access_token(&json)
|
||||
}
|
||||
|
||||
/// The device every token this binary mints is pinned to.
|
||||
fn device_id(localpart: &str) -> String {
|
||||
format!("hyperhive-{localpart}")
|
||||
}
|
||||
|
||||
/// One authenticated JSON POST, returning the status beside the decoded body.
|
||||
async fn post(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
as_token: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<(reqwest::StatusCode, serde_json::Value)> {
|
||||
let resp = client
|
||||
.post(url)
|
||||
.bearer_auth(as_token)
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.context("sending the request")?;
|
||||
let status = resp.status();
|
||||
let json = resp
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.context("decoding the response as JSON")?;
|
||||
Ok((status, json))
|
||||
}
|
||||
|
||||
/// Pull `access_token` out of a successful response.
|
||||
fn access_token(json: &serde_json::Value) -> Result<String> {
|
||||
json["access_token"]
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
// Not `{json}`: on the success path this object holds the credential,
|
||||
// and a response missing the field is exactly when a reflex to print it
|
||||
// would fire.
|
||||
.context("the homeserver's response carried no `access_token`")
|
||||
}
|
||||
|
||||
/// The matrix error code, when the body is a standard error object.
|
||||
fn errcode(json: &serde_json::Value) -> Option<&str> {
|
||||
json["errcode"].as_str()
|
||||
}
|
||||
|
||||
/// Everything about a refusal that is safe to put in a message.
|
||||
///
|
||||
/// The `errcode` is a closed vocabulary from the spec and the status is a
|
||||
/// number; between them they say which of the ladder's arms was taken. The
|
||||
/// `error` string beside them is free-form homeserver text, so it stays out.
|
||||
fn why(status: reqwest::StatusCode, json: &serde_json::Value) -> String {
|
||||
match errcode(json) {
|
||||
Some(code) => format!("HTTP {status}, errcode {code}"),
|
||||
None => format!("HTTP {status}, no errcode"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A throwaway password for [`register`], as hex.
|
||||
///
|
||||
/// From `/dev/urandom` directly rather than through an RNG crate: this is the
|
||||
/// one random value the binary needs, and the kernel is already the source any
|
||||
/// such crate would reach for here.
|
||||
///
|
||||
/// # Errors
|
||||
/// When `/dev/urandom` cannot be read.
|
||||
fn random_password() -> Result<String> {
|
||||
use std::io::Read as _;
|
||||
|
||||
let mut buf = [0u8; PASSWORD_BYTES];
|
||||
std::fs::File::open("/dev/urandom")
|
||||
.context("opening /dev/urandom")?
|
||||
.read_exact(&mut buf)
|
||||
.context("reading from /dev/urandom")?;
|
||||
Ok(buf.iter().fold(String::new(), |mut acc, b| {
|
||||
use std::fmt::Write as _;
|
||||
// Infallible: `write!` into a `String` only fails if the formatter
|
||||
// does, and `{:02x}` of a `u8` has nothing to fail at.
|
||||
let _ = write!(acc, "{b:02x}");
|
||||
acc
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn both_calls_pin_the_same_device_so_a_relogin_replaces_it() {
|
||||
// The whole reason the recovery arm is safe to take repeatedly: an
|
||||
// unpinned login mints a NEW device each time, and a homeserver
|
||||
// accumulating devices for this account is one where revoking the credential
|
||||
// means finding all of them.
|
||||
assert_eq!(device_id("hive"), "hyperhive-hive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_is_described_by_its_errcode_and_never_by_its_body() {
|
||||
// 🩸 The invariant this module exists to keep. `error` is free-form
|
||||
// homeserver text and `access_token` is the credential itself; a
|
||||
// message built from the body would carry whichever of them the
|
||||
// response happened to hold.
|
||||
let json = serde_json::json!({
|
||||
"errcode": "M_FORBIDDEN",
|
||||
"error": "some free-form text",
|
||||
"access_token": "syt_the_actual_secret",
|
||||
});
|
||||
let message = why(reqwest::StatusCode::FORBIDDEN, &json);
|
||||
assert!(message.contains("M_FORBIDDEN"), "{message}");
|
||||
assert!(!message.contains("syt_the_actual_secret"), "{message}");
|
||||
assert!(!message.contains("free-form"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_with_no_errcode_still_produces_a_message() {
|
||||
// A homeserver behind a proxy answers with HTML, not a matrix error
|
||||
// object. The status is then the only thing there is to say, and
|
||||
// saying it is better than an empty report.
|
||||
let message = why(reqwest::StatusCode::BAD_GATEWAY, &serde_json::json!({}));
|
||||
assert!(message.contains("502"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_expected_already_exists_answer_is_recognised_by_its_errcode() {
|
||||
// Matched on the spec's code rather than on message text, because this
|
||||
// is the arm a healthy homeserver takes every time: the hive's `@hive-<hive>:`
|
||||
// account is the appservice registration's own sender, created at startup.
|
||||
let json = serde_json::json!({ "errcode": "M_USER_IN_USE", "error": "User ID taken" });
|
||||
assert_eq!(errcode(&json), Some("M_USER_IN_USE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_response_without_a_token_is_an_error_that_does_not_quote_it() {
|
||||
let e = access_token(&serde_json::json!({ "user_id": "@hive:t.local" }))
|
||||
.expect_err("no access_token in this object");
|
||||
assert!(!format!("{e}").contains("@hive:t.local"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_minted_password_is_hex_of_the_declared_length() {
|
||||
// The control on the hex fold: a short or non-hex password would be
|
||||
// accepted by the homeserver and only surface much later, if at all.
|
||||
let pw = random_password().expect("/dev/urandom is readable");
|
||||
assert_eq!(pw.len(), PASSWORD_BYTES * 2);
|
||||
assert!(pw.bytes().all(|b| b.is_ascii_hexdigit()), "not hex");
|
||||
}
|
||||
}
|
||||
96
swarm-matrix-ctl/src/main.rs
Normal file
96
swarm-matrix-ctl/src/main.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
//! `swarm-matrix-ctl` — the rust that runs *inside* `containers.hive-matrix`.
|
||||
//!
|
||||
//! One binary with subcommands rather than one binary per job. The container
|
||||
//! is an awkward place to put code — it needs its own store identity, its own
|
||||
//! bind mounts and its own cert role — and all of that is per-*container*, not
|
||||
//! per-task. A second single-purpose crate would have had to duplicate the
|
||||
//! identity plumbing to add one action, so the next thing that has to run in
|
||||
//! here is a verb below, not a new crate.
|
||||
//!
|
||||
//! Today that is one verb, [`mint`]: publish the appservice sender account's
|
||||
//! homeserver access token to the swarm's secret store, once.
|
||||
//!
|
||||
//! It lives in the container because the appservice `as_token` that authorises
|
||||
//! the mint is *already* there — the registration tuwunel loads is bind-mounted
|
||||
//! in — so no second holder of that secret is created.
|
||||
//!
|
||||
//! 🩸 **A secret is a path, never a value.** The only identifier any verb here
|
||||
//! logs is the store path; see `homeserver`'s module doc for the same rule
|
||||
//! applied to error messages.
|
||||
|
||||
mod homeserver;
|
||||
mod mint;
|
||||
mod registration;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "swarm-matrix-ctl",
|
||||
about = "Act on the swarm's matrix homeserver from inside its container"
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
/// Publish the appservice sender account's access token to the swarm
|
||||
/// secret store, once.
|
||||
///
|
||||
/// Configured entirely by the `MATRIX_MINT_*` environment the unit sets —
|
||||
/// no flags, because a systemd `Environment=` block is what a nix module
|
||||
/// can render and a command line full of paths is not.
|
||||
Mint,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
match Cli::parse().command {
|
||||
Command::Mint => mint::run().await,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::CommandFactory;
|
||||
|
||||
#[test]
|
||||
fn the_clap_tree_is_well_formed() {
|
||||
Cli::command().debug_assert();
|
||||
}
|
||||
|
||||
/// The unit's `ExecStart` names a verb, so a rename of it is a deploy-time
|
||||
/// failure with no local signal. This is that signal.
|
||||
#[test]
|
||||
fn mint_is_spelled_the_way_the_unit_invokes_it() {
|
||||
let cli = Cli::try_parse_from(["swarm-matrix-ctl", "mint"]).expect("`mint` is a verb");
|
||||
assert!(matches!(cli.command, Command::Mint));
|
||||
}
|
||||
|
||||
/// The control: without it the case above passes on a parser that accepts
|
||||
/// anything.
|
||||
#[test]
|
||||
fn an_unknown_verb_is_refused() {
|
||||
Cli::try_parse_from(["swarm-matrix-ctl", "conjure"])
|
||||
.expect_err("only declared verbs are accepted");
|
||||
}
|
||||
|
||||
/// A bare invocation must not silently do something. `mint` writes a
|
||||
/// credential, so "no verb" defaulting to it would make a typo in the unit
|
||||
/// mint rather than fail.
|
||||
#[test]
|
||||
fn no_verb_at_all_is_refused() {
|
||||
Cli::try_parse_from(["swarm-matrix-ctl"]).expect_err("a verb is required");
|
||||
}
|
||||
}
|
||||
301
swarm-matrix-ctl/src/mint.rs
Normal file
301
swarm-matrix-ctl/src/mint.rs
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
//! `swarm-matrix-ctl mint` — publish the appservice sender account's
|
||||
//! homeserver access token to the swarm's secret store, once.
|
||||
//!
|
||||
//! A oneshot inside `containers.hive-matrix`, not a daemon and not part of the
|
||||
//! swarm controller. It mints for **one hive** — the hive this container runs
|
||||
//! on, named by [`ENV_HIVE`] — and publishes to that hive's own path, so a
|
||||
//! swarm whose hives share a homeserver gets one account and one token per
|
||||
//! hive rather than one shared between all of them. "Only once" is therefore
|
||||
//! once per hive, and it is still a property of what is being minted rather
|
||||
//! than of a lock: nothing else writes that path.
|
||||
//!
|
||||
//! The store, not the homeserver, is the idempotency key — see
|
||||
//! [`already_published`]. On the hive side `hive-c0re`'s
|
||||
//! `matrix::ensure_hive_user` reads exactly the path written here, which is how
|
||||
//! a hive that holds no `as_token` still gets its matrix account.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use swarm_secret_client::{
|
||||
SecretStore,
|
||||
client::{DEFAULT_CERT_MOUNT, Settings},
|
||||
matrix,
|
||||
};
|
||||
|
||||
use crate::{homeserver, registration};
|
||||
|
||||
/// Role on the store's `cert` auth mount to log in with. Its policy is what
|
||||
/// allows the write below; the certificate the `BAO_*` variables name has to
|
||||
/// carry the CN that role accepts.
|
||||
const ENV_CERT_ROLE: &str = "MATRIX_MINT_CERT_ROLE";
|
||||
/// Client-server API base of the homeserver beside us — loopback, since the
|
||||
/// container shares the host netns.
|
||||
const ENV_API_URL: &str = "MATRIX_MINT_API_URL";
|
||||
/// The bind-mounted appservice registration, which is where the `as_token`
|
||||
/// comes from. A path, never a value.
|
||||
const ENV_REGISTRATION: &str = "MATRIX_MINT_REGISTRATION";
|
||||
/// Localpart of this hive's sender account. The registration's own
|
||||
/// `sender_localpart`, rendered by `hive-matrix.nix` from the hive name — the
|
||||
/// same string `swarm_secret_client::matrix::hive_localpart` builds, which is
|
||||
/// what `hive-c0re` derives its own copy with.
|
||||
const ENV_LOCALPART: &str = "MATRIX_MINT_LOCALPART";
|
||||
/// Name of the hive this container belongs to, and so the segment of the store
|
||||
/// path the token is published under. It is what keeps one hive's token out of
|
||||
/// another hive's reach — see `swarm_secret_client::matrix::sender_token_path`.
|
||||
const ENV_HIVE: &str = "MATRIX_MINT_HIVE";
|
||||
/// Public base URL of the homeserver, stored beside the token so a reader can
|
||||
/// reconstruct where it is good for. Optional: a swarm with no gateway vhost
|
||||
/// has no such URL, and `matrix::Credential` types the field to say so.
|
||||
const ENV_HOMESERVER: &str = "MATRIX_MINT_HOMESERVER";
|
||||
|
||||
/// Everything the unit tells this verb, checked before anything is opened.
|
||||
///
|
||||
/// Separate from the work for the reason `swarm_secret_client::client::Settings`
|
||||
/// is: every arm is a misconfiguration an operator reads an error about, and
|
||||
/// none of them needs a reachable homeserver or store to happen.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Config {
|
||||
cert_role: String,
|
||||
api_url: String,
|
||||
registration: String,
|
||||
localpart: String,
|
||||
hive: String,
|
||||
homeserver: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Read the `MATRIX_MINT_*` variables from the process environment.
|
||||
///
|
||||
/// # Errors
|
||||
/// Naming the first variable that is unset or empty.
|
||||
fn from_env() -> Result<Self> {
|
||||
Self::from_lookup(|k| std::env::var(k).ok())
|
||||
}
|
||||
|
||||
/// [`Config::from_env`] against an arbitrary lookup.
|
||||
///
|
||||
/// # Errors
|
||||
/// Naming the first variable that is unset or empty.
|
||||
fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
|
||||
let required = |var: &'static str| -> Result<String> {
|
||||
get(var)
|
||||
.filter(|v| !v.is_empty())
|
||||
.with_context(|| format!("{var} is unset or empty"))
|
||||
};
|
||||
Ok(Self {
|
||||
cert_role: required(ENV_CERT_ROLE)?,
|
||||
api_url: required(ENV_API_URL)?,
|
||||
registration: required(ENV_REGISTRATION)?,
|
||||
localpart: required(ENV_LOCALPART)?,
|
||||
hive: required(ENV_HIVE)?,
|
||||
// Empty is absent: systemd renders an unset nix option as
|
||||
// `Environment=VAR=`, so that is the shape this arrives in.
|
||||
homeserver: get(ENV_HOMESERVER).filter(|v| !v.is_empty()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the credential already in the store?
|
||||
///
|
||||
/// **This read is the "and only once".** The homeserver is not asked — a
|
||||
/// re-run of the container, or of this unit, costs one store read and stops.
|
||||
/// It is also the read-back of what a previous run wrote, so the path published
|
||||
/// and the path consulted cannot drift apart: they are one function call.
|
||||
///
|
||||
/// A failure to read is reported and treated as absent rather than raised. The
|
||||
/// two cases that reach it are a path that has never been written (the first
|
||||
/// run, which must go on to mint) and a token whose policy does not cover the
|
||||
/// path — and the second fails again, loudly and with the store's own message,
|
||||
/// at the write below.
|
||||
async fn already_published(store: &SecretStore, path: &str) -> bool {
|
||||
match store.read::<matrix::Credential>(path).await {
|
||||
Ok(credential) => !credential.value.trim().is_empty(),
|
||||
Err(e) => {
|
||||
tracing::info!(%path, error = %e, "nothing readable in the store yet");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the verb.
|
||||
///
|
||||
/// # Errors
|
||||
/// If the environment is incomplete, the store refuses the login or the write,
|
||||
/// the registration cannot be read, or the homeserver refuses both the
|
||||
/// registration and the appservice login.
|
||||
pub async fn run() -> Result<()> {
|
||||
let config = Config::from_env()?;
|
||||
// Explicitly, rather than through `SecretStore::from_env`: a missing or
|
||||
// misspelled `BAO_*` variable is the most likely thing to be wrong with a
|
||||
// freshly deployed unit, and this reports it before the homeserver is
|
||||
// touched at all.
|
||||
let settings = Settings::from_env().context("reading the store's BAO_* environment")?;
|
||||
let store = SecretStore::connect(&settings, &config.cert_role, DEFAULT_CERT_MOUNT)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"logging in to the swarm secret store as cert role {}",
|
||||
config.cert_role
|
||||
)
|
||||
})?;
|
||||
|
||||
let path = matrix::sender_token_path(&config.hive)
|
||||
.with_context(|| format!("building the store path for hive {}", config.hive))?;
|
||||
if already_published(&store, &path).await {
|
||||
tracing::info!(%path, "the sender token is already published; not minting");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let as_token = registration::as_token(&config.registration)?;
|
||||
let http = homeserver::client()?;
|
||||
let token =
|
||||
match homeserver::register(&http, &config.api_url, &config.localpart, &as_token).await? {
|
||||
homeserver::Registered::Token(token) => token,
|
||||
homeserver::Registered::AlreadyExists => {
|
||||
// The expected arm, not an edge case: this account is the
|
||||
// appservice's own `sender_localpart`, so the homeserver creates it
|
||||
// when it loads the registration — before anything gets to ask.
|
||||
tracing::info!("the sender account exists; logging in as the appservice instead");
|
||||
homeserver::appservice_login(&http, &config.api_url, &config.localpart, &as_token)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
store
|
||||
.write(
|
||||
&path,
|
||||
&matrix::Credential {
|
||||
value: token,
|
||||
homeserver: config.homeserver,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("writing the sender token to {path}"))?;
|
||||
tracing::info!(%path, "published the sender token");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A lookup standing in for a fully-configured unit's environment.
|
||||
fn full(k: &str) -> Option<String> {
|
||||
match k {
|
||||
ENV_CERT_ROLE => Some("swarm-matrix-ctl".to_owned()),
|
||||
ENV_API_URL => Some("http://127.0.0.1:8008".to_owned()),
|
||||
ENV_REGISTRATION => {
|
||||
Some("/var/lib/hyperhive/matrix-appservice/hyperhive.yaml".to_owned())
|
||||
}
|
||||
ENV_LOCALPART => Some("hive-pr1ma".to_owned()),
|
||||
ENV_HIVE => Some("pr1ma".to_owned()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_complete_environment_is_accepted() {
|
||||
// The control: without it every assertion below could be passing
|
||||
// because `from_lookup` rejects everything.
|
||||
let c = Config::from_lookup(full).expect("every required variable is set");
|
||||
assert_eq!(c.localpart, "hive-pr1ma");
|
||||
assert_eq!(c.hive, "pr1ma");
|
||||
assert_eq!(c.homeserver, None, "an absent public URL is not an error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_required_variable_is_named_when_it_is_the_missing_one() {
|
||||
for var in [
|
||||
ENV_CERT_ROLE,
|
||||
ENV_API_URL,
|
||||
ENV_REGISTRATION,
|
||||
ENV_LOCALPART,
|
||||
ENV_HIVE,
|
||||
] {
|
||||
let e = Config::from_lookup(|k| if k == var { None } else { full(k) })
|
||||
.expect_err("one required variable is absent");
|
||||
assert!(
|
||||
format!("{e}").contains(var),
|
||||
"dropping {var} should name {var}, got {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_variable_is_as_absent_as_an_unset_one() {
|
||||
// systemd writes `Environment=VAR=` for an unset nix option, so empty
|
||||
// is the shape these actually arrive in.
|
||||
let e = Config::from_lookup(|k| {
|
||||
if k == ENV_CERT_ROLE {
|
||||
Some(String::new())
|
||||
} else {
|
||||
full(k)
|
||||
}
|
||||
})
|
||||
.expect_err("an empty role is not a role");
|
||||
assert!(format!("{e}").contains(ENV_CERT_ROLE), "{e}");
|
||||
|
||||
let c = Config::from_lookup(|k| {
|
||||
if k == ENV_HOMESERVER {
|
||||
Some(String::new())
|
||||
} else {
|
||||
full(k)
|
||||
}
|
||||
})
|
||||
.expect("an empty public URL is optional, not fatal");
|
||||
assert_eq!(c.homeserver, None);
|
||||
}
|
||||
|
||||
/// The environment prefix is a contract with the nix unit, and the crate
|
||||
/// rename that produced it moved every one of these. A verb-scoped prefix
|
||||
/// is the point: the next verb brings its own, instead of widening a
|
||||
/// binary-scoped one nobody can then narrow.
|
||||
#[test]
|
||||
fn every_variable_is_scoped_to_the_verb() {
|
||||
for var in [
|
||||
ENV_CERT_ROLE,
|
||||
ENV_API_URL,
|
||||
ENV_REGISTRATION,
|
||||
ENV_LOCALPART,
|
||||
ENV_HIVE,
|
||||
ENV_HOMESERVER,
|
||||
] {
|
||||
assert!(
|
||||
var.starts_with("MATRIX_MINT_"),
|
||||
"{var} is not scoped to the mint verb"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_published_path_is_the_one_the_hive_reads() {
|
||||
// Both ends of this slice's loop resolve the same function, so there is
|
||||
// no second spelling to drift — this pins that the loop exists at all,
|
||||
// and names the literal so a move of the path is a deliberate edit on
|
||||
// both sides rather than a silent 404 on the reading one.
|
||||
assert_eq!(
|
||||
matrix::sender_token_path("pr1ma").expect("a plain name is legal"),
|
||||
"swarm/hives/pr1ma/matrix/sender-token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_hives_are_published_to_two_paths() {
|
||||
// What the hive segment is FOR: this binary runs beside a homeserver
|
||||
// several hives share, so a path without the hive name in it would
|
||||
// have each run overwrite the last and leave every hive holding one
|
||||
// identity — which is the shape this change exists to end.
|
||||
let a = matrix::sender_token_path("alpha").expect("legal");
|
||||
let b = matrix::sender_token_path("beta").expect("legal");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_localpart_the_unit_hands_over_is_the_one_derived_from_the_hive() {
|
||||
// The nix unit renders both variables independently; this pins that
|
||||
// the pair it is expected to render agrees with the shared derivation,
|
||||
// so a unit still passing the old bare `hive` fails here rather than
|
||||
// silently logging in as another hive's account.
|
||||
let c = Config::from_lookup(full).expect("every required variable is set");
|
||||
assert_eq!(c.localpart, matrix::hive_localpart(&c.hive));
|
||||
}
|
||||
}
|
||||
104
swarm-matrix-ctl/src/registration.rs
Normal file
104
swarm-matrix-ctl/src/registration.rs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
//! Reading the `as_token` out of the appservice registration the matrix
|
||||
//! container already has.
|
||||
//!
|
||||
//! No new credential is delivered for this. `nix/host-modules/hive-matrix.nix`
|
||||
//! binds the registration directory into the container read-only so tuwunel can
|
||||
//! load it, and the registration **is** the `as_token` — so the file this
|
||||
//! module opens is one this process could already read, and one the homeserver
|
||||
//! beside it reads too.
|
||||
//!
|
||||
//! Scanned line-by-line rather than parsed as YAML. The file has exactly one
|
||||
//! renderer (`appserviceRegistrationScript` in that same module, a `printf` of
|
||||
//! `as_token: <hex>`), so a parser would be a second, looser reading of a shape
|
||||
//! this repo writes itself — and it would pull a YAML crate into a binary whose
|
||||
//! only other input is JSON.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
/// The key the token is stored under, and the whole of the agreement with the
|
||||
/// renderer.
|
||||
const KEY: &str = "as_token:";
|
||||
|
||||
/// Read the registration at `path` and return its `as_token`.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the file cannot be read, or holds no `as_token` with a value — which is
|
||||
/// what a registration rendered by something other than this repo looks like
|
||||
/// from here.
|
||||
pub fn as_token(path: &str) -> Result<String> {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("reading the appservice registration at {path}"))?;
|
||||
// The path, not the file's contents: every line of it is either a secret or
|
||||
// a shape this module already knows.
|
||||
extract(&text).with_context(|| format!("no `as_token` in the registration at {path}"))
|
||||
}
|
||||
|
||||
/// [`as_token`] over text already in hand, so the agreement with the renderer
|
||||
/// can be tested without a file.
|
||||
fn extract(text: &str) -> Result<String> {
|
||||
for line in text.lines() {
|
||||
if let Some(rest) = line.strip_prefix(KEY) {
|
||||
let token = rest.trim();
|
||||
if token.is_empty() {
|
||||
bail!("the registration's `as_token` is empty");
|
||||
}
|
||||
return Ok(token.to_owned());
|
||||
}
|
||||
}
|
||||
bail!("the registration carries no `as_token` line")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The registration exactly as `appserviceRegistrationScript` renders it —
|
||||
/// the quoted heredoc, then the `printf` of the two tokens. Reproduced
|
||||
/// verbatim because that script is the other end of this agreement and
|
||||
/// lives in a file no Rust test can reach.
|
||||
const RENDERED: &str = "id: hyperhive\n\
|
||||
url: null\n\
|
||||
sender_localpart: hive\n\
|
||||
rate_limited: false\n\
|
||||
namespaces:\n \
|
||||
users:\n \
|
||||
- exclusive: false\n \
|
||||
regex: '@[a-z0-9._=/+-]+:example\\.test$'\n \
|
||||
aliases: []\n \
|
||||
rooms: []\n\
|
||||
as_token: deadbeef\n\
|
||||
hs_token: cafebabe\n";
|
||||
|
||||
#[test]
|
||||
fn the_token_is_taken_from_the_registration_this_repo_renders() {
|
||||
assert_eq!(
|
||||
extract(RENDERED).expect("the rendered shape parses"),
|
||||
"deadbeef"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_homeservers_own_token_is_not_mistaken_for_the_appservices() {
|
||||
// `hs_token` authenticates the homeserver TO the appservice and is a
|
||||
// different secret with a confusingly similar name; a substring search
|
||||
// would find it inside neither, but a `contains("s_token")`-shaped one
|
||||
// would. The control is that the line order in `RENDERED` puts
|
||||
// `as_token` first, so this arm needs the reverse to mean anything.
|
||||
let reversed = "hs_token: cafebabe\nas_token: deadbeef\n";
|
||||
assert_eq!(
|
||||
extract(reversed).expect("order does not matter"),
|
||||
"deadbeef"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_registration_with_no_token_is_an_error_rather_than_an_empty_string() {
|
||||
// An empty token authenticates nothing, and a homeserver answers a
|
||||
// request carrying one with a 403 that names the account rather than
|
||||
// the credential — so failing here is the only report an operator can
|
||||
// act on.
|
||||
for bad in ["id: hyperhive\n", "as_token:\n", "as_token: \n"] {
|
||||
assert!(extract(bad).is_err(), "{bad:?} must not yield a token");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,52 @@ pub fn account_path(agent: &str, account: &str) -> Result<String, Error> {
|
|||
Ok(format!("{prefix}/matrix/{account}"))
|
||||
}
|
||||
|
||||
/// The localpart `hive` acts as on the homeserver, and the `sender_localpart`
|
||||
/// of that hive's appservice registration.
|
||||
///
|
||||
/// **Derived from the hive's name, which is what makes it one account per
|
||||
/// hive.** It used to be the bare constant `hive`: one swarm runs one
|
||||
/// homeserver, so every hive on it logged in as the same `@hive:` and the
|
||||
/// homeserver could not tell them apart — no attribution, and no way to revoke
|
||||
/// one hive without revoking all of them.
|
||||
///
|
||||
/// The prefix is kept so the account still reads as a hive's rather than an
|
||||
/// agent's; `nix/host-modules/hive-matrix.nix` renders the same string into
|
||||
/// the registration's `sender_localpart`, and **the two must match** — nothing
|
||||
/// wires an override across.
|
||||
///
|
||||
/// Infallible on purpose: a hive name is `[a-z0-9-]` (`hive_types::Ident`) and
|
||||
/// the appservice namespace regex admits that charset, so there is no
|
||||
/// rejection arm to write. [`sender_token_path`] below does the checking, and
|
||||
/// it is the one that interpolates into a store path.
|
||||
#[must_use]
|
||||
pub fn hive_localpart(hive: &str) -> String {
|
||||
format!("hive-{hive}")
|
||||
}
|
||||
|
||||
/// The path holding `hive`'s matrix sender account's homeserver access token.
|
||||
///
|
||||
/// Keyed per **hive**, like [`appservice_token_path`] below and unlike the
|
||||
/// per-homeserver constant this replaced. The old path
|
||||
/// (`swarm/services/matrix/sender-token`) held **one value for the whole
|
||||
/// swarm** and sat under the [`Kind::Service`] tree that
|
||||
/// [`crate::policy::render`] grants *every* hive read on — so every hive could
|
||||
/// read, and act as, the one shared account.
|
||||
///
|
||||
/// Under [`Kind::Hive`] the grant that reaches it is the hive's **own**
|
||||
/// stanza, `secret/data/swarm/hives/<this hive>/*`, which interpolates the
|
||||
/// name: hive `alpha` reads its own token and gets a 403 on `beta`'s. Nothing
|
||||
/// was added to the policy to do that — the path moved out from under the
|
||||
/// broad grant, which is the half that makes it real.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::PathSegment`] when `hive` contains anything but `[A-Za-z0-9_-]`,
|
||||
/// which is what keeps one hive's name from addressing another hive's secret.
|
||||
pub fn sender_token_path(hive: &str) -> Result<String, Error> {
|
||||
let prefix = principal_prefix(Kind::Hive, hive)?;
|
||||
Ok(format!("{prefix}/matrix/sender-token"))
|
||||
}
|
||||
|
||||
/// The path holding `hive`'s matrix appservice token (`as_token`).
|
||||
///
|
||||
/// Keyed per **hive**, not per agent, like [`crate::queue::agent_client_path`]
|
||||
|
|
@ -86,6 +132,62 @@ mod tests {
|
|||
assert_eq!(p, "swarm/agents/atlas/matrix/ops-relay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sender_token_lands_under_the_hive_prefix_the_grant_covers() {
|
||||
// Spelled out rather than rebuilt from the same pieces the code uses,
|
||||
// for the reason above — and with a second job here: the hive's own
|
||||
// policy grants read on `secret/data/swarm/hives/<this hive>/*`, so
|
||||
// this exact string is what makes the path reachable at all.
|
||||
assert_eq!(
|
||||
sender_token_path("pr1ma").expect("a plain name is legal"),
|
||||
"swarm/hives/pr1ma/matrix/sender-token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sender_token_left_the_tree_every_hive_can_read() {
|
||||
// 🩸 The whole point of the move. `policy::render` grants every hive
|
||||
// read on `secret/data/swarm/services/*` — a deliberate grant that
|
||||
// stays, because a service's OIDC secret is read with the certificate
|
||||
// of whatever hive hosts it. What must not stay is this credential
|
||||
// sitting inside it: under `services/` one shared token was readable
|
||||
// by every hive, which is one matrix identity for the whole swarm.
|
||||
let p = sender_token_path("pr1ma").expect("legal");
|
||||
assert!(!p.starts_with("swarm/services/"), "{p}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_hives_sender_token_is_not_another_hives() {
|
||||
// The property the per-hive path exists for: two hives never name the
|
||||
// same object, so the `hives/<name>/*` grant that reaches one cannot
|
||||
// reach the other.
|
||||
let a = sender_token_path("alpha").expect("legal");
|
||||
let b = sender_token_path("beta").expect("legal");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_traversal_in_the_hive_name_is_refused_by_the_sender_path_too() {
|
||||
let e = sender_token_path("../beta").expect_err("a traversal is not legal");
|
||||
assert!(matches!(e, Error::PathSegment { kind: "hive", .. }), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_localpart_is_derived_from_the_hive_name() {
|
||||
// The literal is the point: `nix/host-modules/hive-matrix.nix` renders
|
||||
// the same string into the registration's `sender_localpart` and into
|
||||
// `MATRIX_MINT_LOCALPART`, and nothing wires an override across — so a
|
||||
// change here is a change there.
|
||||
assert_eq!(hive_localpart("pr1ma"), "hive-pr1ma");
|
||||
assert_ne!(
|
||||
hive_localpart("pr1ma"),
|
||||
hive_localpart("secunda"),
|
||||
"two hives must not land on one account"
|
||||
);
|
||||
// And it is never the bare `hive` that used to be shared swarm-wide.
|
||||
assert_ne!(hive_localpart("pr1ma"), "hive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_appservice_token_lands_under_the_hive_prefix_the_grant_covers() {
|
||||
// Spelled out for the same reason as above, and with a second job here:
|
||||
|
|
|
|||
|
|
@ -24,10 +24,10 @@
|
|||
//! The **hive** stanza has no such problem and is therefore narrow: that path
|
||||
//! names its principal, so scoping it to the reader's own name costs nothing
|
||||
//! and drifts nowhere. Do not widen it to match its neighbours — the asymmetry
|
||||
//! is the point.
|
||||
//! is the point, and only holds if a one-per-hive credential is stored under
|
||||
//! [`Kind::Hive`], not [`Kind::Service`] — see docs/trust-boundary/security.md.
|
||||
//!
|
||||
//! Rendering stays separate from writing so the text can be asserted with no
|
||||
//! store to talk to.
|
||||
//! Rendering stays separate from writing so the text can be asserted with no store to talk to.
|
||||
|
||||
use crate::{
|
||||
Error,
|
||||
|
|
@ -251,6 +251,60 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// 🩸 The per-hive matrix sender-token scoping, asserted on the rendered
|
||||
/// document rather than on the path function alone: it is the policy text
|
||||
/// that decides what a hive may fetch, so "the path is per-hive" only
|
||||
/// means something if the stanza that reaches it is too.
|
||||
///
|
||||
/// The `services/*` stanza above is deliberate and stays — it is how a
|
||||
/// host reads the OIDC secret of a service it runs. The property here is
|
||||
/// that the matrix sender token is no longer *inside* it.
|
||||
#[test]
|
||||
fn a_hive_reaches_its_own_matrix_sender_token_and_no_other_hives() {
|
||||
let alpha = render("alpha").expect("legal");
|
||||
let own = crate::matrix::sender_token_path("alpha").expect("legal");
|
||||
let other = crate::matrix::sender_token_path("beta").expect("legal");
|
||||
|
||||
// Reachable: the hive's own stanza is the prefix of its own path.
|
||||
assert!(
|
||||
alpha.contains("path \"secret/data/swarm/hives/alpha/*\""),
|
||||
"{alpha}"
|
||||
);
|
||||
assert!(own.starts_with("swarm/hives/alpha/"), "{own}");
|
||||
|
||||
// Unreachable: no stanza in alpha's document is a prefix of beta's
|
||||
// path. Checked by walking the stanzas rather than by asserting the
|
||||
// absence of the string "beta", so a future stanza that happened to
|
||||
// cover it — `swarm/hives/*`, say — would fail this too.
|
||||
assert!(
|
||||
!stanza_paths(&alpha)
|
||||
.iter()
|
||||
.any(|granted| covers(granted, &other)),
|
||||
"alpha's document reaches {other}:\n{alpha}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every `path "…"` a rendered document grants, with the `secret/data/`
|
||||
/// ACL prefix stripped so it compares against a store path.
|
||||
fn stanza_paths(document: &str) -> Vec<String> {
|
||||
document
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_prefix("path \""))
|
||||
.filter_map(|rest| rest.split('"').next())
|
||||
.filter_map(|p| p.strip_prefix("secret/data/"))
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Does a granted policy path cover `path`? Only the trailing-`*` form
|
||||
/// this module renders, which is the only form it has to understand.
|
||||
fn covers(granted: &str, path: &str) -> bool {
|
||||
match granted.strip_suffix('*') {
|
||||
Some(prefix) => path.starts_with(prefix),
|
||||
None => granted == path,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grant_is_read_only() {
|
||||
// A hive reads credentials; a hive that could write one could hand
|
||||
|
|
|
|||
Loading…
Reference in a new issue