Compare commits
32 changed files with 344 additions and 2105 deletions
14
Cargo.lock
generated
14
Cargo.lock
generated
|
|
@ -4924,20 +4924,6 @@ dependencies = [
|
|||
"swarm-queue-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swarm-matrix-ctl"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"swarm-secret-client",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swarm-nats-auth"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ members = [
|
|||
"swarm-authelia-bridge",
|
||||
"swarm-authelia-bridge-sock",
|
||||
"swarm-controller",
|
||||
"swarm-matrix-ctl",
|
||||
"swarm-nats-auth",
|
||||
"swarm-queue-client",
|
||||
"swarm-logs",
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ which. A hive that does **not** deploy the store names the reader's three
|
|||
itself: the operator issues that leaf out of band, and it's the one credential
|
||||
the store can't hand you, being what opens it. ⚠️ Not the gateway's HTTPS certificates and not the hive CA — this is
|
||||
**mTLS between services and the store**, a separate trust domain, because a
|
||||
store that took its identity from an authority it itself distributes could
|
||||
store that took its identity from an authority it will itself distribute could
|
||||
never come up before that authority.
|
||||
|
||||
### 4 · Swarm SSO (only when `deploy.authelia`)
|
||||
|
|
@ -203,8 +203,8 @@ surfaces (the swarm UI below) gate on that group, and an account
|
|||
without it authenticates successfully and is then refused — which reads
|
||||
like a broken login rather than a missing group.
|
||||
|
||||
If an account already exists without it, `user add` refuses rather
|
||||
than amends — adding the group afterwards is `swarmctl user update mara
|
||||
If an account already exists without it, `user add` will refuse rather
|
||||
than amend — adding the group afterwards is `swarmctl user update mara
|
||||
--add-group admins`.
|
||||
|
||||
Detail, including what the password is and why this stays manual:
|
||||
|
|
@ -229,7 +229,7 @@ control: [`swarm/ui.md`](../swarm/ui.md).
|
|||
### 6 · Matrix
|
||||
|
||||
```bash
|
||||
# Ensure the appservice's sender account exists first
|
||||
# Ensure the hive-internal admin account exists first
|
||||
hivectl matrix sync-admin
|
||||
|
||||
# Provision ruth's own matrix account — same bootstrap-bypass reasoning
|
||||
|
|
@ -293,7 +293,7 @@ See [`tools/hivectl.md`](../tools/hivectl.md) for every `hivectl` verb.
|
|||
|
||||
- **No forge admin token is stored in any agent state dir.** Agents
|
||||
hold a regular agent token in their `forge-token` file; sensitive
|
||||
creds (the core token) live on the host.
|
||||
creds (the core token, the matrix admin token) live on the host.
|
||||
- All config changes (forge PRs on `agent-configs/<name>`) go through
|
||||
operator approval — agents can't unilaterally rebuild containers, by design.
|
||||
See [`boundary.md`](../trust-boundary/boundary.md) and [`security.md`](../trust-boundary/security.md).
|
||||
|
|
|
|||
|
|
@ -147,86 +147,29 @@ a token.
|
|||
(`hive-matrix-daemon.path` watching for `matrix-token` appearance)
|
||||
brings the daemon up on the same boot cycle anyway.
|
||||
|
||||
### The appservice's sender account, and why it isn't an admin
|
||||
### The admin account, and why it needs no first-user luck
|
||||
|
||||
`@hive-<hive>:<server_name>` is the appservice's own `sender_localpart`,
|
||||
which the homeserver creates itself when it loads the registration — on a
|
||||
`@hive:<server_name>` is the appservice's own `sender_localpart`, which
|
||||
the homeserver creates itself when it loads the registration — on a
|
||||
zero-user database, inside startup, before the HTTP listener accepts
|
||||
anything. It's an **ordinary account**: nothing promotes it, and the
|
||||
homeserver runs no `admin_execute` for it.
|
||||
anything. Its **admin rights** then come from an explicit
|
||||
`make_user_admin`, run by tuwunel's `admin_execute` in the same startup
|
||||
and likewise before the listener — so a fresh hive has a joined,
|
||||
power-level-100 admin on its first boot.
|
||||
|
||||
**One account per hive.** The localpart carries the hive's name, so a swarm
|
||||
whose hives share a homeserver gives each of them its own identity: the
|
||||
homeserver attributes an action to the hive that took it, and revoking one
|
||||
hive's standing leaves the others alone.
|
||||
This replaces a dependency on being the first account ever registered,
|
||||
which was fragile in both directions: the design excludes an
|
||||
appservice-created account from that automatic grant, and on a homeserver that
|
||||
already had users the rule never fired at all.
|
||||
|
||||
Its access token is the **sender token**, and it's the credential
|
||||
hive-c0re presents for every homeserver call it makes on the hive's
|
||||
behalf. It's per hive for the same reason the account is:
|
||||
`swarm-matrix-ctl` mints it inside the `hive-matrix` container and
|
||||
publishes it to `swarm/hives/<hive>/matrix/sender-token`, and the hive
|
||||
reads it from there under its own certificate. That path sits inside the
|
||||
hive's own read grant (`swarm/hives/<hive>/*`), so a hive fetches its own
|
||||
token and gets a refusal on any other hive's. The name says what it
|
||||
authenticates as — an account the appservice registration brings into being
|
||||
— rather than any privilege level, because it carries none.
|
||||
|
||||
It needs no promotion for what the hive does with it. Creating the hive
|
||||
Space and the chat room, writing their hierarchy and join rules, and
|
||||
inviting agents into them are all ordinary client calls that ride on
|
||||
being the rooms' own creator at power level 100 — there is no homeserver
|
||||
admin in any of it, and no Synapse admin API to reach for either, since
|
||||
tuwunel has none.
|
||||
|
||||
Two operations need an admin **sender**: `hivectl matrix promote-user`
|
||||
and `hivectl matrix reset-password`. Both are `!admin …` messages into
|
||||
`#admins:<server_name>`, and tuwunel only treats a message as a command
|
||||
when its sender is already an admin. They're swarm-level operations,
|
||||
rehomed to the swarm tier rather than granted here; from the hive,
|
||||
`@hive-<hive>:` has no admin sender to make that call with, so both get the
|
||||
admin room's refusal rather than an over-privileged credential that
|
||||
every other call site would also carry. The one hive-side path that
|
||||
depends on them is the automatic password recovery for an agent that
|
||||
has lost its stored password — the admin-sender limitation doesn't
|
||||
touch the ordinary appservice re-login above.
|
||||
|
||||
<details><summary>Upgrading a hive that shared one sender account with every other hive</summary>
|
||||
|
||||
Nothing to do, and no window where the hive is without an account.
|
||||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
- **The old shared value at `swarm/services/matrix/sender-token` is read by
|
||||
nothing.** `hive-c0re` and `swarm-matrix-ctl` both build the path from the
|
||||
same function, and it now carries the hive's name — so the old object
|
||||
stays in the store, unread, until an operator deletes it. Delete it or
|
||||
leave it; the accounts it authenticates as keep their own standing either
|
||||
way, since an access token lives on the device that minted it.
|
||||
- **The hive re-mints, per hive, on the next boot.** `ensure_hive_user` runs
|
||||
from the startup sweep. It short-circuits on the token file it already has
|
||||
— so the first boot after the upgrade keeps running on the shared token
|
||||
until that file goes — and on a hive with no such file it reads the new
|
||||
per-hive store path, finds nothing on a store that has never held one, and
|
||||
falls through to the register-or-appservice-login ladder against
|
||||
`@hive-<hive>:`. That ladder needs only the `as_token`, which is per hive
|
||||
and on local disk, so it works with or without a reachable store.
|
||||
- **To move a hive onto its own account now**, delete its sender-token file
|
||||
(`hivectl matrix sync-admin` re-provisions, or the next sweep does) — the
|
||||
ladder then registers `@hive-<hive>:` and persists that account's token.
|
||||
Only then does the hive stop presenting the shared one.
|
||||
- **The rooms the shared account created don't follow the new account, and
|
||||
this is the one step that needs a decision.** Membership is per account.
|
||||
`ensure_hive_space` takes the stored room id first, so the sweep hands the
|
||||
new account the old Space's id, the invite it then sends comes back
|
||||
refused (a non-member can't invite), and the sweep logs it and carries
|
||||
on — degraded, not crashed. Two ways out, both operator-chosen. Either invite
|
||||
`@hive-<hive>:` into the existing Space and chat room from a client, which
|
||||
keeps the history; or delete the hive's stored room-id files, after which
|
||||
the next sweep creates a Space and chat room owned by the new account and
|
||||
invites every agent into them. Do one of the two; leaving it gives a hive
|
||||
that provisions no rooms.
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
</details>
|
||||
Tuwunel doesn't support bootstrapping promotion over the API, and
|
||||
that's upstream's design rather than a gap: it only treats an admin-room message as a
|
||||
command when its sender is already an admin. `admin_execute` is the one
|
||||
lever with no sender to check. hive-c0re re-checks the result on every
|
||||
sweep by reading the admin account's own joined-rooms list; if the rights
|
||||
are missing it says so, names
|
||||
`systemctl restart container@hive-matrix` as the fix, and carries on —
|
||||
agent accounts, the hive Space and the chat room need no admin.
|
||||
|
||||
<details><summary>Upgrading a hive that used the registration token</summary>
|
||||
|
||||
|
|
@ -242,10 +185,10 @@ restarts, so the first boot after the switch already has both halves.
|
|||
- **The per-agent sweep honours existing token files.** It skips any
|
||||
agent that already has a `matrix-token`, so it re-registers no account
|
||||
and displaces no session.
|
||||
- **The sender account may already be an admin** on such a hive (it won the
|
||||
first-user grant when the hive was new). Nothing here demotes it; the
|
||||
homeserver no longer promotes it, so a hive built fresh has an
|
||||
ordinary account and an older one keeps whatever standing it acquired.
|
||||
- **The admin account is already admin** on such a hive (it won the
|
||||
first-user grant when the hive was new), so the startup promotion is a
|
||||
no-op — upstream's `make_user_admin` short-circuits when the user is
|
||||
already joined at power level 100.
|
||||
- **`/var/lib/hyperhive/matrix-register-token` stays on disk**, read by
|
||||
nothing. Delete it or leave it; neither does any harm.
|
||||
- **`registrationTokenFile` is a removed option.** A config that still
|
||||
|
|
@ -294,8 +237,8 @@ Initial rollout settings:
|
|||
## Hive Matrix Space
|
||||
|
||||
On first boot, after hive-c0re provisions all agent accounts, it
|
||||
creates a private **Matrix Space** named `"hive"` using its own hive
|
||||
account (`@hive-<hive>:<server_name>`) and invites every provisioned agent
|
||||
creates a private **Matrix Space** named `"hive"` using the admin
|
||||
account (`@hive:<server_name>`) and invites every provisioned agent
|
||||
into it. This gives the operator a single Space in FluffyChat or any
|
||||
Matrix client that groups all agent-to-agent + operator rooms in one
|
||||
place.
|
||||
|
|
@ -333,9 +276,9 @@ services.hyperhive.swarm.matrix = {
|
|||
```
|
||||
|
||||
**`trustedServers`** (default `[]`) — list of peer homeserver names
|
||||
whose signing keys tuwunel fetches and trusts. tuwunel enables
|
||||
federation at the protocol level from first boot (`allow_federation =
|
||||
true`) but trusts no remote homeserver until you list it here. For a closed
|
||||
whose signing keys tuwunel will fetch and trust. tuwunel enables federation
|
||||
at the protocol level from first boot (`allow_federation = true`) but
|
||||
trusts no remote homeserver until you list it here. For a closed
|
||||
single-hive deployment the default empty list is correct — add peer
|
||||
hive domains here when connecting hives into a swarm (see
|
||||
[`docs/swarm/`](../swarm/README.md)).
|
||||
|
|
|
|||
|
|
@ -55,11 +55,10 @@ strategy for every credential, including the mTLS leaf.
|
|||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
| store path | minter | reader — pulls at runtime, holds in memory | renewal |
|
||||
| -------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------- |
|
||||
| -------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | -------------- |
|
||||
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated |
|
||||
| `swarm/agents/<agent>/bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated |
|
||||
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated |
|
||||
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated |
|
||||
| `swarm/hives/<hive>/queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated |
|
||||
| `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated |
|
||||
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated |
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ Manual entry point to the same idempotent provisioning c0re runs at boot — for
|
|||
###### **Subcommands:**
|
||||
|
||||
* `create-user` — Create or refresh the matrix account + access token for `<name>`
|
||||
* `sync-admin` — Provision (or re-provision) the matrix appservice's sender account
|
||||
* `sync-admin` — Provision (or re-provision) the hive system admin matrix account
|
||||
* `promote-user` — Promote a matrix user to homeserver admin
|
||||
* `reset-password` — Reset a matrix user's password via the admin API
|
||||
* `invite` — Invite a matrix user to the hive Space, or a specific room with `--room`. Idempotent
|
||||
|
|
@ -193,9 +193,9 @@ For an existing agent, persists the token to its state dir; for a human/other ac
|
|||
|
||||
## `hivectl matrix sync-admin`
|
||||
|
||||
Provision (or re-provision) the matrix appservice's sender account.
|
||||
Provision (or re-provision) the hive system admin matrix account.
|
||||
|
||||
Runs automatically on startup; run manually to recover a missing access token.
|
||||
Runs automatically on startup; run manually to recover a missing admin token.
|
||||
|
||||
**Usage:** `hivectl matrix sync-admin`
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ running (`services.hyperhive.deploy.matrix.enable = true`).
|
|||
hivectl matrix create-user iris # provision (or re-provision) matrix account for agent `iris`
|
||||
hivectl matrix create-user mara # create matrix account for a human; prints access_token to stdout
|
||||
hivectl matrix create-user mara --password hunter2 # set a client-login password
|
||||
hivectl matrix sync-admin # provision / refresh the appservice's sender account
|
||||
hivectl matrix sync-admin # provision / refresh the hive internal admin account
|
||||
hivectl matrix promote-user mara # promote an existing matrix user to homeserver admin
|
||||
hivectl matrix reset-password iris # generate and set a new random password for `iris`; prints it
|
||||
hivectl matrix invite mara # invite a user to the hive Space
|
||||
|
|
@ -74,23 +74,19 @@ hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a spec
|
|||
- `create-user`: for agents, persists the `access_token` to
|
||||
`<state>/matrix-token`. Skips registration when the file already
|
||||
exists — delete it first to force re-registration.
|
||||
- `sync-admin`: ensures this hive's appservice sender account
|
||||
(`@hive-<hive>:<server_name>`, one per hive) exists
|
||||
(the account `hive-c0re` provisions rooms with). Token persisted to the
|
||||
access token path. Safe to run again — idempotent.
|
||||
- `sync-admin`: ensures the hive's internal admin matrix user exists
|
||||
(used by `hive-c0re` for admin-room commands). Token persisted to the
|
||||
admin token path. Safe to run again — idempotent.
|
||||
- `promote-user`: promotes an already-registered user to homeserver
|
||||
admin by an `!admin` command in `#admins`. ⚠️ Needs an admin **sender**,
|
||||
which `@hive-<hive>:` isn't — promotion is a swarm-level operation, rehomed to
|
||||
the swarm tier rather than granted here, so it has no admin sender to
|
||||
call it with from the hive.
|
||||
- `reset-password`: asks the admin room to set a new random
|
||||
password and prints it to stdout. ⚠️ Same admin-**sender** requirement,
|
||||
and the same swarm-level rehoming, so it's unavailable from the hive
|
||||
too. Useful if an agent or human lost credentials.
|
||||
admin via the matrix admin API. Requires `sync-admin` to have run
|
||||
first (needs a valid admin token).
|
||||
- `reset-password`: calls the matrix admin API to set a new random
|
||||
password and prints it to stdout. Useful if an agent or human lost
|
||||
credentials.
|
||||
- `invite`: invites a matrix user (full `@user:server` or a bare
|
||||
localpart, qualified with the homeserver's `server_name`) to the hive
|
||||
Space by default, or to a `--room` id / `#alias`. Uses the sender
|
||||
token; the account must be a member of the target room with
|
||||
Space by default, or to a `--room` id / `#alias`. Uses the hive admin
|
||||
token; the admin account must be a member of the target room with
|
||||
invite power (it owns the hive Space, so that case always works).
|
||||
Idempotent — already-member / already-invited is a no-op.
|
||||
|
||||
|
|
@ -229,7 +225,7 @@ granting that group the one action `machinectl shell` needs); `choom`
|
|||
checks this itself and refuses up front with a message naming the
|
||||
requirement rather than letting systemd reject the exec later with an
|
||||
opaque polkit prompt. Same caveat as the socket grant: a shell opened
|
||||
before the operator added you to `hive-admin` doesn't see it until you log
|
||||
before the operator added you to `hive-admin` won't see it until you log
|
||||
back in — secondary group membership applies at login.
|
||||
|
||||
It also needs the daemon socket, unlike the other exec-into-a-container
|
||||
|
|
|
|||
|
|
@ -94,20 +94,6 @@ it.
|
|||
the grant per hive (and pay for the re-emission), or give each agent container
|
||||
its own store identity so credentials never pass through a hive at all.
|
||||
|
||||
### Per-hive credentials live under the hive kind, not beside a service
|
||||
|
||||
`swarm_secret_client::policy` scopes the **hive** stanza narrowly (unlike the
|
||||
agent and service stanzas above) because a hive's own path names it, so
|
||||
scoping to the reader's own name costs nothing and drifts nowhere. That only
|
||||
holds if a credential that must be one-per-hive is actually stored under the
|
||||
hive kind — the matrix sender token was originally published to
|
||||
`swarm/services/matrix/sender-token`, under the **service** kind, and so was
|
||||
readable by every hive though it belonged to only one. Making it per-hive meant
|
||||
moving its path under the hive kind, not narrowing the service stanza's grant.
|
||||
**A credential that must be one-per-hive goes under `Kind::Hive`**; putting it
|
||||
under `Kind::Service` and expecting the grant to scope it — that's the mistake
|
||||
this note exists to stop.
|
||||
|
||||
### Threat model: prompt injection → confused deputy
|
||||
|
||||
The realistic adversary **never needs to breach the container**. They supply
|
||||
|
|
|
|||
|
|
@ -156,9 +156,6 @@
|
|||
services.hyperhive.deploy.nats.authPackage =
|
||||
lib.mkDefault
|
||||
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-nats-auth;
|
||||
services.hyperhive.deploy.matrix.ctlPackage =
|
||||
lib.mkDefault
|
||||
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-matrix-ctl;
|
||||
services.hyperhive.deploy.authelia.bridgePackage =
|
||||
lib.mkDefault
|
||||
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-authelia-bridge;
|
||||
|
|
|
|||
|
|
@ -65,43 +65,18 @@ const HTTP_TIMEOUT_SECS: u64 = 10;
|
|||
/// store it nowhere.
|
||||
const PASSWORD_BYTES: usize = 32;
|
||||
|
||||
/// Matrix localpart this hive acts as. Not an agent; has no state dir.
|
||||
///
|
||||
/// **Per hive, derived from the hive's name** — `hive-<name>`, built by
|
||||
/// `swarm_secret_client::matrix::hive_localpart`, which is the one place the
|
||||
/// derivation lives. It used to be the bare constant `hive`, and a swarm runs
|
||||
/// one homeserver: every hive on it logged in as the same `@hive:`, so the
|
||||
/// homeserver could not attribute an action to a hive and revoking one hive's
|
||||
/// standing revoked all of them.
|
||||
/// Matrix localpart for the hive system admin account. Not an agent; has
|
||||
/// no state dir.
|
||||
///
|
||||
/// Also the `sender_localpart` of the hive's appservice registration,
|
||||
/// which is what creates this account on a homeserver that has never had
|
||||
/// one: the homeserver creates a registration's sender user itself, at
|
||||
/// startup, before it accepts a request. See
|
||||
/// `nix/host-modules/hive-matrix.nix`, where the same string is rendered as
|
||||
/// `hiveLocalpart`. **The two must match**; nothing wires an override
|
||||
/// across.
|
||||
///
|
||||
/// An ordinary account, with no homeserver-admin standing: what it
|
||||
/// provisions — the Space, the chat room, the invites — it provisions as
|
||||
/// the creator of those rooms.
|
||||
///
|
||||
/// # Errors
|
||||
/// When `HYPERHIVE_HIVE_NAME` is unset or empty. That is the same variable
|
||||
/// [`stored_sender_token`] logs in with and `hive-c0re`'s meta flake asserts
|
||||
/// for every hive, so its absence is a broken deployment rather than a
|
||||
/// supported one — and there is no safe default to fall back to: the only one
|
||||
/// available is the shared account this replaced.
|
||||
pub fn hive_localpart() -> Result<String> {
|
||||
let hive = std::env::var("HYPERHIVE_HIVE_NAME")
|
||||
.ok()
|
||||
.filter(|h| !h.is_empty())
|
||||
.context(
|
||||
"matrix: HYPERHIVE_HIVE_NAME is unset, so this hive has no matrix account of its own \
|
||||
— the localpart is derived from the hive name and has no swarm-wide fallback",
|
||||
)?;
|
||||
Ok(swarm_secret_client::matrix::hive_localpart(&hive))
|
||||
}
|
||||
/// startup, before it accepts a request. The account's *admin rights*
|
||||
/// then come from the `admin_execute` promotion beside that registration
|
||||
/// — see `nix/host-modules/hive-matrix.nix`, where this same literal
|
||||
/// appears as `adminLocalpart`. **The two must match**; nothing wires an
|
||||
/// override across.
|
||||
pub const HIVE_ADMIN_LOCALPART: &str = "hive";
|
||||
|
||||
/// Display name of the hive Space. Plain text, no special characters, so
|
||||
/// the Space stays rediscoverable by name (no room alias needed) even when
|
||||
|
|
@ -119,11 +94,11 @@ pub const HIVE_CHAT_ROOM_NAME: &str = "hive-chat";
|
|||
const HIVE_CHAT_ROOM_TOPIC: &str =
|
||||
"Hive-wide chat for all agents and the operator. Auto-provisioned by hive-c0re.";
|
||||
|
||||
/// Host path for the appservice sender account's matrix access token. Outside every
|
||||
/// Host path for the hive admin matrix access token. Outside every
|
||||
/// purgeable path — not deleted by `destroy --purge` on any agent.
|
||||
#[must_use]
|
||||
pub fn sender_token_path() -> PathBuf {
|
||||
crate::paths::matrix_sender_token()
|
||||
pub fn admin_token_path() -> PathBuf {
|
||||
crate::paths::matrix_admin_token()
|
||||
}
|
||||
|
||||
/// Token file inside the agent's bind-mounted state dir (visible as
|
||||
|
|
@ -407,26 +382,21 @@ async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Re
|
|||
extract_access_token(&json)
|
||||
}
|
||||
|
||||
/// Auto-recovery helper: reset a user's matrix password through the admin
|
||||
/// room when the locally stored password is missing. Returns the new
|
||||
/// password (already persisted to [`password_path`]) on success.
|
||||
///
|
||||
/// ⚠️ Needs an **admin sender**, which `@hive-<hive>:` is not — the reset is a
|
||||
/// `!admin` command and tuwunel only treats a message as a command when
|
||||
/// its sender is an admin in that room. So this recovery path fails until
|
||||
/// the two admin operations are rehomed at swarm level; the ordinary
|
||||
/// route (the stored password, or an appservice login) is unaffected.
|
||||
/// Auto-recovery helper: reset a user's matrix password via the admin API
|
||||
/// when the locally stored password is missing. Requires a valid hive admin
|
||||
/// token at [`admin_token_path()`]. Returns the new password (already
|
||||
/// persisted to [`password_path`]) on success.
|
||||
///
|
||||
/// 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 admin_token = read_admin_token()
|
||||
.context("matrix: admin token unavailable for auto-recovery; provision hive admin first")?;
|
||||
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)
|
||||
let effective_password = reset_user_password(client, &admin_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");
|
||||
|
|
@ -446,7 +416,7 @@ fn encode_room_id_for_url(room_id: &str) -> String {
|
|||
/// Look up the room ID for the `#admins:<server>` alias.
|
||||
async fn discover_admin_room_id(
|
||||
client: &reqwest::Client,
|
||||
sender_token: &str,
|
||||
admin_token: &str,
|
||||
server_name: &str,
|
||||
) -> Result<String> {
|
||||
let base = matrix_base()?;
|
||||
|
|
@ -455,7 +425,7 @@ async fn discover_admin_room_id(
|
|||
let url = format!("{base}/_matrix/client/v3/directory/room/{encoded_alias}");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(sender_token)
|
||||
.bearer_auth(admin_token)
|
||||
.send()
|
||||
.await
|
||||
.context("matrix: GET admin room alias")?;
|
||||
|
|
@ -583,7 +553,7 @@ mod extract_new_password_tests {
|
|||
/// Generic over `T` so both password-returning and `()` callers share the loop.
|
||||
async fn admin_room_send_and_poll<T>(
|
||||
client: &reqwest::Client,
|
||||
sender_token: &str,
|
||||
admin_token: &str,
|
||||
server_name: &str,
|
||||
room_url: &str,
|
||||
command: &str,
|
||||
|
|
@ -596,7 +566,7 @@ async fn admin_room_send_and_poll<T>(
|
|||
format!("{base}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}");
|
||||
let send_resp = client
|
||||
.put(&send_url)
|
||||
.bearer_auth(sender_token)
|
||||
.bearer_auth(admin_token)
|
||||
.json(&serde_json::json!({"msgtype": "m.text", "body": command}))
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -617,13 +587,13 @@ async fn admin_room_send_and_poll<T>(
|
|||
// Poll for bot response: fetch the 20 most recent events (newest-first)
|
||||
// on each tick. Walk the list until we hit our own command event_id;
|
||||
// everything *before* that marker arrived after our command.
|
||||
let own_user_id = format!("@{}:{server_name}", hive_localpart()?);
|
||||
let own_user_id = format!("@{HIVE_ADMIN_LOCALPART}:{server_name}");
|
||||
let poll_url = format!("{base}/_matrix/client/v3/rooms/{room_url}/messages?dir=b&limit=20");
|
||||
for _ in 0..15_u8 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
let poll_json = client
|
||||
.get(&poll_url)
|
||||
.bearer_auth(sender_token)
|
||||
.bearer_auth(admin_token)
|
||||
.send()
|
||||
.await
|
||||
.context("matrix: admin room poll")?
|
||||
|
|
@ -671,22 +641,22 @@ async fn admin_room_send_and_poll<T>(
|
|||
}
|
||||
|
||||
/// Reset a user's password via the Matrix admin room (`#admins:<server>`).
|
||||
/// Sends `!admin users reset-password @<localpart>:<server>` as `@hive-<hive>:`, polls for
|
||||
/// the bot's response containing the new password.
|
||||
/// Sends `!admin users reset-password @<localpart>:<server>` as @hive, polls for the bot's
|
||||
/// response containing the new password.
|
||||
///
|
||||
/// Returns the new password; caller is responsible for persisting it.
|
||||
async fn admin_room_reset_password(
|
||||
client: &reqwest::Client,
|
||||
sender_token: &str,
|
||||
admin_token: &str,
|
||||
server_name: &str,
|
||||
localpart: &str,
|
||||
) -> Result<String> {
|
||||
let room_id = discover_admin_room_id(client, sender_token, server_name).await?;
|
||||
let room_id = discover_admin_room_id(client, admin_token, server_name).await?;
|
||||
let room_url = encode_room_id_for_url(&room_id);
|
||||
let command = format!("!admin users reset-password @{localpart}:{server_name}");
|
||||
admin_room_send_and_poll(
|
||||
client,
|
||||
sender_token,
|
||||
admin_token,
|
||||
server_name,
|
||||
&room_url,
|
||||
&command,
|
||||
|
|
@ -791,15 +761,13 @@ pub async fn ensure_user_for(client: &reqwest::Client, name: &str, as_token: &st
|
|||
{
|
||||
pw
|
||||
} else {
|
||||
// Password file missing — attempt auto-recovery through the
|
||||
// admin room.
|
||||
// Password file missing — attempt auto-recovery via admin API.
|
||||
// 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.
|
||||
// homeserver still has the accounts. Requires the hive admin
|
||||
// token at /var/lib/hyperhive/matrix/admin-token.
|
||||
tracing::info!(
|
||||
%name,
|
||||
"matrix: stored password missing, attempting admin-room auto-recovery"
|
||||
"matrix: stored password missing, attempting admin-API auto-recovery"
|
||||
);
|
||||
match auto_reset_password(client, name).await {
|
||||
Ok(new_pw) => new_pw,
|
||||
|
|
@ -915,52 +883,42 @@ pub async fn sync_agent_standalone(name: &str) {
|
|||
sync_agent(&client, name, &as_token).await;
|
||||
}
|
||||
|
||||
/// Ensure the hive's `@hive-<hive>:` matrix user exists and that its access token is
|
||||
/// persisted at [`sender_token_path()`].
|
||||
/// Ensure the hive system admin matrix user exists, that its token is
|
||||
/// persisted at [`admin_token_path()`], and that it actually holds admin
|
||||
/// rights.
|
||||
///
|
||||
/// **Nothing here depends on registration order, and nothing here is
|
||||
/// privileged.** The account used to have to be the first ever
|
||||
/// registered, to win tuwunel's automatic first-user grant — a rule that
|
||||
/// cannot fire for an appservice-created account at all. It is now an
|
||||
/// ordinary account: the homeserver creates it because it is the
|
||||
/// appservice registration's `sender_localpart`, and everything the hive
|
||||
/// provisions with it, it provisions as the creator of those rooms.
|
||||
/// **Nothing here depends on registration order any more.** The account
|
||||
/// used to have to be the first ever registered, to win tuwunel's
|
||||
/// automatic first-user grant — a rule that cannot fire for an
|
||||
/// appservice-created account at all, and one that silently did nothing
|
||||
/// on a homeserver that already had users. Admin rights now come from an
|
||||
/// explicit `make_user_admin`: the `admin_execute` entry beside the
|
||||
/// appservice registration performs it at homeserver startup, and
|
||||
/// [`ensure_admin_rights`] checks the result and says so when it is
|
||||
/// missing.
|
||||
///
|
||||
/// Idempotent — skips the account work when the token file already exists
|
||||
/// and is non-empty.
|
||||
///
|
||||
/// The token is taken from the **swarm secret store** when it is there:
|
||||
/// `swarm-matrix-ctl`, the oneshot inside the matrix container, publishes
|
||||
/// it under an identity of its own, and taking it from there is what lets a
|
||||
/// hive that holds no `as_token` have an admin at all. The mint ladder below
|
||||
/// stays as the fallback for a store that is empty, unconfigured or
|
||||
/// unreachable — which is every swarm whose matrix container predates that
|
||||
/// binary.
|
||||
pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Result<()> {
|
||||
pub async fn ensure_admin_user(client: &reqwest::Client, as_token: &str) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let path = sender_token_path();
|
||||
let path = admin_token_path();
|
||||
if path.exists()
|
||||
&& let Ok(existing) = std::fs::read_to_string(&path)
|
||||
&& !existing.trim().is_empty()
|
||||
{
|
||||
tracing::debug!("matrix: the sender token is already present");
|
||||
tracing::debug!("matrix: hive admin token already present");
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(token) = stored_sender_token().await {
|
||||
return persist_sender_token(&path, &token);
|
||||
}
|
||||
// Per hive, and fatal when it cannot be derived: the fallback ladder below
|
||||
// must not mint under some other hive's name.
|
||||
let localpart = hive_localpart()?;
|
||||
let password = random_password()?;
|
||||
let access_token = match register_user(client, &localpart, as_token, &password).await {
|
||||
let access_token = match register_user(client, HIVE_ADMIN_LOCALPART, as_token, &password).await
|
||||
{
|
||||
Ok(token) => {
|
||||
let pw_path = password_path(&localpart);
|
||||
let pw_path = password_path(HIVE_ADMIN_LOCALPART);
|
||||
if let Some(parent) = pw_path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
if let Err(e) = std::fs::write(&pw_path, format!("{password}\n")) {
|
||||
tracing::warn!(error = ?e, %localpart, "matrix: failed to persist the sender account password");
|
||||
tracing::warn!(error = ?e, "matrix: failed to persist hive admin password");
|
||||
} else {
|
||||
let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
|
@ -973,108 +931,125 @@ pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Resul
|
|||
// hive-c0re gets a chance to ask. An appservice login needs
|
||||
// no password, which is just as well since an account the
|
||||
// homeserver created has none.
|
||||
tracing::info!(%localpart, "matrix: the sender account already exists, logging in as the appservice");
|
||||
match appservice_login(client, as_token, &localpart).await {
|
||||
tracing::info!("matrix: hive admin user already exists, logging in as the appservice");
|
||||
match appservice_login(client, as_token, HIVE_ADMIN_LOCALPART).await {
|
||||
Ok(token) => token,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, %localpart, "matrix: appservice login for the sender account failed; falling back to the stored password");
|
||||
let pw_path = password_path(&localpart);
|
||||
tracing::warn!(error = ?e, "matrix: appservice login for the hive admin failed; falling back to the stored password");
|
||||
let pw_path = password_path(HIVE_ADMIN_LOCALPART);
|
||||
let stored = std::fs::read_to_string(&pw_path)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_owned())
|
||||
.filter(|s| !s.is_empty())
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"matrix: @{localpart}: exists, appservice login failed, and no \
|
||||
"matrix: hive admin user exists, appservice login failed, and no \
|
||||
password is stored at {} — check that the registration file's \
|
||||
namespace covers @{localpart} and that the homeserver \
|
||||
namespace covers @{HIVE_ADMIN_LOCALPART} and that the homeserver \
|
||||
loaded it",
|
||||
pw_path.display()
|
||||
)
|
||||
})?;
|
||||
login_user(client, &localpart, &stored).await?
|
||||
login_user(client, HIVE_ADMIN_LOCALPART, &stored).await?
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(other) => return Err(other),
|
||||
};
|
||||
persist_sender_token(&path, &access_token)
|
||||
}
|
||||
|
||||
/// Write the appservice sender account's access token to `path`, 0600, creating the
|
||||
/// directory if it is not there.
|
||||
///
|
||||
/// Shared by both arms of [`ensure_hive_user`] rather than duplicated into
|
||||
/// the store one: the file's mode is the only thing keeping an unprivileged
|
||||
/// reader off the hive's matrix credential, and a second copy of that decision
|
||||
/// is one that can be edited alone.
|
||||
fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
std::fs::write(path, format!("{access_token}\n"))
|
||||
.with_context(|| format!("matrix: write the sender token to {}", path.display()))?;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||
tracing::info!(path = %path.display(), "matrix: provisioned the sender token");
|
||||
std::fs::write(&path, format!("{access_token}\n"))
|
||||
.with_context(|| format!("matrix: write hive admin token to {}", path.display()))?;
|
||||
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
|
||||
tracing::info!(path = %path.display(), "matrix: provisioned hive admin token");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch the sender token `swarm-matrix-ctl` published, under
|
||||
/// this hive's own store identity.
|
||||
/// Check that the hive admin account holds admin rights, and repair it
|
||||
/// through the admin room when it does not.
|
||||
///
|
||||
/// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` —
|
||||
/// the same role string `workers::credential` logs in with, and already in
|
||||
/// this process's environment, so the store read costs no plumbing through
|
||||
/// [`ensure_all`]. That name is also the path's own hive segment, which is
|
||||
/// what makes this read reach **this** hive's token and 403 on any other's:
|
||||
/// the grant behind it is the hive's own `swarm/hives/<name>/*` stanza, not
|
||||
/// the swarm-wide `swarm/services/*` one the shared token used to sit under.
|
||||
/// Admin-ness in tuwunel is membership of the admin room, so that is what
|
||||
/// this reads: the account's own joined-rooms list, which needs no
|
||||
/// privileges to fetch. When the admin room is in it there is nothing to
|
||||
/// do and nothing is sent — worth insisting on, because the repair is a
|
||||
/// message in a room and this runs on every sweep.
|
||||
///
|
||||
/// `None`, never an error, for every way this can come up empty — no hive
|
||||
/// name, no `BAO_*` identity, an unreachable store, nothing at the path. All
|
||||
/// four mean the same thing to the caller ("mint it the old way"), and three
|
||||
/// of them are the ordinary state of a swarm that has not deployed `swarm-matrix-ctl`
|
||||
/// yet, so raising would turn a supported deployment into a warning every
|
||||
/// sweep.
|
||||
/// When it is absent, the repair is attempted anyway (a stale or failed
|
||||
/// read is cheaper to retry than to reason about) and a failure is
|
||||
/// reported rather than raised: agent accounts, the hive Space and the
|
||||
/// chat room all work without an admin, so a hive with an unpromoted
|
||||
/// admin is degraded, not broken. Only `hivectl matrix promote-user` /
|
||||
/// `reset-password` need it.
|
||||
///
|
||||
/// 🩸 Logs the store **path** and never the value.
|
||||
async fn stored_sender_token() -> Option<String> {
|
||||
let hive = std::env::var("HYPERHIVE_HIVE_NAME")
|
||||
.ok()
|
||||
.filter(|h| !h.is_empty())?;
|
||||
let path = match swarm_secret_client::matrix::sender_token_path(&hive) {
|
||||
Ok(path) => path,
|
||||
/// Returns whether the account holds admin rights now. A `false` has
|
||||
/// already been logged, with what to do about it.
|
||||
async fn ensure_admin_rights(
|
||||
client: &reqwest::Client,
|
||||
admin_token: &str,
|
||||
server_name: &str,
|
||||
) -> bool {
|
||||
let room_id = match discover_admin_room_id(client, admin_token, server_name).await {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "matrix: this hive's name does not form a store path");
|
||||
return None;
|
||||
tracing::warn!(error = ?e, "matrix: cannot resolve #admins — not checking the hive admin's rights");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let store = match swarm_secret_client::SecretStore::from_env(&hive).await {
|
||||
Ok(store) => store,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "matrix: no swarm secret store to read the sender token from");
|
||||
return None;
|
||||
match joined_rooms(client, admin_token).await {
|
||||
Some(rooms) if rooms.iter().any(|r| r == &room_id) => {
|
||||
tracing::debug!("matrix: hive admin is in the admin room");
|
||||
return true;
|
||||
}
|
||||
};
|
||||
match store
|
||||
.read::<swarm_secret_client::matrix::Credential>(&path)
|
||||
.await
|
||||
Some(_) => {
|
||||
tracing::warn!(
|
||||
"matrix: hive admin is not in the admin room — attempting to promote it"
|
||||
);
|
||||
}
|
||||
None => tracing::debug!(
|
||||
"matrix: could not read the hive admin's joined rooms; attempting to promote it"
|
||||
),
|
||||
}
|
||||
if let Err(e) =
|
||||
promote_user_to_admin(client, admin_token, HIVE_ADMIN_LOCALPART, server_name).await
|
||||
{
|
||||
Ok(credential) if !credential.value.trim().is_empty() => {
|
||||
tracing::info!(%path, "matrix: taking the sender token from the swarm store");
|
||||
Some(credential.value)
|
||||
}
|
||||
Ok(_) => {
|
||||
tracing::warn!(%path, "matrix: the stored sender token is empty; minting instead");
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(%path, error = %e, "matrix: no sender token in the store; minting instead");
|
||||
None
|
||||
// The honest message. Promoting through the admin room requires
|
||||
// being admin already, so the one lever that works on a
|
||||
// homeserver with no admin at all is the `admin_execute` entry in
|
||||
// the hive-matrix module — which runs at startup, which means a
|
||||
// restart is the fix rather than another sweep.
|
||||
tracing::warn!(
|
||||
error = ?e,
|
||||
"matrix: hive admin '@{HIVE_ADMIN_LOCALPART}' holds no admin rights. \
|
||||
The homeserver promotes it at startup (admin_execute in hive-matrix.nix), \
|
||||
so `systemctl restart container@hive-matrix` grants it. Agent accounts and \
|
||||
room provisioning are unaffected; `hivectl matrix promote-user` and \
|
||||
`reset-password` need it."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
tracing::info!("matrix: promoted the hive admin through the admin room");
|
||||
true
|
||||
}
|
||||
|
||||
/// The rooms `token`'s account has joined, or `None` when the list could
|
||||
/// not be read. `None` is deliberately not an empty list: "no rooms" and
|
||||
/// "no answer" lead to different decisions in [`ensure_admin_rights`].
|
||||
async fn joined_rooms(client: &reqwest::Client, token: &str) -> Option<Vec<String>> {
|
||||
let base = matrix_http()?;
|
||||
let url = format!("{base}/_matrix/client/v3/joined_rooms");
|
||||
let resp = client.get(&url).bearer_auth(token).send().await.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let body = resp.json::<serde_json::Value>().await.ok()?;
|
||||
Some(
|
||||
body["joined_rooms"]
|
||||
.as_array()?
|
||||
.iter()
|
||||
.filter_map(|r| r.as_str().map(ToOwned::to_owned))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether an admin-room reply says a `make-user-admin` succeeded.
|
||||
|
|
@ -1128,16 +1103,12 @@ mod is_make_admin_success_tests {
|
|||
|
||||
/// Promote a user to homeserver admin via the Matrix admin room
|
||||
/// (`#admins:<server>`). Sends `!admin users make-user-admin @<localpart>:<server>` as
|
||||
/// `@hive-<hive>:`, polls for the bot's success reply.
|
||||
/// @hive, polls for the bot's success reply.
|
||||
///
|
||||
/// ⚠️ Requires the **sender** to be an admin already — tuwunel only
|
||||
/// treats a message as a command when its sender is in the admin room.
|
||||
/// `@hive-<hive>:` is an ordinary account (`hive-matrix.nix` grants it no
|
||||
/// `admin_execute` promotion), so this call has no working sender from
|
||||
/// the hive and fails with the admin room's refusal. Promotion is a
|
||||
/// swarm-level operation and is being rehomed as such; this stays here,
|
||||
/// failing loudly, rather than justifying an over-privileged token that
|
||||
/// all 13 ordinary call sites would also carry.
|
||||
/// So this promotes a *second* user; it cannot bootstrap the first one.
|
||||
/// That is what the `admin_execute` entry in `hive-matrix.nix` is for.
|
||||
///
|
||||
/// Goes through the admin room rather than a direct HTTP call because
|
||||
/// tuwunel implements parts of the Synapse admin API but not user
|
||||
|
|
@ -1145,16 +1116,16 @@ mod is_make_admin_success_tests {
|
|||
/// intended long-term mechanism, not a stopgap awaiting an upstream fix.
|
||||
pub async fn promote_user_to_admin(
|
||||
client: &reqwest::Client,
|
||||
sender_token: &str,
|
||||
admin_token: &str,
|
||||
localpart: &str,
|
||||
server_name: &str,
|
||||
) -> Result<()> {
|
||||
let room_id = discover_admin_room_id(client, sender_token, server_name).await?;
|
||||
let room_id = discover_admin_room_id(client, admin_token, server_name).await?;
|
||||
let room_url = encode_room_id_for_url(&room_id);
|
||||
let command = format!("!admin users make-user-admin @{localpart}:{server_name}");
|
||||
admin_room_send_and_poll(
|
||||
client,
|
||||
sender_token,
|
||||
admin_token,
|
||||
server_name,
|
||||
&room_url,
|
||||
&command,
|
||||
|
|
@ -1172,25 +1143,19 @@ pub async fn promote_user_to_admin(
|
|||
|
||||
/// Reset a user's password via the Matrix admin room (`#admins:<server>`).
|
||||
///
|
||||
/// Sends `!admin users reset-password @<localpart>:<server>` to the admin room as
|
||||
/// `@hive-<hive>:`, polls for the bot's response containing the new password, and persists
|
||||
/// Sends `!admin users reset-password @<localpart>:<server>` to the admin room as @hive,
|
||||
/// polls for the bot's response containing the new password, and persists
|
||||
/// it to the non-purgeable creds path so [`ensure_user_for`] can re-login
|
||||
/// on the next provisioning sweep.
|
||||
///
|
||||
/// ⚠️ Same admin-**sender** requirement as [`promote_user_to_admin`], and
|
||||
/// the same consequence: `@hive-<hive>:` is an ordinary account with no admin
|
||||
/// sender, and reset, like promotion, is a swarm-level operation rehomed
|
||||
/// to the swarm tier rather than granted here — so it has no working
|
||||
/// sender from the hive either.
|
||||
///
|
||||
/// Returns the new password for use in subsequent `login_user` calls.
|
||||
pub async fn reset_user_password(
|
||||
client: &reqwest::Client,
|
||||
sender_token: &str,
|
||||
admin_token: &str,
|
||||
localpart: &str,
|
||||
server_name: &str,
|
||||
) -> Result<String> {
|
||||
let pw = admin_room_reset_password(client, sender_token, server_name, localpart)
|
||||
let pw = admin_room_reset_password(client, admin_token, server_name, localpart)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("matrix: admin-room password reset for @{localpart}:{server_name}")
|
||||
|
|
@ -1240,19 +1205,19 @@ pub async fn discover_server_name(client: &reqwest::Client) -> Result<String> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Read the sender token from disk. Returns an error if it
|
||||
/// is absent — callers should gate their homeserver calls on this.
|
||||
pub fn read_sender_token() -> Result<String> {
|
||||
let path = sender_token_path();
|
||||
/// Read the hive admin access token from disk. Returns an error if it
|
||||
/// is absent — callers should gate their admin-API calls on this.
|
||||
pub fn read_admin_token() -> Result<String> {
|
||||
let path = admin_token_path();
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_owned())
|
||||
.filter(|s| !s.is_empty())
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"the matrix sender token was not found at {} — \
|
||||
"hive admin matrix token not found at {} — \
|
||||
ensure hive-c0re has started at least once with matrix enabled \
|
||||
(it provisions the hive's @hive-<hive>: account on boot)",
|
||||
(it provisions the admin account on boot)",
|
||||
path.display()
|
||||
)
|
||||
})
|
||||
|
|
@ -1279,12 +1244,12 @@ fn persist_space_room_id(room_id: &str) -> Result<()> {
|
|||
///
|
||||
/// Name-based (not alias-based) rediscovery keeps the Space free of any
|
||||
/// special-char room alias — the hardcoded plain name is the anchor.
|
||||
async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Option<String> {
|
||||
async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Option<String> {
|
||||
let base = matrix_http()?;
|
||||
let joined_url = format!("{base}/_matrix/client/v3/joined_rooms");
|
||||
let joined: serde_json::Value = client
|
||||
.get(&joined_url)
|
||||
.bearer_auth(sender_token)
|
||||
.bearer_auth(admin_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?
|
||||
|
|
@ -1301,7 +1266,7 @@ async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Opt
|
|||
let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/");
|
||||
let is_space = match client
|
||||
.get(&create_url)
|
||||
.bearer_auth(sender_token)
|
||||
.bearer_auth(admin_token)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
|
|
@ -1317,7 +1282,7 @@ async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Opt
|
|||
}
|
||||
// …and named HIVE_SPACE_NAME (m.room.name `name`).
|
||||
let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/");
|
||||
let name_matches = match client.get(&name_url).bearer_auth(sender_token).send().await {
|
||||
let name_matches = match client.get(&name_url).bearer_auth(admin_token).send().await {
|
||||
Ok(r) if r.status().is_success() => r
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
|
|
@ -1334,7 +1299,7 @@ async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Opt
|
|||
|
||||
/// Create (or recover) the hive Matrix Space and persist its room ID to
|
||||
/// [`hive_space_room_id_path()`]. The Space is a private `m.space` owned by
|
||||
/// `@hive-<hive>:`, identified by its hardcoded name [`HIVE_SPACE_NAME`] (no alias).
|
||||
/// `@hive`, identified by its hardcoded name [`HIVE_SPACE_NAME`] (no alias).
|
||||
///
|
||||
/// Dedup strategy (single canonical space):
|
||||
/// 1. If the room-id file exists, reuse it.
|
||||
|
|
@ -1348,7 +1313,7 @@ async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Opt
|
|||
///
|
||||
/// Returns an error if the homeserver is unreachable, `createRoom` fails,
|
||||
/// or the room-ID file cannot be written.
|
||||
pub async fn ensure_hive_space(client: &reqwest::Client, sender_token: &str) -> Result<String> {
|
||||
pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> Result<String> {
|
||||
let base = matrix_base()?;
|
||||
// 1. Stored room id wins (fast path).
|
||||
if let Ok(existing) = std::fs::read_to_string(hive_space_room_id_path()) {
|
||||
|
|
@ -1361,7 +1326,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, sender_token: &str) ->
|
|||
|
||||
// 2. No stored id — rediscover the existing space by its hardcoded name
|
||||
// before creating a new one (prevents duplicate spaces after a wipe).
|
||||
if let Some(room_id) = find_space_by_name(client, sender_token).await {
|
||||
if let Some(room_id) = find_space_by_name(client, admin_token).await {
|
||||
persist_space_room_id(&room_id)?;
|
||||
tracing::info!(%room_id, "matrix: recovered hive space by name");
|
||||
return Ok(room_id);
|
||||
|
|
@ -1377,7 +1342,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, sender_token: &str) ->
|
|||
});
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(sender_token)
|
||||
.bearer_auth(admin_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -1417,12 +1382,12 @@ fn persist_chat_room_id(room_id: &str) -> Result<()> {
|
|||
/// [`find_space_by_name`] so a lost room-id file recovers the existing chat
|
||||
/// room instead of spawning a duplicate. `None` if the homeserver is
|
||||
/// unreachable or no match exists.
|
||||
async fn find_chat_room_by_name(client: &reqwest::Client, sender_token: &str) -> Option<String> {
|
||||
async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) -> Option<String> {
|
||||
let base = matrix_http()?;
|
||||
let joined_url = format!("{base}/_matrix/client/v3/joined_rooms");
|
||||
let joined: serde_json::Value = client
|
||||
.get(&joined_url)
|
||||
.bearer_auth(sender_token)
|
||||
.bearer_auth(admin_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?
|
||||
|
|
@ -1439,7 +1404,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, sender_token: &str) ->
|
|||
let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/");
|
||||
let is_space = match client
|
||||
.get(&create_url)
|
||||
.bearer_auth(sender_token)
|
||||
.bearer_auth(admin_token)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
|
|
@ -1455,7 +1420,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, sender_token: &str) ->
|
|||
}
|
||||
// …and named HIVE_CHAT_ROOM_NAME (m.room.name `name`).
|
||||
let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/");
|
||||
let name_matches = match client.get(&name_url).bearer_auth(sender_token).send().await {
|
||||
let name_matches = match client.get(&name_url).bearer_auth(admin_token).send().await {
|
||||
Ok(r) if r.status().is_success() => r
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
|
|
@ -1495,10 +1460,10 @@ fn state_needs_write(current: Option<&serde_json::Value>, desired: &serde_json::
|
|||
/// too, loudly — and a 404 is the expected first-setup case.
|
||||
async fn current_room_state(
|
||||
client: &reqwest::Client,
|
||||
sender_token: &str,
|
||||
admin_token: &str,
|
||||
url: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
let resp = match client.get(url).bearer_auth(sender_token).send().await {
|
||||
let resp = match client.get(url).bearer_auth(admin_token).send().await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = ?e, url, "matrix: state read unreachable; writing");
|
||||
|
|
@ -1526,7 +1491,7 @@ async fn current_room_state(
|
|||
}
|
||||
}
|
||||
|
||||
/// PUT a state event into `room_id` using the sender token, **skipping the
|
||||
/// PUT a state event into `room_id` using the admin token, **skipping the
|
||||
/// write when the room already carries identical content**.
|
||||
///
|
||||
/// The read is not an optimisation. A PUT of identical content is a no-op
|
||||
|
|
@ -1541,7 +1506,7 @@ async fn current_room_state(
|
|||
/// missing or divergent link still gets written.
|
||||
async fn set_room_state(
|
||||
client: &reqwest::Client,
|
||||
sender_token: &str,
|
||||
admin_token: &str,
|
||||
room_id: &str,
|
||||
event_type: &str,
|
||||
state_key: &str,
|
||||
|
|
@ -1552,7 +1517,7 @@ async fn set_room_state(
|
|||
let encoded_key = encode_room_id_for_url(state_key);
|
||||
let url =
|
||||
format!("{base}/_matrix/client/v3/rooms/{encoded_room}/state/{event_type}/{encoded_key}");
|
||||
let current = current_room_state(client, sender_token, &url).await;
|
||||
let current = current_room_state(client, admin_token, &url).await;
|
||||
if !state_needs_write(current.as_ref(), content) {
|
||||
tracing::debug!(
|
||||
%room_id,
|
||||
|
|
@ -1563,7 +1528,7 @@ async fn set_room_state(
|
|||
}
|
||||
let resp = client
|
||||
.put(&url)
|
||||
.bearer_auth(sender_token)
|
||||
.bearer_auth(admin_token)
|
||||
.json(content)
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -1600,7 +1565,7 @@ async fn set_room_state(
|
|||
/// link is logged but not fatal (the room still exists + is joinable).
|
||||
pub async fn ensure_hive_chat_room(
|
||||
client: &reqwest::Client,
|
||||
sender_token: &str,
|
||||
admin_token: &str,
|
||||
space_room_id: &str,
|
||||
server_name: &str,
|
||||
) -> Result<String> {
|
||||
|
|
@ -1614,7 +1579,7 @@ pub async fn ensure_hive_chat_room(
|
|||
{
|
||||
tracing::debug!(room_id = %id, "matrix: hive chat room already provisioned");
|
||||
id
|
||||
} else if let Some(id) = find_chat_room_by_name(client, sender_token).await {
|
||||
} else if let Some(id) = find_chat_room_by_name(client, admin_token).await {
|
||||
persist_chat_room_id(&id)?;
|
||||
tracing::info!(room_id = %id, "matrix: recovered hive chat room by name");
|
||||
id
|
||||
|
|
@ -1654,7 +1619,7 @@ pub async fn ensure_hive_chat_room(
|
|||
});
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(sender_token)
|
||||
.bearer_auth(admin_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -1685,7 +1650,7 @@ pub async fn ensure_hive_chat_room(
|
|||
});
|
||||
if let Err(e) = set_room_state(
|
||||
client,
|
||||
sender_token,
|
||||
admin_token,
|
||||
space_room_id,
|
||||
"m.space.child",
|
||||
&room_id,
|
||||
|
|
@ -1703,21 +1668,21 @@ pub async fn ensure_hive_chat_room(
|
|||
/// account. Idempotent — treats already-member responses as success.
|
||||
async fn invite_to_room(
|
||||
client: &reqwest::Client,
|
||||
sender_token: &str,
|
||||
admin_token: &str,
|
||||
room_id: &str,
|
||||
localpart: &str,
|
||||
server_name: &str,
|
||||
) -> Result<()> {
|
||||
let user_id = format!("@{localpart}:{server_name}");
|
||||
invite_user_id(client, sender_token, room_id, &user_id).await
|
||||
invite_user_id(client, admin_token, room_id, &user_id).await
|
||||
}
|
||||
|
||||
/// Fetch a user's current membership in a room via the sender token, or
|
||||
/// Fetch a user's current membership in a room via the admin token, or
|
||||
/// `None` if there is no membership event (never invited) or the lookup
|
||||
/// fails. Returns the raw membership string (`invite`, `join`, `leave`, …).
|
||||
async fn room_membership(
|
||||
client: &reqwest::Client,
|
||||
sender_token: &str,
|
||||
admin_token: &str,
|
||||
encoded_room_id: &str,
|
||||
user_id: &str,
|
||||
) -> Option<String> {
|
||||
|
|
@ -1730,7 +1695,7 @@ async fn room_membership(
|
|||
);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(sender_token)
|
||||
.bearer_auth(admin_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
|
|
@ -1744,13 +1709,13 @@ async fn room_membership(
|
|||
}
|
||||
|
||||
/// Invite a fully-qualified Matrix user id (`@user:server`) to `room_id`
|
||||
/// using the sender token. Idempotent: a user who is already a member or
|
||||
/// using the admin token. Idempotent: a user who is already a member or
|
||||
/// already has a pending invite is left untouched (no fresh invite is sent,
|
||||
/// so they are not re-notified), and a 403 `M_FORBIDDEN` / `M_BAD_STATE`
|
||||
/// from a racing invite is still treated as success.
|
||||
async fn invite_user_id(
|
||||
client: &reqwest::Client,
|
||||
sender_token: &str,
|
||||
admin_token: &str,
|
||||
room_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<()> {
|
||||
|
|
@ -1762,7 +1727,7 @@ async fn invite_user_id(
|
|||
// Skip the invite entirely when the user is already invited or joined.
|
||||
// Re-POSTing an invite to a pending member re-sends the invite event,
|
||||
// which re-notifies the agent on every provisioning sweep.
|
||||
if let Some(membership) = room_membership(client, sender_token, &encoded_room_id, user_id).await
|
||||
if let Some(membership) = room_membership(client, admin_token, &encoded_room_id, user_id).await
|
||||
&& matches!(membership.as_str(), "invite" | "join")
|
||||
{
|
||||
tracing::debug!(%user_id, %room_id, %membership, "matrix: invite skipped (already a member/invited)");
|
||||
|
|
@ -1772,7 +1737,7 @@ async fn invite_user_id(
|
|||
let url = format!("{base}/_matrix/client/v3/rooms/{encoded_room_id}/invite");
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(sender_token)
|
||||
.bearer_auth(admin_token)
|
||||
.json(&serde_json::json!({ "user_id": user_id }))
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -1805,13 +1770,13 @@ async fn invite_user_id(
|
|||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the sender token or `server_name` can't be read,
|
||||
/// Returns an error if the admin token or `server_name` can't be read,
|
||||
/// the target room can't be resolved (no `--room` and no persisted hive
|
||||
/// space), or the invite POST fails for a reason other than the user
|
||||
/// already being a member / invited.
|
||||
pub async fn invite_user(
|
||||
client: &reqwest::Client,
|
||||
sender_token: &str,
|
||||
admin_token: &str,
|
||||
user: &str,
|
||||
room_override: Option<&str>,
|
||||
server_name: &str,
|
||||
|
|
@ -1825,7 +1790,7 @@ pub async fn invite_user(
|
|||
// Resolve the room: explicit override (id or #alias) wins; otherwise
|
||||
// the persisted hive Space.
|
||||
let room_id = match room_override {
|
||||
Some(r) if r.starts_with('#') => resolve_room_alias(client, sender_token, r).await?,
|
||||
Some(r) if r.starts_with('#') => resolve_room_alias(client, admin_token, r).await?,
|
||||
Some(r) if r.starts_with('!') => r.to_owned(),
|
||||
Some(r) => anyhow::bail!(
|
||||
"matrix: --room {r:?} is neither a room id nor an alias; \
|
||||
|
|
@ -1839,14 +1804,14 @@ pub async fn invite_user(
|
|||
(run the hive-c0re matrix sweep first)",
|
||||
)?,
|
||||
};
|
||||
invite_user_id(client, sender_token, &room_id, &user_id).await?;
|
||||
invite_user_id(client, admin_token, &room_id, &user_id).await?;
|
||||
Ok(room_id)
|
||||
}
|
||||
|
||||
/// Resolve a `#alias:server` to its room id via the directory API.
|
||||
async fn resolve_room_alias(
|
||||
client: &reqwest::Client,
|
||||
sender_token: &str,
|
||||
admin_token: &str,
|
||||
alias: &str,
|
||||
) -> Result<String> {
|
||||
let base = matrix_base()?;
|
||||
|
|
@ -1854,7 +1819,7 @@ async fn resolve_room_alias(
|
|||
let url = format!("{base}/_matrix/client/v3/directory/room/{encoded}");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(sender_token)
|
||||
.bearer_auth(admin_token)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("matrix: GET directory for {alias}"))?;
|
||||
|
|
@ -1910,12 +1875,13 @@ pub async fn ensure_all() -> bool {
|
|||
return false;
|
||||
}
|
||||
};
|
||||
// The hive's `@hive-<hive>:` account first, because everything below provisions
|
||||
// THROUGH it (the Space, the chat room and every invite are sent with
|
||||
// its token) — as an ordinary user that created those rooms, not as a
|
||||
// homeserver admin.
|
||||
if let Err(e) = ensure_hive_user(&client, &as_token).await {
|
||||
tracing::warn!(error = ?e, "matrix: ensure_hive_user failed");
|
||||
// The hive admin first, because everything below provisions THROUGH
|
||||
// it (the Space, the chat room and every invite are sent with its
|
||||
// token). Not, any more, so that it wins a first-registered-user
|
||||
// grant: it holds admin rights by explicit promotion, checked in
|
||||
// `provision_space` once the server name is known.
|
||||
if let Err(e) = ensure_admin_user(&client, &as_token).await {
|
||||
tracing::warn!(error = ?e, "matrix: ensure_admin_user failed");
|
||||
ok = false;
|
||||
}
|
||||
let Ok(containers) = crate::lifecycle::list().await else {
|
||||
|
|
@ -1945,10 +1911,10 @@ pub async fn ensure_all() -> bool {
|
|||
async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bool {
|
||||
let mut ok = true;
|
||||
// server_name is needed to form full Matrix user IDs for invites.
|
||||
let sender_token = match read_sender_token() {
|
||||
let admin_token = match read_admin_token() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no matrix sender token)");
|
||||
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no admin token)");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
@ -1960,55 +1926,60 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
|
|||
return false;
|
||||
}
|
||||
};
|
||||
let room_id = match ensure_hive_space(client, &sender_token).await {
|
||||
// Before the rooms: the one thing in this sweep that is about the
|
||||
// admin account itself rather than about what it provisions.
|
||||
if !ensure_admin_rights(client, &admin_token, &server_name).await {
|
||||
ok = false;
|
||||
}
|
||||
let room_id = match ensure_hive_space(client, &admin_token).await {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "matrix: ensure_hive_space failed");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// This hive's own account first, then all agents.
|
||||
let localpart = match hive_localpart() {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if let Err(e) = invite_to_room(client, &sender_token, &room_id, &localpart, &server_name).await
|
||||
// Invite @hive admin first, then all agents.
|
||||
if let Err(e) = invite_to_room(
|
||||
client,
|
||||
&admin_token,
|
||||
&room_id,
|
||||
HIVE_ADMIN_LOCALPART,
|
||||
&server_name,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = ?e, %localpart, "matrix: invite the hive account to the space failed");
|
||||
tracing::warn!(error = ?e, "matrix: invite @hive to space failed");
|
||||
ok = false;
|
||||
}
|
||||
for name in agent_names {
|
||||
if let Err(e) = invite_to_room(client, &sender_token, &room_id, name, &server_name).await {
|
||||
if let Err(e) = invite_to_room(client, &admin_token, &room_id, name, &server_name).await {
|
||||
tracing::warn!(%name, error = ?e, "matrix: invite agent to space failed");
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Provision the default hive chat room as an m.space.child of the Space
|
||||
// and invite `@hive-<hive>:` + every agent. Joining a Space alone surfaces no
|
||||
// and invite @hive + every agent. Joining a Space alone surfaces no
|
||||
// rooms to chat in (Matrix semantics — children aren't auto-joined), so
|
||||
// without this the Space is empty. The restricted join rule additionally
|
||||
// lets the operator (a Space member) join from the Space hierarchy.
|
||||
match ensure_hive_chat_room(client, &sender_token, &room_id, &server_name).await {
|
||||
match ensure_hive_chat_room(client, &admin_token, &room_id, &server_name).await {
|
||||
Ok(chat_room_id) => {
|
||||
if let Err(e) = invite_to_room(
|
||||
client,
|
||||
&sender_token,
|
||||
&admin_token,
|
||||
&chat_room_id,
|
||||
&localpart,
|
||||
HIVE_ADMIN_LOCALPART,
|
||||
&server_name,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = ?e, %localpart, "matrix: invite the hive account to the chat room failed");
|
||||
tracing::warn!(error = ?e, "matrix: invite @hive to chat room failed");
|
||||
ok = false;
|
||||
}
|
||||
for name in agent_names {
|
||||
if let Err(e) =
|
||||
invite_to_room(client, &sender_token, &chat_room_id, name, &server_name).await
|
||||
invite_to_room(client, &admin_token, &chat_room_id, name, &server_name).await
|
||||
{
|
||||
tracing::warn!(%name, error = ?e, "matrix: invite agent to chat room failed");
|
||||
ok = false;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//! `/run/hyperhive` + `/run/hive-agent` runtime roots).
|
||||
//!
|
||||
//! Historically these were flat string literals scattered across many
|
||||
//! modules (`broker.sqlite`, `matrix-sender-token`, `agent-sockets.json`,
|
||||
//! modules (`broker.sqlite`, `matrix-admin-token`, `agent-sockets.json`,
|
||||
//! …). This module is the **single Rust-side source** for every host
|
||||
//! path — the strictly host-side ones grouped into subdirs (`db/`,
|
||||
//! `forge/`, `matrix/`, `run/`), plus the **nix-coupled** roots
|
||||
|
|
@ -128,7 +128,7 @@ pub fn agent_identity_dir(name: &str) -> PathBuf {
|
|||
agent_identity_root().join(name)
|
||||
}
|
||||
|
||||
/// `matrix/` — host-side matrix provisioning state (the appservice sender token, hive
|
||||
/// `matrix/` — host-side matrix provisioning state (admin token, hive
|
||||
/// Space room id, per-agent password creds). The shared registration
|
||||
/// token is bind-mounted into the tuwunel container via nix and stays
|
||||
/// at its own path (tracked separately).
|
||||
|
|
@ -137,10 +137,10 @@ pub fn matrix_dir() -> PathBuf {
|
|||
state_root().join("matrix")
|
||||
}
|
||||
|
||||
/// `matrix/access-token` — the matrix appservice sender account's access token.
|
||||
/// `matrix/admin-token` — hive system admin access token.
|
||||
#[must_use]
|
||||
pub fn matrix_sender_token() -> PathBuf {
|
||||
matrix_dir().join("access-token")
|
||||
pub fn matrix_admin_token() -> PathBuf {
|
||||
matrix_dir().join("admin-token")
|
||||
}
|
||||
|
||||
/// `matrix/space-room-id` — persisted hive Space room id.
|
||||
|
|
@ -327,7 +327,7 @@ pub fn relocate_legacy_state() {
|
|||
"forge-agent-configs-avatar-set",
|
||||
forge_config_org_avatar_marker(),
|
||||
),
|
||||
("matrix-sender-token", matrix_sender_token()),
|
||||
("matrix-admin-token", matrix_admin_token()),
|
||||
("matrix-space-room-id", matrix_space_room_id()),
|
||||
("matrix-creds", matrix_creds_dir()),
|
||||
("agent-sockets.json", agent_sockets_file()),
|
||||
|
|
|
|||
|
|
@ -455,7 +455,7 @@ async fn stream_agent_status(
|
|||
// The `hivectl matrix` subcommands used to run these in-process, which forced
|
||||
// the standalone CLI to link the whole daemon crate (matrix-sdk, reqwest, …).
|
||||
// They now run daemon-side over the host socket: the daemon already holds the
|
||||
// register + sender tokens and the matrix creds dir. Each op returns the
|
||||
// register + admin tokens and the matrix creds dir. Each op returns the
|
||||
// operator-facing lines hivectl used to `println!` in `HostResponse::messages`
|
||||
// for the client to print verbatim.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -777,14 +777,14 @@ async fn handle_matrix_sync_admin() -> Result<HostResponse> {
|
|||
let as_token =
|
||||
crate::matrix::read_appservice_token().context("read matrix appservice token")?;
|
||||
let client = matrix_http_client()?;
|
||||
crate::matrix::ensure_hive_user(&client, &as_token)
|
||||
crate::matrix::ensure_admin_user(&client, &as_token)
|
||||
.await
|
||||
.context("matrix sync-admin")?;
|
||||
let path = crate::matrix::sender_token_path();
|
||||
let path = crate::matrix::admin_token_path();
|
||||
Ok(HostResponse::messages(vec![
|
||||
format!(
|
||||
"matrix: the @{}: user is provisioned",
|
||||
crate::matrix::hive_localpart()?
|
||||
"matrix: hive admin user '@{}' provisioned",
|
||||
crate::matrix::HIVE_ADMIN_LOCALPART
|
||||
),
|
||||
format!("token persisted at: {}", path.display()),
|
||||
]))
|
||||
|
|
@ -792,12 +792,12 @@ async fn handle_matrix_sync_admin() -> Result<HostResponse> {
|
|||
|
||||
async fn handle_matrix_promote_user(name: &str) -> Result<HostResponse> {
|
||||
require_matrix_present()?;
|
||||
let sender_token = crate::matrix::read_sender_token()?;
|
||||
let admin_token = crate::matrix::read_admin_token()?;
|
||||
let client = matrix_http_client()?;
|
||||
let server_name = crate::matrix::discover_server_name(&client)
|
||||
.await
|
||||
.context("discover matrix server_name")?;
|
||||
crate::matrix::promote_user_to_admin(&client, &sender_token, name, &server_name)
|
||||
crate::matrix::promote_user_to_admin(&client, &admin_token, name, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix promote-user {name}"))?;
|
||||
Ok(HostResponse::messages(vec![format!(
|
||||
|
|
@ -807,12 +807,12 @@ async fn handle_matrix_promote_user(name: &str) -> Result<HostResponse> {
|
|||
|
||||
async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResponse> {
|
||||
require_matrix_present()?;
|
||||
let sender_token = crate::matrix::read_sender_token()?;
|
||||
let admin_token = crate::matrix::read_admin_token()?;
|
||||
let client = matrix_http_client()?;
|
||||
let server_name = crate::matrix::discover_server_name(&client)
|
||||
.await
|
||||
.context("discover matrix server_name")?;
|
||||
let room_id = crate::matrix::invite_user(&client, &sender_token, user, room, &server_name)
|
||||
let room_id = crate::matrix::invite_user(&client, &admin_token, user, room, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix invite {user}"))?;
|
||||
let target = if user.starts_with('@') {
|
||||
|
|
@ -827,12 +827,12 @@ async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResp
|
|||
|
||||
async fn handle_matrix_reset_password(name: &str) -> Result<HostResponse> {
|
||||
require_matrix_present()?;
|
||||
let sender_token = crate::matrix::read_sender_token()?;
|
||||
let admin_token = crate::matrix::read_admin_token()?;
|
||||
let client = matrix_http_client()?;
|
||||
let server_name = crate::matrix::discover_server_name(&client)
|
||||
.await
|
||||
.context("discover matrix server_name")?;
|
||||
crate::matrix::reset_user_password(&client, &sender_token, name, &server_name)
|
||||
crate::matrix::reset_user_password(&client, &admin_token, name, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix reset-password {name}"))?;
|
||||
// Password is persisted by reset_user_password.
|
||||
|
|
|
|||
|
|
@ -335,10 +335,10 @@ pub enum MatrixCmd {
|
|||
#[arg(long, conflicts_with = "password")]
|
||||
password_stdin: bool,
|
||||
},
|
||||
/// Provision (or re-provision) the matrix appservice's sender account.
|
||||
/// Provision (or re-provision) the hive system admin matrix account.
|
||||
///
|
||||
/// Runs automatically on startup; run manually to recover a missing
|
||||
/// access token.
|
||||
/// admin token.
|
||||
SyncAdmin,
|
||||
/// Promote a matrix user to homeserver admin.
|
||||
PromoteUser {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! `hivectl matrix` — matrix account provisioning verbs. hivectl forwards
|
||||
//! each request to the daemon (which owns the register + sender tokens and
|
||||
//! each request to the daemon (which owns the register + admin tokens and
|
||||
//! the matrix creds dir) and renders the reply; it no longer links the
|
||||
//! matrix machinery itself.
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ pub(crate) async fn run_matrix_cmd(socket: &Path, cmd: MatrixCmd) -> Result<()>
|
|||
|
||||
/// Send a matrix provisioning request to the daemon and print the
|
||||
/// operator-facing result lines it returns. The daemon owns the register +
|
||||
/// sender tokens and the matrix creds dir, so hivectl no longer links the
|
||||
/// admin tokens and the matrix creds dir, so hivectl no longer links the
|
||||
/// matrix machinery — it just forwards the request and renders the reply.
|
||||
async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Result<()> {
|
||||
let resp = crate::client::request(socket, req)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@
|
|||
./glue-controller-bao-identity.nix
|
||||
./glue-grafana-oidc-client.nix
|
||||
./glue-matrix-bao-token.nix
|
||||
./glue-matrix-ctl-bao-identity.nix
|
||||
./glue-queue-agent-credential.nix
|
||||
./glue-secret-publisher-bao-identity.nix
|
||||
./glue-swarm-otel-oidc-client.nix
|
||||
|
|
|
|||
|
|
@ -147,19 +147,6 @@ in
|
|||
# to be this one whenever the store has a host of its own.
|
||||
[ -s ${pkiDir}/secret-publisher.pem ] || ${signLeaf} ${pkiDir} secret-publisher \
|
||||
${lib.escapeShellArg deployCfg.bao.secretPublisherCommonName} "" clientAuth
|
||||
|
||||
# The matrix container's `swarm-matrix-ctl`. Minted unconditionally like the two
|
||||
# above, and for the third variant of the same reason: the homeserver
|
||||
# is a swarm singleton, so on every hive but the one running it this
|
||||
# leaf is the file an operator copies rather than a file anything
|
||||
# local reads.
|
||||
#
|
||||
# Its own CN, not the reader's: the reader's leaf carries this hive's
|
||||
# name and its policy reads the whole store, while this principal may
|
||||
# only write one path — which is the entire point of giving the
|
||||
# container an identity instead of lending it the hive's.
|
||||
[ -s ${pkiDir}/matrix-ctl.pem ] || ${signLeaf} ${pkiDir} matrix-ctl \
|
||||
${lib.escapeShellArg deployCfg.bao.matrixCtlCommonName} "" clientAuth
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
# Glue: point the matrix container's `swarm-matrix-ctl` at the bao leaf minted for it.
|
||||
#
|
||||
# ONE PAIRING PER FILE — matrix-ctl ← bao, and nothing else. Deleting this leaves a
|
||||
# binary that takes operator-provided certificate paths, which is what any
|
||||
# deployment not minting its own already does.
|
||||
#
|
||||
# ⚠️ The minting is NOT here. ./glue-bao-tls.nix holds the CA and signs the
|
||||
# leaf, because the thing that owns a private key owns issuing from it. What
|
||||
# belongs here is the pairing: which paths matrix-ctl presents.
|
||||
#
|
||||
# ⚠️ Gated on the leaf existing, not on the store being enabled — the same rule
|
||||
# ./glue-secret-publisher-bao-identity.nix states, and it bites harder here: a
|
||||
# swarm runs ONE homeserver, so the hive hosting it is the one least likely to
|
||||
# also be the hive hosting the store.
|
||||
#
|
||||
# Everything is `mkDefault`. An operator naming their own paths wins.
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
hyperhiveCfg = config.services.hyperhive;
|
||||
deployCfg = hyperhiveCfg.deploy;
|
||||
baoDeploy = deployCfg.bao;
|
||||
|
||||
# Where ./glue-bao-tls.nix puts the leaves, derived from the reader's own path
|
||||
# rather than repeating that file's directory literal: an operator who moves
|
||||
# the PKI moves both, and the two cannot drift apart.
|
||||
haveMintedPki = baoDeploy.clientCertFile != null;
|
||||
pkiDir = if haveMintedPki then builtins.dirOf baoDeploy.clientCertFile else null;
|
||||
in
|
||||
{
|
||||
config = lib.mkIf (hyperhiveCfg.enable && deployCfg.matrix.enable && haveMintedPki) {
|
||||
services.hyperhive.deploy.matrix = {
|
||||
ctlBaoClientCertFile = lib.mkDefault "${pkiDir}/matrix-ctl.pem";
|
||||
ctlBaoClientKeyFile = lib.mkDefault "${pkiDir}/matrix-ctl-key.pem";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -26,11 +26,6 @@ let
|
|||
# `null/.well-known/…`.
|
||||
autheliaCfg = config.services.hyperhive.swarm.authelia;
|
||||
deployCfg = config.services.hyperhive.deploy;
|
||||
|
||||
# Where the swarm's secret store answers. Swarm-tier, identical on every
|
||||
# host, which is what lets the container address it without knowing whether
|
||||
# it stands here.
|
||||
baoCfg = config.services.hyperhive.swarm.bao;
|
||||
autheliaUrl = autheliaCfg.url;
|
||||
|
||||
# The all-local case: this host runs BOTH the homeserver and the swarm's
|
||||
|
|
@ -64,30 +59,18 @@ let
|
|||
# namespace below, with no shared registration secret in the picture.
|
||||
appserviceId = "hyperhive";
|
||||
|
||||
# The appservice's own user, and the account the hive acts as. An
|
||||
# ordinary user, not a homeserver admin: everything the hive does with
|
||||
# it — the Space, the chat room, the invites — it does as the creator of
|
||||
# those rooms. Loading a registration CREATES its `sender_localpart` user
|
||||
# when absent (tuwunel `src/service/appservice/mod.rs`), on a zero-user
|
||||
# database, inside `Services::start()` — before the HTTP listener accepts
|
||||
# anything, so the account exists on the very first boot of a fresh
|
||||
# homeserver without hive-c0re racing to register it.
|
||||
# The appservice's own user, and deliberately the hive admin account.
|
||||
# Loading a registration CREATES its `sender_localpart` user when absent
|
||||
# (tuwunel `src/service/appservice/mod.rs`), on a zero-user database,
|
||||
# inside `Services::start()` — before the HTTP listener accepts anything.
|
||||
# That is what lets the `admin_execute` promotion below land on the very
|
||||
# first boot of a fresh homeserver, instead of depending on hive-c0re
|
||||
# racing to register the first account and win the auto-admin grant.
|
||||
#
|
||||
# 🩸 **Derived from the hive name, so it is one account per hive.** It was
|
||||
# the bare `hive` and a swarm runs one homeserver: every hive on it acted
|
||||
# as the same `@hive:`, which is one identity with no attribution and no
|
||||
# way to revoke a single hive. The hives that do not run this container
|
||||
# register their own `hive-<name>` through the appservice namespace below,
|
||||
# which already admits it.
|
||||
#
|
||||
# ⚠️ Must equal `swarm_secret_client::matrix::hive_localpart`, which
|
||||
# hive-c0re and swarm-matrix-ctl both derive from independently with
|
||||
# nothing wiring an override across — same agreement, and same reason for
|
||||
# saying so, as the token path below.
|
||||
#
|
||||
# `hiveName` has no fallback here for the reason ./glue-matrix-bao-token.nix
|
||||
# gives at its own use of it: it is asserted set for every hyperhive host.
|
||||
hiveLocalpart = "hive-${toString config.services.hyperhive.hiveName}";
|
||||
# ⚠️ Must equal `matrix::HIVE_ADMIN_LOCALPART` in hive-c0re, which derives
|
||||
# it independently with nothing wiring an override across — same
|
||||
# agreement, and same reason for saying so, as the token path below.
|
||||
adminLocalpart = "hive";
|
||||
|
||||
# The `as_token`, and the `hs_token` the spec requires alongside it. Both
|
||||
# minted by the render script below, mode 0600; the `as_token` is the one
|
||||
|
|
@ -124,62 +107,7 @@ let
|
|||
appserviceCredentialId = "${appserviceId}-appservice.yaml";
|
||||
appserviceCredentialDir = "/run/credentials/tuwunel.service";
|
||||
|
||||
# ── swarm-matrix-ctl ────────────────────────────────────────────────
|
||||
#
|
||||
# The oneshot that publishes the appservice sender account's access token to
|
||||
# the swarm's secret store. It runs INSIDE the container, beside tuwunel,
|
||||
# because the appservice token that authorises the mint is already in here —
|
||||
# `appserviceDir` below is bound read-only precisely so the homeserver can
|
||||
# load it — and minting anywhere else would create a second holder of that
|
||||
# secret, which is the thing this whole arrangement exists to stop.
|
||||
#
|
||||
# Gated on the identity, not on `deploy.bao.enable`: a swarm's ONE homeserver
|
||||
# is the host least likely to also be the host running the store, so
|
||||
# "co-located with bao" would leave the intended deployment silently minting
|
||||
# nothing. Same rule ./swarm-secret-publisher.nix's `haveClientIdentity`
|
||||
# states, for a sharper reason.
|
||||
ctlActive =
|
||||
deployCfg.matrix.ctlBaoClientCertFile != null && deployCfg.matrix.ctlBaoClientKeyFile != null;
|
||||
|
||||
# The role on the store's `cert` auth mount, and so the single source of the
|
||||
# string both ends must agree on: ./swarm-bao.nix writes the role under
|
||||
# `matrixCtlPolicyName` and this hands it to the binary in the
|
||||
# environment, so the binary itself spells no role at all.
|
||||
ctlCertRole = "swarm-matrix-ctl";
|
||||
|
||||
# Every host directory matrix-ctl's bao identity is spread across. Normally
|
||||
# one — ./glue-bao-tls.nix puts all three files in ./glue's PKI dir — but
|
||||
# derived rather than assumed, because an operator naming paths by hand is
|
||||
# exactly the deployment the gate above is written for.
|
||||
#
|
||||
# Directories rather than the files, for the reason `appserviceDir`'s own
|
||||
# comment gives: a re-issued leaf is a new inode, and binding the file would
|
||||
# pin the one the container saw when it started.
|
||||
ctlPkiDirs = lib.optionals ctlActive (
|
||||
lib.unique (
|
||||
map builtins.dirOf (
|
||||
[
|
||||
deployCfg.matrix.ctlBaoClientCertFile
|
||||
deployCfg.matrix.ctlBaoClientKeyFile
|
||||
]
|
||||
++ lib.optional (deployCfg.bao.serverCaFile != null) deployCfg.bao.serverCaFile
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
ctlBindMounts = lib.genAttrs ctlPkiDirs (dir: {
|
||||
hostPath = dir;
|
||||
isReadOnly = true;
|
||||
});
|
||||
|
||||
# Where a reader of the published credential is told the token is good for.
|
||||
# Empty when this hive serves no vhost: `matrix::Credential.homeserver` is an
|
||||
# `Option`, and matrix-ctl reads an empty variable as absent rather than as
|
||||
# the string "null" — which is what a hive with no gateway host actually
|
||||
# knows about itself.
|
||||
ctlHomeserverUrl = if cfg.gatewayHost == null then "" else "https://${toString cfg.gatewayHost}";
|
||||
|
||||
# Every local user this hive may provision — agents, `@hive-<hive>:` itself, and
|
||||
# Every local user this hive may provision — agents, the hive admin, and
|
||||
# the operator accounts `hivectl matrix create-user` makes, which is the
|
||||
# whole matrix localpart charset.
|
||||
#
|
||||
|
|
@ -245,7 +173,7 @@ let
|
|||
cat <<'REGISTRATION'
|
||||
id: ${appserviceId}
|
||||
url: null
|
||||
sender_localpart: ${hiveLocalpart}
|
||||
sender_localpart: ${adminLocalpart}
|
||||
rate_limited: false
|
||||
namespaces:
|
||||
users:
|
||||
|
|
@ -758,56 +686,6 @@ in
|
|||
match authelia's register.
|
||||
'';
|
||||
};
|
||||
|
||||
ctlPackage = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-matrix-ctl";
|
||||
description = ''
|
||||
The `swarm-matrix-ctl` build run inside the matrix container.
|
||||
|
||||
⚠️ Named `ctlPackage`, not folded into `package` above: that one is
|
||||
the homeserver, and this is a hyperhive binary that happens to run
|
||||
beside it. Same split, and same reason, as
|
||||
{option}`services.hyperhive.deploy.nats.authPackage`.
|
||||
'';
|
||||
};
|
||||
|
||||
ctlBaoClientCertFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = ''
|
||||
Client certificate the matrix container's `swarm-matrix-ctl` presents to the
|
||||
swarm's secret store. Its subject must be
|
||||
{option}`services.hyperhive.deploy.bao.matrixCtlCommonName` — cert
|
||||
auth matches on the CN, and the role accepts nothing else.
|
||||
|
||||
⚠️ **Not the hive's own leaf**, and that is the whole deliverable of
|
||||
giving this container an identity: the hive's certificate reads every
|
||||
secret in the store, while this one may write a single path. Pointing
|
||||
this at `deploy.bao.clientCertFile` would evaluate, deploy and work —
|
||||
and give away the separation in one line.
|
||||
|
||||
No default: a module that guessed would be holding the CA opinion
|
||||
./swarm-bao.nix deliberately does not hold.
|
||||
./glue-matrix-ctl-bao-identity.nix points it at the leaf
|
||||
./glue-bao-tls.nix mints, where this host mints one.
|
||||
|
||||
The file and its key are bind-mounted into the container read-only.
|
||||
Co-located in the hive's filesystem and therefore readable by hive
|
||||
**root** — accepted: the boundary this buys is identity (no hive
|
||||
*process* holds the appservice token), not physical isolation.
|
||||
'';
|
||||
};
|
||||
|
||||
ctlBaoClientKeyFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = ''
|
||||
Private key for
|
||||
{option}`services.hyperhive.deploy.matrix.ctlBaoClientCertFile`.
|
||||
Both or neither — the unit does not exist unless each is set.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf deployCfg.matrix.enable {
|
||||
|
|
@ -1204,11 +1082,7 @@ in
|
|||
isReadOnly = true;
|
||||
};
|
||||
}
|
||||
// caTrust.bindMount
|
||||
# matrix-ctl's bao client identity, and nothing else of the hive's. See
|
||||
# `ctlPkiDirs` above for why it is a derived set of directories
|
||||
# rather than one literal.
|
||||
// ctlBindMounts;
|
||||
// caTrust.bindMount;
|
||||
config =
|
||||
{ ... }:
|
||||
{
|
||||
|
|
@ -1318,21 +1192,30 @@ in
|
|||
# `Services::start()`, before the listener accepts anything.
|
||||
appservice_dir = appserviceCredentialDir;
|
||||
|
||||
# No `admin_execute` promotion for `@${hiveLocalpart}`. The
|
||||
# hive's account is an ordinary user: it creates the hive
|
||||
# Space and chat room and invites agents into them, all of
|
||||
# which ride on being the rooms' own creator at power level
|
||||
# 100, and none of which is a homeserver-admin capability.
|
||||
# Granting it server admin at boot would hand a credential
|
||||
# that every hive reads far more than the work needs.
|
||||
# The zero-user bootstrap, and the only thing here that needs
|
||||
# no account to already exist. These run after startup and
|
||||
# BEFORE the HTTP listener, with no sender and no permission
|
||||
# check — which is what makes them the one lever that can
|
||||
# promote the hive admin on a homeserver where nobody is admin
|
||||
# yet. The appservice registration above creates
|
||||
# `@${adminLocalpart}` as its sender user moments earlier;
|
||||
# this joins it to the admin room at power level 100.
|
||||
#
|
||||
# The two operations that do need an admin sender —
|
||||
# `!admin users make-user-admin` and
|
||||
# `!admin users reset-password`, both messages into
|
||||
# `#admins:${effectiveServerName}` — therefore have no
|
||||
# working sender here. They are swarm-level operations and
|
||||
# are being rehomed as such; until then they fail, loudly,
|
||||
# rather than being served by an over-privileged token.
|
||||
# Idempotent by upstream's own guard: `make_user_admin`
|
||||
# short-circuits when the user is already joined at 100, so a
|
||||
# hive that has had an admin for months emits nothing.
|
||||
admin_execute = [
|
||||
"users make-user-admin @${adminLocalpart}:${effectiveServerName}"
|
||||
];
|
||||
|
||||
# ⚠️ Load-bearing, not tidiness. An `admin_execute` command
|
||||
# that fails aborts startup outright when this is false — so
|
||||
# the one boot where the promotion cannot work (a homeserver
|
||||
# that has no `@${adminLocalpart}` and no appservice user yet,
|
||||
# e.g. a registration file that arrived late) would take the
|
||||
# homeserver down with it rather than converging on the next
|
||||
# start.
|
||||
admin_execute_errors_ignore = true;
|
||||
# Server-side E2EE is opt-in (default off); the agent matrix
|
||||
# client always supports decryption regardless.
|
||||
allow_encryption = cfg.allowEncryption;
|
||||
|
|
@ -1403,66 +1286,6 @@ in
|
|||
"oidc_client_secret:${toString deployCfg.matrix.sso.clientSecretFile}"
|
||||
];
|
||||
|
||||
# Publish the appservice sender account's access token to the swarm
|
||||
# store, once, under an identity that belongs to this container and
|
||||
# not to the hive. See `ctlActive` above for why it runs here.
|
||||
#
|
||||
# A `oneshot` with no timer and no retry loop of its own: the whole
|
||||
# of "and only once" is the binary's first act, a read of the path it
|
||||
# would write. `Restart=on-failure` covers a store that is sealed or
|
||||
# a homeserver still starting; `RemainAfterExit` is deliberately NOT
|
||||
# set, because the unit having succeeded is not the idempotency
|
||||
# record — the store is, and it outlives this machine.
|
||||
systemd.services.swarm-matrix-ctl = lib.mkIf ctlActive {
|
||||
description = "publish the matrix sender token to the swarm secret store";
|
||||
# Ordered after the homeserver because both of the ladder's arms
|
||||
# are client-server API calls. `wants`, not `requires`: a run that
|
||||
# finds the credential already published never touches tuwunel at
|
||||
# all, so a homeserver that is slow to come up should delay this,
|
||||
# not cancel it.
|
||||
after = [ "tuwunel.service" ];
|
||||
wants = [ "tuwunel.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
# The verb is part of the contract: `swarm-matrix-ctl` is a
|
||||
# subcommand binary and refuses a bare invocation, so dropping
|
||||
# `mint` here fails the unit rather than doing something else.
|
||||
ExecStart = "${deployCfg.matrix.ctlPackage}/bin/swarm-matrix-ctl mint";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 30;
|
||||
# Bounded here rather than left to systemd's default, for the
|
||||
# reason ./swarm-secret-publisher.nix states: a sealed store
|
||||
# answers on the port and never answers the read.
|
||||
TimeoutStartSec = 60;
|
||||
SyslogIdentifier = "swarm-matrix-ctl";
|
||||
};
|
||||
environment = {
|
||||
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
|
||||
BAO_CLIENT_CERT = deployCfg.matrix.ctlBaoClientCertFile;
|
||||
BAO_CLIENT_KEY = deployCfg.matrix.ctlBaoClientKeyFile;
|
||||
MATRIX_MINT_CERT_ROLE = ctlCertRole;
|
||||
# Loopback: this container shares the host netns, so the
|
||||
# homeserver it must talk to is the one in this very unit's
|
||||
# netns and needs no name, no vhost and no TLS.
|
||||
MATRIX_MINT_API_URL = "http://127.0.0.1:${toString cfg.httpPort}";
|
||||
# The bind-mounted registration, which IS the as_token. A path,
|
||||
# never a value.
|
||||
MATRIX_MINT_REGISTRATION = appserviceRegistrationPath;
|
||||
MATRIX_MINT_LOCALPART = hiveLocalpart;
|
||||
# The hive segment of the store path the token is published
|
||||
# under, and so the thing that keeps this hive's token out of
|
||||
# every other hive's reach: the grant that reaches it is the
|
||||
# hive's own `swarm/hives/<name>/*` stanza. ./swarm-bao.nix
|
||||
# spells the same name into matrix-ctl's write grant.
|
||||
MATRIX_MINT_HIVE = toString config.services.hyperhive.hiveName;
|
||||
MATRIX_MINT_HOMESERVER = ctlHomeserverUrl;
|
||||
}
|
||||
// lib.optionalAttrs (deployCfg.bao.serverCaFile != null) {
|
||||
BAO_CACERT = deployCfg.bao.serverCaFile;
|
||||
};
|
||||
};
|
||||
|
||||
environment.systemPackages = [ deployCfg.matrix.package ];
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -236,54 +236,6 @@ let
|
|||
}
|
||||
'';
|
||||
|
||||
# The identity the matrix container's `swarm-matrix-ctl` presents. Named outside `hive-*`
|
||||
# for the reason its two siblings above give — the controller may rewrite
|
||||
# every policy under that prefix, and a policy it can rewrite constrains
|
||||
# nothing.
|
||||
matrixCtlPolicyName = "swarm-matrix-ctl";
|
||||
matrixCtlCn = baoDeploy.matrixCtlCommonName;
|
||||
|
||||
# ONE path, and every narrowing in it is load-bearing.
|
||||
#
|
||||
# `secret/data/` is KV v2's ACL prefix, inserted by the engine rather than
|
||||
# written by the caller — the same trap as the two grants above.
|
||||
#
|
||||
# Not `swarm/services/*` like the publisher's: this principal produces
|
||||
# exactly one secret, its own hive's matrix sender account access token, and
|
||||
# a homeserver is not entitled to overwrite Grafana's OIDC client. The path
|
||||
# is spelled to the leaf for that reason, not for tidiness.
|
||||
#
|
||||
# 🩸 And the leaf now carries a HIVE segment, which is the narrowing that
|
||||
# matters: the credential used to live at `swarm/services/matrix/sender-token`
|
||||
# — one value for the whole swarm, under the `services/*` tree every hive's
|
||||
# own policy grants read on. Under `swarm/hives/<name>/` the only read grant
|
||||
# that reaches it is that hive's own stanza, so one hive cannot fetch
|
||||
# another's. `matrixCtlHive` below is the name this principal may write, and
|
||||
# it is one hive rather than a `hives/*` wildcard for the same reason.
|
||||
#
|
||||
# `read` as well as write, unlike either sibling, and it is what makes "and
|
||||
# only once" mechanical: matrix-ctl's first act is to read this path back and
|
||||
# stop if something is there, so without the capability every container
|
||||
# restart would mint a second access token and invalidate the hive's. A read
|
||||
# here recovers one secret this principal itself wrote, which is a much
|
||||
# narrower grant than the publisher's would have been.
|
||||
matrixCtlPolicyText = ''
|
||||
path "${credentialMountPath}/data/swarm/hives/${matrixCtlHive}/matrix/sender-token" {
|
||||
capabilities = ["create", "update", "read"]
|
||||
}
|
||||
'';
|
||||
|
||||
# Which hive matrix-ctl mints for. This host's own by default, which is right
|
||||
# whenever the store and the homeserver are co-located and is the shape the
|
||||
# `mkDefault` deployments produce; an operator running them apart names the
|
||||
# homeserver's hive here, because the policy is written where the store is
|
||||
# and the container runs where the homeserver is.
|
||||
#
|
||||
# A wrong value is loud rather than silent: matrix-ctl's write comes back 403
|
||||
# with the store's own message and the hive falls back to minting its account
|
||||
# locally, which is the same degrade a store that was never deployed gives.
|
||||
matrixCtlHive = baoDeploy.matrixCtlHiveName;
|
||||
|
||||
# The KV v2 engine the controller writes agent credentials through. Named
|
||||
# once because the grant above and the `secrets enable` in the bootstrap unit
|
||||
# have to agree: a policy pointing at a mount nobody created is precisely the
|
||||
|
|
@ -707,54 +659,6 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
matrixCtlCommonName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "swarm-matrix-ctl";
|
||||
example = "swarm-matrix-ctl.svc";
|
||||
description = ''
|
||||
Subject the store's matrix-ctl cert-auth role accepts — the
|
||||
identity the oneshot inside the matrix container presents when it
|
||||
publishes the appservice sender account's access token.
|
||||
|
||||
A **third** identity rather than reuse of either sibling above, and
|
||||
the narrowest of the three: its grant is one path, that
|
||||
credential itself. The point of the whole arrangement is that the
|
||||
process holding the appservice token is not the hive, so handing it
|
||||
the hive's own leaf — which reads every secret in the store — would
|
||||
give the separation away in one line.
|
||||
|
||||
⚠️ Same collision as its siblings, and the same answer: ./swarm.nix
|
||||
feeds this value into the guard on
|
||||
{option}`services.hyperhive.swarm.hives`, so a hive named after it
|
||||
fails evaluation rather than silently receiving matrix-ctl's grant.
|
||||
'';
|
||||
};
|
||||
|
||||
matrixCtlHiveName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = toString hyperhiveCfg.hiveName;
|
||||
defaultText = lib.literalExpression "services.hyperhive.hiveName";
|
||||
example = "pr1ma";
|
||||
description = ''
|
||||
Hive whose matrix sender token the store's matrix-ctl role may write.
|
||||
|
||||
The sender account's access token is **per hive**: it lives at
|
||||
`swarm/hives/<name>/matrix/sender-token`, and the only read grant that
|
||||
reaches it is that hive's own. So matrix-ctl's write grant names one
|
||||
hive too — the hive whose homeserver container it runs in.
|
||||
|
||||
Defaults to this host's own {option}`services.hyperhive.hiveName`,
|
||||
which is correct whenever the store and the homeserver are co-located.
|
||||
Set it when they are not: the policy is written where the store runs,
|
||||
and the oneshot runs where the homeserver does.
|
||||
|
||||
A wrong value degrades rather than breaks — matrix-ctl's write is
|
||||
refused with the store's own message and the hive mints its account
|
||||
locally instead, the same fallback a swarm that never deployed the
|
||||
store already uses.
|
||||
'';
|
||||
};
|
||||
|
||||
serverCaFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
|
|
@ -1177,7 +1081,7 @@ in
|
|||
# ⚠️ Deliberately EMPTY: nothing here generates an issuer into it, so
|
||||
# the role below can be written but cannot yet issue. That is the
|
||||
# whole shape of this step — the mount, its narrowing and its grant
|
||||
# exist, and the binary that uses them arrives separately. Undoing it
|
||||
# exist, and the minter that uses them arrives separately. Undoing it
|
||||
# is `bao secrets disable ${servicesPkiMountPath}`.
|
||||
case "$mounts" in
|
||||
*'"${servicesPkiMountPath}/"'*) ;;
|
||||
|
|
@ -1201,7 +1105,7 @@ in
|
|||
# is a name type this issuer is unconstrained for.
|
||||
# `server_flag` alone because that is what today's leaf carries
|
||||
# (`extendedKeyUsage=serverAuth`, ./hive-tls.nix:144), and rsa:4096
|
||||
# because that is the key the current implementation generates.
|
||||
# because that is the key the current minter generates.
|
||||
#
|
||||
# An empty `swarm.serviceDomains` renders an empty `allowed_domains`,
|
||||
# which with `allow_any_name=false` is a role that issues nothing —
|
||||
|
|
@ -1318,54 +1222,6 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
# A THIRD sibling, for the reason the second one's comment gives: these
|
||||
# unit names are operator-facing strings, and the grant written here
|
||||
# belongs to a principal neither of the other two names. Same `after`
|
||||
# rather than `requires`, and for the same stated reason — the first unit
|
||||
# creates the mounts this one writes into, but a failed oneshot still
|
||||
# counts as finished, so only ordering plus this unit's own retry
|
||||
# converges.
|
||||
systemd.services.swarm-bao-matrix-ctl-policy = lib.mkIf haveBootstrapToken {
|
||||
description = "write swarm-matrix-ctl's bao policy and cert-auth role";
|
||||
after = [
|
||||
"container@${cfg.machine}.service"
|
||||
"swarm-bao-controller-policy.service"
|
||||
];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
path = [
|
||||
baoCli
|
||||
pkgs.coreutils
|
||||
];
|
||||
unitConfig.ConditionPathExists = baoDeploy.bootstrapTokenFile;
|
||||
# Same unseal wait as its two siblings above, for the reason stated
|
||||
# there: under `seal = "shamir"` a human unseals by hand.
|
||||
startLimitBurst = 2880;
|
||||
startLimitIntervalSec = 90000;
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
Restart = "on-failure";
|
||||
RestartSec = 30;
|
||||
};
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
|
||||
BAO_TOKEN="$(cat ${lib.escapeShellArg baoDeploy.bootstrapTokenFile})"
|
||||
export BAO_TOKEN
|
||||
|
||||
printf '%s' ${lib.escapeShellArg matrixCtlPolicyText} |
|
||||
bao policy write ${lib.escapeShellArg matrixCtlPolicyName} -
|
||||
''
|
||||
+ lib.optionalString (baoDeploy.clientCaFile != null) ''
|
||||
|
||||
bao write auth/cert/certs/${lib.escapeShellArg matrixCtlPolicyName} \
|
||||
certificate=@${tlsDir}/client-ca.pem \
|
||||
allowed_common_names=${lib.escapeShellArg matrixCtlCn} \
|
||||
token_policies=${lib.escapeShellArg matrixCtlPolicyName} \
|
||||
display_name=${lib.escapeShellArg matrixCtlCn}
|
||||
'';
|
||||
};
|
||||
|
||||
containers.${cfg.machine} = {
|
||||
autoStart = true;
|
||||
ephemeral = false;
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ let
|
|||
certAuthCns = [
|
||||
deployCfg.bao.controllerCommonName
|
||||
deployCfg.bao.secretPublisherCommonName
|
||||
deployCfg.bao.matrixCtlCommonName
|
||||
];
|
||||
|
||||
# Public hostnames of the swarm's own services, in declaration order.
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue