Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
Author SHA1 Message Date
atlas
85ba45b2de docs: agents' matrix accounts come from the swarm
The integration, tool, persistence and setup pages described hive-c0re
minting each agent's token into its state dir. They now describe the
swarm's appservice, its admin sender, the controller's mint and pass, the
daemon's store read and re-start timer, and the two new credential rows.
ruth's matrix account comes from the same pass once she holds the store
identity the setup page already has the operator mint.
2026-09-25 08:31:01 +02:00
atlas
89a5dd752c hive-c0re: stop minting agents' matrix accounts
The swarm mints each agent's `main` account now, so the hive's own mint
goes: `ensure_user_for`, `finish_user_provisioning`, `sync_agent`,
`sync_agent_standalone`, `token_path`, `legacy_password_path`,
`auto_reset_password` and `token_file_present`, and the calls from the startup sweep and the
rebuild bookkeeping. Both mints pinned the device `hyperhive-<agent>`, so
leaving this one would have each re-login kill the other's token.

`hivectl matrix create-user` refuses an agent's name and says where its
account comes from. Everything that still uses the hive's appservice token
stays: the hive's own account, the Space and chat room, and operator
accounts.
2026-09-25 08:31:01 +02:00
atlas
ab153bda2f hive-matrix-mcp: read the main account's token from the store too
The daemon reads each account's token from `swarm/agents/<agent>/matrix/`
as the agent itself, inside its own container, and falls back to the file
only when the store has none. This is #4519's read, without its `main`
carve-out: the swarm now mints `main` there and no hive writes the file.

The daemon unit gets the agent's store identity, spelled the way
forge-token.nix spells it. A timer re-starts it while it is down: a token
the swarm mints or replaces in the store changes no file, so the path
watcher never fires for it, and a daemon that exited on a replaced token
would otherwise stay down until the container restarts.
2026-09-25 08:31:01 +02:00
atlas
2776e121e5 swarm-controller: mint each agent's matrix account with the swarm's token
A `MintAgentMatrixAccount` node creates the agent's account on the swarm's
homeserver with the swarm appservice token, stores its token at
`swarm/agents/<agent>/matrix/main`, and reads it back with whoami before
reporting success. It is a root of agent creation, `after_any` into the
deploy, and a five-minute backfill over every agent with a store identity
queues the same node — the shape of the forge-token mint.

The decision reads the stored token back rather than only checking that one
is stored: the swarm and a hive both pin the device `hyperhive-<agent>`, so
each login replaces the other's token. A failed read plans nothing, so an
outage never rotates every agent's token.

`matrixHomeserverUrl` now defaults to the swarm's `chat.` vhost, since the
mint is what consults it.
2026-09-25 08:31:01 +02:00
atlas
9308752a09 hive-matrix: load the swarm's appservice and promote its sender
The matrix container renders the swarm registration before tuwunel starts
(`requiredBy` it, no network), tuwunel loads it as a second `.yaml`
credential, and a publish unit hands its token to the store. tuwunel 1.9.1
refuses only a duplicate id or as_token, not overlapping non-exclusive
namespaces, so it sits beside the hive's `hyperhive` registration.

`admin_execute` promotes exactly `@swarm` at boot. The module-eval pin
narrows from "no account" to that one list, compared whole so a second
entry fails; `admin_execute_errors_ignore` is pinned too. matrix-ctl may
write the swarm token's leaf, and the controller may only read it.

Everything is gated on matrix-ctl's store identity: with nobody to publish
the token, the registration would be an admin credential nobody reads.
2026-09-25 08:31:01 +02:00
atlas
89aff8d613 swarm-matrix-ctl: mint the swarm's own appservice registration
The swarm gets an appservice identity of its own, separate from each hive's
`hyperhive` registration. `swarm-matrix-ctl appservice render` mints its
tokens inside the matrix container when they are absent and renders the
registration tuwunel loads; `appservice publish` writes its as_token to
`swarm/controller/swarm-controller/matrix/appservice-token`, the one kind no
hive's policy grants.

The homeserver calls move out of swarm-matrix-ctl into swarm-matrix-client,
with a `whoami`, so swarm-controller can mint agents' accounts through the
same pinned device id instead of a copy of them.
2026-09-25 08:31:01 +02:00
39 changed files with 2164 additions and 572 deletions

13
Cargo.lock generated
View file

