Compare commits
39 changed files with 572 additions and 2164 deletions
13
Cargo.lock
generated
13
Cargo.lock
generated
|
|
@ -1989,7 +1989,6 @@ dependencies = [
|
||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"swarm-secret-client",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|
@ -4914,7 +4913,6 @@ dependencies = [
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"strum",
|
"strum",
|
||||||
"swarm-authelia-bridge-sock",
|
"swarm-authelia-bridge-sock",
|
||||||
"swarm-matrix-client",
|
|
||||||
"swarm-queue-client",
|
"swarm-queue-client",
|
||||||
"swarm-secret-client",
|
"swarm-secret-client",
|
||||||
"time",
|
"time",
|
||||||
|
|
@ -4937,23 +4935,14 @@ dependencies = [
|
||||||
"swarm-queue-client",
|
"swarm-queue-client",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "swarm-matrix-client"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"reqwest",
|
|
||||||
"serde_json",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swarm-matrix-ctl"
|
name = "swarm-matrix-ctl"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
|
"reqwest",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"swarm-matrix-client",
|
|
||||||
"swarm-secret-client",
|
"swarm-secret-client",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ members = [
|
||||||
"swarm-authelia-bridge",
|
"swarm-authelia-bridge",
|
||||||
"swarm-authelia-bridge-sock",
|
"swarm-authelia-bridge-sock",
|
||||||
"swarm-controller",
|
"swarm-controller",
|
||||||
"swarm-matrix-client",
|
|
||||||
"swarm-matrix-ctl",
|
"swarm-matrix-ctl",
|
||||||
"swarm-nats-auth",
|
"swarm-nats-auth",
|
||||||
"swarm-queue-client",
|
"swarm-queue-client",
|
||||||
|
|
@ -102,7 +101,6 @@ hive-priv-sock = { path = "hive-priv-sock" }
|
||||||
hive-sock-client = { path = "hive-sock-client" }
|
hive-sock-client = { path = "hive-sock-client" }
|
||||||
hive-types = { path = "hive-types" }
|
hive-types = { path = "hive-types" }
|
||||||
swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" }
|
swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" }
|
||||||
swarm-matrix-client = { path = "swarm-matrix-client" }
|
|
||||||
swarm-queue-client = { path = "swarm-queue-client" }
|
swarm-queue-client = { path = "swarm-queue-client" }
|
||||||
swarm-secret-client = { path = "swarm-secret-client" }
|
swarm-secret-client = { path = "swarm-secret-client" }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
|
|
||||||
|
|
@ -569,9 +569,8 @@ 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
|
agent has one; a `null` URL with no operator-declared account is the
|
||||||
"this agent has no matrix" state.
|
"this agent has no matrix" state.
|
||||||
|
|
||||||
**First-boot ordering**: a token can arrive after the container comes
|
**First-boot ordering**: hive-c0re provisions the matrix token AFTER
|
||||||
up — a file the hive delivers, or a token the swarm mints into the store.
|
agent containers come up. Without the path-trigger sibling
|
||||||
Without the path-trigger sibling
|
|
||||||
(`systemd.paths.hive-matrix-daemon`, `PathExistsGlob =
|
(`systemd.paths.hive-matrix-daemon`, `PathExistsGlob =
|
||||||
<this agent's state dir>/matrix-token*` — the trailing `*` also catches
|
<this agent's state dir>/matrix-token*` — the trailing `*` also catches
|
||||||
a secondary multi-account token like `matrix-token-ccc`), the daemon
|
a secondary multi-account token like `matrix-token-ccc`), the daemon
|
||||||
|
|
@ -579,9 +578,7 @@ 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
|
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
|
of the token re-fire the service so the daemon comes alive in the
|
||||||
same boot cycle as
|
same boot cycle as
|
||||||
provisioning. A token in the store changes no file, so a store-backed agent
|
provisioning. The same token watcher also drives avatar setting: on a
|
||||||
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
|
restart the daemon re-runs each account's bring-up, which sets the
|
||||||
avatar (see below).
|
avatar (see below).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,7 @@ hivectl agent ruth rebuild
|
||||||
|
|
||||||
You don't need to do anything else. The controller's next pass creates
|
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
|
ruth's forge user and mints her token, and her container fetches it within
|
||||||
about ten minutes. The same identity puts her in the matrix pass too (step
|
about ten minutes. `swarmctl agent mint-forge-token ruth` skips the wait for
|
||||||
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
|
the pass. A hive without a swarm secret store has no path to a forge token
|
||||||
for ruth at all.
|
for ruth at all.
|
||||||
|
|
||||||
|
|
@ -238,6 +237,10 @@ control: [`swarm/ui.md`](../swarm/ui.md).
|
||||||
# Ensure the appservice's sender account exists first
|
# Ensure the appservice's sender account exists first
|
||||||
hivectl matrix sync-admin
|
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)
|
# Invite the operator to the hive Space (and optionally to rooms)
|
||||||
hivectl matrix invite mara
|
hivectl matrix invite mara
|
||||||
hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver'
|
hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver'
|
||||||
|
|
@ -246,12 +249,6 @@ hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver'
|
||||||
hivectl matrix promote-user mara
|
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
|
Swarm SSO creates the human operator's own matrix account instead of
|
||||||
a manual `hivectl` step — see _Swarm SSO_ above (`swarmctl user add`).
|
a manual `hivectl` step — see _Swarm SSO_ above (`swarmctl user add`).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -129,52 +129,31 @@ a token.
|
||||||
the bind-mount path. It reads only `.yaml`/`.yml` entries from there,
|
the bind-mount path. It reads only `.yaml`/`.yml` entries from there,
|
||||||
so the sibling credentials are invisible to it. The `.yaml` suffix on
|
so the sibling credentials are invisible to it. The `.yaml` suffix on
|
||||||
the credential id is what makes this work.
|
the credential id is what makes this work.
|
||||||
5. **hive-c0re** reads the appservice token and creates its own
|
5. **hive-c0re** reads the appservice token and creates each account with
|
||||||
`@hive-<hive>:` account, and the accounts an operator asks for with
|
one `POST /register` typed `m.login.application_service`, persisting
|
||||||
`hivectl matrix create-user`. It never mints the token itself: the value
|
the returned `access_token` to `<agent-state>/matrix-token`. It never
|
||||||
has to be the one the rendered registration names, and only the nix
|
mints the token itself: the value has to be the one the rendered
|
||||||
side writes that.
|
registration names, and only the nix side writes that.
|
||||||
6. **Agents' accounts aren't this hive's.** `swarm-controller` creates each
|
6. **An account that exists but has lost its token file** is re-tokened
|
||||||
one with the swarm's own appservice token (next section), stores its
|
by an appservice `POST /login` — no password and no admin rights
|
||||||
token at `swarm/agents/<agent>/matrix/main`, and the agent's
|
involved. A stored-password login and an admin-room password reset
|
||||||
`hive-matrix-daemon` reads it from there as the agent itself.
|
remain behind that, for accounts created before the appservice existed
|
||||||
|
or named outside its namespace.
|
||||||
### The swarm's appservice, and its admin sender
|
7. **hive-c0re restarts `hive-matrix-daemon`** for the agent
|
||||||
|
immediately after writing the token so the daemon picks up the
|
||||||
A second registration sits beside the hive's: id `swarm`, sender `@swarm`,
|
new credential without waiting for a full container restart. If
|
||||||
the same non-exclusive namespace. It's the swarm's identity on the
|
the restart fails (for example daemon not yet running on first boot)
|
||||||
homeserver, and `swarm-controller` creates every agent's account with it.
|
hive-c0re logs the error as a warning and the `.path`-trigger sibling
|
||||||
tuwunel loads every `.yaml` in `appservice_dir` and refuses only a
|
(`hive-matrix-daemon.path` watching for `matrix-token` appearance)
|
||||||
duplicate `id` or `as_token`, so the two overlapping namespaces coexist.
|
brings the daemon up on the same boot cycle anyway.
|
||||||
|
|
||||||
`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
|
### The appservice's sender account, and why it isn't an admin
|
||||||
|
|
||||||
`@hive-<hive>:<server_name>` is the appservice's own `sender_localpart`,
|
`@hive-<hive>:<server_name>` is the appservice's own `sender_localpart`,
|
||||||
which the homeserver creates itself when it loads the registration — on a
|
which the homeserver creates itself when it loads the registration — on a
|
||||||
zero-user database, inside startup, before the HTTP listener accepts
|
zero-user database, inside startup, before the HTTP listener accepts
|
||||||
anything. It's an **ordinary account**: nothing promotes it; the
|
anything. It's an **ordinary account**: nothing promotes it, and the
|
||||||
homeserver's `admin_execute` promotes only `@swarm`.
|
homeserver runs no `admin_execute` for it.
|
||||||
|
|
||||||
**One account per hive.** The localpart carries the hive's name, so a 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
|
whose hives share a homeserver gives each of them its own identity: the
|
||||||
|
|
@ -206,8 +185,10 @@ when its sender is already an admin. They're swarm-level operations,
|
||||||
rehomed to the swarm tier rather than granted here; from the hive,
|
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
|
`@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
|
admin room's refusal rather than an over-privileged credential that
|
||||||
every other call site would also carry. The swarm's own sender is the
|
every other call site would also carry. The one hive-side path that
|
||||||
admin they need; moving them there is separate work.
|
depends on them is the automatic password recovery for an agent that
|
||||||
|
has lost its stored password — the admin-sender limitation doesn't
|
||||||
|
touch the ordinary appservice re-login above.
|
||||||
|
|
||||||
<details><summary>Upgrading a hive that shared one sender account with every other hive</summary>
|
<details><summary>Upgrading a hive that shared one sender account with every other hive</summary>
|
||||||
|
|
||||||
|
|
@ -258,6 +239,9 @@ restarts, so the first boot after the switch already has both halves.
|
||||||
device that minted it; removing the registration token touches no
|
device that minted it; removing the registration token touches no
|
||||||
device, no account and no session. `login_with_password` stays on, so
|
device, no account and no session. `login_with_password` stays on, so
|
||||||
the password fallback is still there too.
|
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
|
- **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
|
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
|
homeserver no longer promotes it, so a hive built fresh has an
|
||||||
|
|
|
||||||
|
|
@ -54,35 +54,20 @@ strategy for every credential, including the mTLS leaf.
|
||||||
|
|
||||||
<!-- vale write-good.Passive = NO -->
|
<!-- vale write-good.Passive = NO -->
|
||||||
|
|
||||||
| store path | minter | reader — pulls at runtime, holds in memory | renewal |
|
| store path | minter | reader — pulls at runtime, holds in memory | renewal |
|
||||||
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `swarm/agents/<agent>/matrix/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/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/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>/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>/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>/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/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/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/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/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>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated |
|
| `swarm/hives/<hive>/queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated |
|
||||||
| `swarm/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/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated |
|
||||||
| `swarm/hives/<hive>/queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | 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 |
|
||||||
| `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 -->
|
<!-- 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
|
**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
|
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
|
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:**
|
###### **Subcommands:**
|
||||||
|
|
||||||
* `create-user` — Create a matrix account for a person or other non-agent `<name>` and print its access token to stdout
|
* `create-user` — Create or refresh the matrix account + access token for `<name>`
|
||||||
* `sync-admin` — Provision (or re-provision) the matrix appservice's sender account
|
* `sync-admin` — Provision (or re-provision) the matrix appservice's sender account
|
||||||
* `promote-user` — Promote a matrix user to homeserver admin
|
* `promote-user` — Promote a matrix user to homeserver admin
|
||||||
* `reset-password` — Reset a matrix user's password via the admin API
|
* `reset-password` — Reset a matrix user's password via the admin API
|
||||||
|
|
@ -152,15 +152,15 @@ Manual entry point to the same idempotent provisioning c0re runs at boot — for
|
||||||
|
|
||||||
## `hivectl matrix create-user`
|
## `hivectl matrix create-user`
|
||||||
|
|
||||||
Create a matrix account for a person or other non-agent `<name>` and print its access token to stdout.
|
Create or refresh the matrix account + access token for `<name>`.
|
||||||
|
|
||||||
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).
|
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).
|
||||||
|
|
||||||
**Usage:** `hivectl matrix create-user [OPTIONS] <NAME>`
|
**Usage:** `hivectl matrix create-user [OPTIONS] <NAME>`
|
||||||
|
|
||||||
###### **Arguments:**
|
###### **Arguments:**
|
||||||
|
|
||||||
* `<NAME>` — Matrix localpart of a non-agent account — `mara`, `damocles`, etc
|
* `<NAME>` — Matrix localpart. For agents: the container/agent name. For humans: any matrix localpart — `mara`, `damocles`, etc
|
||||||
|
|
||||||
###### **Options:**
|
###### **Options:**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ Manual entry to the same idempotent matrix provisioning flow
|
||||||
running (`services.hyperhive.deploy.matrix.enable = true`).
|
running (`services.hyperhive.deploy.matrix.enable = true`).
|
||||||
|
|
||||||
```bash
|
```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 # create matrix account for a human; prints access_token to stdout
|
||||||
hivectl matrix create-user mara --password hunter2 # set a client-login password
|
hivectl matrix create-user mara --password hunter2 # set a client-login password
|
||||||
hivectl matrix sync-admin # provision / refresh the appservice's sender account
|
hivectl matrix sync-admin # provision / refresh the appservice's sender account
|
||||||
|
|
@ -59,9 +60,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
|
hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a specific room/alias
|
||||||
```
|
```
|
||||||
|
|
||||||
- `create-user`: for people and other non-agent accounts. It refuses
|
- `create-user`: for agents, persists the `access_token` to
|
||||||
an agent's name: `swarm-controller` creates an agent's account and
|
`<state>/matrix-token`. Skips registration when the file already
|
||||||
stores its token where the agent's daemon reads it.
|
exists — delete it first to force re-registration.
|
||||||
- `sync-admin`: ensures this hive's appservice sender account
|
- `sync-admin`: ensures this hive's appservice sender account
|
||||||
(`@hive-<hive>:<server_name>`, one per hive) exists
|
(`@hive-<hive>:<server_name>`, one per hive) exists
|
||||||
(the account `hive-c0re` provisions rooms with). Token persisted to the
|
(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
|
always named `main` and is always the primary; the matrix module
|
||||||
declares it for you as an ordinary entry of this map, from
|
declares it for you as an ordinary entry of this map, from
|
||||||
`services.hyperhive.agent.matrix.url` + agent state, so what you add
|
`services.hyperhive.agent.matrix.url` + agent state, so what you add
|
||||||
here are the *further* accounts. Its token comes from the swarm secret
|
here are the *further* accounts. hive-c0re pins its `tokenFile` to
|
||||||
store (`swarm-controller` mints it); its `tokenFile` stays pinned to
|
`<state>/matrix-token` and provisions it there, and the
|
||||||
`<state>/matrix-token` as the fallback, and the dashboard's link-account
|
dashboard's link-account route refuses to create an account by that
|
||||||
route refuses to create an account by that name.
|
name.
|
||||||
<!-- vale write-good.Passive = YES -->
|
<!-- vale write-good.Passive = YES -->
|
||||||
|
|
||||||
`main` is present exactly when that URL is non-null, which is the whole
|
`main` is present exactly when that URL is non-null, which is the whole
|
||||||
|
|
@ -122,10 +122,9 @@ directly over streamable-http on `services.hyperhive.agent.mcp.matrixHttpPort`
|
||||||
`{ type = "http"; url = ...; }`). Same shape as `hive-bash-daemon` and
|
`{ type = "http"; url = ...; }`). Same shape as `hive-bash-daemon` and
|
||||||
the built-in `hyperhive` surface (`hive-mcp-http`) — claude reconnects
|
the built-in `hyperhive` surface (`hive-mcp-http`) — claude reconnects
|
||||||
to the stable URL every turn instead of respawning a stdio child.
|
to the stable URL every turn instead of respawning a stdio child.
|
||||||
Silently exits when the primary account has no token yet; the
|
Silently exits when `<state>/matrix-token` is absent (account not yet
|
||||||
`systemd.paths.hive-matrix-daemon` watcher restarts it when a token file
|
provisioned); the `systemd.paths.hive-matrix-daemon` watcher restarts
|
||||||
appears, and, on an agent with a store, a timer restarts it while it's
|
it the moment hive-c0re provisions the token.
|
||||||
down so it re-reads a token the swarm minted into the store.
|
|
||||||
|
|
||||||
Incoming room events wake the agent via `AgentRequest::Wake` with
|
Incoming room events wake the agent via `AgentRequest::Wake` with
|
||||||
`from: "matrix"`. The wake body format depends on the unread state:
|
`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
|
/// The post-`Swap` bookkeeping tail, split into its own node for dashboard
|
||||||
/// visibility + retry granularity. Deps `AfterOk(Swap)`, so reaching here
|
/// visibility + retry granularity. Deps `AfterOk(Swap)`, so reaching here
|
||||||
/// means the profile swap succeeded. Store/forge work only — no nix
|
/// means the profile swap succeeded. Store/forge/matrix work only — no nix
|
||||||
/// build (build-slot-exempt); the agent lease taken at `Swap` is still held
|
/// build (build-slot-exempt); the agent lease taken at `Swap` is still held
|
||||||
/// (the whole chain up to `Reconcile` is one agent's subgraph).
|
/// (the whole chain up to `Reconcile` is one agent's subgraph).
|
||||||
async fn run_rebuild_bookkeeping(coord: &Arc<Coordinator>, name: &str) -> Result<()> {
|
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
|
// 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`
|
// `EmitRebuilt` tail — emitting ok here and letting a failed tail `Reconcile`
|
||||||
// add a contradictory !ok would double-report the same rebuild.
|
// add a contradictory !ok would double-report the same rebuild.
|
||||||
// Full forge sync on every successful rebuild so the rebuild path is
|
// Full forge + matrix sync on every successful rebuild so the rebuild
|
||||||
// equivalent to the startup sweep: tokens, config-repo mirror, meta
|
// path is equivalent to the startup sweep: tokens, config-repo mirror,
|
||||||
// access all recover without a hive-c0re restart. (No matrix step: the
|
// meta access all recover without a hive-c0re restart.
|
||||||
// swarm mints an agent's matrix account, not this hive.)
|
|
||||||
crate::forge::sync_agent(name, crate::forge::core_token().as_deref()).await;
|
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"
|
// 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 →
|
// hint; rescan so dashboards drop the "needs update" chip; lock bump →
|
||||||
// meta-inputs re-render.
|
// meta-inputs re-render.
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ use std::path::PathBuf;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
|
|
||||||
|
use crate::coordinator::Coordinator;
|
||||||
|
|
||||||
/// Client-server API base this daemon provisions against, from
|
/// Client-server API base this daemon provisions against, from
|
||||||
/// `HIVE_MATRIX_API_URL` (set by the hyperhive NixOS module from
|
/// `HIVE_MATRIX_API_URL` (set by the hyperhive NixOS module from
|
||||||
/// `services.hyperhive.swarm.matrix.apiUrl`).
|
/// `services.hyperhive.swarm.matrix.apiUrl`).
|
||||||
|
|
@ -124,15 +126,45 @@ pub fn sender_token_path() -> PathBuf {
|
||||||
crate::paths::matrix_sender_token()
|
crate::paths::matrix_sender_token()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Password file for a matrix account this hive holds a password for: its own
|
/// Token file inside the agent's bind-mounted state dir (visible as
|
||||||
/// `@hive-<hive>:` account, or one reset through the admin room. Stored OUTSIDE
|
/// `/state/matrix-token` from inside the container).
|
||||||
/// the purgeable `agent_state_root` tree so it survives `destroy --purge`.
|
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.
|
||||||
///
|
///
|
||||||
/// Path: `/var/lib/hyperhive/matrix/creds/<name>-password`
|
/// 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 {
|
fn password_path(name: &str) -> PathBuf {
|
||||||
crate::paths::matrix_creds_dir().join(format!("{name}-password"))
|
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.
|
/// Host path where the hive Matrix Space room ID is persisted.
|
||||||
/// Outside every purgeable path — not deleted by `destroy --purge`.
|
/// Outside every purgeable path — not deleted by `destroy --purge`.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|
@ -287,8 +319,8 @@ async fn register_user(
|
||||||
"type": "m.login.application_service",
|
"type": "m.login.application_service",
|
||||||
"username": localpart,
|
"username": localpart,
|
||||||
"password": password,
|
"password": password,
|
||||||
// device_id stays stable across re-runs so a re-mint doesn't
|
// device_id stays stable across re-runs of ensure_user_for so
|
||||||
// strand orphan devices in tuwunel.
|
// a re-mint doesn't strand orphan devices in tuwunel.
|
||||||
"device_id": format!("hyperhive-{agent}"),
|
"device_id": format!("hyperhive-{agent}"),
|
||||||
"initial_device_display_name": format!("hyperhive ({agent})"),
|
"initial_device_display_name": format!("hyperhive ({agent})"),
|
||||||
"inhibit_login": false,
|
"inhibit_login": false,
|
||||||
|
|
@ -384,6 +416,32 @@ async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Re
|
||||||
extract_access_token(&json)
|
extract_access_token(&json)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Auto-recovery helper: reset a user's matrix password through the admin
|
||||||
|
/// 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
|
// Admin-room fallback for password reset
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -653,13 +711,162 @@ 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`
|
/// Register a matrix account for `name` with the supplied `password`
|
||||||
/// and return the freshly-minted access token. The token is **not**
|
/// and return the freshly-minted access token. Unlike [`ensure_user_for`],
|
||||||
/// persisted to disk — the caller is responsible for storing it. Used by
|
/// the token is **not** persisted to disk — the caller is responsible
|
||||||
/// `hivectl matrix create-user` for human (non-agent) accounts. For operator accounts the caller passes a real
|
/// 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
|
||||||
/// password so the operator can `m.login.password` into matrix web
|
/// password so the operator can `m.login.password` into matrix web
|
||||||
/// clients afterwards; without one the caller passes
|
/// clients afterwards; for headless agent re-provisioning the caller
|
||||||
/// [`random_password`].
|
/// can pass [`random_password`] to keep the existing throwaway
|
||||||
|
/// behaviour.
|
||||||
///
|
///
|
||||||
/// **Not idempotent**: the matrix `/register` endpoint returns
|
/// **Not idempotent**: the matrix `/register` endpoint returns
|
||||||
/// `M_USER_IN_USE` (HTTP 400) on a second call for the same localpart,
|
/// `M_USER_IN_USE` (HTTP 400) on a second call for the same localpart,
|
||||||
|
|
@ -675,6 +882,44 @@ pub async fn provision_user_token(
|
||||||
register_user(client, name, as_token, password).await
|
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
|
/// Ensure the hive's `@hive-<hive>:` matrix user exists and that its access token is
|
||||||
/// persisted at [`sender_token_path()`].
|
/// persisted at [`sender_token_path()`].
|
||||||
///
|
///
|
||||||
|
|
@ -934,7 +1179,8 @@ pub async fn promote_user_to_admin(
|
||||||
///
|
///
|
||||||
/// Sends `!admin users reset-password @<localpart>:<server>` to the admin room as
|
/// Sends `!admin users reset-password @<localpart>:<server>` to the admin room as
|
||||||
/// `@hive-<hive>:`, polls for the bot's response containing the new password, and persists
|
/// `@hive-<hive>:`, polls for the bot's response containing the new password, and persists
|
||||||
/// it to the non-purgeable creds path.
|
/// it to the non-purgeable creds path so [`ensure_user_for`] can re-login
|
||||||
|
/// on the next provisioning sweep.
|
||||||
///
|
///
|
||||||
/// ⚠️ Same admin-**sender** requirement as [`promote_user_to_admin`], and
|
/// ⚠️ Same admin-**sender** requirement as [`promote_user_to_admin`], and
|
||||||
/// the same consequence: `@hive-<hive>:` is an ordinary account with no admin
|
/// the same consequence: `@hive-<hive>:` is an ordinary account with no admin
|
||||||
|
|
@ -1628,11 +1874,10 @@ async fn resolve_room_alias(
|
||||||
.ok_or_else(|| anyhow::anyhow!("matrix: alias {alias} response missing room_id: {json}"))
|
.ok_or_else(|| anyhow::anyhow!("matrix: alias {alias} response missing room_id: {json}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Make sure the hive's own `@hive-<hive>:` account exists, then provision
|
/// Sweep every existing container (manager + sub-agents) and ensure
|
||||||
/// the hive Space and chat room and invite every agent container to both.
|
/// each has a matrix user + token on the local homeserver. Called at
|
||||||
/// Agents' own accounts are not created here: `swarm-controller` mints them
|
/// hive-c0re startup, alongside `forge::ensure_all`, and then
|
||||||
/// with the swarm's appservice token. Called at hive-c0re startup, alongside
|
/// periodically (see the caller in `main.rs`). No-op when the
|
||||||
/// `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
|
/// hive-matrix container isn't running. Per-step failures are logged
|
||||||
/// but don't abort the sweep.
|
/// but don't abort the sweep.
|
||||||
///
|
///
|
||||||
|
|
@ -1648,7 +1893,9 @@ pub async fn ensure_all() -> bool {
|
||||||
}
|
}
|
||||||
let mut ok = true;
|
let mut ok = true;
|
||||||
// Loud and non-destructive: with no appservice token this sweep can
|
// Loud and non-destructive: with no appservice token this sweep can
|
||||||
// create nothing, so it does nothing.
|
// 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.
|
||||||
let as_token = match read_appservice_token() {
|
let as_token = match read_appservice_token() {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -1656,7 +1903,8 @@ pub async fn ensure_all() -> bool {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// One HTTP client for the whole sweep.
|
// One HTTP client for the whole sweep — connection pool is
|
||||||
|
// reused across agents.
|
||||||
let client = match reqwest::Client::builder()
|
let client = match reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
|
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
|
||||||
.build()
|
.build()
|
||||||
|
|
@ -1684,6 +1932,7 @@ pub async fn ensure_all() -> bool {
|
||||||
let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else {
|
let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
sync_agent(&client, name, &as_token).await;
|
||||||
agent_names.push(name.to_owned());
|
agent_names.push(name.to_owned());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1852,4 +2101,30 @@ mod tests {
|
||||||
let err = extract_access_token(&body).unwrap_err();
|
let err = extract_access_token(&body).unwrap_err();
|
||||||
assert!(err.to_string().contains("missing access_token"));
|
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,37 +539,51 @@ async fn handle_matrix_create_user(
|
||||||
password: Option<&str>,
|
password: Option<&str>,
|
||||||
) -> Result<HostResponse> {
|
) -> Result<HostResponse> {
|
||||||
require_matrix_present()?;
|
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 =
|
let as_token =
|
||||||
crate::matrix::read_appservice_token().context("read matrix appservice token")?;
|
crate::matrix::read_appservice_token().context("read matrix appservice token")?;
|
||||||
let client = matrix_http_client()?;
|
let client = matrix_http_client()?;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let effective_password = match password {
|
if agent_exists(name)? {
|
||||||
Some(p) => p.to_owned(),
|
if password.is_some() {
|
||||||
None => crate::matrix::random_password().context("generate random matrix password")?,
|
// Agents auth by access_token, never by password — the
|
||||||
};
|
// boot-sweep provisioning path doesn't accept one. Refuse
|
||||||
let token =
|
// rather than silently dropping it.
|
||||||
crate::matrix::provision_user_token(&client, name.as_str(), &as_token, &effective_password)
|
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)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("matrix create-user {name}"))?;
|
.with_context(|| format!("matrix create-user {name}"))?;
|
||||||
out.push(format!(
|
let path = Coordinator::agent_notes_dir(name).join("matrix-token");
|
||||||
"matrix: provisioned user '{name}' (not an agent — token not persisted)"
|
out.push(format!("matrix: provisioned agent user '{name}'"));
|
||||||
));
|
out.push(format!("token persisted at: {}", path.display()));
|
||||||
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 {
|
} else {
|
||||||
out.push(
|
let effective_password = match password {
|
||||||
"password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(),
|
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(HostResponse::messages(out))
|
Ok(HostResponse::messages(out))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ rmcp.workspace = true
|
||||||
schemars.workspace = true
|
schemars.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
swarm-secret-client.workspace = true
|
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,13 @@
|
||||||
//! matrix-sdk `Client` setup for the daemon: take the per-agent access
|
//! matrix-sdk `Client` setup for the daemon: read the per-agent access
|
||||||
//! token [`crate::credential`] resolved, probe `whoami` to recover the
|
//! token from the state-dir file `hive-c0re::matrix::ensure_user_for`
|
||||||
//! agent's matrix `user_id` + `device_id`, restore the matrix-sdk
|
//! wrote, probe `whoami` to recover the agent's matrix `user_id` +
|
||||||
//! session, return the Client ready to start sync.
|
//! `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
|
//! No OAuth dance / cross-signing setup (in contrast to damocles-daemon's
|
||||||
//! ccc.de connection): for the swarm's tuwunel the swarm already minted the
|
//! ccc.de connection): for the in-hive tuwunel hive-c0re already minted
|
||||||
//! token + user/device as its appservice and handed us the bearer. matrix-sdk's `restore_session` with a constructed
|
//! 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
|
||||||
//! `MatrixSession` skips the login flow entirely.
|
//! `MatrixSession` skips the login flow entirely.
|
||||||
//!
|
//!
|
||||||
//! E2EE is enabled via `with_encryption_settings(EncryptionSettings::default())`.
|
//! E2EE is enabled via `with_encryption_settings(EncryptionSettings::default())`.
|
||||||
|
|
@ -36,12 +34,10 @@ use matrix_sdk::{
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
use crate::credential::Token;
|
|
||||||
|
|
||||||
/// Sentinel returned when `build_and_restore` detects that the token is
|
/// Sentinel returned when `build_and_restore` detects that the token is
|
||||||
/// permanently invalid (`M_UNKNOWN_TOKEN`). The token has already been
|
/// permanently invalid (`M_UNKNOWN_TOKEN`). The token has already been
|
||||||
/// discarded ([`Token::discard_stale`]). Callers should NOT retry — the
|
/// removed from disk. Callers should NOT retry — the account needs
|
||||||
/// account needs re-provisioning.
|
/// re-provisioning by hive-c0re.
|
||||||
///
|
///
|
||||||
/// Distinct from the general `anyhow::Error` path so callers can use
|
/// Distinct from the general `anyhow::Error` path so callers can use
|
||||||
/// `err.downcast_ref::<PermanentBringUpError>()` to distinguish "retry
|
/// `err.downcast_ref::<PermanentBringUpError>()` to distinguish "retry
|
||||||
|
|
@ -68,53 +64,63 @@ struct WhoamiResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build + restore a matrix-sdk `Client` for the per-agent bearer
|
/// Build + restore a matrix-sdk `Client` for the per-agent bearer
|
||||||
/// `token`. The Client points at `homeserver`, persists its sqlite cache
|
/// token at `token_file`. The Client points at `homeserver`, persists
|
||||||
/// under `state_dir`, and is ready for sync once returned.
|
/// its sqlite cache under `state_dir`, and is ready for sync once
|
||||||
|
/// returned.
|
||||||
///
|
///
|
||||||
/// Steps:
|
/// Steps:
|
||||||
/// 1. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with
|
/// 1. Read the bearer token from `token_file` (trim trailing whitespace).
|
||||||
|
/// 2. Plain reqwest GET to `/_matrix/client/v3/account/whoami` with
|
||||||
/// the bearer — this gives us back the matrix `user_id` +
|
/// the bearer — this gives us back the matrix `user_id` +
|
||||||
/// `device_id` (the registration response had them but only the
|
/// `device_id` (the registration response had them but hive-c0re
|
||||||
/// token was kept; whoami is the cheapest recovery path
|
/// only persisted the token; whoami is the cheapest recovery path
|
||||||
/// and avoids matrix-sdk's circular requirement of needing a
|
/// and avoids matrix-sdk's circular requirement of needing a
|
||||||
/// session to call whoami).
|
/// session to call whoami).
|
||||||
/// 2. Build the real Client with the sqlite store + `restore_session`
|
/// 3. Build the real Client with the sqlite store + `restore_session`
|
||||||
/// using a synthetic `MatrixSession`.
|
/// using a synthetic `MatrixSession`.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns an error if the `whoami` request fails, or if the matrix-sdk
|
/// Returns an error if the token file is missing or empty, if the
|
||||||
/// client fails to build or restore the session.
|
/// `whoami` request fails, or if the matrix-sdk client fails to build
|
||||||
|
/// or restore the session.
|
||||||
pub async fn build_and_restore(
|
pub async fn build_and_restore(
|
||||||
homeserver: &str,
|
homeserver: &str,
|
||||||
token: &Token,
|
token_file: &Path,
|
||||||
state_dir: &Path,
|
state_dir: &Path,
|
||||||
is_primary: bool,
|
is_primary: bool,
|
||||||
) -> Result<Client> {
|
) -> Result<Client> {
|
||||||
let (user_id, device_id) = match whoami(homeserver, token.expose()).await {
|
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 {
|
||||||
Ok(ids) => ids,
|
Ok(ids) => ids,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let msg = format!("{e:#}");
|
let msg = format!("{e:#}");
|
||||||
if msg.contains("M_UNKNOWN_TOKEN") {
|
if msg.contains("M_UNKNOWN_TOKEN") {
|
||||||
// Homeserver rejected our token — stale session after a homeserver
|
// Homeserver rejected our token — stale session after a homeserver
|
||||||
// state wipe or token expiry. Discard it so we don't loop on it
|
// state wipe or token expiry. Delete the stale token file so we
|
||||||
// (which for a store-held credential is a log line and nothing
|
// don't loop on it. The rest of the handling depends on whether
|
||||||
// else — see `Token::discard_stale`). The rest of the handling
|
// this is the primary (hive-internal) account or a secondary one,
|
||||||
// depends on whether this is the primary (hive-internal) account
|
// because a single bad secondary token must NOT take down the
|
||||||
// or a secondary one, because a single bad secondary token must
|
// whole daemon (and with it every healthy account).
|
||||||
// NOT take down the whole daemon (and with it every healthy
|
|
||||||
// account).
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
credential = %token.origin(),
|
path = %token_file.display(),
|
||||||
is_primary,
|
is_primary,
|
||||||
"matrix token rejected (M_UNKNOWN_TOKEN); discarding stale token"
|
"matrix token rejected (M_UNKNOWN_TOKEN); removing stale token"
|
||||||
);
|
);
|
||||||
token.discard_stale().await;
|
let _ = fs::remove_file(token_file).await;
|
||||||
if is_primary {
|
if is_primary {
|
||||||
// Primary: also drop the matrix-sdk sqlite state keyed to the
|
// Primary: also drop the matrix-sdk sqlite state keyed to the
|
||||||
// now-invalid session, then exit 0; the swarm's backfill
|
// now-invalid session, then exit 0 so hive-c0re's `ensure_all`
|
||||||
// re-mints the token and systemd restarts us once it
|
// re-provisions the account and the systemd.paths watcher
|
||||||
// exists (store re-check timer, or the path watcher). Exit 0 (not
|
// restarts us once the fresh token file appears. Exit 0 (not
|
||||||
// Err) keeps systemd's Restart=on-failure from looping; the
|
// Err) keeps systemd's Restart=on-failure from looping; the
|
||||||
// call site is before any tasks are spawned so there's
|
// call site is before any tasks are spawned so there's
|
||||||
// nothing to clean up.
|
// nothing to clean up.
|
||||||
|
|
@ -127,13 +133,13 @@ pub async fn build_and_restore(
|
||||||
}
|
}
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
// Secondary: token discarded (so it's cleanly skipped next boot
|
// Secondary: token removed (so it's cleanly skipped next boot
|
||||||
// rather than re-erroring); leave the sdk state in place in case
|
// rather than re-erroring); leave the sdk state in place in case
|
||||||
// the operator re-provisions a fresh token for the same device.
|
// the operator re-provisions a fresh token for the same device.
|
||||||
// Return a PermanentBringUpError so the caller can distinguish
|
// Return a PermanentBringUpError so the caller can distinguish
|
||||||
// "don't retry" from a transient network/DNS failure.
|
// "don't retry" from a transient network/DNS failure.
|
||||||
return Err(anyhow::Error::new(PermanentBringUpError(
|
return Err(anyhow::Error::new(PermanentBringUpError(
|
||||||
"matrix token rejected (M_UNKNOWN_TOKEN); discarded stale token, skipping account".into(),
|
"matrix token rejected (M_UNKNOWN_TOKEN); removed stale token, skipping account".into(),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
return Err(e);
|
return Err(e);
|
||||||
|
|
@ -153,7 +159,7 @@ pub async fn build_and_restore(
|
||||||
let session = MatrixSession {
|
let session = MatrixSession {
|
||||||
meta: SessionMeta { user_id, device_id },
|
meta: SessionMeta { user_id, device_id },
|
||||||
tokens: SessionTokens {
|
tokens: SessionTokens {
|
||||||
access_token: token.expose().to_owned(),
|
access_token: token,
|
||||||
refresh_token: None,
|
refresh_token: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,429 +0,0 @@
|
||||||
//! 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,7 +9,6 @@
|
||||||
|
|
||||||
pub mod accounts;
|
pub mod accounts;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod credential;
|
|
||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod paths;
|
pub mod paths;
|
||||||
|
|
|
||||||
|
|
@ -7,24 +7,22 @@
|
||||||
//! Lifecycle:
|
//! Lifecycle:
|
||||||
//! 1. Read the configured account list (`accounts::configured()` —
|
//! 1. Read the configured account list (`accounts::configured()` —
|
||||||
//! `HIVE_MATRIX_ACCOUNTS` JSON, or the single legacy account).
|
//! `HIVE_MATRIX_ACCOUNTS` JSON, or the single legacy account).
|
||||||
//! 2. For each account: resolve its access token (`account_token` — the
|
//! 2. For each account: whoami probe → recover `user_id` + `device_id`
|
||||||
//! 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
|
//! → restore matrix-sdk session (no login flow), install the
|
||||||
//! message-event handler, and spawn its own sync loop.
|
//! message-event handler, and spawn its own sync loop.
|
||||||
//! 3. Serve the MCP tools against an account→Client registry; each tool
|
//! 3. Serve the MCP tools against an account→Client registry; each tool
|
||||||
//! call routes to the account named in its `account` arg (the
|
//! call routes to the account named in its `account` arg (the
|
||||||
//! primary account when omitted).
|
//! primary account when omitted).
|
||||||
//!
|
//!
|
||||||
//! Standalone-degraded boot: the PRIMARY account having no token →
|
//! Standalone-degraded boot: the PRIMARY account having no token file →
|
||||||
//! exit 0 cleanly; systemd restarts us once one exists (the path-watcher for
|
//! exit 0 cleanly so systemd's path-watcher restarts us once hive-c0re
|
||||||
//! a token file, the store re-check timer for a stored token). A SECONDARY
|
//! provisions it. A SECONDARY account missing its token is skipped (the
|
||||||
//! account missing its token is skipped (the daemon still serves the others).
|
//! daemon still serves the others).
|
||||||
//!
|
//!
|
||||||
//! Stale-token recovery (`M_UNKNOWN_TOKEN`): handled in
|
//! Stale-token recovery (`M_UNKNOWN_TOKEN`): handled in
|
||||||
//! `client::build_and_restore`, and it mirrors the missing-token policy
|
//! `client::build_and_restore`, and it mirrors the missing-token policy
|
||||||
//! above — a rejected PRIMARY token drops the stale token + sdk state and
|
//! above — a rejected PRIMARY token drops the stale token + sdk state and
|
||||||
//! exits 0 until a fresh token exists, while a rejected SECONDARY token
|
//! exits 0 for systemd re-provisioning, while a rejected SECONDARY token
|
||||||
//! is removed and that one account is skipped so the daemon keeps serving
|
//! is removed and that one account is skipped so the daemon keeps serving
|
||||||
//! the primary and any other healthy account.
|
//! the primary and any other healthy account.
|
||||||
|
|
||||||
|
|
@ -36,8 +34,7 @@ use matrix_sdk::{Client, config::SyncSettings};
|
||||||
|
|
||||||
use hive_matrix_mcp::accounts::{AccountCfg, Registry};
|
use hive_matrix_mcp::accounts::{AccountCfg, Registry};
|
||||||
use hive_matrix_mcp::client::PermanentBringUpError;
|
use hive_matrix_mcp::client::PermanentBringUpError;
|
||||||
use hive_matrix_mcp::credential::Token;
|
use hive_matrix_mcp::{accounts, client, mcp, paths, timeline, wake};
|
||||||
use hive_matrix_mcp::{accounts, client, credential, mcp, paths, timeline, wake};
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "hive-matrix-daemon", about = "matrix-sdk client + MCP daemon")]
|
#[command(name = "hive-matrix-daemon", about = "matrix-sdk client + MCP daemon")]
|
||||||
|
|
@ -100,13 +97,13 @@ async fn main() -> Result<()> {
|
||||||
sync_loops.push(sync_loop);
|
sync_loops.push(sync_loop);
|
||||||
}
|
}
|
||||||
// No token yet: for the primary that means the daemon isn't
|
// No token yet: for the primary that means the daemon isn't
|
||||||
// useful — exit 0, and systemd restarts us once a token exists
|
// useful — exit 0 like the legacy single-account path so the
|
||||||
// (the path-watcher for a file, the re-check timer for the store).
|
// systemd path-watcher restarts us when the token appears.
|
||||||
Ok(None) if is_primary => {
|
Ok(None) if is_primary => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
account = %cfg.name,
|
account = %cfg.name,
|
||||||
"primary matrix account has no token yet; exiting cleanly \
|
"primary matrix account has no token yet; exiting cleanly \
|
||||||
(systemd restarts us once one exists)"
|
(systemd restarts us when hive-c0re provisions it)"
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
@ -262,46 +259,6 @@ async fn bring_up_secondary_with_retry(
|
||||||
None
|
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
|
/// Restore one account's client (when its token exists), install its
|
||||||
/// message handler, and build its sync loop. Returns
|
/// message handler, and build its sync loop. Returns
|
||||||
/// `Ok(Some((client, sync_loop)))` when the account came up, `Ok(None)`
|
/// `Ok(Some((client, sync_loop)))` when the account came up, `Ok(None)`
|
||||||
|
|
@ -323,19 +280,23 @@ async fn bring_up_account(
|
||||||
);
|
);
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let Some(token) = account_token(cfg).await? else {
|
if !tokio::fs::try_exists(&cfg.token_file)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
account = %cfg.name,
|
account = %cfg.name,
|
||||||
homeserver,
|
homeserver,
|
||||||
credential = %token.origin(),
|
token_file = %cfg.token_file.display(),
|
||||||
state_dir = %cfg.state_dir.display(),
|
state_dir = %cfg.state_dir.display(),
|
||||||
"bringing up matrix account"
|
"bringing up matrix account"
|
||||||
);
|
);
|
||||||
let client = client::build_and_restore(&homeserver, &token, &cfg.state_dir, is_primary)
|
let client =
|
||||||
.await
|
client::build_and_restore(&homeserver, &cfg.token_file, &cfg.state_dir, is_primary)
|
||||||
.with_context(|| format!("build matrix client for account {}", cfg.name))?;
|
.await
|
||||||
|
.with_context(|| format!("build matrix client for account {}", cfg.name))?;
|
||||||
// Best-effort: sync the agent icon to this account's matrix avatar over
|
// Best-effort: sync the agent icon to this account's matrix avatar over
|
||||||
// the live (authenticated, correct-homeserver) Client. Replaces the old
|
// the live (authenticated, correct-homeserver) Client. Replaces the old
|
||||||
// curl oneshot; failures are swallowed inside sync_avatar.
|
// 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
|
/// Resolve the matrix access-token file path. Override via
|
||||||
/// `HIVE_MATRIX_TOKEN_FILE`; default is `<HYPERHIVE_STATE_DIR>/matrix-token`,
|
/// `HIVE_MATRIX_TOKEN_FILE`; default is `<HYPERHIVE_STATE_DIR>/matrix-token`,
|
||||||
/// where a hive used to write the `main` account's token (the daemon now reads
|
/// the path `hive-c0re::matrix::ensure_user_for` writes to on agent
|
||||||
/// it from the store first; see `main.rs`'s `account_token`).
|
/// account provisioning.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn token_file() -> PathBuf {
|
pub fn token_file() -> PathBuf {
|
||||||
if let Some(p) = std::env::var_os("HIVE_MATRIX_TOKEN_FILE") {
|
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)]
|
#[derive(Subcommand)]
|
||||||
pub enum MatrixCmd {
|
pub enum MatrixCmd {
|
||||||
/// Create a matrix account for a person or other non-agent `<name>` and
|
/// Create or refresh the matrix account + access token for `<name>`.
|
||||||
/// print its access token to stdout.
|
|
||||||
///
|
///
|
||||||
/// Refuses an agent's name: its account comes from the swarm
|
/// For an existing agent, persists the token to its state dir; for a
|
||||||
/// (`swarm-controller` creates it and stores its token where the agent
|
/// human/other account, prints the access token to stdout. Set a
|
||||||
/// reads it). Set a password to enable matrix web-client login
|
/// password to enable matrix web-client login (otherwise it uses a
|
||||||
/// (otherwise it uses a random throwaway).
|
/// random throwaway).
|
||||||
CreateUser {
|
CreateUser {
|
||||||
/// Matrix localpart of a non-agent account — `mara`, `damocles`, etc.
|
/// Matrix localpart. For agents: the container/agent name.
|
||||||
|
/// For humans: any matrix localpart — `mara`, `damocles`, etc.
|
||||||
name: String,
|
name: String,
|
||||||
/// Set the account password to this string instead of a random
|
/// Set the account password to this string instead of a random
|
||||||
/// throwaway. Use this for operator accounts that need to log
|
/// throwaway. Use this for operator accounts that need to log
|
||||||
|
|
|
||||||
|
|
@ -14,21 +14,12 @@
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
userName = config.services.hyperhive.agent.user.name;
|
userName = config.services.hyperhive.agent.user.name;
|
||||||
# This agent's own state dir, where a hive used to write the `main`
|
# This agent's own state dir, where hive-c0re provisions the
|
||||||
# account's token (`matrix-token`, now the store's fallback) and the
|
# hive-internal account's token (`matrix-token`) and the daemon keeps
|
||||||
# daemon keeps its matrix-sdk store. Shared by the `main` account entry
|
# its matrix-sdk store. Shared by the `main` account entry below and
|
||||||
# below and the path-watcher glob at the bottom of this file.
|
# the path-watcher glob at the bottom of this file.
|
||||||
stateDir = "/agents/${userName}/state";
|
stateDir = "/agents/${userName}/state";
|
||||||
accounts = config.services.hyperhive.agent.matrixAccounts;
|
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
|
# **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
|
# 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.
|
# do: no account, no Client, no sync, no tool surface worth injecting.
|
||||||
|
|
@ -99,9 +90,8 @@ in
|
||||||
Path to this account's bearer-token file. The daemon reads
|
Path to this account's bearer-token file. The daemon reads
|
||||||
the token from here to restore the matrix session; how the
|
the token from here to restore the matrix session; how the
|
||||||
file gets populated is the provisioner's concern
|
file gets populated is the provisioner's concern
|
||||||
(for `main`, a file only when the store has no token: the
|
(hive-c0re for the hive-internal `main` account, an
|
||||||
swarm mints `main` into the store; an operator-supplied
|
operator-supplied secret for an external one). The daemon
|
||||||
secret for an external one). The daemon
|
|
||||||
skips an extra account whose token file is absent, and
|
skips an extra account whose token file is absent, and
|
||||||
exits cleanly to wait on the path watcher when `main`'s is.
|
exits cleanly to wait on the path watcher when `main`'s is.
|
||||||
'';
|
'';
|
||||||
|
|
@ -153,7 +143,7 @@ in
|
||||||
homeserver (`homeserver`, else
|
homeserver (`homeserver`, else
|
||||||
`services.hyperhive.agent.matrix.url`). The daemon auto-skips an
|
`services.hyperhive.agent.matrix.url`). The daemon auto-skips an
|
||||||
account whose URL or token file is missing, and a `systemd.paths`
|
account whose URL or token file is missing, and a `systemd.paths`
|
||||||
watcher restarts it the moment a token file appears
|
watcher restarts it the moment hive-c0re provisions the token
|
||||||
(same path-trigger shape as `forge-avatar-sync`).
|
(same path-trigger shape as `forge-avatar-sync`).
|
||||||
- exposes the matrix tool surface (send_message, send_dm,
|
- exposes the matrix tool surface (send_message, send_dm,
|
||||||
send_reaction, send_reply, mark_read, list_rooms,
|
send_reaction, send_reply, mark_read, list_rooms,
|
||||||
|
|
@ -183,8 +173,8 @@ in
|
||||||
like any other, so it shows up in the account list --- what you
|
like any other, so it shows up in the account list --- what you
|
||||||
add here are the *further* accounts (e.g. an external
|
add here are the *further* accounts (e.g. an external
|
||||||
public-matrix account). Its `tokenFile` stays pinned to
|
public-matrix account). Its `tokenFile` stays pinned to
|
||||||
`<state>/matrix-token` (an assertion; the file the daemon falls back
|
`<state>/matrix-token` (an assertion; that is the one path
|
||||||
to when the store has no `main` token), and the
|
hive-c0re provisions the hive-internal token to), and the
|
||||||
dashboard's link-account route refuses to create an account named
|
dashboard's link-account route refuses to create an account named
|
||||||
`main` --- the entry belongs to the module, not to a provisioner.
|
`main` --- the entry belongs to the module, not to a provisioner.
|
||||||
|
|
||||||
|
|
@ -225,8 +215,8 @@ in
|
||||||
# `main` is not a forbidden key --- this module declares it
|
# `main` is not a forbidden key --- this module declares it
|
||||||
# itself (see the `matrixAccounts.main` definition below), so the
|
# itself (see the `matrixAccounts.main` definition below), so the
|
||||||
# name must be allowed. What stays rejected is retargeting *its
|
# name must be allowed. What stays rejected is retargeting *its
|
||||||
# token file*: the daemon's file fallback for `main` is
|
# token file*: hive-c0re writes the hive-internal account's token
|
||||||
# `<state>/matrix-token` and nothing else, so an override there
|
# to `<state>/matrix-token` and nowhere else, so an override there
|
||||||
# is an account that evaluates fine and then never restores. The
|
# is an account that evaluates fine and then never restores. The
|
||||||
# other two fields are free to override (a `mkDefault` each).
|
# other two fields are free to override (a `mkDefault` each).
|
||||||
#
|
#
|
||||||
|
|
@ -237,8 +227,8 @@ in
|
||||||
assertion = !(accounts ? main) || accounts.main.tokenFile == "${stateDir}/matrix-token";
|
assertion = !(accounts ? main) || accounts.main.tokenFile == "${stateDir}/matrix-token";
|
||||||
message =
|
message =
|
||||||
"services.hyperhive.agent.matrixAccounts.main.tokenFile must stay "
|
"services.hyperhive.agent.matrixAccounts.main.tokenFile must stay "
|
||||||
+ "\"${stateDir}/matrix-token\" --- that is the file the daemon falls back to for the "
|
+ "\"${stateDir}/matrix-token\" --- that is where hive-c0re provisions the "
|
||||||
+ "main account's token. Declare a separate account instead of "
|
+ "hive-internal account's token. Declare a separate account instead of "
|
||||||
+ "pointing `main` elsewhere.";
|
+ "pointing `main` elsewhere.";
|
||||||
}
|
}
|
||||||
# Token files must land at the `matrix-token*` name the daemon
|
# Token files must land at the `matrix-token*` name the daemon
|
||||||
|
|
@ -270,7 +260,7 @@ in
|
||||||
# so the one it always has belongs in it. `mkDefault` per field so an
|
# so the one it always has belongs in it. `mkDefault` per field so an
|
||||||
# operator can retarget e.g. the homeserver without a
|
# operator can retarget e.g. the homeserver without a
|
||||||
# conflicting-definition error (the token file is pinned by an
|
# conflicting-definition error (the token file is pinned by an
|
||||||
# assertion above, since the daemon's fallback reads that path).
|
# assertion above, since hive-c0re owns that path).
|
||||||
#
|
#
|
||||||
# ⚠️ Gated on the homeserver URL, and that gate is what keeps `matrixEnabled`
|
# ⚠️ 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
|
# from being trivially true for every agent in the hive. A `main` with no
|
||||||
|
|
@ -319,20 +309,6 @@ in
|
||||||
HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
|
HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
|
||||||
RUST_LOG = "info";
|
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
|
# Homeserver URL. hive-c0re writes this option per agent from the
|
||||||
# hive's own gateway URL (`gatewayHost`'s vhost, `chat.<swarm-domain>`
|
# hive's own gateway URL (`gatewayHost`'s vhost, `chat.<swarm-domain>`
|
||||||
# by default — agents run in a private
|
# by default — agents run in a private
|
||||||
|
|
@ -381,8 +357,7 @@ in
|
||||||
# `on-failure`, not `always`: the daemon deliberately exits 0
|
# `on-failure`, not `always`: the daemon deliberately exits 0
|
||||||
# (a clean, non-failure exit) when no token is provisioned yet
|
# (a clean, non-failure exit) when no token is provisioned yet
|
||||||
# (see the module doc above) — the `systemd.paths` watcher
|
# (see the module doc above) — the `systemd.paths` watcher
|
||||||
# below re-fires it the moment a token file appears (the timer
|
# below re-fires it the moment hive-c0re provisions one,
|
||||||
# above, for a token in the store),
|
|
||||||
# instead of `always` busy-looping every `RestartSec` until
|
# instead of `always` busy-looping every `RestartSec` until
|
||||||
# then. Once a token exists this is no different from
|
# then. Once a token exists this is no different from
|
||||||
# `hive-bash-daemon`'s reasoning (a down window loses the MCP
|
# `hive-bash-daemon`'s reasoning (a down window loses the MCP
|
||||||
|
|
@ -392,33 +367,11 @@ in
|
||||||
RestartSec = 5;
|
RestartSec = 5;
|
||||||
User = userName;
|
User = userName;
|
||||||
Group = 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-start the daemon while it is down, when its token lives in the store.
|
# Re-fire the daemon when the matrix token appears (hive-c0re
|
||||||
# The path unit below only sees files, and a token the swarm mints or
|
# provisions it after agent containers come up). Without this
|
||||||
# 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
|
# the daemon would exit 0 silently on first boot and the MCP
|
||||||
# would have no backend until next restart. See
|
# would have no backend until next restart. See
|
||||||
# `docs/agent-lifecycle/persistence.md` (same section as above).
|
# `docs/agent-lifecycle/persistence.md` (same section as above).
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue