Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
Author SHA1 Message Date
atlas
1261b525d6 matrix: one sender account and one sender token per hive
A swarm runs one homeserver and every hive on it logged in as the same
`@hive:` localpart, holding the same access token out of one swarm-wide
store path. That is one matrix identity for N hives: the homeserver
cannot attribute an action to the hive that took it, and revoking one
hive's standing revokes every hive's.

Three changes, and the third is the one that makes the other two real:

- **The localpart carries the hive's name** (`hive-<hive>`), derived in
  one place, `swarm_secret_client::matrix::hive_localpart`.
  `hive-matrix.nix` renders the same string as the appservice
  registration's `sender_localpart`, so the shared account stops being
  created rather than merely stops being used.
- **The store path is templated by hive**, not a constant. The
  "a swarm runs one homeserver, so this is a constant rather than a
  parameter" rationale went with it; it stopped holding the moment two
  hives shared the homeserver it describes.
- **The path moved out from under the grant every hive has.** It sat at
  `swarm/services/matrix/sender-token`, inside the
  `secret/data/swarm/services/*` read stanza `policy::render` gives every
  hive. It now sits under that hive's own stanza,
  `secret/data/swarm/hives/<hive>/*`, which interpolates the reader's
  name — so a hive reads its own token and is refused another's. The
  policy renderer itself is unchanged: narrowing the `services/*` grant
  would break the OIDC-secret read it exists for, and moving the
  credential is what this needed instead. A policy test walks the
  rendered stanzas and asserts none of hive alpha's covers hive beta's
  sender token, so a later stanza that widened it fails here.

`swarm-matrix-ctl` takes a new required `MATRIX_MINT_HIVE` and writes
that hive's path; its store grant in `swarm-bao.nix` follows, scoped to
one hive's leaf via the new `deploy.bao.matrixCtlHiveName` (defaulting to
this host's `hiveName`) rather than a `hives/*` wildcard, which would
hand the matrix container every hive's token back.

Migration: no outage at deploy. `ensure_hive_user` short-circuits on the
local token file, so a hive keeps running on what it has; with no such
file it reads the new per-hive path, finds nothing, and falls through to
the existing register-or-appservice-login ladder against its own
localpart — which needs only the per-hive `as_token` on local disk. The
old shared object is read by nothing afterwards. Rooms do not follow the
identity, and that is the one operator step; both ways out are written
into `docs/integrations/matrix.md`.