@ -1989,6 +1989,7 @@ dependencies = [
"schemars",
"serde",
"serde_json",
"swarm-secret-client",
"tokio",
"tracing",
"tracing-subscriber",
@ -4913,6 +4914,7 @@ dependencies = [
"sha2 0.11.0",
"strum",
"swarm-authelia-bridge-sock",
"swarm-matrix-client",
"swarm-queue-client",
"swarm-secret-client",
"time",
@ -4935,14 +4937,23 @@ dependencies = [
"swarm-queue-client",
]
[[package]]
name = "swarm-matrix-client"
version = "0.1.0"
dependencies = [
"anyhow",
"reqwest",
"serde_json",
]
[[package]]
name = "swarm-matrix-ctl"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"reqwest",
"serde_json",
"swarm-matrix-client",
"swarm-secret-client",
"tokio",
"tracing",

View file

@ -27,6 +27,7 @@ members = [
"swarm-authelia-bridge",
"swarm-authelia-bridge-sock",
"swarm-controller",
"swarm-matrix-client",
"swarm-matrix-ctl",
"swarm-nats-auth",
"swarm-queue-client",
@ -101,6 +102,7 @@ hive-priv-sock = { path = "hive-priv-sock" }
hive-sock-client = { path = "hive-sock-client" }
hive-types = { path = "hive-types" }
swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" }
swarm-matrix-client = { path = "swarm-matrix-client" }
swarm-queue-client = { path = "swarm-queue-client" }
swarm-secret-client = { path = "swarm-secret-client" }
thiserror = "2"

View file

@ -569,8 +569,9 @@ so on a real hive (where hive-c0re renders that URL per agent) every
agent has one; a `null` URL with no operator-declared account is the
"this agent has no matrix" state.
**First-boot ordering**: hive-c0re provisions the matrix token AFTER
agent containers come up. Without the path-trigger sibling
**First-boot ordering**: a token can arrive after the container comes
up — a file the hive delivers, or a token the swarm mints into the store.
Without the path-trigger sibling
(`systemd.paths.hive-matrix-daemon`, `PathExistsGlob =
<this agent's state dir>/matrix-token*` — the trailing `*` also catches
a secondary multi-account token like `matrix-token-ccc`), the daemon
@ -578,7 +579,9 @@ would exit 0 quietly the first time it ran and the MCP would have no
daemon until the next restart. The `.path` unit makes the appearance
of the token re-fire the service so the daemon comes alive in the
same boot cycle as
provisioning. The same token watcher also drives avatar setting: on a
provisioning. A token in the store changes no file, so a store-backed agent
also gets a timer that restarts the daemon five minutes after it last
exited. The same token watcher also drives avatar setting: on a
restart the daemon re-runs each account's bring-up, which sets the
avatar (see below).

View file

@ -44,7 +44,8 @@ hivectl agent ruth rebuild
You don't need to do anything else. The controller's next pass creates
ruth's forge user and mints her token, and her container fetches it within
about ten minutes. `swarmctl agent mint-forge-token ruth` skips the wait for
about ten minutes. The same identity puts her in the matrix pass too (step
6). `swarmctl agent mint-forge-token ruth` skips the wait for
the pass. A hive without a swarm secret store has no path to a forge token
for ruth at all.
@ -237,10 +238,6 @@ control: [`swarm/ui.md`](../swarm/ui.md).
# Ensure the appservice's sender account exists first
hivectl matrix sync-admin
# Provision ruth's own matrix account — same bootstrap-bypass reasoning
# as forge above, still a required manual step.
hivectl matrix create-user ruth
# Invite the operator to the hive Space (and optionally to rooms)
hivectl matrix invite mara
hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver'
@ -249,6 +246,12 @@ hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver'
hivectl matrix promote-user mara
```
ruth's own matrix account comes from the swarm, like every agent's:
`swarm-controller` creates it within five minutes of her holding a store
identity (step 1), and her matrix daemon reads its token from the store.
Without that identity she has no matrix account, and this hive no longer
creates one.
Swarm SSO creates the human operator's own matrix account instead of
a manual `hivectl` step — see _Swarm SSO_ above (`swarmctl user add`).

View file

@ -129,31 +129,52 @@ a token.
the bind-mount path. It reads only `.yaml`/`.yml` entries from there,
so the sibling credentials are invisible to it. The `.yaml` suffix on
the credential id is what makes this work.
5. **hive-c0re** reads the appservice token and creates each account with
one `POST /register` typed `m.login.application_service`, persisting
the returned `access_token` to `<agent-state>/matrix-token`. It never
mints the token itself: the value has to be the one the rendered
registration names, and only the nix side writes that.
6. **An account that exists but has lost its token file** is re-tokened
by an appservice `POST /login` — no password and no admin rights
involved. A stored-password login and an admin-room password reset
remain behind that, for accounts created before the appservice existed
or named outside its namespace.
7. **hive-c0re restarts `hive-matrix-daemon`** for the agent
immediately after writing the token so the daemon picks up the
new credential without waiting for a full container restart. If
the restart fails (for example daemon not yet running on first boot)
hive-c0re logs the error as a warning and the `.path`-trigger sibling
(`hive-matrix-daemon.path` watching for `matrix-token` appearance)
brings the daemon up on the same boot cycle anyway.
5. **hive-c0re** reads the appservice token and creates its own
`@hive-<hive>:` account, and the accounts an operator asks for with
`hivectl matrix create-user`. It never mints the token itself: the value
has to be the one the rendered registration names, and only the nix
side writes that.
6. **Agents' accounts aren't this hive's.** `swarm-controller` creates each
one with the swarm's own appservice token (next section), stores its
token at `swarm/agents/<agent>/matrix/main`, and the agent's
`hive-matrix-daemon` reads it from there as the agent itself.
### The swarm's appservice, and its admin sender
A second registration sits beside the hive's: id `swarm`, sender `@swarm`,
the same non-exclusive namespace. It's the swarm's identity on the
homeserver, and `swarm-controller` creates every agent's account with it.
tuwunel loads every `.yaml` in `appservice_dir` and refuses only a
duplicate `id` or `as_token`, so the two overlapping namespaces coexist.
`swarm-matrix-ctl appservice render` mints its tokens inside the
`hive-matrix` container, before tuwunel starts, and renders the
registration tuwunel loads as a second credential. `appservice publish`
then writes its `as_token` to
`swarm/controller/swarm-controller/matrix/appservice-token`. No hive's
policy reaches that path: only matrix-ctl (which writes it) and
`swarm-controller` (which reads it) hold a grant to it.
`@swarm` is the **one** account the homeserver promotes to admin at boot
(`admin_execute`). Only those two principals read its token, which is
the difference from the hive sender below: every hive reads its own
sender's token. `swarm` is a reserved name, so nothing can create an agent
as this account.
`swarm-controller` mints each agent's account with the device id
`hyperhive-<agent>`, and a login on that device replaces its token. The controller therefore reads
the stored token back with `whoami` every five minutes and re-mints only
when it's missing, unknown to the homeserver, or someone else's. An
agent's daemon picks a re-minted token up on its next start, and a timer
restarts it while it's down.
### The appservice's sender account, and why it isn't an admin
`@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. It's an **ordinary account**: nothing promotes it, and the
homeserver runs no `admin_execute` for it.
anything. It's an **ordinary account**: nothing promotes it; the
homeserver's `admin_execute` promotes only `@swarm`.
**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
@ -185,10 +206,8 @@ 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.
every other call site would also carry. The swarm's own sender is the
admin they need; moving them there is separate work.
<details><summary>Upgrading a hive that shared one sender account with every other hive</summary>
@ -239,9 +258,6 @@ restarts, so the first boot after the switch already has both halves.
device that minted it; removing the registration token touches no
device, no account and no session. `login_with_password` stays on, so
the password fallback is still there too.
- **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 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

View file

@ -54,20 +54,35 @@ 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/agents/<agent>/queue` | `swarm-controller`, at agent creation | the agent container itself, under its own certificate — the identity it presents to the swarm queue, naming that one agent rather than its hive | none: the secret is fixed for the life of the agent and is revoked by deleting the path. A rotation mechanism is tracked as separate work, because rotating this credential needs a reconnect path — a queue client holding a revoked secret doesn't find out until it reconnects |
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes); the agent re-fetches on a 10-minute timer |
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated |
| `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 |
| store path | minter | reader — pulls at runtime, holds in memory | renewal |
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `swarm/agents/<agent>/matrix/main` | `swarm-controller`, with the swarm's appservice token, at agent creation and in a five-minute pass | the agent container itself, under the certificate its hive passed in | the pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's |
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated |
| `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl`, inside the `hive-matrix` container, once | `swarm-controller`, under its own certificate | none: the container keeps its copy and republishes it when the store's differs |
| `swarm/agents/<agent>/bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated |
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | the agent container itself, under its own certificate — the identity it presents to the swarm queue, naming that one agent rather than its hive | none: the secret is fixed for the life of the agent and is revoked by deleting the path. A rotation mechanism is tracked as separate work, because rotating this credential needs a reconnect path — a queue client holding a revoked secret doesn't find out until it reconnects |
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes); the agent re-fetches on a 10-minute timer |
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated |
| `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 -->
**Two rows share the `matrix/` prefix and have different minters, on
purpose.** An agent's `main` account is on the swarm's own homeserver, and
`swarm-controller` creates it with the swarm's appservice token. Every other
account under that prefix is somewhere else entirely, and an operator hands
the controller a credential for it. The operator-facing route refuses the
name `main` for exactly this reason: two writers, one name, and that refusal
is what keeps them apart.
**The swarm's appservice token sits under `controller/`, the one kind no
hive's policy reads.** Its sender is the homeserver's admin. Under
`agents/`, `hives/` or `services/` every hive could read it. matrix-ctl may
write and read that one leaf; the controller may only read it.
**An agent's mTLS leaf is in the store; a hive's isn't, and the difference
isn't an inconsistency.** The rule the exception protects is that nothing
can fetch from the store the credential it would need in order to fetch. A

View file

@ -142,7 +142,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>`
* `create-user` — Create a matrix account for a person or other non-agent `<name>` and print its access token to stdout
* `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
@ -152,15 +152,15 @@ Manual entry point to the same idempotent provisioning c0re runs at boot — for
## `hivectl matrix create-user`
Create or refresh the matrix account + access token for `<name>`.
Create a matrix account for a person or other non-agent `<name>` and print its access token to stdout.
For an existing agent, persists the token to its state dir; for a human/other account, prints the access token to stdout. Set a password to enable matrix web-client login (otherwise it uses a random throwaway).
Refuses an agent's name: its account comes from the swarm (`swarm-controller` creates it and stores its token where the agent reads it). Set a password to enable matrix web-client login (otherwise it uses a random throwaway).
**Usage:** `hivectl matrix create-user [OPTIONS] <NAME>`
###### **Arguments:**
* `<NAME>` — Matrix localpart. For agents: the container/agent name. For humans: any matrix localpart — `mara`, `damocles`, etc
* `<NAME>` — Matrix localpart of a non-agent account — `mara`, `damocles`, etc
###### **Options:**

View file

@ -50,7 +50,6 @@ Manual entry to the same idempotent matrix provisioning flow
running (`services.hyperhive.deploy.matrix.enable = true`).
```bash
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 appservice's sender account
@ -60,9 +59,9 @@ hivectl matrix invite mara # invite a user to the hive Space
hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a specific room/alias
```
- `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.
- `create-user`: for people and other non-agent accounts. It refuses
an agent's name: `swarm-controller` creates an agent's account and
stores its token where the agent's daemon reads it.
- `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

View file

@ -88,10 +88,10 @@ cache), and an optional `homeserver` (defaults to
always named `main` and is always the primary; the matrix module
declares it for you as an ordinary entry of this map, from
`services.hyperhive.agent.matrix.url` + agent state, so what you add
here are the *further* accounts. hive-c0re pins its `tokenFile` to
`<state>/matrix-token` and provisions it there, and the
dashboard's link-account route refuses to create an account by that
name.
here are the *further* accounts. Its token comes from the swarm secret
store (`swarm-controller` mints it); its `tokenFile` stays pinned to
`<state>/matrix-token` as the fallback, and the dashboard's link-account
route refuses to create an account by that name.
<!-- vale write-good.Passive = YES -->
`main` is present exactly when that URL is non-null, which is the whole
@ -122,9 +122,10 @@ directly over streamable-http on `services.hyperhive.agent.mcp.matrixHttpPort`
`{ type = "http"; url = ...; }`). Same shape as `hive-bash-daemon` and
the built-in `hyperhive` surface (`hive-mcp-http`) — claude reconnects
to the stable URL every turn instead of respawning a stdio child.
Silently exits when `<state>/matrix-token` is absent (account not yet
provisioned); the `systemd.paths.hive-matrix-daemon` watcher restarts
it the moment hive-c0re provisions the token.
Silently exits when the primary account has no token yet; the
`systemd.paths.hive-matrix-daemon` watcher restarts it when a token file
appears, and, on an agent with a store, a timer restarts it while it's
down so it re-reads a token the swarm minted into the store.
Incoming room events wake the agent via `AgentRequest::Wake` with
`from: "matrix"`. The wake body format depends on the unread state:

View file

@ -465,7 +465,7 @@ async fn run_swap(coord: &Arc<Coordinator>, name: &str, id: NodeId) -> Result<()
/// The post-`Swap` bookkeeping tail, split into its own node for dashboard
/// visibility + retry granularity. Deps `AfterOk(Swap)`, so reaching here
/// means the profile swap succeeded. Store/forge/matrix work only — no nix
/// means the profile swap succeeded. Store/forge work only — no nix
/// build (build-slot-exempt); the agent lease taken at `Swap` is still held
/// (the whole chain up to `Reconcile` is one agent's subgraph).
async fn run_rebuild_bookkeeping(coord: &Arc<Coordinator>, name: &str) -> Result<()> {
@ -477,11 +477,11 @@ async fn run_rebuild_bookkeeping(coord: &Arc<Coordinator>, name: &str) -> Result
// The `Rebuilt` manager event is emitted exactly once per agent by the DAG's
// `EmitRebuilt` tail — emitting ok here and letting a failed tail `Reconcile`
// add a contradictory !ok would double-report the same rebuild.
// Full forge + matrix sync on every successful rebuild so the rebuild
// path is equivalent to the startup sweep: tokens, config-repo mirror,
// meta access all recover without a hive-c0re restart.
// Full forge sync on every successful rebuild so the rebuild path is
// equivalent to the startup sweep: tokens, config-repo mirror, meta
// access all recover without a hive-c0re restart. (No matrix step: the
// swarm mints an agent's matrix account, not this hive.)
crate::forge::sync_agent(name, crate::forge::core_token().as_deref()).await;
crate::matrix::sync_agent_standalone(name).await;
// Wake the agent on its next turn so claude sees a "you were rebuilt"
// hint; rescan so dashboards drop the "needs update" chip; lock bump →
// meta-inputs re-render.

View file

@ -20,8 +20,6 @@ use std::path::PathBuf;
use anyhow::{Context, Result};
use reqwest::StatusCode;
use crate::coordinator::Coordinator;
/// Client-server API base this daemon provisions against, from
/// `HIVE_MATRIX_API_URL` (set by the hyperhive NixOS module from
/// `services.hyperhive.swarm.matrix.apiUrl`).
@ -126,45 +124,15 @@ pub fn sender_token_path() -> PathBuf {
crate::paths::matrix_sender_token()
}
/// Token file inside the agent's bind-mounted state dir (visible as
/// `/state/matrix-token` from inside the container).
fn token_path(name: &hive_types::Ident) -> PathBuf {
Coordinator::agent_notes_dir(name).join("matrix-token")
}
/// Whether an agent's token file is present: a non-empty regular file.
/// Decided from metadata alone, because hive-priv writes the file 0600
/// and owned by the agent, so `hive-core` cannot read it but can stat it
/// through the 0755 state dir. A check that reads the file always
/// fails here and makes every sweep re-mint the token.
fn token_file_present(path: &std::path::Path) -> bool {
std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() > 0)
}
/// Password file for the agent's matrix account. Stored OUTSIDE the
/// purgeable `agent_state_root` tree so it survives `destroy --purge`
/// and allows re-login recovery when the same agent name is re-spawned.
/// Password file for a matrix account this hive holds a password for: its own
/// `@hive-<hive>:` account, or one reset through the admin room. Stored OUTSIDE
/// the purgeable `agent_state_root` tree so it survives `destroy --purge`.
///
/// Path: `/var/lib/hyperhive/matrix/creds/<name>-password`
///
/// The token file lives inside the agent's bind-mounted state dir (under
/// `agent_notes_dir`) so the agent container can read it; the password
/// file is host-side only (agents never log in by password — they use
/// the access token exclusively) and belongs with other hive-c0re
/// credential state, not inside the purgeable per-agent tree.
fn password_path(name: &str) -> PathBuf {
crate::paths::matrix_creds_dir().join(format!("{name}-password"))
}
/// Legacy password path (inside the old purgeable `agent_notes_dir`).
/// Used only during the one-time migration in [`ensure_user_for`] to
/// move credentials from old deployments to the new location. Safe to
/// call after `destroy --purge` — the path will simply not exist and
/// the migration is a no-op.
fn legacy_password_path(name: &hive_types::Ident) -> PathBuf {
Coordinator::agent_notes_dir(name).join("matrix-password")
}
/// Host path where the hive Matrix Space room ID is persisted.
/// Outside every purgeable path — not deleted by `destroy --purge`.
#[must_use]
@ -319,8 +287,8 @@ async fn register_user(
"type": "m.login.application_service",
"username": localpart,
"password": password,
// device_id stays stable across re-runs of ensure_user_for so
// a re-mint doesn't strand orphan devices in tuwunel.
// device_id stays stable across re-runs so a re-mint doesn't
// strand orphan devices in tuwunel.
"device_id": format!("hyperhive-{agent}"),
"initial_device_display_name": format!("hyperhive ({agent})"),
"inhibit_login": false,
@ -416,32 +384,6 @@ 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 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 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, &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");
Ok(effective_password)
}
// ---------------------------------------------------------------------------
// Admin-room fallback for password reset
// ---------------------------------------------------------------------------
@ -711,162 +653,13 @@ async fn admin_room_reset_password(
})
}
/// Ensure `name` has a matrix user + token file on the local
/// homeserver. Skips provisioning entirely if the token file already
/// exists (treating a present token as proof the account is good).
/// To force re-provisioning, delete the token file.
///
/// When registration fails with `M_USER_IN_USE` (account exists in the
/// homeserver but the token file was deleted) this falls back to
/// `m.login.password` using the persisted `matrix-password` file. If
/// that file is also missing, recovery requires manual intervention:
/// `hivectl matrix create-user <name> --password <pw>`.
///
/// `client` is shared across the sweep so we build one reqwest
/// connection pool for all agents rather than one per call.
pub async fn ensure_user_for(client: &reqwest::Client, name: &str, as_token: &str) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let agent = hive_types::Ident::parse(name)
.map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?;
if token_file_present(&token_path(&agent)) {
tracing::debug!(%name, "matrix: token already present");
return Ok(());
}
// One-time migration: move the password from the old location inside
// agent_notes_dir (purgeable) to the new location outside it.
let new_pw_path = password_path(name);
let old_pw_path = legacy_password_path(&agent);
if !new_pw_path.exists() && old_pw_path.exists() {
if let Some(parent) = new_pw_path.parent() {
std::fs::create_dir_all(parent).ok();
}
if let Err(e) = std::fs::rename(&old_pw_path, &new_pw_path) {
// Rename across filesystems or read-only src — copy + delete.
if let Ok(content) = std::fs::read(&old_pw_path) {
if std::fs::write(&new_pw_path, &content).is_ok() {
let _ = std::fs::remove_file(&old_pw_path);
tracing::info!(%name, "matrix: migrated password file to non-purgeable location");
}
} else {
tracing::warn!(%name, rename_error = ?e, "matrix: password migration failed — could not read old path (old path stays)");
}
} else {
tracing::info!(%name, "matrix: migrated password file to non-purgeable location");
}
}
let password = random_password()?;
let access_token = match register_user(client, name, as_token, &password).await {
Ok(token) => {
// Successful registration — persist the password so we can
// fall back to login if the token file is deleted later.
let pw_path = password_path(name);
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!(%name, error = ?e, "matrix: failed to persist password (token still saved)");
} else {
let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600));
}
token
}
Err(reg_err) if reg_err.to_string().contains("M_USER_IN_USE") => {
// Account already exists and its token file was lost. The
// appservice can mint a session for any account in its
// namespace, so this needs no password and no admin rights —
// try it before the password paths below, which exist for
// accounts created before the appservice did (or named
// outside its namespace) and stay as the fallback.
tracing::info!(%name, "matrix: user already exists, logging in as the appservice");
match appservice_login(client, as_token, name).await {
Ok(token) => return finish_user_provisioning(name, &token).await,
Err(e) => tracing::warn!(
%name,
error = ?e,
"matrix: appservice login failed; falling back to the stored password"
),
}
let pw_path = password_path(name);
let stored = if let Some(pw) = std::fs::read_to_string(&pw_path)
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
{
pw
} else {
// 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'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-room auto-recovery"
);
match auto_reset_password(client, name).await {
Ok(new_pw) => new_pw,
Err(e) => {
anyhow::bail!(
"matrix: user {name} already exists but password is missing \
and admin auto-recovery failed ({e:#}) — run:\n\
hivectl matrix reset-password {name}\n\
hivectl matrix create-user {name}"
)
}
}
};
login_user(client, name, &stored).await.with_context(|| {
format!(
"matrix: login fallback for {name} failed — if homeserver was wiped, delete \
the matrix-password file and retry"
)
})?
}
Err(other) => return Err(other),
};
finish_user_provisioning(name, &access_token).await
}
/// Persist a freshly-obtained agent access token and kick the agent's
/// matrix daemon. Shared by every way [`ensure_user_for`] can end up
/// holding a token — creation, appservice login, password login — so a
/// new recovery path cannot forget half of it.
async fn finish_user_provisioning(name: &str, access_token: &str) -> Result<()> {
// Write the token via hive-priv (root helper): hive-c0re runs as the
// unprivileged `hive-core` user and cannot write to agent-owned state
// directories directly. hive-priv writes the file 0600 and chowns it
// to the agent user so it is readable from inside the container.
crate::priv_client::write_agent_matrix_token(name, access_token, None, None)
.await
.with_context(|| format!("matrix: write matrix-token for {name} via hive-priv"))?;
tracing::info!(%name, "matrix: provisioned access token");
// Kick the daemon so it picks up the new token without waiting for a
// full container restart — see docs/integrations/matrix.md::Provisioning flow.
if let Err(e) = crate::priv_client::restart_matrix_daemon(name).await {
tracing::warn!(%name, error = ?e, "matrix: could not restart hive-matrix-daemon (token written; daemon will reload on next container start)");
} else {
tracing::info!(%name, "matrix: restarted hive-matrix-daemon to pick up new token");
}
Ok(())
}
/// Register a matrix account for `name` with the supplied `password`
/// and return the freshly-minted access token. Unlike [`ensure_user_for`],
/// the token is **not** persisted to disk — the caller is responsible
/// for storing it. Used by `hivectl matrix create-user` for human
/// (non-agent) accounts so we don't create stray
/// `/var/lib/hyperhive/agents/<name>/` directories for users that
/// aren't agents. For operator accounts the caller passes a real
/// and return the freshly-minted access token. The token is **not**
/// persisted to disk — the caller is responsible for storing it. Used by
/// `hivectl matrix create-user` for human (non-agent) accounts. For operator accounts the caller passes a real
/// password so the operator can `m.login.password` into matrix web
/// clients afterwards; for headless agent re-provisioning the caller
/// can pass [`random_password`] to keep the existing throwaway
/// behaviour.
/// clients afterwards; without one the caller passes
/// [`random_password`].
///
/// **Not idempotent**: the matrix `/register` endpoint returns
/// `M_USER_IN_USE` (HTTP 400) on a second call for the same localpart,
@ -882,44 +675,6 @@ pub async fn provision_user_token(
register_user(client, name, as_token, password).await
}
/// Per-agent matrix sync: ensure the agent has a matrix account + token.
/// All operations are idempotent; failures are logged as warnings but
/// don't abort the caller.
pub async fn sync_agent(client: &reqwest::Client, name: &str, as_token: &str) {
if let Err(e) = ensure_user_for(client, name, as_token).await {
tracing::warn!(%name, error = ?e, "matrix: ensure_user failed");
}
}
/// Standalone per-agent sync that handles its own setup: checks if
/// hive-matrix is present, reads the appservice token, and builds
/// an HTTP client before delegating to [`sync_agent`]. Mirrors the
/// setup in [`ensure_all`] so the rebuild path and the startup sweep
/// stay equivalent. No-op when the matrix container is absent.
pub async fn sync_agent_standalone(name: &str) {
if !is_present() {
return;
}
let as_token = match read_appservice_token() {
Ok(t) => t,
Err(e) => {
tracing::warn!(%name, error = ?e, "matrix: read_appservice_token failed");
return;
}
};
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
.build()
{
Ok(c) => c,
Err(e) => {
tracing::warn!(%name, error = ?e, "matrix: build HTTP client failed");
return;
}
};
sync_agent(&client, name, &as_token).await;
}
/// Ensure the hive's `@hive-<hive>:` matrix user exists and that its access token is
/// persisted at [`sender_token_path()`].
///
@ -1179,8 +934,7 @@ pub async fn promote_user_to_admin(
///
/// 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.
/// it to the non-purgeable creds path.
///
/// ⚠️ Same admin-**sender** requirement as [`promote_user_to_admin`], and
/// the same consequence: `@hive-<hive>:` is an ordinary account with no admin
@ -1874,10 +1628,11 @@ async fn resolve_room_alias(
.ok_or_else(|| anyhow::anyhow!("matrix: alias {alias} response missing room_id: {json}"))
}
/// Sweep every existing container (manager + sub-agents) and ensure
/// each has a matrix user + token on the local homeserver. Called at
/// hive-c0re startup, alongside `forge::ensure_all`, and then
/// periodically (see the caller in `main.rs`). No-op when the
/// Make sure the hive's own `@hive-<hive>:` account exists, then provision
/// the hive Space and chat room and invite every agent container to both.
/// Agents' own accounts are not created here: `swarm-controller` mints them
/// with the swarm's appservice token. Called at hive-c0re startup, alongside
/// `forge::ensure_all`, and then periodically (see the caller in `main.rs`). No-op when the
/// hive-matrix container isn't running. Per-step failures are logged
/// but don't abort the sweep.
///
@ -1893,9 +1648,7 @@ pub async fn ensure_all() -> bool {
}
let mut ok = true;
// Loud and non-destructive: with no appservice token this sweep can
// create nothing, so it does nothing. Agents that already hold a
// token keep using it — their accounts and sessions are untouched by
// anything in here.
// create nothing, so it does nothing.
let as_token = match read_appservice_token() {
Ok(t) => t,
Err(e) => {
@ -1903,8 +1656,7 @@ pub async fn ensure_all() -> bool {
return false;
}
};
// One HTTP client for the whole sweep — connection pool is
// reused across agents.
// One HTTP client for the whole sweep.
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
.build()
@ -1932,7 +1684,6 @@ pub async fn ensure_all() -> bool {
let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else {
continue;
};
sync_agent(&client, name, &as_token).await;
agent_names.push(name.to_owned());
}
@ -2101,30 +1852,4 @@ mod tests {
let err = extract_access_token(&body).unwrap_err();
assert!(err.to_string().contains("missing access_token"));
}
#[test]
fn token_file_present_only_for_a_non_empty_regular_file() {
let dir = tempfile::tempdir().expect("tempdir");
let token = dir.path().join("matrix-token");
assert!(!token_file_present(&token), "missing file");
std::fs::write(&token, "").unwrap();
assert!(!token_file_present(&token), "empty file");
std::fs::write(&token, "tok\n").unwrap();
assert!(token_file_present(&token), "non-empty file");
assert!(!token_file_present(dir.path()), "directory");
}
/// The sweep runs as `hive-core`, which cannot read the agent-owned
/// 0600 token file. `chmod 000` does not stop root, so this test uses
/// content that `read_to_string` rejects instead: the predicate must
/// still report the file as present, which holds only if it never
/// reads the content.
#[test]
fn token_file_present_does_not_read_the_content() {
let dir = tempfile::tempdir().expect("tempdir");
let token = dir.path().join("matrix-token");
std::fs::write(&token, [0xff, 0xfe]).unwrap();
assert!(std::fs::read_to_string(&token).is_err());
assert!(token_file_present(&token));
}
}

View file

@ -539,51 +539,37 @@ async fn handle_matrix_create_user(
password: Option<&str>,
) -> Result<HostResponse> {
require_matrix_present()?;
if agent_exists(name)? {
// The swarm mints an agent's account and stores its token where the
// agent reads it; a second minter here would replace that token on the
// same device.
anyhow::bail!(
"matrix create-user: '{name}' is an agent, and an agent's matrix account comes from \
the swarm: swarm-controller creates it and re-checks it every five minutes"
);
}
let as_token =
crate::matrix::read_appservice_token().context("read matrix appservice token")?;
let client = matrix_http_client()?;
let mut out = Vec::new();
if agent_exists(name)? {
if password.is_some() {
// Agents auth by access_token, never by password — the
// boot-sweep provisioning path doesn't accept one. Refuse
// rather than silently dropping it.
anyhow::bail!(
"matrix create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via access_token"
);
}
crate::matrix::ensure_user_for(&client, name.as_str(), &as_token)
let effective_password = match password {
Some(p) => p.to_owned(),
None => crate::matrix::random_password().context("generate random matrix password")?,
};
let token =
crate::matrix::provision_user_token(&client, name.as_str(), &as_token, &effective_password)
.await
.with_context(|| format!("matrix create-user {name}"))?;
let path = Coordinator::agent_notes_dir(name).join("matrix-token");
out.push(format!("matrix: provisioned agent user '{name}'"));
out.push(format!("token persisted at: {}", path.display()));
out.push(format!(
"matrix: provisioned user '{name}' (not an agent — token not persisted)"
));
out.push(format!("token: {token}"));
if password.is_some() {
out.push("password: set as supplied — use it to log into a matrix web client".to_owned());
} else {
let effective_password = match password {
Some(p) => p.to_owned(),
None => crate::matrix::random_password().context("generate random matrix password")?,
};
let token = crate::matrix::provision_user_token(
&client,
name.as_str(),
&as_token,
&effective_password,
)
.await
.with_context(|| format!("matrix create-user {name}"))?;
out.push(format!(
"matrix: provisioned user '{name}' (not an agent — token not persisted)"
));
out.push(format!("token: {token}"));
if password.is_some() {
out.push(
"password: set as supplied — use it to log into a matrix web client".to_owned(),
);
} else {
out.push(
"password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(),
);
}
out.push(
"password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(),
);
}
Ok(HostResponse::messages(out))
}

View file

@ -21,6 +21,7 @@ rmcp.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
swarm-secret-client.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true

View file

@ -1,13 +1,15 @@
//! matrix-sdk `Client` setup for the daemon: read the per-agent access
//! token from the state-dir file `hive-c0re::matrix::ensure_user_for`
//! wrote, probe `whoami` to recover the agent's matrix `user_id` +
//! `device_id`, restore the matrix-sdk session, return the Client ready
//! to start sync.
//! matrix-sdk `Client` setup for the daemon: take the per-agent access
//! token [`crate::credential`] resolved, probe `whoami` to recover the
//! agent's matrix `user_id` + `device_id`, restore the matrix-sdk
//! session, return the Client ready to start sync.
//!
//! 🩸 The token arrives as a [`Token`] and never as a path this module
//! reads — where it came from is [`crate::credential`]'s question, and the
//! only thing said about it here is its [`crate::credential::Origin`].
//!
//! No OAuth dance / cross-signing setup (in contrast to damocles-daemon's
//! ccc.de connection): for the in-hive tuwunel hive-c0re already minted
//! the token + user/device as the hive's appservice and handed us the
//! bearer in a file. matrix-sdk's `restore_session` with a constructed
//! ccc.de connection): for the swarm's tuwunel the swarm already minted the
//! token + user/device as its appservice and handed us the bearer. matrix-sdk's `restore_session` with a constructed
//! `MatrixSession` skips the login flow entirely.
//!
//! E2EE is enabled via `with_encryption_settings(EncryptionSettings::default())`.
@ -34,10 +36,12 @@ use matrix_sdk::{
use serde::Deserialize;
use tokio::fs;
use crate::credential::Token;
/// Sentinel returned when `build_and_restore` detects that the token is
/// permanently invalid (`M_UNKNOWN_TOKEN`). The token has already been
/// removed from disk. Callers should NOT retry — the account needs
/// re-provisioning by hive-c0re.
/// discarded ([`Token::discard_stale`]). Callers should NOT retry — the
/// account needs re-provisioning.
///
/// Distinct from the general `anyhow::Error` path so callers can use
/// `err.downcast_ref::<PermanentBringUpError>()` to distinguish "retry
@ -64,63 +68,53 @@ struct WhoamiResponse {
}
/// Build + restore a matrix-sdk `Client` for the per-agent bearer
/// token at `token_file`. The Client points at `homeserver`, persists
/// its sqlite cache under `state_dir`, and is ready for sync once
/// returned.
/// `token`. The Client points at `homeserver`, persists its sqlite cache
/// under `state_dir`, and is ready for sync once returned.
///
/// Steps:
/// 1. Read the bearer token from `token_file` (trim trailing whitespace).
/// 2. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with
/// 1. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with
/// the bearer — this gives us back the matrix `user_id` +
/// `device_id` (the registration response had them but hive-c0re
/// only persisted the token; whoami is the cheapest recovery path
/// `device_id` (the registration response had them but only the
/// token was kept; whoami is the cheapest recovery path
/// and avoids matrix-sdk's circular requirement of needing a
/// session to call whoami).
/// 3. Build the real Client with the sqlite store + `restore_session`
/// 2. Build the real Client with the sqlite store + `restore_session`
/// using a synthetic `MatrixSession`.
///
/// # Errors
///
/// Returns an error if the token file is missing or empty, if the
/// `whoami` request fails, or if the matrix-sdk client fails to build
/// or restore the session.
/// Returns an error if the `whoami` request fails, or if the matrix-sdk
/// client fails to build or restore the session.
pub async fn build_and_restore(
homeserver: &str,
token_file: &Path,
token: &Token,
state_dir: &Path,
is_primary: bool,
) -> Result<Client> {
let token = fs::read_to_string(token_file)
.await
.with_context(|| format!("read matrix token from {}", token_file.display()))?
.trim()
.to_owned();
if token.is_empty() {
return Err(anyhow!("matrix token at {} is empty", token_file.display()));
}
let (user_id, device_id) = match whoami(homeserver, &token).await {
let (user_id, device_id) = match whoami(homeserver, token.expose()).await {
Ok(ids) => ids,
Err(e) => {
let msg = format!("{e:#}");
if msg.contains("M_UNKNOWN_TOKEN") {
// Homeserver rejected our token — stale session after a homeserver
// state wipe or token expiry. Delete the stale token file so we
// don't loop on it. The rest of the handling depends on whether
// this is the primary (hive-internal) account or a secondary one,
// because a single bad secondary token must NOT take down the
// whole daemon (and with it every healthy account).
// state wipe or token expiry. Discard it so we don't loop on it
// (which for a store-held credential is a log line and nothing
// else — see `Token::discard_stale`). The rest of the handling
// depends on whether this is the primary (hive-internal) account
// or a secondary one, because a single bad secondary token must
// NOT take down the whole daemon (and with it every healthy
// account).
tracing::warn!(
path = %token_file.display(),
credential = %token.origin(),
is_primary,
"matrix token rejected (M_UNKNOWN_TOKEN); removing stale token"
"matrix token rejected (M_UNKNOWN_TOKEN); discarding stale token"
);
let _ = fs::remove_file(token_file).await;
token.discard_stale().await;
if is_primary {
// Primary: also drop the matrix-sdk sqlite state keyed to the
// now-invalid session, then exit 0 so hive-c0re's `ensure_all`
// re-provisions the account and the systemd.paths watcher
// restarts us once the fresh token file appears. Exit 0 (not
// now-invalid session, then exit 0; the swarm's backfill
// re-mints the token and systemd restarts us once it
// exists (store re-check timer, or the path watcher). Exit 0 (not
// Err) keeps systemd's Restart=on-failure from looping; the
// call site is before any tasks are spawned so there's
// nothing to clean up.
@ -133,13 +127,13 @@ pub async fn build_and_restore(
}
std::process::exit(0);
}
// Secondary: token removed (so it's cleanly skipped next boot
// Secondary: token discarded (so it's cleanly skipped next boot
// rather than re-erroring); leave the sdk state in place in case
// the operator re-provisions a fresh token for the same device.
// Return a PermanentBringUpError so the caller can distinguish
// "don't retry" from a transient network/DNS failure.
return Err(anyhow::Error::new(PermanentBringUpError(
"matrix token rejected (M_UNKNOWN_TOKEN); removed stale token, skipping account".into(),
"matrix token rejected (M_UNKNOWN_TOKEN); discarded stale token, skipping account".into(),
)));
}
return Err(e);
@ -159,7 +153,7 @@ pub async fn build_and_restore(
let session = MatrixSession {
meta: SessionMeta { user_id, device_id },
tokens: SessionTokens {
access_token: token,
access_token: token.expose().to_owned(),
refresh_token: None,
},
};

View file

@ -0,0 +1,429 @@
//! The matrix access token as a value this process holds, and the two places
//! it comes from.
//!
//! 🩸 **A secret is a path, not a value.** Nothing here writes the token
//! anywhere, interpolates it into a command, or lets it reach a log line or an
//! error message: [`Token`] has no `Debug` derive, and every message below
//! names an [`Origin`] — which is a path in the store or a path on disk, never
//! the bytes at either.
//!
//! The store read is this agent reading **its own** credential under **its own
//! identity, from inside its own container** — the path
//! `swarm/agents/<agent>/matrix/<account>` is already agent-scoped, and the
//! certificate the login presents is the one this agent's hive delivered as a
//! systemd credential (`nix/agent-modules/bao.nix`). The hive is not in the
//! path of the value at all.
//!
//! The file arm is the hive-side delivery that still runs beside this one for
//! extra accounts (`hive_c0re::workers::credential`), and the `main` token a
//! hive minted before the swarm did. That is what this replaces, not something
//! it depends on, and it is the arm that goes when the hive-side loop does.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow};
use swarm_secret_client::{
SecretStore,
client::{DEFAULT_CERT_MOUNT, ENV_ADDR, ENV_CACERT, Settings},
matrix, policy,
};
/// Names the agent this container belongs to, set by
/// `nix/agent-modules/matrix.nix` from the agent's own unix user name.
///
/// The name and nothing else: `swarm_secret_client` owns both spellings
/// derived from it — the credential's path ([`matrix::account_path`]) and the
/// cert-auth role the login selects ([`policy::agent_object_name`]) — so a
/// path or a role name forwarded from nix would be a second copy of a string
/// whose mismatch is a 403 that names neither.
pub const ENV_AGENT: &str = "HIVE_AGENT_NAME";
/// Where a token came from.
///
/// A **path** in both arms, which is what makes it safe to put in a log line:
/// the whole point of this module is that the thing beside it never is.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Origin {
/// A file in this agent's state dir, written by the hive-side delivery.
File(PathBuf),
/// A path in the swarm secret store, read by this agent as itself.
Store(String),
}
impl std::fmt::Display for Origin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::File(p) => write!(f, "{}", p.display()),
Self::Store(p) => write!(f, "the store's {p}"),
}
}
}
/// A matrix access token, held in memory for as long as it takes to restore a
/// session with it.
///
/// ⚠️ **No `Debug` derive**, for the reason
/// [`swarm_secret_client::mtls::Credential`] states for the private key it
/// carries: this value is threaded through `anyhow` context chains and
/// `tracing` fields, both of which format whatever they are handed. The
/// hand-written impl below reports the origin and the length, which is every
/// question a reader of a log line actually has.
pub struct Token {
value: String,
origin: Origin,
}
impl std::fmt::Debug for Token {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Token")
.field("origin", &self.origin)
.field(
"value",
&format_args!("<redacted, {} bytes>", self.value.len()),
)
.finish()
}
}
impl Token {
/// The bytes, for the one caller that has to present them to the
/// homeserver.
#[must_use]
pub fn expose(&self) -> &str {
&self.value
}
/// Where this token came from — the only half of it that may be printed.
#[must_use]
pub fn origin(&self) -> &Origin {
&self.origin
}
/// Read this agent's credential for `account` out of the swarm secret
/// store, under this agent's own certificate.
///
/// `Ok(None)` means this deployment has no store: the agent's hive was
/// given no `BAO_ADDR` to forward, so `nix/agent-modules/bao.nix` minted no
/// identity check and there is nothing here to log in to. That is an absent
/// integration, not a failure — the caller falls back to the file the hive
/// delivered.
///
/// # Errors
/// A name that is not a single path segment, an environment naming an
/// identity that cannot be read, a store that refuses the certificate or
/// the path, or a credential stored empty.
pub async fn from_store(agent: &str, account: &str) -> Result<Option<Self>> {
let Some(settings) = store_settings(|k| std::env::var(k).ok())? else {
return Ok(None);
};
let path = matrix::account_path(agent, account)
.context("building this agent's credential path in the store")?;
let role = policy::agent_object_name(agent)
.context("building this agent's cert-auth role name")?;
let store = SecretStore::connect(&settings, &role, DEFAULT_CERT_MOUNT)
.await
.context("logging in to the swarm secret store as this agent")?;
// The stored object also carries the account's homeserver, and it is
// deliberately dropped here: the account's URL is already settled by
// the time this runs (`AccountCfg::homeserver`), and taking it from the
// store instead would change which accounts come up at all — that is
// the discovery half of this move, which goes with the hive-side loop
// rather than with the read.
let credential: matrix::Credential = store
.read(&path)
.await
.with_context(|| format!("reading {path} from the store"))?;
let value = credential.value.trim().to_owned();
if value.is_empty() {
return Err(anyhow!("the credential at {path} in the store is empty"));
}
Ok(Some(Self {
value,
origin: Origin::Store(path),
}))
}
/// Read a token out of the file the hive-side delivery wrote.
///
/// `Ok(None)` when the file is not there — the account has not been
/// provisioned yet, which the caller treats as "skip", not "fail".
///
/// # Errors
/// The file existing but being unreadable or empty.
pub async fn from_file(path: &Path) -> Result<Option<Self>> {
if !tokio::fs::try_exists(path).await.unwrap_or(false) {
return Ok(None);
}
let value = tokio::fs::read_to_string(path)
.await
.with_context(|| format!("read matrix token from {}", path.display()))?
.trim()
.to_owned();
if value.is_empty() {
return Err(anyhow!("matrix token at {} is empty", path.display()));
}
Ok(Some(Self {
value,
origin: Origin::File(path.to_owned()),
}))
}
/// Drop this token after the homeserver rejected it, so a restart does not
/// loop on the same dead value.
///
/// Only a file can be dropped. A credential in the store is not this
/// agent's to delete — its policy grants `read` and nothing else — and
/// replacing it is renewal, a different job from this one. Saying so is the
/// whole of what the store arm does.
pub async fn discard_stale(&self) {
match &self.origin {
Origin::File(p) => {
let _ = tokio::fs::remove_file(p).await;
}
Origin::Store(path) => tracing::warn!(
credential = %path,
"the stored matrix credential was rejected; leaving it alone (this agent may only read it)"
),
}
}
}
/// This agent's name, or `None` when the harness did not say.
///
/// `None` is not a failure: it is what an agent whose harness predates
/// [`ENV_AGENT`] looks like, and such an agent keeps working off the file its
/// hive delivers.
#[must_use]
pub fn agent_name() -> Option<String> {
std::env::var(ENV_AGENT).ok().filter(|v| !v.is_empty())
}
/// The store's coordinates as this container was given them, or `None` when it
/// was given none.
///
/// Split out of [`Token::from_store`] and taken through a lookup so the two
/// decisions it makes — "is there a store at all" and "is the delivered CA
/// usable" — can be asserted without a store to talk to or an environment to
/// mutate.
///
/// # Errors
/// [`swarm_secret_client::Error::MissingEnv`] when an address was forwarded but
/// the identity beside it was not, which is a half-delivered container rather
/// than one without a store.
fn store_settings(get: impl Fn(&str) -> Option<String>) -> Result<Option<Settings>> {
if get(ENV_ADDR).is_none_or(|v| v.is_empty()) {
return Ok(None);
}
let settings = Settings::from_lookup(|k| get(k).filter(|v| k != ENV_CACERT || ca_is_usable(v)))
.context("reading the swarm secret store's coordinates from the environment")?;
Ok(Some(settings))
}
/// Whether the CA bundle at `path` is a file with bytes in it.
///
/// `BAO_CACERT` names a systemd credential, and the bare `LoadCredential=` form
/// is non-fatal when the manager received no such credential — so the variable
/// can name a file that is not there. Absent means "verify the store's listener
/// against the container's own trust store", which is what a deployment with a
/// real CA wants; handing the path through regardless would fail the TLS
/// handshake on a file that was never meant to exist and blame the store.
fn ca_is_usable(path: &str) -> bool {
std::fs::metadata(path).is_ok_and(|m| m.len() > 0)
}
#[cfg(test)]
mod tests {
use super::*;
/// A lookup standing in for a container that was handed a store.
fn with_store(k: &str) -> Option<String> {
match k {
ENV_ADDR => Some("https://bao.t.local:8200".to_owned()),
"BAO_CLIENT_CERT" => Some("/run/credentials/x/cert".to_owned()),
"BAO_CLIENT_KEY" => Some("/run/credentials/x/key".to_owned()),
_ => None,
}
}
/// The property the hand-written `Debug` exists for: a token that reaches a
/// log line is a token in a journal somebody else can read.
#[test]
fn formatting_a_token_does_not_reveal_it() {
let token = Token {
value: "syt_SUPER_SECRET_ACCESS_TOKEN".to_owned(),
origin: Origin::Store("swarm/agents/a1/matrix/ccc".to_owned()),
};
let rendered = format!("{token:?}");
assert!(
!rendered.contains("syt_SUPER_SECRET_ACCESS_TOKEN"),
"the token must not survive formatting, got {rendered}"
);
assert!(rendered.contains("<redacted"), "got {rendered}");
// The control: the origin IS meant to survive, so the assertion above
// is not passing because the impl prints nothing at all.
assert!(
rendered.contains("swarm/agents/a1/matrix/ccc"),
"got {rendered}"
);
}
/// The same property for the arm a reader actually reaches for: `Display`
/// on the origin is what every message in this crate interpolates.
#[test]
fn an_origin_renders_as_a_path_in_both_arms() {
assert_eq!(
Origin::File(PathBuf::from("/agents/a1/state/matrix-token-ccc")).to_string(),
"/agents/a1/state/matrix-token-ccc"
);
assert_eq!(
Origin::Store("swarm/agents/a1/matrix/ccc".to_owned()).to_string(),
"the store's swarm/agents/a1/matrix/ccc"
);
}
#[test]
fn a_container_with_no_store_address_reads_no_store() {
// The absence arm, and what the file fallback exists for: a hive that
// was given no store forwards nothing, and this must be "there is no
// store" rather than an error about a missing certificate.
assert!(
store_settings(|_| None)
.expect("an absent store is not a failure")
.is_none()
);
// Empty is how systemd delivers an unset nix option, so it has to read
// the same as unset.
assert!(
store_settings(|k| if k == ENV_ADDR {
Some(String::new())
} else {
with_store(k)
})
.expect("an empty address is not an address")
.is_none()
);
}
#[test]
fn an_address_without_an_identity_is_a_half_delivered_container() {
// Not the same thing as having no store: the hive said where the store
// is and then delivered nothing to present to it, which is a deployment
// bug and must be named rather than silently degraded.
let e = store_settings(|k| if k == ENV_ADDR { with_store(k) } else { None })
.expect_err("an address with no certificate beside it is a failure");
assert!(e.to_string().contains("coordinates"), "got {e:#}");
}
#[test]
fn a_store_address_with_an_identity_is_usable() {
// The control for the two arms above.
assert!(
store_settings(with_store)
.expect("a full environment")
.is_some()
);
}
#[test]
fn a_ca_that_was_never_delivered_is_dropped_rather_than_handed_on() {
// `LoadCredential=` in its bare form is non-fatal when the credential
// is absent, so this variable routinely names a file that is not there.
// The resulting settings must be the ones a container with no CA at all
// gets — anything else fails the handshake and blames the store.
let named = store_settings(|k| {
if k == ENV_CACERT {
Some("/nonexistent/server-ca.pem".to_owned())
} else {
with_store(k)
}
})
.expect("a full environment")
.expect("a store is configured");
let absent = store_settings(with_store)
.expect("a full environment")
.expect("a store is configured");
assert_eq!(named, absent);
}
#[test]
fn a_ca_with_bytes_in_it_is_kept() {
// The control: without it, the test above would pass against a
// correction that dropped every CA it was ever given.
let dir = std::env::temp_dir().join(format!(
"hh-cred-ca-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let ca = dir.join("server-ca.pem");
std::fs::write(&ca, "-----BEGIN CERTIFICATE-----\n").unwrap();
assert!(ca_is_usable(ca.to_str().unwrap()));
let kept = store_settings(|k| {
if k == ENV_CACERT {
Some(ca.to_string_lossy().into_owned())
} else {
with_store(k)
}
})
.expect("a full environment")
.expect("a store is configured");
assert_ne!(
kept,
store_settings(with_store)
.expect("a full environment")
.expect("configured"),
"a delivered CA must change the settings it is delivered into"
);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn an_unprovisioned_account_has_no_token_rather_than_an_error() {
// The daemon skips such an account (a secondary) or waits on the path
// watcher (the primary); neither is a failure.
let missing = std::env::temp_dir().join("hh-cred-definitely-not-here/matrix-token");
assert!(Token::from_file(&missing).await.unwrap().is_none());
}
#[tokio::test]
async fn an_empty_token_file_names_the_file_and_not_its_contents() {
let dir = std::env::temp_dir().join(format!(
"hh-cred-empty-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("matrix-token");
std::fs::write(&path, " \n").unwrap();
let e = Token::from_file(&path)
.await
.expect_err("whitespace is not a token");
assert!(
e.to_string().contains(&path.display().to_string()),
"got {e:#}"
);
// And the control: a real token comes back, trimmed, with the file as
// its origin.
std::fs::write(&path, "syt_abc\n").unwrap();
let token = Token::from_file(&path)
.await
.expect("a readable file")
.expect("a non-empty one");
assert_eq!(token.expose(), "syt_abc");
assert_eq!(token.origin(), &Origin::File(path.clone()));
std::fs::remove_dir_all(&dir).ok();
}
}

View file

@ -9,6 +9,7 @@
pub mod accounts;
pub mod client;
pub mod credential;
pub mod handlers;
pub mod mcp;
pub mod paths;

View file

@ -7,22 +7,24 @@
//! Lifecycle:
//! 1. Read the configured account list (`accounts::configured()` —
//! `HIVE_MATRIX_ACCOUNTS` JSON, or the single legacy account).
//! 2. For each account: whoami probe → recover `user_id` + `device_id`
//! 2. For each account: resolve its access token (`account_token` — the
//! swarm secret store first, read by this agent as itself, then the
//! file its hive delivered) → whoami probe → recover `user_id` + `device_id`
//! → restore matrix-sdk session (no login flow), install the
//! message-event handler, and spawn its own sync loop.
//! 3. Serve the MCP tools against an account→Client registry; each tool
//! call routes to the account named in its `account` arg (the
//! primary account when omitted).
//!
//! Standalone-degraded boot: the PRIMARY account having no token file →
//! exit 0 cleanly so systemd's path-watcher restarts us once hive-c0re
//! provisions it. A SECONDARY account missing its token is skipped (the
//! daemon still serves the others).
//! Standalone-degraded boot: the PRIMARY account having no token →
//! exit 0 cleanly; systemd restarts us once one exists (the path-watcher for
//! a token file, the store re-check timer for a stored token). A SECONDARY
//! account missing its token is skipped (the daemon still serves the others).
//!
//! Stale-token recovery (`M_UNKNOWN_TOKEN`): handled in
//! `client::build_and_restore`, and it mirrors the missing-token policy
//! above — a rejected PRIMARY token drops the stale token + sdk state and
//! exits 0 for systemd re-provisioning, while a rejected SECONDARY token
//! exits 0 until a fresh token exists, while a rejected SECONDARY token
//! is removed and that one account is skipped so the daemon keeps serving
//! the primary and any other healthy account.
@ -34,7 +36,8 @@ use matrix_sdk::{Client, config::SyncSettings};
use hive_matrix_mcp::accounts::{AccountCfg, Registry};
use hive_matrix_mcp::client::PermanentBringUpError;
use hive_matrix_mcp::{accounts, client, mcp, paths, timeline, wake};
use hive_matrix_mcp::credential::Token;
use hive_matrix_mcp::{accounts, client, credential, mcp, paths, timeline, wake};
#[derive(Parser)]
#[command(name = "hive-matrix-daemon", about = "matrix-sdk client + MCP daemon")]
@ -97,13 +100,13 @@ async fn main() -> Result<()> {
sync_loops.push(sync_loop);
}
// No token yet: for the primary that means the daemon isn't
// useful — exit 0 like the legacy single-account path so the
// systemd path-watcher restarts us when the token appears.
// useful — exit 0, and systemd restarts us once a token exists
// (the path-watcher for a file, the re-check timer for the store).
Ok(None) if is_primary => {
tracing::warn!(
account = %cfg.name,
"primary matrix account has no token yet; exiting cleanly \
(systemd restarts us when hive-c0re provisions it)"
(systemd restarts us once one exists)"
);
return Ok(());
}
@ -259,6 +262,46 @@ async fn bring_up_secondary_with_retry(
None
}
/// Resolve `cfg`'s access token, preferring the copy this agent can fetch
/// itself.
///
/// 🏛️ The store read is the point: the credential at
/// `swarm/agents/<agent>/matrix/<account>` is **this agent's**, and it is read
/// from inside this agent's container under this agent's own certificate,
/// never by the hive on the agent's behalf. It is held in memory from here to
/// `restore_session` and is written nowhere.
///
/// `main` included: `swarm-controller` mints it with the swarm's appservice
/// token and stores it at `swarm/agents/<agent>/matrix/main`, and no hive
/// mints it any more. An agent whose harness forwards no
/// [`credential::ENV_AGENT`] cannot name its own subtree, so it reads the file.
///
/// The file is what remains when the store answers nothing: an extra account
/// the hive-side delivery loop still writes, or a `main` token a hive minted
/// before the swarm did. A store read that *fails* falls back too, and says
/// why.
///
/// # Errors
/// As [`credential::Token::from_file`]: a token file that exists but cannot be
/// read, or is empty.
async fn account_token(cfg: &AccountCfg) -> Result<Option<Token>> {
if let Some(agent) = credential::agent_name() {
match Token::from_store(&agent, &cfg.name).await {
Ok(Some(token)) => return Ok(Some(token)),
// No store in this deployment: nothing to report, the hive-side
// delivery is the whole mechanism here.
Ok(None) => {}
Err(e) => tracing::warn!(
account = %cfg.name,
error = %format!("{e:#}"),
"could not read this account's credential from the store as this agent; \
falling back to the token the hive delivered"
),
}
}
Token::from_file(&cfg.token_file).await
}
/// Restore one account's client (when its token exists), install its
/// message handler, and build its sync loop. Returns
/// `Ok(Some((client, sync_loop)))` when the account came up, `Ok(None)`
@ -280,23 +323,19 @@ async fn bring_up_account(
);
return Ok(None);
};
if !tokio::fs::try_exists(&cfg.token_file)
.await
.unwrap_or(false)
{
let Some(token) = account_token(cfg).await? else {
return Ok(None);
}
};
tracing::info!(
account = %cfg.name,
homeserver,
token_file = %cfg.token_file.display(),
credential = %token.origin(),
state_dir = %cfg.state_dir.display(),
"bringing up matrix account"
);
let client =
client::build_and_restore(&homeserver, &cfg.token_file, &cfg.state_dir, is_primary)
.await
.with_context(|| format!("build matrix client for account {}", cfg.name))?;
let client = client::build_and_restore(&homeserver, &token, &cfg.state_dir, is_primary)
.await
.with_context(|| format!("build matrix client for account {}", cfg.name))?;
// Best-effort: sync the agent icon to this account's matrix avatar over
// the live (authenticated, correct-homeserver) Client. Replaces the old
// curl oneshot; failures are swallowed inside sync_avatar.

View file

@ -8,8 +8,8 @@ use std::path::PathBuf;
/// Resolve the matrix access-token file path. Override via
/// `HIVE_MATRIX_TOKEN_FILE`; default is `<HYPERHIVE_STATE_DIR>/matrix-token`,
/// the path `hive-c0re::matrix::ensure_user_for` writes to on agent
/// account provisioning.
/// where a hive used to write the `main` account's token (the daemon now reads
/// it from the store first; see `main.rs`'s `account_token`).
#[must_use]
pub fn token_file() -> PathBuf {
if let Some(p) = std::env::var_os("HIVE_MATRIX_TOKEN_FILE") {

View file

@ -286,15 +286,15 @@ impl From<ReconcileFrom> for hive_host_sock::ReconcileDirection {
#[derive(Subcommand)]
pub enum MatrixCmd {
/// Create or refresh the matrix account + access token for `<name>`.
/// Create a matrix account for a person or other non-agent `<name>` and
/// print its access token to stdout.
///
/// For an existing agent, persists the token to its state dir; for a
/// human/other account, prints the access token to stdout. Set a
/// password to enable matrix web-client login (otherwise it uses a
/// random throwaway).
/// Refuses an agent's name: its account comes from the swarm
/// (`swarm-controller` creates it and stores its token where the agent
/// reads it). Set a password to enable matrix web-client login
/// (otherwise it uses a random throwaway).
CreateUser {
/// Matrix localpart. For agents: the container/agent name.
/// For humans: any matrix localpart — `mara`, `damocles`, etc.
/// Matrix localpart of a non-agent account — `mara`, `damocles`, etc.
name: String,
/// Set the account password to this string instead of a random
/// throwaway. Use this for operator accounts that need to log

View file

@ -14,12 +14,21 @@
}:
let
userName = config.services.hyperhive.agent.user.name;
# This agent's own state dir, where hive-c0re provisions the
# hive-internal account's token (`matrix-token`) and the daemon keeps
# its matrix-sdk store. Shared by the `main` account entry below and
# the path-watcher glob at the bottom of this file.
# This agent's own state dir, where a hive used to write the `main`
# account's token (`matrix-token`, now the store's fallback) and the
# daemon keeps its matrix-sdk store. Shared by the `main` account entry
# below and the path-watcher glob at the bottom of this file.
stateDir = "/agents/${userName}/state";
accounts = config.services.hyperhive.agent.matrixAccounts;
# This agent's own identity at the swarm secret store (./bao.nix). The daemon
# reads each account's token from the store itself, `main` included (the
# swarm mints that one), so the store's coordinates belong on its unit. The
# same three credential ids ./bao.nix and ./forge-token.nix load.
baoCfg = config.services.hyperhive.agent.bao;
storeConfigured = baoCfg.addr != null;
certCredential = "hive-agent-bao-cert";
keyCredential = "hive-agent-bao-key";
serverCaCredential = "hive-agent-bao-server-ca";
# **The enable signal.** Matrix is on for this agent exactly when it has at
# least one account, because an account is the only thing the daemon has to
# do: no account, no Client, no sync, no tool surface worth injecting.
@ -90,8 +99,9 @@ in
Path to this account's bearer-token file. The daemon reads
the token from here to restore the matrix session; how the
file gets populated is the provisioner's concern
(hive-c0re for the hive-internal `main` account, an
operator-supplied secret for an external one). The daemon
(for `main`, a file only when the store has no token: the
swarm mints `main` into the store; an operator-supplied
secret for an external one). The daemon
skips an extra account whose token file is absent, and
exits cleanly to wait on the path watcher when `main`'s is.
'';
@ -143,7 +153,7 @@ in
homeserver (`homeserver`, else
`services.hyperhive.agent.matrix.url`). The daemon auto-skips an
account whose URL or token file is missing, and a `systemd.paths`
watcher restarts it the moment hive-c0re provisions the token
watcher restarts it the moment a token file appears
(same path-trigger shape as `forge-avatar-sync`).
- exposes the matrix tool surface (send_message, send_dm,
send_reaction, send_reply, mark_read, list_rooms,
@ -173,8 +183,8 @@ in
like any other, so it shows up in the account list --- what you
add here are the *further* accounts (e.g. an external
public-matrix account). Its `tokenFile` stays pinned to
`<state>/matrix-token` (an assertion; that is the one path
hive-c0re provisions the hive-internal token to), and the
`<state>/matrix-token` (an assertion; the file the daemon falls back
to when the store has no `main` token), and the
dashboard's link-account route refuses to create an account named
`main` --- the entry belongs to the module, not to a provisioner.
@ -215,8 +225,8 @@ in
# `main` is not a forbidden key --- this module declares it
# itself (see the `matrixAccounts.main` definition below), so the
# name must be allowed. What stays rejected is retargeting *its
# token file*: hive-c0re writes the hive-internal account's token
# to `<state>/matrix-token` and nowhere else, so an override there
# token file*: the daemon's file fallback for `main` is
# `<state>/matrix-token` and nothing else, so an override there
# is an account that evaluates fine and then never restores. The
# other two fields are free to override (a `mkDefault` each).
#
@ -227,8 +237,8 @@ in
assertion = !(accounts ? main) || accounts.main.tokenFile == "${stateDir}/matrix-token";
message =
"services.hyperhive.agent.matrixAccounts.main.tokenFile must stay "
+ "\"${stateDir}/matrix-token\" --- that is where hive-c0re provisions the "
+ "hive-internal account's token. Declare a separate account instead of "
+ "\"${stateDir}/matrix-token\" --- that is the file the daemon falls back to for the "
+ "main account's token. Declare a separate account instead of "
+ "pointing `main` elsewhere.";
}
# Token files must land at the `matrix-token*` name the daemon
@ -260,7 +270,7 @@ in
# so the one it always has belongs in it. `mkDefault` per field so an
# operator can retarget e.g. the homeserver without a
# conflicting-definition error (the token file is pinned by an
# assertion above, since hive-c0re owns that path).
# assertion above, since the daemon's fallback reads that path).
#
# ⚠️ Gated on the homeserver URL, and that gate is what keeps `matrixEnabled`
# from being trivially true for every agent in the hive. A `main` with no
@ -309,6 +319,20 @@ in
HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
RUST_LOG = "info";
}
# The store's coordinates and which agent this is, so the daemon reads
# `swarm/agents/<agent>/matrix/<account>` as itself. The NAME and not a
# path: `swarm_secret_client` builds both the path and the cert-auth role
# from it. Paths only — `%d` is this unit's own credentials directory.
// lib.optionalAttrs storeConfigured {
HIVE_AGENT_NAME = userName;
BAO_ADDR = baoCfg.addr;
BAO_CLIENT_CERT = "%d/${certCredential}";
BAO_CLIENT_KEY = "%d/${keyCredential}";
# Named even when no CA was delivered: a bare `LoadCredential=` is
# non-fatal when absent, and the daemon treats a missing or empty file
# as "use the container's own trust store".
BAO_CACERT = "%d/${serverCaCredential}";
}
# Homeserver URL. hive-c0re writes this option per agent from the
# hive's own gateway URL (`gatewayHost`'s vhost, `chat.<swarm-domain>`
# by default — agents run in a private
@ -357,7 +381,8 @@ in
# `on-failure`, not `always`: the daemon deliberately exits 0
# (a clean, non-failure exit) when no token is provisioned yet
# (see the module doc above) — the `systemd.paths` watcher
# below re-fires it the moment hive-c0re provisions one,
# below re-fires it the moment a token file appears (the timer
# above, for a token in the store),
# instead of `always` busy-looping every `RestartSec` until
# then. Once a token exists this is no different from
# `hive-bash-daemon`'s reasoning (a down window loses the MCP
@ -367,11 +392,33 @@ in
RestartSec = 5;
User = userName;
Group = userName;
}
# This agent's own certificate, imported by id so systemd materialises it
# under this unit's `User=` — the same bare form ./forge-token.nix uses.
// lib.optionalAttrs storeConfigured {
LoadCredential = [
certCredential
keyCredential
serverCaCredential
];
};
};
# Re-fire the daemon when the matrix token appears (hive-c0re
# provisions it after agent containers come up). Without this
# Re-start the daemon while it is down, when its token lives in the store.
# The path unit below only sees files, and a token the swarm mints or
# replaces in the store changes no file: a daemon that exited on a missing
# or replaced token would otherwise stay down until the container restarts.
# Relative to the daemon's last exit, so it never fires while the daemon
# runs; five minutes is the swarm's own re-mint cadence.
systemd.timers.hive-matrix-daemon = lib.mkIf (matrixEnabled && storeConfigured) {
description = "re-start hive-matrix-daemon while it is down, to re-read its token from the store";
wantedBy = [ "timers.target" ];
timerConfig.OnUnitInactiveSec = "5min";
};
# Re-fire the daemon when the matrix token appears (a token
# file: an extra account the hive delivers, or a `main` from before the
# swarm minted it). Without this
# the daemon would exit 0 silently on first boot and the MCP
# would have no backend until next restart. See
# `docs/agent-lifecycle/persistence.md` (same section as above).

View file

@ -172,6 +172,26 @@ let
isReadOnly = true;
});
# ── the swarm's own appservice ──────────────────────────────────────
#
# A second registration beside the hive's `hyperhive` one, and the swarm's
# identity on this homeserver: `swarm-controller` creates every agent's
# account with its token. Minted INSIDE this container by
# `swarm-matrix-ctl appservice render` and published to the store by
# `appservice publish`, which is why it is gated on the same identity as
# the sender mint: with nobody to publish it, a registration here would be
# an admin credential nobody reads.
#
# Its sender is promoted to homeserver admin at boot (`admin_execute`
# below), so its token goes only to a store path no hive's policy reaches.
# `swarm` is in ../reserved-names.nix, so no agent can be created as this
# account.
swarmSenderLocalpart = "swarm";
# Inside the container: the render unit's `StateDirectory`. `ephemeral =
# false` keeps it across restarts, so "mint when absent" is mint once.
swarmAppserviceDir = "/var/lib/swarm-matrix-appservice";
swarmAppserviceCredentialId = "swarm-appservice.yaml";
# Where a reader of the published credential is told the token is good for.
# Empty when this hive serves no vhost: `matrix::Credential.homeserver` is an
# `Option`, and matrix-ctl reads an empty variable as absent rather than as
@ -1304,21 +1324,27 @@ in
# `Services::start()`, before the listener accepts anything.
appservice_dir = appserviceCredentialDir;
# 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.
#
# 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.
}
# Exactly one account is promoted to homeserver admin at boot:
# the swarm appservice's sender. Its token is read by
# matrix-ctl and swarm-controller only. `@${hiveLocalpart}`
# stays an ordinary user, because every hive reads its
# credential; what it does (the Space, the chat room, the
# invites) rides on being the rooms' creator, not on admin.
#
# The registration load creates the sender inside
# `Services::start()`, and `admin_execute` runs after that and
# before the listener accepts anything. `make-user-admin` is a
# no-op on an account that is already admin.
# `admin_execute_errors_ignore` is load-bearing: a failing
# command aborts startup when it is false.
// lib.optionalAttrs ctlActive {
admin_execute = [
"users make-user-admin @${swarmSenderLocalpart}:${effectiveServerName}"
];
admin_execute_errors_ignore = true;
}
// {
# Server-side E2EE is opt-in (default off); the agent matrix
# client always supports decryption regardless.
allow_encryption = cfg.allowEncryption;
@ -1387,7 +1413,13 @@ in
# every OAuth exchange, not just at startup, so it has to
# outlive the unit's start — a credentials path does.
"oidc_client_secret:${toString deployCfg.matrix.sso.clientSecretFile}"
];
]
# The swarm registration, a second `.yaml` in the same directory:
# tuwunel loads every one it finds (`appservice/mod.rs`), and
# refuses only a duplicate `id` or `as_token`, never an
# overlapping namespace. A missing source fails this unit, which
# is why the render below is `requiredBy` it and local only.
++ lib.optional ctlActive "${swarmAppserviceCredentialId}:${swarmAppserviceDir}/swarm.yaml";
# Publish the appservice sender account's access token to the swarm
# store, once, under an identity that belongs to this container and
@ -1449,6 +1481,61 @@ in
};
};
# The swarm registration, minted and rendered before the homeserver
# loads it. No network and no store: this is on tuwunel's start
# path, and a store outage must not keep the homeserver down.
systemd.services.swarm-matrix-appservice-render = lib.mkIf ctlActive {
description = "render the swarm's appservice registration";
before = [ "tuwunel.service" ];
requiredBy = [ "tuwunel.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "${deployCfg.matrix.ctlPackage}/bin/swarm-matrix-ctl appservice render";
StateDirectory = baseNameOf swarmAppserviceDir;
StateDirectoryMode = "0700";
UMask = "0077";
SyslogIdentifier = "swarm-matrix-appservice";
};
environment = {
MATRIX_APPSERVICE_DIR = swarmAppserviceDir;
MATRIX_APPSERVICE_SENDER = swarmSenderLocalpart;
# The hive registration's namespace. Agents' localparts are
# bare `[a-z0-9-]`, so nothing narrower covers them without
# also covering people.
MATRIX_APPSERVICE_USER_REGEX = appserviceUserRegex;
};
};
# Hand the swarm registration's token to swarm-controller, through
# the store. Same identity and retry shape as `swarm-matrix-ctl`
# above; the write lands at a path only matrix-ctl and the
# controller are granted (./swarm-bao.nix).
systemd.services.swarm-matrix-appservice-publish = lib.mkIf ctlActive {
description = "publish the swarm's appservice token to the swarm secret store";
after = [ "swarm-matrix-appservice-render.service" ];
requires = [ "swarm-matrix-appservice-render.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${deployCfg.matrix.ctlPackage}/bin/swarm-matrix-ctl appservice publish";
Restart = "on-failure";
RestartSec = 30;
TimeoutStartSec = 60;
SyslogIdentifier = "swarm-matrix-appservice";
};
environment = {
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
BAO_CLIENT_CERT = deployCfg.matrix.ctlBaoClientCertFile;
BAO_CLIENT_KEY = deployCfg.matrix.ctlBaoClientKeyFile;
MATRIX_APPSERVICE_CERT_ROLE = ctlCertRole;
MATRIX_APPSERVICE_DIR = swarmAppserviceDir;
}
// lib.optionalAttrs (deployCfg.bao.serverCaFile != null) {
BAO_CACERT = deployCfg.bao.serverCaFile;
};
};
environment.systemPackages = [ deployCfg.matrix.package ];
};
};

