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