No admin standing is granted to the per-hive accounts: `admin_execute`
stays empty and the assertion pinning it is untouched.
2026-09-20 22:07:16 +02:00
iris
637b308d4d docs/integrations/matrix.md: clear write-good.Passive hits from the rebase
Three hits the recent write-good.Passive=error flip (hyperhive#4546) now
catches on this branch's content:
- "stored password is gone" -> "has lost its stored password"
- "the ordinary appservice re-login above is unaffected" -> named the
  actual actor (the admin-sender limitation) instead of leaving it
  passive
- "Federation is enabled" -> "tuwunel enables federation" (tuwunel is
  already named one clause earlier in the same paragraph)

No suppressions, no content dropped.
2026-09-20 22:07:16 +02:00
atlas
19cae964c8 matrix, docs: describe promotion/reset as rehomed, not as a pending fix
Both spots hedged the swarm-level framing with a temporal qualifier
("until then", "today") that read as future work awaiting a fix, on
top of the framing itself. Drops the qualifiers; the operations are
swarm-level and rehomed there now, not pending anything from the hive.
2026-09-20 22:07:16 +02:00
atlas
67ba28448f swarm-matrix-ctl: one control binary for the matrix container, not one per job
Renames `swarm-matrix-minter` and reshapes it around subcommands. Minting
is now `swarm-matrix-ctl mint`.

Running rust inside `containers.hive-matrix` 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 there should be a verb here rather than a new crate.
The old name guaranteed the opposite.

`main.rs` is clap dispatch; the minting logic moves to `mint.rs` unchanged.
A bare invocation is refused: `mint` writes a credential, so "no verb"
defaulting to it would make a typo in the unit mint rather than fail.

The environment prefix moves with it, `MATRIX_MINTER_*` → `MATRIX_MINT_*`.
Scoped to the verb and not to the binary, because a binary-scoped prefix is
one the next verb has to share or widen, and a widened one never narrows
again. A test asserts every variable carries the verb's prefix.

The principal renames too. The cert role, bao policy, granting unit, leaf
filename and `certAuthCns` entry all have to spell one string the same way,
so leaving them as `swarm-matrix-minter` would have rebuilt the naming
split this branch exists to remove. Renaming the nix options alongside is
free here: every one of them is introduced by this PR and has never been
released, so no operator config names them yet.

`ExecStart` now names the verb, which is a contract between a nix string
and a clap enum that fails at deploy time with no local signal. Both ends
assert it: `mint_is_spelled_the_way_the_unit_invokes_it` in the crate, and
a new module-eval arm reading the rendered `ExecStart`.

docs/getting-started/setup.md drops the sender token from its "live on the
host" list: setup does not touch this credential, so a setup guide has no
reason to name it.
2026-09-20 22:07:16 +02:00
atlas
fb9c6122df matrix: name the credential after the account it authenticates as
The store path and every identifier around it called this an admin
token. It is not one: of ~15 hive-c0re call sites only two need
homeserver admin, and the homeserver no longer promotes the account at
boot, so the name overstated both what the credential is and what it may
do.

Renaming it to the account was not enough either. "The `@hive:` token"
reads as the token of a hive user, and no such user is provisioned —
`@hive:<server_name>` is the appservice registration's own
`sender_localpart`, an account the homeserver creates for itself when it
loads the registration.

So it is the **sender token**: the matrix appservice sender account's
access token, at `swarm/services/matrix/sender-token`. The name says
what it authenticates as rather than what it may do, which is the part
that was wrong.

The path has one constructor, and the bao grant, the grant assertion and
three unit tests pin its literal independently — so a half-finished
rename fails a check rather than leaving the minter and its readers
disagreeing at runtime. `tracing` messages are renamed with the code, so
the journal reads the way the source does.

The host-side file keeps its name (`matrix/access-token`): it carried no
admin framing, and renaming it would orphan the file on every deployed
hive for nothing.

`docs/tools/hivectl-cli.md` is regenerated from the clap tree.
2026-09-20 22:07:16 +02:00
atlas
bbb4e471ea matrix, docs: drop the last admin framing, fix tense and prose lint
Three leftovers from the rename, plus the gating prose job.

`nix/packages/default.nix` still described the minter as the "matrix
admin credential's minter", and shipped that claim in the package's
`meta.description` — a PR-visible string.

`promote_user_to_admin`'s doc comment pointed at the `admin_execute`
entry in `hive-matrix.nix` as the thing that bootstraps the first
admin. That entry is gone, so the comment referenced nothing. It now
records that the account is ordinary, that the call therefore has no
working sender, and that rehoming at swarm level is the fix rather than
re-granting. `reset_user_password` gained the matching warning; it had
none.

The prose fixes clear all 8 `CI / prose lint (vale, errors)` failures,
all of which were in docs this branch touches. No vale config change,
no exception, no carve-out: contractions, one recast sentence, one
de-hyphenation and one dropped "simply".

Tense: four docs described system behaviour in the future ("will
refuse", "will fetch and trust"). Reference docs get read from the
other side of the change, so they say what the system does.
2026-09-20 22:07:16 +02:00
atlas
88b4a7841d docs/swarm/credentials.md: add the matrix sender-token row
`swarm/services/matrix/sender-token` has a real minter
(`swarm-matrix-minter`, in the `hive-matrix` container) and two
readers now: the minter's own pre-flight read-back before it mints,
and hive-c0re's `stored_sender_token()` under the hive's own
certificate. The credentials table catalogs minter/reader/renewal for
every credential path; this one was missing a row.

Refs #4345
2026-09-20 22:07:16 +02:00
atlas
f778122f5a matrix: mint the appservice sender token in the matrix container
A swarm runs one homeserver and a homeserver has one appservice sender
account, so "mint it once" is a property of the thing being minted
rather than something a lock has to enforce. That is what makes this
account the one to move first: no trigger route, no controller change
and no agent list — a boot-time oneshot beside tuwunel is the whole
mechanism.

`swarm-matrix-minter` runs inside `containers.hive-matrix`, which
already holds the appservice token: the rendered registration is bound
in read-only because that is how tuwunel is handed it. What the
container lacked was an identity of its own, so this adds one — a leaf
from the store's CA with a grant of exactly one path, not the hive's
leaf, which reads every secret in the store.

Both ends of the credential ship here. The minter reads the path it
publishes to before it touches the homeserver, and returning on a
non-empty read IS the "only once"; `hive-c0re`'s `ensure_hive_user`
reads the same path, authenticating with the hive name already in
`HYPERHIVE_HIVE_NAME`. The existing mint-then-`M_USER_IN_USE`-login
ladder stays as the fallback for a store that is empty, unconfigured or
unreachable, which is every swarm deployed before this — so nothing
needs backfilling and nothing breaks if the rest of the sequence never
lands.

The credential is not an admin credential, and is not named like one.
It is the access token of the appservice registration's own
`sender_localpart` — `@hive:<server_name>`, an account the homeserver
creates for itself when it loads the registration. The store path is
`swarm/services/matrix/sender-token`, the host path is
`matrix/access-token`, and the homeserver no longer runs an
`admin_execute` promotion for that account at boot. Everything the hive
provisions with it — the Space, the chat room, their hierarchy and join
rules, the invites — rides on being the creator of those rooms at power
level 100, not on homeserver admin; there is no Synapse admin API here
to need, tuwunel has none.

Two operations do need an admin *sender* and therefore stop working:
`hivectl matrix promote-user` and `hivectl matrix reset-password`, both
`!admin …` messages into `#admins:<server>`, plus the password-reset
recovery path that an agent with a lost password file falls back to.
They are swarm-level operations and are left failing loudly rather than
served by an over-privileged token every other call site would also
carry. The sweep's own admin-rights check and self-repair go with them:
an account that is deliberately not an admin has nothing to check.

`ephemeral = false` stays, and hive root can still read the container's
filesystem. Accepted: what this buys is identity separation — no hive
*process* holds or reads the appservice token — not physical isolation.

Refs #4345
2026-09-20 22:07:16 +02:00
32 changed files with 2105 additions and 344 deletions

14
Cargo.lock generated
View file

@ -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"

View file

@ -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",

View file

@ -181,7 +181,7 @@ which. A hive that does **not** deploy the store names the reader's three
itself: the operator issues that leaf out of band, and it's the one credential
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).

View file

@ -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)).

View file

@ -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 -->

View file

@ -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`

View file

@ -64,7 +64,7 @@ running (`services.hyperhive.deploy.matrix.enable = true`).
hivectl matrix create-user iris # provision (or re-provision) matrix account for agent `iris`
hivectl matrix create-user 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

View file

@ -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

View file

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

View file

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

View file

@ -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()),

View 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.

View file

@ -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 {

View file

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

View file

@ -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

View file

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

View 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";
};
};
}

View file

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

View file

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

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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

View 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

View 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.

View 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");
}
}

View 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");
}
}

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

View 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");
}
}
}

View file

@ -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:

View file

@ -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