View file

@ -194,6 +194,10 @@ let
# `read` too: `mint_and_verify` reads a credential back before writing so a
# re-run keeps the value a live agent already holds instead of rotating it —
# the read is required, not incidental.
#
# The swarm appservice token, read-only: the controller creates agents'
# matrix accounts with it and never writes it. matrix-ctl mints and publishes
# it (`matrixCtlPolicyText` below).
controllerPolicyText = ''
path "auth/cert/certs/hive-*" {
capabilities = ["create", "update", "read", "delete"]
@ -214,6 +218,10 @@ let
path "${credentialMountPath}/data/swarm/agents/*" {
capabilities = ["create", "read", "update"]
}
path "${credentialMountPath}/data/${swarmAppserviceTokenLeaf}" {
capabilities = ["read"]
}
'';
# The identity that copies authelia's minted OIDC client secrets into the
@ -278,10 +286,18 @@ let
# restart would mint a second access token and invalidate the hive's. A read
# here recovers one secret this principal itself wrote, which is a much
# narrower grant than the publisher's would have been.
#
# The second stanza is the swarm appservice's token, which matrix-ctl mints
# inside the container and publishes here for the controller. `read` for the
# same reason: publish compares before it writes.
matrixCtlPolicyText = ''
path "${credentialMountPath}/data/swarm/hives/${matrixCtlHive}/matrix/sender-token" {
capabilities = ["create", "update", "read"]
}
path "${credentialMountPath}/data/${swarmAppserviceTokenLeaf}" {
capabilities = ["create", "update", "read"]
}
'';
# Which hive matrix-ctl mints for. This host's own by default, which is right
@ -295,6 +311,12 @@ let
# locally, which is the same degrade a store that was never deployed gives.
matrixCtlHive = baoDeploy.matrixCtlHiveName;
# The swarm appservice token's leaf, the nix half of
# `swarm_secret_client::matrix::swarm_appservice_token_path`. Under
# `controller/`, the one kind no hive's policy reads: its sender is the
# homeserver's admin.
swarmAppserviceTokenLeaf = "swarm/controller/swarm-controller/matrix/appservice-token";
# 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

View file

@ -227,11 +227,9 @@ let
SWARM_CONTROLLER_AUTH_BRIDGE_URL = deployCfg.swarm-controller.authBridgeUrl;
};
# Swarm-wide default for `PUT .../matrix-accounts/{account}`'s own
# `homeserver` field, for a request that omits one. Same shape as
# `authBridgeEnv` above: genuinely optional, gated on the option
# resolving rather than assumed. Not read by anything yet — see the
# option's own description.
# The swarm's homeserver: where `MintAgentMatrixAccount` creates each
# agent's own account. Gated on the option resolving: a swarm with no
# domain has no homeserver URL, and the mint node then fails by name.
matrixHomeserverEnv = lib.optionalAttrs (deployCfg.swarm-controller.matrixHomeserverUrl != null) {
SWARM_CONTROLLER_MATRIX_HOMESERVER_URL = deployCfg.swarm-controller.matrixHomeserverUrl;
};
@ -561,19 +559,24 @@ in
matrixHomeserverUrl = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
# The same `chat.<swarm domain>` ./hive-matrix.nix serves its vhost on
# (`gatewayHost`): a swarm runs one homeserver.
default = if swarmDomain == null then null else "https://chat.${swarmDomain}";
defaultText = lib.literalExpression ''"https://chat.''${services.hyperhive.swarm.domain}"'';
example = "https://matrix.example.org";
description = ''
Swarm-wide default homeserver for `PUT
Client-server API base of the swarm's homeserver. The controller
creates each agent's own matrix account there, with the swarm's
appservice token, and stores its token where the agent reads it.
`null` leaves agents with no matrix account: the mint node fails,
naming this option's variable, and the agent is deployed anyway.
Also the swarm-wide default homeserver for `PUT
.../matrix-accounts/{account}` requests that omit their own
`homeserver` — see that route's own doc comment
(`swarm-controller/src/matrix_account.rs`) for why the field is
optional in token mode and what omitting it currently resolves to.
`null` (the default) leaves that per-request resolution exactly as
it is today. **Not yet consulted by the route at all**: this option
only exists to carry the value in, ahead of the route being taught
to fall back to it.
That route does not consult it yet.
'';
};

View file

@ -18,6 +18,7 @@ let
;
})
agent
agentWith
runGroup
;
@ -45,6 +46,15 @@ let
homeserver = "https://matrix.example.invalid";
};
};
# The daemon that reads its tokens from the store, `main` included. Paired
# with `agentMatrix` above — same daemon, no store — so each case below can
# tell "carries the store's coordinates" from "every matrix daemon does".
agentMatrixBao = agentWith {
services.hyperhive.agent.bao.addr = "https://bao.t.local:8200";
services.hyperhive.agent.matrix.url = "https://chat.t.local";
};
daemon = machine: machine.systemd.services.hive-matrix-daemon;
cases = [
{
# A homeserver URL is the whole input: from it the module derives the
@ -102,6 +112,69 @@ let
# No hive homeserver, so nothing may claim one.
&& !(env ? HIVE_MATRIX_URL);
}
{
# The daemon reads this agent's tokens from the store ITSELF, as itself,
# from inside this container — `main` too, since the swarm mints it and
# no hive does. So it needs the same identity ./agent-forge-bao.nix's
# fetch carries, in its own credentials directory.
name = "the matrix daemon carries this agent's own store identity";
ok =
let
u = daemon agentMatrixBao;
in
u.serviceConfig.LoadCredential == [
"hive-agent-bao-cert"
"hive-agent-bao-key"
"hive-agent-bao-server-ca"
]
&& u.environment.BAO_ADDR == "https://bao.t.local:8200"
&& u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
&& u.environment.BAO_CLIENT_KEY == "%d/hive-agent-bao-key";
}
{
# The agent's name, and nothing derived from it: the daemon builds its
# store path and its cert-auth role from this one string.
name = "the matrix daemon is told which agent it is and not where its credentials live";
ok =
let
e = (daemon agentMatrixBao).environment;
in
e.HIVE_AGENT_NAME == agentMatrixBao.services.hyperhive.agent.user.name
&& !(lib.any (lib.hasInfix "swarm/agents") (lib.attrValues e));
}
{
# 🩸 A secret is a path: every `BAO_*` entry is the store's address or a
# file under this unit's own credentials directory, never bytes in an
# environment `/proc/<pid>/environ` publishes.
name = "the matrix daemon is handed store paths and never store values";
ok =
let
store = lib.filterAttrs (n: _: lib.hasPrefix "BAO_" n) (daemon agentMatrixBao).environment;
in
store != { }
&& lib.all (n: n == "BAO_ADDR" || lib.hasPrefix "%d/" store.${n}) (lib.attrNames store);
}
{
# A token the swarm mints or replaces in the store changes no file, so
# the path watcher never sees it. The timer is what re-starts a daemon
# that exited on a missing or replaced token.
name = "a store-backed matrix daemon is re-started while it is down";
ok =
agentMatrixBao.systemd.timers.hive-matrix-daemon.timerConfig.OnUnitInactiveSec or null == "5min";
}
{
# The absence arm for the four above: with no store, no identity, no
# timer, and the file is the whole mechanism.
name = "a matrix daemon on an agent with no store declares no identity and no timer";
ok =
let
u = daemon agentMatrix;
in
!(u.serviceConfig ? LoadCredential)
&& !(u.environment ? BAO_ADDR)
&& !(u.environment ? HIVE_AGENT_NAME)
&& !(agentMatrix.systemd.timers ? hive-matrix-daemon);
}
];
in
runGroup "agent-matrix" cases

View file

@ -59,6 +59,15 @@ let
baoWrapperCmd = if baoWrapper == null then "" else (baoWrapper.buildCommand or "");
cases = [
{
# No hive mints an agent's matrix account any more, so a controller that
# did not know the homeserver would create every agent without one. The
# default is the swarm's own `chat.` vhost, with no operator setting.
name = "the controller is told the swarm's homeserver by default";
ok =
controllerNoStore.systemd.services.swarm-controller.environment.SWARM_CONTROLLER_MATRIX_HOMESERVER_URL
or null == "https://chat.t.local";
}
{
# Nothing asserted the PKI script before this, so a third leaf could be
# added to it and every case still passed — measured, not assumed: the

View file

@ -334,12 +334,18 @@ let
# 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";
#
# The one other leaf is the swarm appservice token, which matrix-ctl
# mints and publishes for the controller. Counted, so a third stanza
# fails rather than riding along beside two correct ones.
name = "matrix-ctl's grant is one hive's sender token and the swarm appservice token, 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 "path \"secret/data/swarm/controller/swarm-controller/matrix/appservice-token\" {\n capabilities = [\"create\", \"update\", \"read\"]\n}" s
&& lib.length (lib.splitString "path \"" s) == 3
&& !(lib.hasInfix "secret/data/swarm/services" s)
&& !(lib.hasInfix "secret/data/swarm/agents" s)
&& !(lib.hasInfix "secret/data/swarm/hives/*" s)
@ -747,6 +753,14 @@ let
in
lib.hasInfix "path \"secret/data/swarm/agents/*\" {\n capabilities = [\"create\", \"read\", \"update\"]" s;
}
{
# The swarm appservice token is a homeserver-admin credential. The
# controller mints agents' accounts with it and has no business replacing
# it: matrix-ctl is its one writer. Pinned as the whole stanza, so an
# added capability fails.
name = "the controller reads the swarm appservice token and cannot write it";
ok = lib.hasInfix "path \"secret/data/swarm/controller/swarm-controller/matrix/appservice-token\" {\n capabilities = [\"read\"]\n}" baoGrantHere.systemd.services.swarm-bao-controller-policy.script;
}
{
# The policy above grants paths under a mount nothing else creates, so
# the unit that writes the policy has to create it too — otherwise every

View file

@ -392,18 +392,64 @@ let
&& !(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";
# 🩸 The privilege arm: exactly one account is a homeserver admin, the
# swarm appservice's sender, whose token only matrix-ctl and the
# controller read. Read on the rendered settings rather than on an
# option, because the grant is a boot command in `admin_execute`, and a
# command list is exactly the shape a later edit extends without anything
# noticing — so the list is compared whole, and a second entry fails.
name = "the homeserver promotes exactly the swarm sender to admin at boot, and no hive's sender";
ok =
let
g = baoWithMatrix.containers.hive-matrix.config.services.matrix-tuwunel.settings.global;
in
!(g ? admin_execute) || g.admin_execute == [ ];
g.admin_execute == [ "users make-user-admin @swarm:${g.server_name}" ]
&& !(lib.any (c: lib.hasInfix "@hive-" c) g.admin_execute);
}
{
# Load-bearing rather than lenient: tuwunel aborts startup on a failing
# `admin_execute` command when this is false, and the homeserver must not
# stay down over a promotion.
name = "a failed admin promotion does not stop the homeserver";
ok =
baoWithMatrix.containers.hive-matrix.config.services.matrix-tuwunel.settings.global.admin_execute_errors_ignore
or false;
}
{
# The swarm registration reaches tuwunel the way the hive's does: a
# `.yaml` credential in the directory `appservice_dir` names.
name = "tuwunel loads the swarm registration as a yaml credential";
ok = lib.elem "swarm-appservice.yaml:/var/lib/swarm-matrix-appservice/swarm.yaml" (
baoWithMatrix.containers.hive-matrix.config.systemd.services.tuwunel.serviceConfig.LoadCredential
);
}
{
# A missing `LoadCredential` source fails tuwunel, so the render has to
# have run first — and it is local only, so it cannot fail on a store.
name = "the swarm registration is rendered before tuwunel and required by it, without the store";
ok =
let
r = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-appservice-render;
in
lib.elem "tuwunel.service" r.before
&& lib.elem "tuwunel.service" r.requiredBy
&& lib.hasSuffix "swarm-matrix-ctl appservice render" r.serviceConfig.ExecStart
&& !(r.environment ? BAO_ADDR);
}
{
# The absence arm for the four above: with no matrix-ctl identity there
# is nobody to publish the swarm token, so no admin, no registration and
# no render — rather than an admin credential nobody reads.
name = "a matrix container with no store identity has no swarm appservice and promotes nobody";
ok =
let
c = matrixNoBaoIdentity.containers.hive-matrix.config;
g = c.services.matrix-tuwunel.settings.global;
in
!(g ? admin_execute)
&& !(c.systemd.services ? swarm-matrix-appservice-render)
&& !(c.systemd.services ? swarm-matrix-appservice-publish)
&& !(lib.any (lib.hasPrefix "swarm-appservice.yaml") c.systemd.services.tuwunel.serviceConfig.LoadCredential);
}
{
# 🩸 The arm the whole refusal exists for. Both readers are gated on their

View file

@ -96,6 +96,9 @@ swarm-queue-client = { workspace = true, features = ["kv"] }
# name and the object's shape are agreements between the two ends, and a
# second spelling here would be a store this hive could not read.
swarm-secret-client.workspace = true
# The appservice calls `matrix_account::agent_token` mints agents' accounts
# with — shared with `swarm-matrix-ctl`, which pins the same device id.
swarm-matrix-client.workspace = true
# `agent_identity.rs` signs the per-agent client leaf the store authenticates
# an agent container by. The one runtime signer in this tree — every other CA
# here is a deploy-time `openssl` oneshot — because this one issues per agent

View file

@ -113,6 +113,14 @@ enum SwarmNodeKind {
/// Carries no hive: the token's store path has no hive segment, and the
/// agent pulls it from wherever it runs.
MintAgentForgeToken { agent: String },
/// Make sure `agent` holds a live token for its own account on the swarm's
/// homeserver, creating the account with the swarm's appservice token
/// when it does not. See `matrix_account::agent_token` — including why a
/// stored token is read back before anything is minted.
///
/// Carries no hive: the path it writes has no hive segment, so an agent
/// that moves between hives keeps one matrix identity.
MintAgentMatrixAccount { agent: String },
/// Declare `agent` on `hive` as `Paused` in the swarm's wanted-state
/// store, so a freshly created agent does not start driving turns the
/// moment it's deployed — the operator has to explicitly flip it to `Up`.
@ -139,6 +147,7 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
SwarmNodeKind::InitAgentConfigRepo { .. } => "init_agent_config_repo".to_owned(),
SwarmNodeKind::MintAgentIdentity { .. } => "mint_agent_identity".to_owned(),
SwarmNodeKind::MintAgentForgeToken { .. } => "mint_agent_forge_token".to_owned(),
SwarmNodeKind::MintAgentMatrixAccount { .. } => "mint_agent_matrix_account".to_owned(),
SwarmNodeKind::SetAgentWanted { .. } => "set_agent_wanted".to_owned(),
SwarmNodeKind::TriggerDeploy { .. } => "trigger_deploy".to_owned(),
}
@ -157,7 +166,8 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
| SwarmNodeKind::CreateForgeUser { agent }
| SwarmNodeKind::AddRepoMember { agent }
| SwarmNodeKind::InitAgentConfigRepo { agent }
| SwarmNodeKind::MintAgentForgeToken { agent } => {
| SwarmNodeKind::MintAgentForgeToken { agent }
| SwarmNodeKind::MintAgentMatrixAccount { agent } => {
serde_json::json!({ "agent": agent })
}
SwarmNodeKind::TriggerDeploy { hive, agent }
@ -209,6 +219,11 @@ struct WorkerDeps {
/// see `wanted_writer`. `None` exactly when no swarm queue is configured
/// on this host, same as `queue`.
wanted: Option<std::sync::Arc<wanted::WantedWriter>>,
/// Client-server API base of the swarm's homeserver, for the node that
/// mints agents' accounts on it. `None` when
/// `matrix_account::DEFAULT_HOMESERVER_ENV` is unset, which that node
/// **fails** on rather than skips, same as every other field here.
matrix_homeserver: Option<std::sync::Arc<str>>,
}
/// What [`SwarmNodeKind::SetAgentWanted`] declares a brand-new agent to be.
@ -304,21 +319,13 @@ async fn run_swarm_node(
Err(e) => Outcome::Failed(format!("{e:#}")),
},
},
SwarmNodeKind::MintAgentIdentity { hive, agent } => match deps.agent_ca {
None => Outcome::Failed(
"no agent certificate authority is configured on this host \
(SWARM_CONTROLLER_AGENT_CA_FILE / SWARM_CONTROLLER_AGENT_CA_KEY_FILE unset), \
so this agent has no identity at the swarm secret store"
.to_owned(),
),
Some(authority) => {
match agent_identity::mint_and_verify(&authority, &agent, &hive).await {
Ok(()) => Outcome::Done,
Err(e) => Outcome::Failed(format!("{e:#}")),
}
}
},
SwarmNodeKind::MintAgentIdentity { hive, agent } => {
mint_identity(deps.agent_ca.as_deref(), &agent, &hive).await
}
SwarmNodeKind::MintAgentForgeToken { agent } => mint_forge_token(deps.forge, &agent).await,
SwarmNodeKind::MintAgentMatrixAccount { agent } => {
mint_matrix_account(deps.matrix_homeserver.as_deref(), &agent).await
}
SwarmNodeKind::SetAgentWanted { hive, agent } => match deps.wanted {
None => Outcome::Failed(
"no swarm queue is configured on this host, so no wanted-state \
@ -338,6 +345,29 @@ async fn run_swarm_node(
(builder, outcome)
}
/// The `MintAgentIdentity` arm, lifted out so `run_swarm_node` stays under
/// `clippy::too_many_lines`.
async fn mint_identity(
authority: Option<&agent_identity::Authority>,
agent: &str,
hive: &str,
) -> hive_jobq::scheduler::Outcome {
use hive_jobq::scheduler::Outcome;
let Some(authority) = authority else {
return Outcome::Failed(
"no agent certificate authority is configured on this host \
(SWARM_CONTROLLER_AGENT_CA_FILE / SWARM_CONTROLLER_AGENT_CA_KEY_FILE unset), \
so this agent has no identity at the swarm secret store"
.to_owned(),
);
};
match agent_identity::mint_and_verify(authority, agent, hive).await {
Ok(()) => Outcome::Done,
Err(e) => Outcome::Failed(format!("{e:#}")),
}
}
/// The `MintAgentForgeToken` arm, lifted out so `run_swarm_node` stays under
/// `clippy::too_many_lines`.
async fn mint_forge_token(
@ -359,6 +389,28 @@ async fn mint_forge_token(
}
}
/// The `MintAgentMatrixAccount` arm, lifted out for the same reason.
async fn mint_matrix_account(
homeserver: Option<&str>,
agent: &str,
) -> hive_jobq::scheduler::Outcome {
use hive_jobq::scheduler::Outcome;
// Loud on purpose: an agent quietly created without a matrix account is
// an agent nothing can talk to, and no hive mints one any more.
let Some(base) = homeserver else {
return Outcome::Failed(format!(
"no matrix homeserver configured on this host ({} unset), so no agent \
matrix account can be minted",
matrix_account::DEFAULT_HOMESERVER_ENV
));
};
match matrix_account::agent_token::ensure_agent_matrix_account(base, agent).await {
Ok(()) => Outcome::Done,
Err(e) => Outcome::Failed(format!("{e:#}")),
}
}
/// Declare a brand-new agent at [`NEW_AGENT_WANTED_STATE`] — unless it turns
/// out not to be new: an agent that already has a declaration (other than
/// `Destroyed`, which this treats as reusable) is left alone, so a retried
@ -1513,6 +1565,13 @@ fn declare_agent_job(
agent: agent.to_owned(),
})
.after_ok(create_forge_user);
// The agent's account on the swarm's homeserver. A root of its own:
// creating it needs neither an authelia subject nor a forge user nor a
// repo, and chaining it behind one of those would make an unrelated
// failure look like a matrix failure.
let mint_matrix = b.node(SwarmNodeKind::MintAgentMatrixAccount {
agent: agent.to_owned(),
});
// Declared before the deploy trigger so the pause is visible in the
// wanted-state store before the hive brings the container up — see the
// node's own doc comment for why "before", not just "eventually". Needs
@ -1553,6 +1612,11 @@ fn declare_agent_job(
.after_ok(init_config)
.after_any(mint_identity)
.after_any(mint_forge_token)
// `after_any` for the same reason: the container reads this token
// rather than producing it, so the deploy must not overtake the mint —
// but a host with no homeserver configured must still create agents,
// and only this node fails, by name.
.after_any(mint_matrix)
.after_ok(set_wanted);
vec![create_identity.guid()]
}
@ -1795,6 +1859,57 @@ fn queue_forge_token_mints(
Ok(ids)
}
/// Insert one `MintAgentMatrixAccount` job per agent and return the nodes'
/// ids. `matrix_account::agent_token::spawn`'s periodic pass comes through
/// here, so a backfilled mint is the same node a new agent gets.
fn queue_matrix_account_mints(
sched: &Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>,
agents: Vec<String>,
) -> Result<Vec<hive_jobq::NodeId>> {
let mut sched = sched
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut ids = Vec::with_capacity(agents.len());
for agent in agents {
let queued = sched
.insert_job(None, |b| {
vec![
b.node(SwarmNodeKind::MintAgentMatrixAccount { agent })
.guid(),
]
})
.map_err(|e| anyhow::anyhow!("{e}"))?;
ids.extend(queued);
}
Ok(ids)
}
/// The homeserver agents' own matrix accounts are minted on. Unset is this
/// controller creating agents with no matrix account, which the mint node
/// reports by name rather than `main` refusing to start.
fn configured_matrix_homeserver() -> Option<Arc<str>> {
matrix_account::configured_default_homeserver()
.filter(|v| !v.is_empty())
.map(Arc::from)
}
/// Start the matrix-account backfill when a homeserver is configured. Lifted
/// out of `main` for `clippy::too_many_lines`.
fn spawn_matrix_account_backfill(
jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
homeserver: Option<Arc<str>>,
) {
let Some(base) = homeserver else {
return;
};
let sched = Arc::clone(jobq);
matrix_account::agent_token::spawn(base, move |agents| {
if let Err(e) = queue_matrix_account_mints(&sched, agents) {
tracing::warn!(error = %format!("{e:#}"), "agent matrix accounts: queueing failed");
}
});
}
/// Check an agent's forge token now, and mint one if it is missing or stale.
///
/// The periodic pass (`forge::agent_token::spawn`) does the same every five
@ -2167,12 +2282,14 @@ async fn main() -> Result<()> {
queue: status.as_ref().map(|s| s.queue_client()),
agent_ca: load_agent_authority(),
wanted: wanted_writer(status.as_ref()),
matrix_homeserver: configured_matrix_homeserver(),
};
let jobq = Arc::new(Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),
)));
spawn_matrix_account_backfill(&jobq, deps.matrix_homeserver.clone());
spawn_jobq_worker(Arc::clone(&jobq), deps);
// Bound to a named variable, not `_` — dropping the provider stops its
// `PeriodicReader`, so it must live as long as `main` does (which it
@ -2844,6 +2961,7 @@ mod tests {
queue: None,
agent_ca: None,
wanted: None,
matrix_homeserver: None,
};
let runner =
hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| {
@ -2898,6 +3016,7 @@ mod tests {
queue: None,
agent_ca: None,
wanted: None,
matrix_homeserver: None,
};
let runner =
hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| {
@ -2952,6 +3071,7 @@ mod tests {
queue: None,
agent_ca: None,
wanted: None,
matrix_homeserver: None,
};
let runner =
hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| {
@ -3004,6 +3124,7 @@ mod tests {
queue: None,
agent_ca: None,
wanted: None,
matrix_homeserver: None,
};
let runner =
hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| {
@ -3242,6 +3363,92 @@ mod tests {
assert_eq!(kind.data(1)["agent"], "atlas");
}
#[test]
fn a_matrix_account_node_renders_the_agent() {
use hive_jobq_wire::WireNode as _;
let kind = SwarmNodeKind::MintAgentMatrixAccount {
agent: "atlas".to_owned(),
};
assert_eq!(kind.label(), "mint_agent_matrix_account");
assert_eq!(kind.data(1)["agent"], "atlas");
}
/// Agent creation mints the matrix account as a root of its own, and the
/// deploy waits for it without being cancelled by it: a host with no
/// homeserver configured must still deploy the agent.
#[tokio::test]
async fn the_matrix_account_mint_is_a_root_and_does_not_block_the_deploy() {
use hive_jobq_wire::WireNode as _;
let (state, sched) = state_with_roster();
let _queued = super::create_agent(
axum::extract::State(state),
axum::Json(super::CreateAgentRequest {
name: "atlas".to_owned(),
hive: "pr1ma".to_owned(),
}),
)
.await
.expect("a hive in the roster must be accepted");
let guard = sched
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let graph = guard.graph();
let find = |label: &str| {
graph
.nodes()
.find(|n| n.payload.label() == label)
.unwrap_or_else(|| panic!("the graph holds a {label} node"))
};
let mint = find("mint_agent_matrix_account");
assert!(mint.deps.is_empty(), "the mint waits for nothing");
let when = find("trigger_deploy")
.deps
.iter()
.find_map(|d| match d {
hive_jobq::Dep::Node { id, when } if *id == mint.id => Some(*when),
_ => None,
})
.expect("the deploy waits for the matrix mint");
assert!(
when.accepts(hive_jobq::TerminalState::Failed),
"a host with no homeserver must not cancel the deploy; this edge \
has to be `after_any`, not `after_ok`"
);
}
/// The backfill's queueing: one mint node per agent, nothing else.
#[test]
fn a_queued_matrix_mint_is_one_node_per_agent() {
use hive_jobq_wire::WireNode as _;
let sched = std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),
));
let ids = super::queue_matrix_account_mints(&sched, vec!["a".to_owned(), "b".to_owned()])
.expect("two jobs insert");
assert_eq!(ids.len(), 2);
let guard = sched
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut agents: Vec<String> = guard
.graph()
.nodes()
.map(|n| {
assert_eq!(n.payload.label(), "mint_agent_matrix_account");
n.payload.data(n.id.get())["agent"]
.as_str()
.expect("agent is a string")
.to_owned()
})
.collect();
agents.sort();
assert_eq!(agents, ["a", "b"]);
}
/// The manual route and the periodic pass both go through
/// `queue_forge_token_mints`, so this is the assertion that a backfilled
/// mint is the same pair of nodes agent creation inserts: the forge user,

View file

@ -1,5 +1,13 @@
//! Give one agent an external matrix account: put the credential in the
//! swarm's secret store, then tell that agent's hive it is there.
//! An agent's matrix accounts, from this daemon's two sides of them.
//!
//! **The internal one** — [`agent_token`] — is the agent's own `main` account on
//! the swarm's homeserver, minted with the swarm's appservice token and stored
//! where the agent's daemon reads it. See docs/swarm/credentials.md for why
//! `main` is the one name [`put_matrix_account`] refuses to write.
//!
//! **The external one** — [`put_matrix_account`] and everything under it — is
//! an account somewhere else that an operator hands us a credential for: put it
//! in the swarm's secret store, then tell that agent's hive it is there.
//!
//! The hive end is `hive-c0re/src/workers/credential.rs`, which reads the
//! value under its own identity and writes it into the agent's state dir. The
@ -31,6 +39,8 @@ use utoipa::ToSchema;
use super::{AppState, error_problem, swarm_hive};
pub mod agent_token;
fn default_mode() -> String {
"token".to_owned()
}
@ -41,9 +51,9 @@ fn default_mode() -> String {
/// when a caller omits one. Read via [`configured_default_homeserver`], not
/// directly — see that fn's doc.
///
/// Not yet consulted by [`put_matrix_account`]: `homeserver_or_configured_default`
/// below exists for a later slice of this homeserver-default rollout to
/// call; this one only wires the config through.
/// Also the homeserver [`agent_token`] mints agents' own accounts on. Not yet
/// consulted by [`put_matrix_account`]: `homeserver_or_configured_default`
/// below exists for a later slice of this homeserver-default rollout to call.
pub(crate) const DEFAULT_HOMESERVER_ENV: &str = "SWARM_CONTROLLER_MATRIX_HOMESERVER_URL";
/// `caller`'s own homeserver, or `default` when the caller left it unset.
@ -250,11 +260,11 @@ pub async fn put_matrix_account(
Ok(Json(PutMatrixAccountResponse { user_id }))
}
/// Whether `account` is the hive-internal name every hive declares per
/// agent (`nix/agent-modules/matrix.nix`) — see the call site's own comment
/// for why this route must never write one.
/// Whether `account` is the agent's own account, which [`agent_token`] mints
/// and `nix/agent-modules/matrix.nix` declares per agent — see the call site's
/// own comment for why this route must never write one.
fn is_reserved_account(account: &str) -> bool {
account == "main"
account == agent_token::ACCOUNT
}
/// Token-mode's only requirement: a token was actually given. Split out of

View file

@ -0,0 +1,357 @@
//! Each agent's own account on the swarm's homeserver: created here with the
//! **swarm's** appservice token, stored at `swarm/agents/<agent>/matrix/main`,
//! and pulled from there by the agent's matrix daemon itself.
//!
//! The appservice token is minted inside the matrix container and published
//! to `swarm_secret_client::matrix::swarm_appservice_token_path`, which only
//! matrix-ctl and this daemon may read. No hive holds it, and no hive mints an
//! agent's account any more.
//!
//! ⚠️ **Every mint replaces the agent's live token.** Both calls pin the
//! device id `hyperhive-<agent>`, so a login for an existing account replaces
//! that device's token: the one in the store before, or the file token a hive
//! minted. That is why the decision reads the stored token back with `whoami`
//! ([`classify`]) instead of only checking that the store holds something, and
//! why a failed read never mints ([`Observed::Unknown`]).
//!
//! Two callers insert the same `MintAgentMatrixAccount` job node: agent
//! creation, and [`spawn`]'s pass at start and every [`RECONCILE_INTERVAL`]
//! over every agent that holds a store identity — the same roster and shape as
//! `crate::forge::agent_token`.
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use swarm_matrix_client::{self as homeserver, Whoami};
use swarm_secret_client::{SecretStore, client::DEFAULT_CERT_MOUNT, matrix, policy};
/// The account name every agent's own matrix credential is stored under.
///
/// `main` is what `nix/agent-modules/matrix.nix` declares per agent and what
/// the agent's daemon reads first. ⚠️ `PUT .../matrix-accounts/{account}`
/// refuses this exact name (`super::is_reserved_account`): that route stores
/// an **external** account an operator supplies, and one called `main` would
/// overwrite this. Two writers, one reserved name, and the reservation is what
/// keeps them apart.
pub const ACCOUNT: &str = "main";
/// How often [`spawn`] re-checks every agent's account.
const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5);
/// Why an account's token has to be (re)minted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MintReason {
/// The store holds nothing for this agent: a new agent, or one whose
/// account a hive created before the swarm did this.
NotStored,
/// The homeserver does not know the stored token: another login on the
/// same device replaced it.
Revoked,
/// The stored token is live but belongs to another account.
OtherUser,
}
/// What one look at an agent's stored token found.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Probe {
/// Nothing at the agent's path.
NotStored,
/// The homeserver's verdict on the stored token.
Whoami(Whoami),
}
/// What to do about one agent's account.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
/// The stored token is a live token for this agent's own account.
Keep,
/// Mint and store a new one.
Mint(MintReason),
}
/// What one pass found for one agent.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Observed {
/// Both reads worked, and this is what [`classify`] made of them.
Decided(Decision),
/// A read failed. Nothing is known, so nothing is done.
Unknown,
}
/// The localpart of a full user id, `@<localpart>:<server>`.
fn localpart(user_id: &str) -> Option<&str> {
user_id.strip_prefix('@')?.split_once(':').map(|(l, _)| l)
}
/// Decide what to do about `agent`'s account from what its stored token is.
pub fn classify(agent: &str, probe: &Probe) -> Decision {
match probe {
Probe::NotStored => Decision::Mint(MintReason::NotStored),
Probe::Whoami(Whoami::UnknownToken) => Decision::Mint(MintReason::Revoked),
Probe::Whoami(Whoami::User(user)) if localpart(user) == Some(agent) => Decision::Keep,
Probe::Whoami(Whoami::User(_)) => Decision::Mint(MintReason::OtherUser),
}
}
/// The agents a pass mints for.
///
/// [`Observed::Unknown`] never mints: a homeserver or store outage must not
/// turn into a new token for every agent, each of which kills the one the
/// agent is running on.
pub fn plan(observed: &[(String, Observed)]) -> Vec<String> {
observed
.iter()
.filter(|(_, o)| matches!(o, Observed::Decided(Decision::Mint(_))))
.map(|(agent, _)| agent.clone())
.collect()
}
/// The swarm appservice token, or an error naming why there is none.
async fn appservice_token(store: &SecretStore) -> Result<String> {
let path = matrix::swarm_appservice_token_path()?;
let stored: Option<matrix::Credential> = store
.read_optional(&path)
.await
.with_context(|| format!("reading {path}"))?;
match stored {
Some(c) if !c.value.trim().is_empty() => Ok(c.value.trim().to_owned()),
_ => bail!(
"nothing at {path}: the matrix container has not published the swarm \
appservice token (swarm-matrix-appservice-publish)"
),
}
}
/// What the store and the homeserver say about `agent`'s stored token.
async fn probe(
store: &SecretStore,
http: &reqwest::Client,
base: &str,
agent: &str,
) -> Result<Probe> {
let path = matrix::account_path(agent, ACCOUNT)?;
let stored: Option<matrix::Credential> = store
.read_optional(&path)
.await
.with_context(|| format!("reading {path}"))?;
let Some(stored) = stored else {
return Ok(Probe::NotStored);
};
let verdict = homeserver::whoami(http, base, stored.value.trim())
.await
.with_context(|| format!("asking the homeserver about the token at {path}"))?;
Ok(Probe::Whoami(verdict))
}
/// Make sure `agent` holds a live token for its own account on the swarm's
/// homeserver at `base`, creating the account or logging in to it when it
/// does not. The whole job of the `MintAgentMatrixAccount` node.
///
/// Reads the new token back with `whoami` before reporting success, so the
/// node does not report success on a write nobody has read. A crash between
/// the login and the write leaves a dead token in the store, which the next
/// pass classifies as [`MintReason::Revoked`].
///
/// # Errors
/// When the store or the homeserver refuses a step, the swarm appservice
/// token has not been published, or the new token authenticates as someone
/// else.
pub async fn ensure_agent_matrix_account(base: &str, agent: &str) -> Result<()> {
let store = crate::store::connect()
.await
.context("logging in to the swarm secret store")?;
let as_token = appservice_token(&store).await?;
let http = homeserver::client()?;
let reason = match classify(agent, &probe(&store, &http, base, agent).await?) {
Decision::Keep => {
tracing::debug!(agent, "agent matrix account is current; left as it is");
return Ok(());
}
Decision::Mint(reason) => reason,
};
let token = match homeserver::register(&http, base, agent, &as_token).await? {
homeserver::Registered::Token(token) => token,
// An agent minted before, or one a hive created back when hives did
// this: log in as the appservice on the same device instead.
homeserver::Registered::AlreadyExists => {
homeserver::appservice_login(&http, base, agent, &as_token).await?
}
};
let path = matrix::account_path(agent, ACCOUNT)?;
store
.write(
&path,
&matrix::Credential {
value: token.clone(),
homeserver: Some(base.to_owned()),
},
)
.await
.with_context(|| format!("storing {agent}'s matrix token at {path}"))?;
match homeserver::whoami(&http, base, &token)
.await
.with_context(|| format!("using {agent}'s new matrix token"))?
{
Whoami::User(user) if localpart(&user) == Some(agent) => {}
_ => bail!("{agent}'s new matrix token does not authenticate as {agent}"),
}
tracing::info!(agent, ?reason, %path, "agent matrix account token minted and stored");
Ok(())
}
/// One pass: every agent holding a store identity, observed.
///
/// Fails as a whole, rather than per agent, when the swarm appservice token is
/// not published: every mint would fail on it, and one message says so.
async fn observe_all(base: &str) -> Result<Vec<(String, Observed)>> {
let store = crate::store::connect()
.await
.context("logging in to the swarm secret store")?;
appservice_token(&store).await?;
let http = homeserver::client()?;
let roles = store
.list_cert_roles(DEFAULT_CERT_MOUNT)
.await
.context("listing the store's cert-auth roles")?;
let mut observed = Vec::new();
for agent in policy::agents_from_role_names(&roles) {
let o = match probe(&store, &http, base, &agent).await {
Ok(p) => Observed::Decided(classify(&agent, &p)),
Err(e) => {
tracing::warn!(agent, error = %format!("{e:#}"), "agent matrix account: read failed");
Observed::Unknown
}
};
observed.push((agent, o));
}
Ok(observed)
}
/// Check every agent's account now and every [`RECONCILE_INTERVAL`] after, and
/// hand the agents that need a token to `enqueue`, which inserts a
/// `MintAgentMatrixAccount` node for each.
///
/// The roster is the store's `hive-agent-*` cert-auth roles: exactly the
/// agents that can read what the node writes. A pass that fails is logged and
/// retried on the next tick; it never stops the daemon.
pub fn spawn(base: Arc<str>, enqueue: impl Fn(Vec<String>) + Send + 'static) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
loop {
ticker.tick().await;
match observe_all(&base).await {
Ok(observed) => {
let agents = plan(&observed);
if agents.is_empty() {
tracing::debug!(
checked = observed.len(),
"agent matrix accounts: all current"
);
} else {
tracing::info!(
checked = observed.len(),
minting = agents.len(),
"agent matrix accounts: queueing mints"
);
enqueue(agents);
}
}
Err(e) => tracing::warn!(
error = %format!("{e:#}"),
retry_in_s = RECONCILE_INTERVAL.as_secs(),
"agent matrix accounts: pass failed; retrying next tick"
),
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
fn user(id: &str) -> Probe {
Probe::Whoami(Whoami::User(id.to_owned()))
}
#[test]
fn a_live_token_for_the_agent_is_kept() {
assert_eq!(classify("atlas", &user("@atlas:t.local")), Decision::Keep);
}
#[test]
fn nothing_stored_mints() {
assert_eq!(
classify("atlas", &Probe::NotStored),
Decision::Mint(MintReason::NotStored)
);
}
#[test]
fn a_replaced_token_mints() {
// The shape a hive re-login on the same device leaves behind.
assert_eq!(
classify("atlas", &Probe::Whoami(Whoami::UnknownToken)),
Decision::Mint(MintReason::Revoked)
);
}
#[test]
fn a_token_for_another_account_mints() {
// Localpart compared whole, so a prefix of the agent's name is not it.
for other in ["@argus:t.local", "@atlas2:t.local", "@atla:t.local"] {
assert_eq!(
classify("atlas", &user(other)),
Decision::Mint(MintReason::OtherUser),
"{other}"
);
}
}
#[test]
fn a_pass_mints_only_what_it_knows_needs_one() {
let observed = [
("a".to_owned(), Observed::Decided(Decision::Keep)),
(
"b".to_owned(),
Observed::Decided(Decision::Mint(MintReason::NotStored)),
),
("c".to_owned(), Observed::Unknown),
(
"d".to_owned(),
Observed::Decided(Decision::Mint(MintReason::Revoked)),
),
];
assert_eq!(plan(&observed), ["b", "d"]);
}
#[test]
fn an_outage_plans_nothing() {
// Each mint replaces the token an agent is running on, so a pass that
// could not read must not mint for everyone.
let observed = [
("a".to_owned(), Observed::Unknown),
("b".to_owned(), Observed::Unknown),
];
assert!(plan(&observed).is_empty());
}
#[test]
fn the_published_path_is_the_hiveless_one_the_agent_reads() {
// The literal is the point, and the absence of a hive segment is the
// half of it that was decided: an agent's account follows the agent.
assert_eq!(
matrix::account_path("atlas", ACCOUNT).expect("a plain name is legal"),
"swarm/agents/atlas/matrix/main"
);
}
#[test]
fn the_account_name_is_the_one_the_agent_module_declares() {
// `nix/agent-modules/matrix.nix` renders this as a `matrixAccounts`
// key and nothing wires an override across.
assert_eq!(ACCOUNT, "main");
}
}

View file

@ -0,0 +1,13 @@
[package]
name = "swarm-matrix-client"
version.workspace = true
readme = "README.md"
edition.workspace = true
[dependencies]
anyhow.workspace = true
reqwest.workspace = true
serde_json.workspace = true
[lints]
workspace = true

View file

@ -0,0 +1,17 @@
# swarm-matrix-client
The appservice calls against the swarm's homeserver, shared by the two binaries
that make them: `swarm-matrix-ctl` (inside the matrix container, for a hive's
sender account) and `swarm-controller` (for each agent's account, with the
swarm's own appservice token).
Three calls, all client-server API: register an account as the appservice, log
in to an existing one as the appservice, and ask a token who it is. Both mints
pin the device id `hyperhive-<localpart>`, so a second login **replaces** that
device's token instead of adding a device.
## 🩸 A secret is a path, never a value
Every error here 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.

View file

@ -1,4 +1,6 @@
//! The two calls the mint ladder is made of, against the homeserver next door.
//! The appservice calls against the swarm's homeserver: register, log in, and
//! ask a token who it is. Shared by `swarm-matrix-ctl` and `swarm-controller`;
//! see the README for why both mint through the same device id.
//!
//! 🩸 **Every error in this module is built from the response's `status` and
//! its `errcode`, never its body.** A successful `/register` or `/login` body
@ -8,8 +10,8 @@
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.
/// Every call here is one client-server API round trip; a slow one is a broken
/// one.
const TIMEOUT_SECS: u64 = 10;
/// Bytes of the throwaway password `/register` is given.
@ -21,11 +23,11 @@ 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.
/// An enum rather than a string match on the error text: `M_USER_IN_USE` is an
/// *expected* answer — a registration's own `sender_localpart` is created by
/// the homeserver at startup, and an agent minted once before already exists —
/// 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),
@ -68,7 +70,7 @@ pub async fn register(
// request carries the as_token.
"type": "m.login.application_service",
"username": localpart,
"password": random_password()?,
"password": random_hex(PASSWORD_BYTES)?,
"device_id": device_id(localpart),
"initial_device_display_name": format!("hyperhive ({localpart})"),
"inhibit_login": false,
@ -140,6 +142,52 @@ pub async fn appservice_login(
access_token(&json)
}
/// What the homeserver says a token is.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Whoami {
/// A live token, and the full user id it authenticates as.
User(String),
/// `M_UNKNOWN_TOKEN`: the homeserver does not know this token, because a
/// later login on the same device replaced it or it was revoked.
UnknownToken,
}
/// Ask the homeserver who `token` is.
///
/// # Errors
/// When the request cannot be sent, the response will not decode, or the
/// homeserver refuses with anything other than `M_UNKNOWN_TOKEN`. That one is
/// [`Whoami::UnknownToken`], because it is a verdict about the token and the
/// rest are not.
pub async fn whoami(client: &reqwest::Client, base: &str, token: &str) -> Result<Whoami> {
let resp = client
.get(format!("{base}/_matrix/client/v3/account/whoami"))
.bearer_auth(token)
.send()
.await
.context("GET /account/whoami: sending the request")?;
let status = resp.status();
let json = resp
.json::<serde_json::Value>()
.await
.context("GET /account/whoami: decoding the response as JSON")?;
whoami_verdict(status, &json)
}
/// [`whoami`]'s reading of a response, apart from the IO.
fn whoami_verdict(status: reqwest::StatusCode, json: &serde_json::Value) -> Result<Whoami> {
if status.is_success() {
return json["user_id"]
.as_str()
.map(|u| Whoami::User(u.to_owned()))
.context("the homeserver's whoami response carried no `user_id`");
}
if errcode(json) == Some("M_UNKNOWN_TOKEN") {
return Ok(Whoami::UnknownToken);
}
bail!("the homeserver refused whoami: {}", why(status, json))
}
/// The device every token this binary mints is pinned to.
fn device_id(localpart: &str) -> String {
format!("hyperhive-{localpart}")
@ -195,18 +243,18 @@ fn why(status: reqwest::StatusCode, json: &serde_json::Value) -> String {
}
}
/// A throwaway password for [`register`], as hex.
/// `bytes` random bytes from `/dev/urandom`, as hex: [`register`]'s
/// throwaway password, and the appservice tokens `swarm-matrix-ctl` mints.
///
/// 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.
/// From `/dev/urandom` directly rather than through an RNG crate: 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> {
pub fn random_hex(bytes: usize) -> Result<String> {
use std::io::Read as _;
let mut buf = [0u8; PASSWORD_BYTES];
let mut buf = vec![0u8; bytes];
std::fs::File::open("/dev/urandom")
.context("opening /dev/urandom")?
.read_exact(&mut buf)
@ -275,11 +323,48 @@ mod tests {
assert!(!format!("{e}").contains("@hive:t.local"), "{e}");
}
#[test]
fn whoami_reads_the_user_of_a_live_token() {
let json =
serde_json::json!({ "user_id": "@atlas:t.local", "device_id": "hyperhive-atlas" });
assert_eq!(
whoami_verdict(reqwest::StatusCode::OK, &json).expect("a live token"),
Whoami::User("@atlas:t.local".to_owned())
);
}
#[test]
fn a_replaced_token_is_a_verdict_and_not_an_error() {
// The arm a mint decides on: this is how a token some other login on
// the same device replaced looks.
let json = serde_json::json!({ "errcode": "M_UNKNOWN_TOKEN", "error": "Unknown token" });
assert_eq!(
whoami_verdict(reqwest::StatusCode::UNAUTHORIZED, &json).expect("a verdict"),
Whoami::UnknownToken
);
}
#[test]
fn any_other_refusal_is_an_error_so_nobody_mints_on_it() {
// The control for the arm above: a 401 with another code, or a 5xx,
// says nothing about the token, and a caller that minted on it would
// rotate every token during an outage.
for (status, json) in [
(
reqwest::StatusCode::UNAUTHORIZED,
serde_json::json!({ "errcode": "M_MISSING_TOKEN" }),
),
(reqwest::StatusCode::BAD_GATEWAY, serde_json::json!({})),
] {
assert!(whoami_verdict(status, &json).is_err(), "{status}");
}
}
#[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");
let pw = random_hex(PASSWORD_BYTES).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

@ -14,8 +14,10 @@ anyhow.workspace = true
# 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 appservice calls, shared with `swarm-controller`, which mints agents'
# accounts through the same device id.
swarm-matrix-client.workspace = true
# The agreement this binary is one end of: where the credential lives, what the
# object at that path holds, and the `BAO_*` spellings the unit sets.
swarm-secret-client.workspace = true

View file

@ -0,0 +1,279 @@
//! `swarm-matrix-ctl appservice` — the **swarm's** own appservice
//! registration: minted here, loaded by the homeserver beside us, and published
//! to the one store path `swarm-controller` reads it from.
//!
//! Two verbs, because they have opposite failure rules:
//!
//! - [`render`] runs before tuwunel and touches nothing but this container's
//! state dir. tuwunel loads the file it writes through `LoadCredential`, and
//! a missing credential source fails the homeserver's start, so this half
//! must not be able to fail on a network.
//! - [`publish`] runs after it and needs the store. A sealed store delays it
//! and nothing else.
//!
//! "Only once": the token file in the state dir is the record. [`render`]
//! mints only when it is absent and re-renders from it every time; [`publish`]
//! writes the store only when the store's copy differs.
//!
//! This registration's sender is promoted to homeserver admin at boot
//! (`nix/host-modules/hive-matrix.nix`), which is why its token goes to
//! `swarm_secret_client::matrix::swarm_appservice_token_path` — a path no
//! hive's policy reaches — and to nowhere else.
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use swarm_secret_client::{
SecretStore,
client::{DEFAULT_CERT_MOUNT, Settings},
matrix,
};
use crate::registration;
/// This container's state dir for the registration and its two tokens.
const ENV_DIR: &str = "MATRIX_APPSERVICE_DIR";
/// The registration's `sender_localpart`: the account the homeserver creates
/// for it and the one `admin_execute` promotes.
const ENV_SENDER: &str = "MATRIX_APPSERVICE_SENDER";
/// The user namespace regex, rendered by nix beside the hive registration's.
const ENV_USER_REGEX: &str = "MATRIX_APPSERVICE_USER_REGEX";
/// Role on the store's `cert` auth mount that [`publish`] logs in with.
const ENV_CERT_ROLE: &str = "MATRIX_APPSERVICE_CERT_ROLE";
/// The registration's `id`. Distinct from the hive registration's
/// (`hyperhive`): tuwunel refuses two registrations with one id.
const ID: &str = "swarm";
/// File names inside [`ENV_DIR`]. `REGISTRATION` is what tuwunel loads.
const AS_TOKEN: &str = "as-token";
const HS_TOKEN: &str = "hs-token";
const REGISTRATION: &str = "swarm.yaml";
/// Random bytes per token, as the hive registration's renderer uses.
const TOKEN_BYTES: usize = 32;
/// Read a required variable.
fn required(get: &impl Fn(&str) -> Option<String>, var: &'static str) -> Result<String> {
get(var)
.filter(|v| !v.is_empty())
.with_context(|| format!("{var} is unset or empty"))
}
/// Mint the tokens if absent and render the registration from them.
///
/// # Errors
/// If a variable is missing, or the state dir cannot be read or written.
pub fn render() -> Result<()> {
let get = |k: &str| std::env::var(k).ok();
let dir = PathBuf::from(required(&get, ENV_DIR)?);
let sender = required(&get, ENV_SENDER)?;
let regex = required(&get, ENV_USER_REGEX)?;
render_into(&dir, &sender, &regex)?;
tracing::info!(path = %dir.join(REGISTRATION).display(), "rendered the swarm appservice registration");
Ok(())
}
/// [`render`] against an explicit directory, so a test can run it twice.
fn render_into(dir: &Path, sender: &str, regex: &str) -> Result<()> {
let as_token = existing_or_minted(&dir.join(AS_TOKEN))?;
let hs_token = existing_or_minted(&dir.join(HS_TOKEN))?;
write_secret(
&dir.join(REGISTRATION),
&registration_yaml(sender, regex, &as_token, &hs_token),
)
}
/// The token at `path`, minting and writing one first when there is none.
fn existing_or_minted(path: &Path) -> Result<String> {
match std::fs::read_to_string(path) {
Ok(t) if !t.trim().is_empty() => return Ok(t.trim().to_owned()),
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
}
let token = swarm_matrix_client::random_hex(TOKEN_BYTES)?;
write_secret(path, &token)?;
tracing::info!(path = %path.display(), "minted a swarm appservice token");
Ok(token)
}
/// Write `contents` to `path` as a `0600` file, through a rename so a reader
/// never sees half of it.
fn write_secret(path: &Path, contents: &str) -> Result<()> {
let tmp = path.with_extension("tmp");
let _ = std::fs::remove_file(&tmp);
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp)
.with_context(|| format!("creating {}", tmp.display()))?;
f.write_all(contents.as_bytes())
.and_then(|()| f.sync_all())
.with_context(|| format!("writing {}", tmp.display()))?;
std::fs::rename(&tmp, path).with_context(|| format!("renaming onto {}", path.display()))
}
/// The registration, in the shape `hive-matrix.nix` renders the hive's.
///
/// `exclusive: false` for that file's reason: an exclusive namespace does not
/// widen what this appservice may do, it refuses everyone else — and the hive
/// registration covers the same names until it is retired.
fn registration_yaml(sender: &str, regex: &str, as_token: &str, hs_token: &str) -> String {
format!(
"id: {ID}\n\
url: null\n\
sender_localpart: {sender}\n\
rate_limited: false\n\
namespaces:\n \
users:\n \
- exclusive: false\n \
regex: '{regex}'\n \
aliases: []\n \
rooms: []\n\
as_token: {as_token}\n\
hs_token: {hs_token}\n"
)
}
/// Whether the store needs the local token written to it.
fn needs_publish(stored: Option<&matrix::Credential>, local: &str) -> bool {
stored.is_none_or(|c| c.value.trim() != local)
}
/// Write the rendered registration's `as_token` to the store, unless the store
/// already holds it.
///
/// # Errors
/// If a variable is missing, the registration has not been rendered, or the
/// store refuses the login, the read or the write.
pub async fn publish() -> Result<()> {
let get = |k: &str| std::env::var(k).ok();
let dir = PathBuf::from(required(&get, ENV_DIR)?);
let cert_role = required(&get, ENV_CERT_ROLE)?;
let registration = dir.join(REGISTRATION);
let local = registration::as_token(&registration.to_string_lossy())?;
let settings = Settings::from_env().context("reading the store's BAO_* environment")?;
let store = SecretStore::connect(&settings, &cert_role, DEFAULT_CERT_MOUNT)
.await
.with_context(|| {
format!("logging in to the swarm secret store as cert role {cert_role}")
})?;
let path = matrix::swarm_appservice_token_path()?;
let stored: Option<matrix::Credential> = store
.read_optional(&path)
.await
.with_context(|| format!("reading {path}"))?;
if !needs_publish(stored.as_ref(), &local) {
tracing::info!(%path, "the swarm appservice token is already published");
return Ok(());
}
store
.write(
&path,
&matrix::Credential {
value: local,
homeserver: None,
},
)
.await
.with_context(|| format!("writing the swarm appservice token to {path}"))?;
tracing::info!(%path, "published the swarm appservice token");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const REGEX: &str = "^@[a-z0-9._=/-]+:t\\.local$";
fn scratch() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"swarm-appservice-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("after the epoch")
.as_nanos()
));
std::fs::create_dir_all(&dir).expect("temp dir");
dir
}
#[test]
fn the_rendered_registration_carries_the_token_it_minted() {
let dir = scratch();
render_into(&dir, "swarm", REGEX).expect("renders");
let token = registration::as_token(&dir.join(REGISTRATION).to_string_lossy())
.expect("the as_token line parses");
assert_eq!(token.len(), TOKEN_BYTES * 2);
assert_eq!(
std::fs::read_to_string(dir.join(AS_TOKEN)).expect("minted"),
token
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_second_render_keeps_the_token() {
// "Only once": a re-mint on every boot would hand the controller a
// token the homeserver no longer loads until publish catches up.
let dir = scratch();
render_into(&dir, "swarm", REGEX).expect("first");
let first = std::fs::read_to_string(dir.join(REGISTRATION)).expect("rendered");
render_into(&dir, "swarm", REGEX).expect("second");
let second = std::fs::read_to_string(dir.join(REGISTRATION)).expect("rendered");
assert_eq!(first, second);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_registration_is_the_swarms_and_not_the_hives() {
let y = registration_yaml("swarm", REGEX, "aa", "bb");
assert!(y.starts_with("id: swarm\n"), "{y}");
assert!(y.contains("\nsender_localpart: swarm\n"), "{y}");
assert!(y.contains("\n - exclusive: false\n"), "{y}");
assert!(y.contains(&format!("regex: '{REGEX}'")), "{y}");
assert!(y.contains("\nurl: null\n"), "{y}");
}
#[test]
fn the_registration_and_tokens_are_owner_only() {
use std::os::unix::fs::PermissionsExt as _;
let dir = scratch();
render_into(&dir, "swarm", REGEX).expect("renders");
for f in [AS_TOKEN, HS_TOKEN, REGISTRATION] {
let mode = std::fs::metadata(dir.join(f))
.expect("exists")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "{f}");
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn publish_writes_only_what_the_store_lacks() {
let same = matrix::Credential {
value: "aa".to_owned(),
homeserver: None,
};
assert!(!needs_publish(Some(&same), "aa"));
assert!(needs_publish(None, "aa"));
let other = matrix::Credential {
value: "bb".to_owned(),
homeserver: None,
};
assert!(needs_publish(Some(&other), "aa"));
}
#[test]
fn every_variable_is_scoped_to_the_verb() {
for var in [ENV_DIR, ENV_SENDER, ENV_USER_REGEX, ENV_CERT_ROLE] {
assert!(var.starts_with("MATRIX_APPSERVICE_"), "{var}");
}
}
}

View file

@ -7,18 +7,19 @@
//! 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.
//! [`mint`] publishes a hive's appservice sender token to the swarm's secret
//! store, once. [`appservice`] mints the **swarm's** own appservice
//! registration and publishes its token for `swarm-controller`.
//!
//! It lives in the container because the appservice `as_token` that authorises
//! the mint is *already* there — the registration tuwunel loads is bind-mounted
//! in — so no second holder of that secret is created.
//!
//! 🩸 **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
//! logs is the store path; see `swarm_matrix_client`'s module doc for the same rule
//! applied to error messages.
mod homeserver;
mod appservice;
mod mint;
mod registration;
@ -44,6 +45,20 @@ enum Command {
/// no flags, because a systemd `Environment=` block is what a nix module
/// can render and a command line full of paths is not.
Mint,
/// The swarm's own appservice registration, whose sender is the
/// homeserver's admin account. Configured by `MATRIX_APPSERVICE_*`.
#[command(subcommand)]
Appservice(Appservice),
}
#[derive(Debug, Subcommand)]
enum Appservice {
/// Mint the tokens when absent and render the registration tuwunel loads.
/// Local only: it runs before the homeserver and must not need a network.
Render,
/// Write the rendered `as_token` to the swarm secret store when the
/// store's copy differs.
Publish,
}
#[tokio::main]
@ -57,6 +72,8 @@ async fn main() -> Result<()> {
match Cli::parse().command {
Command::Mint => mint::run().await,
Command::Appservice(Appservice::Render) => appservice::render(),
Command::Appservice(Appservice::Publish) => appservice::publish().await,
}
}
@ -80,6 +97,25 @@ mod tests {
/// The control: without it the case above passes on a parser that accepts
/// anything.
/// The two units name these verbs, same reason as the test above.
#[test]
fn the_appservice_verbs_are_spelled_the_way_the_units_invoke_them() {
let cli =
Cli::try_parse_from(["swarm-matrix-ctl", "appservice", "render"]).expect("a verb");
assert!(matches!(
cli.command,
Command::Appservice(Appservice::Render)
));
let cli =
Cli::try_parse_from(["swarm-matrix-ctl", "appservice", "publish"]).expect("a verb");
assert!(matches!(
cli.command,
Command::Appservice(Appservice::Publish)
));
Cli::try_parse_from(["swarm-matrix-ctl", "appservice"])
.expect_err("a sub-verb is required");
}
#[test]
fn an_unknown_verb_is_refused() {
Cli::try_parse_from(["swarm-matrix-ctl", "conjure"])

View file

@ -21,7 +21,9 @@ use swarm_secret_client::{
matrix,
};
use crate::{homeserver, registration};
use swarm_matrix_client as homeserver;
use crate::registration;
/// Role on the store's `cert` auth mount to log in with. Its policy is what
/// allows the write below; the certificate the `BAO_*` variables name has to

View file

@ -93,6 +93,28 @@ pub fn appservice_token_path(hive: &str) -> Result<String, Error> {
Ok(format!("{prefix}/matrix/appservice-token"))
}
/// The name segment of the controller's own subtree, and the cert-auth role it
/// logs in under (`swarm-controller`'s `store::CERT_ROLE`).
const CONTROLLER: &str = "swarm-controller";
/// The path holding the **swarm's** appservice token: the one registration
/// on the swarm's homeserver that is not a hive's, whose sender is promoted
/// to homeserver admin at boot.
///
/// The matrix container mints it and publishes it here; `swarm-controller`
/// reads it to create agents' accounts. Under [`Kind::Controller`] because
/// that is the one kind [`crate::policy::render`] grants no hive: every hive
/// reads `agents/*`, its own `hives/<hive>/*` and `services/*`, so under any
/// of those this admin credential would be readable by every hive.
///
/// # Errors
/// Never in practice: the name segment is a constant. The `Result` is
/// [`principal_prefix`]'s.
pub fn swarm_appservice_token_path() -> Result<String, Error> {
let prefix = principal_prefix(Kind::Controller, CONTROLLER)?;
Ok(format!("{prefix}/matrix/appservice-token"))
}
/// What an account's path holds: the token, plus the homeserver it belongs to.
///
/// The homeserver rides with the token rather than on the queue notice that
@ -215,6 +237,39 @@ mod tests {
assert!(matches!(e, Error::PathSegment { kind: "hive", .. }), "{e}");
}
#[test]
fn the_swarm_appservice_token_lands_under_the_controller() {
assert_eq!(
swarm_appservice_token_path().expect("a constant segment"),
"swarm/controller/swarm-controller/matrix/appservice-token"
);
}
#[test]
fn no_hive_policy_reaches_the_swarm_appservice_token() {
// 🩸 The swarm's sender is a homeserver admin, so its token must not
// be under any stanza a hive's policy renders. Checked against the
// rendered document rather than against the prefix, because the
// document is what the store enforces.
let doc = crate::policy::render("pr1ma").expect("a plain name is legal");
// Every stanza is `path "secret/data/<prefix>*" { … }`.
let granted: Vec<&str> = doc
.lines()
.filter_map(|l| l.strip_prefix("path \"secret/data/"))
.filter_map(|p| p.strip_suffix("*\" {"))
.collect();
let reads = |path: &str| granted.iter().any(|g| path.starts_with(g));
let path = swarm_appservice_token_path().expect("a constant segment");
assert!(!reads(&path), "{path} is readable under {granted:?}");
// The control: an agent's account path IS under a hive stanza, so the
// assertion above can fail at all.
let agent = account_path("atlas", "main").expect("legal");
assert!(
reads(&agent),
"{agent} should be readable under {granted:?}"
);
}
#[test]
fn a_segment_cannot_escape_its_own_directory() {
// Each of these is a *different* way to address another agent's tree,