Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85ba45b2de | ||
|
|
89a5dd752c | ||
|
|
ab153bda2f | ||
|
|
2776e121e5 | ||
|
|
9308752a09 | ||
|
|
89aff8d613 |
39 changed files with 2164 additions and 572 deletions
13
Cargo.lock
generated
13
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:**
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
429
hive-matrix-mcp/src/credential.rs
Normal file
429
hive-matrix-mcp/src/credential.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
pub mod accounts;
|
||||
pub mod client;
|
||||
pub mod credential;
|
||||
pub mod handlers;
|
||||
pub mod mcp;
|
||||
pub mod paths;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 ];
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
357
swarm-controller/src/matrix_account/agent_token.rs
Normal file
357
swarm-controller/src/matrix_account/agent_token.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
13
swarm-matrix-client/Cargo.toml
Normal file
13
swarm-matrix-client/Cargo.toml
Normal 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
|
||||
17
swarm-matrix-client/README.md
Normal file
17
swarm-matrix-client/README.md
Normal 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.
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
279
swarm-matrix-ctl/src/appservice.rs
Normal file
279
swarm-matrix-ctl/src/appservice.rs
Normal 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, ®ex)?;
|
||||
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),
|
||||
®istration_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(®istration.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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue