Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
32 changed files with 344 additions and 2105 deletions

14
Cargo.lock generated
View file

@ -4924,20 +4924,6 @@ dependencies = [
"swarm-queue-client",
]
[[package]]
name = "swarm-matrix-ctl"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"reqwest",
"serde_json",
"swarm-secret-client",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "swarm-nats-auth"
version = "0.1.0"

View file

@ -26,7 +26,6 @@ members = [
"swarm-authelia-bridge",
"swarm-authelia-bridge-sock",
"swarm-controller",
"swarm-matrix-ctl",
"swarm-nats-auth",
"swarm-queue-client",
"swarm-logs",

View file

@ -181,7 +181,7 @@ which. A hive that does **not** deploy the store names the reader's three
itself: the operator issues that leaf out of band, and it's the one credential
the store can't hand you, being what opens it. ⚠️ Not the gateway's HTTPS certificates and not the hive CA — this is
**mTLS between services and the store**, a separate trust domain, because a
store that took its identity from an authority it itself distributes could
store that took its identity from an authority it will itself distribute could
never come up before that authority.
### 4 · Swarm SSO (only when `deploy.authelia`)
@ -203,8 +203,8 @@ surfaces (the swarm UI below) gate on that group, and an account
without it authenticates successfully and is then refused — which reads
like a broken login rather than a missing group.
If an account already exists without it, `user add` refuses rather
than amends — adding the group afterwards is `swarmctl user update mara
If an account already exists without it, `user add` will refuse rather
than amend — adding the group afterwards is `swarmctl user update mara
--add-group admins`.
Detail, including what the password is and why this stays manual:
@ -229,7 +229,7 @@ control: [`swarm/ui.md`](../swarm/ui.md).
### 6 · Matrix
```bash
# Ensure the appservice's sender account exists first
# Ensure the hive-internal admin account exists first
hivectl matrix sync-admin
# Provision ruth's own matrix account — same bootstrap-bypass reasoning
@ -293,7 +293,7 @@ See [`tools/hivectl.md`](../tools/hivectl.md) for every `hivectl` verb.
- **No forge admin token is stored in any agent state dir.** Agents
hold a regular agent token in their `forge-token` file; sensitive
creds (the core token) live on the host.
creds (the core token, the matrix admin token) live on the host.
- All config changes (forge PRs on `agent-configs/<name>`) go through
operator approval — agents can't unilaterally rebuild containers, by design.
See [`boundary.md`](../trust-boundary/boundary.md) and [`security.md`](../trust-boundary/security.md).

View file

@ -147,86 +147,29 @@ a token.
(`hive-matrix-daemon.path` watching for `matrix-token` appearance)
brings the daemon up on the same boot cycle anyway.
### The appservice's sender account, and why it isn't an admin
### The admin account, and why it needs no first-user luck
`@hive-<hive>:<server_name>` is the appservice's own `sender_localpart`,
which the homeserver creates itself when it loads the registration — on a
`@hive:<server_name>` is the appservice's own `sender_localpart`, which
the homeserver creates itself when it loads the registration — on a
zero-user database, inside startup, before the HTTP listener accepts
anything. It's an **ordinary account**: nothing promotes it, and the
homeserver runs no `admin_execute` for it.
anything. Its **admin rights** then come from an explicit
`make_user_admin`, run by tuwunel's `admin_execute` in the same startup
and likewise before the listener — so a fresh hive has a joined,
power-level-100 admin on its first boot.
**One account per hive.** The localpart carries the hive's name, so a swarm
whose hives share a homeserver gives each of them its own identity: the
homeserver attributes an action to the hive that took it, and revoking one
hive's standing leaves the others alone.
This replaces a dependency on being the first account ever registered,
which was fragile in both directions: the design excludes an
appservice-created account from that automatic grant, and on a homeserver that
already had users the rule never fired at all.
Its access token is the **sender token**, and it's the credential
hive-c0re presents for every homeserver call it makes on the hive's
behalf. It's per hive for the same reason the account is:
`swarm-matrix-ctl` mints it inside the `hive-matrix` container and
publishes it to `swarm/hives/<hive>/matrix/sender-token`, and the hive
reads it from there under its own certificate. That path sits inside the
hive's own read grant (`swarm/hives/<hive>/*`), so a hive fetches its own
token and gets a refusal on any other hive's. The name says what it
authenticates as — an account the appservice registration brings into being
— rather than any privilege level, because it carries none.
It needs no promotion for what the hive does with it. Creating the hive
Space and the chat room, writing their hierarchy and join rules, and
inviting agents into them are all ordinary client calls that ride on
being the rooms' own creator at power level 100 — there is no homeserver
admin in any of it, and no Synapse admin API to reach for either, since
tuwunel has none.
Two operations need an admin **sender**: `hivectl matrix promote-user`
and `hivectl matrix reset-password`. Both are `!admin …` messages into
`#admins:<server_name>`, and tuwunel only treats a message as a command
when its sender is already an admin. They're swarm-level operations,
rehomed to the swarm tier rather than granted here; from the hive,
`@hive-<hive>:` has no admin sender to make that call with, so both get the
admin room's refusal rather than an over-privileged credential that
every other call site would also carry. The one hive-side path that
depends on them is the automatic password recovery for an agent that
has lost its stored password — the admin-sender limitation doesn't
touch the ordinary appservice re-login above.
<details><summary>Upgrading a hive that shared one sender account with every other hive</summary>
Nothing to do, and no window where the hive is without an account.
<!-- vale write-good.Passive = NO -->
- **The old shared value at `swarm/services/matrix/sender-token` is read by
nothing.** `hive-c0re` and `swarm-matrix-ctl` both build the path from the
same function, and it now carries the hive's name — so the old object
stays in the store, unread, until an operator deletes it. Delete it or
leave it; the accounts it authenticates as keep their own standing either
way, since an access token lives on the device that minted it.
- **The hive re-mints, per hive, on the next boot.** `ensure_hive_user` runs
from the startup sweep. It short-circuits on the token file it already has
— so the first boot after the upgrade keeps running on the shared token
until that file goes — and on a hive with no such file it reads the new
per-hive store path, finds nothing on a store that has never held one, and
falls through to the register-or-appservice-login ladder against
`@hive-<hive>:`. That ladder needs only the `as_token`, which is per hive
and on local disk, so it works with or without a reachable store.
- **To move a hive onto its own account now**, delete its sender-token file
(`hivectl matrix sync-admin` re-provisions, or the next sweep does) — the
ladder then registers `@hive-<hive>:` and persists that account's token.
Only then does the hive stop presenting the shared one.
- **The rooms the shared account created don't follow the new account, and
this is the one step that needs a decision.** Membership is per account.
`ensure_hive_space` takes the stored room id first, so the sweep hands the
new account the old Space's id, the invite it then sends comes back
refused (a non-member can't invite), and the sweep logs it and carries
on — degraded, not crashed. Two ways out, both operator-chosen. Either invite
`@hive-<hive>:` into the existing Space and chat room from a client, which
keeps the history; or delete the hive's stored room-id files, after which
the next sweep creates a Space and chat room owned by the new account and
invites every agent into them. Do one of the two; leaving it gives a hive
that provisions no rooms.
<!-- vale write-good.Passive = YES -->
</details>
Tuwunel doesn't support bootstrapping promotion over the API, and
that's upstream's design rather than a gap: it only treats an admin-room message as a
command when its sender is already an admin. `admin_execute` is the one
lever with no sender to check. hive-c0re re-checks the result on every
sweep by reading the admin account's own joined-rooms list; if the rights
are missing it says so, names
`systemctl restart container@hive-matrix` as the fix, and carries on —
agent accounts, the hive Space and the chat room need no admin.
<details><summary>Upgrading a hive that used the registration token</summary>
@ -242,10 +185,10 @@ restarts, so the first boot after the switch already has both halves.
- **The per-agent sweep honours existing token files.** It skips any
agent that already has a `matrix-token`, so it re-registers no account
and displaces no session.
- **The sender account may already be an admin** on such a hive (it won the
first-user grant when the hive was new). Nothing here demotes it; the
homeserver no longer promotes it, so a hive built fresh has an
ordinary account and an older one keeps whatever standing it acquired.
- **The admin account is already admin** on such a hive (it won the
first-user grant when the hive was new), so the startup promotion is a
no-op — upstream's `make_user_admin` short-circuits when the user is
already joined at power level 100.
- **`/var/lib/hyperhive/matrix-register-token` stays on disk**, read by
nothing. Delete it or leave it; neither does any harm.
- **`registrationTokenFile` is a removed option.** A config that still
@ -294,8 +237,8 @@ Initial rollout settings:
## Hive Matrix Space
On first boot, after hive-c0re provisions all agent accounts, it
creates a private **Matrix Space** named `"hive"` using its own hive
account (`@hive-<hive>:<server_name>`) and invites every provisioned agent
creates a private **Matrix Space** named `"hive"` using the admin
account (`@hive:<server_name>`) and invites every provisioned agent
into it. This gives the operator a single Space in FluffyChat or any
Matrix client that groups all agent-to-agent + operator rooms in one
place.
@ -333,9 +276,9 @@ services.hyperhive.swarm.matrix = {
```
**`trustedServers`** (default `[]`) — list of peer homeserver names
whose signing keys tuwunel fetches and trusts. tuwunel enables
federation at the protocol level from first boot (`allow_federation =
true`) but trusts no remote homeserver until you list it here. For a closed
whose signing keys tuwunel will fetch and trust. tuwunel enables federation
at the protocol level from first boot (`allow_federation = true`) but
trusts no remote homeserver until you list it here. For a closed
single-hive deployment the default empty list is correct — add peer
hive domains here when connecting hives into a swarm (see
[`docs/swarm/`](../swarm/README.md)).

View file

@ -55,11 +55,10 @@ strategy for every credential, including the mTLS leaf.
<!-- vale write-good.Passive = NO -->
| store path | minter | reader — pulls at runtime, holds in memory | renewal |
| -------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------- |
| -------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | -------------- |
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated |
| `swarm/agents/<agent>/bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated |
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated |
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated |
| `swarm/hives/<hive>/queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated |
| `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated |
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated |

View file

@ -165,7 +165,7 @@ Manual entry point to the same idempotent provisioning c0re runs at boot — for
###### **Subcommands:**
* `create-user` — Create or refresh the matrix account + access token for `<name>`
* `sync-admin` — Provision (or re-provision) the matrix appservice's sender account
* `sync-admin` — Provision (or re-provision) the hive system admin matrix account
* `promote-user` — Promote a matrix user to homeserver admin
* `reset-password` — Reset a matrix user's password via the admin API
* `invite` — Invite a matrix user to the hive Space, or a specific room with `--room`. Idempotent
@ -193,9 +193,9 @@ For an existing agent, persists the token to its state dir; for a human/other ac
## `hivectl matrix sync-admin`
Provision (or re-provision) the matrix appservice's sender account.
Provision (or re-provision) the hive system admin matrix account.
Runs automatically on startup; run manually to recover a missing access token.
Runs automatically on startup; run manually to recover a missing admin token.
**Usage:** `hivectl matrix sync-admin`

View file

@ -64,7 +64,7 @@ running (`services.hyperhive.deploy.matrix.enable = true`).
hivectl matrix create-user iris # provision (or re-provision) matrix account for agent `iris`
hivectl matrix create-user mara # create matrix account for a human; prints access_token to stdout
hivectl matrix create-user mara --password hunter2 # set a client-login password
hivectl matrix sync-admin # provision / refresh the appservice's sender account
hivectl matrix sync-admin # provision / refresh the hive internal admin account
hivectl matrix promote-user mara # promote an existing matrix user to homeserver admin
hivectl matrix reset-password iris # generate and set a new random password for `iris`; prints it
hivectl matrix invite mara # invite a user to the hive Space
@ -74,23 +74,19 @@ hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a spec
- `create-user`: for agents, persists the `access_token` to
`<state>/matrix-token`. Skips registration when the file already
exists — delete it first to force re-registration.
- `sync-admin`: ensures this hive's appservice sender account
(`@hive-<hive>:<server_name>`, one per hive) exists
(the account `hive-c0re` provisions rooms with). Token persisted to the
access token path. Safe to run again — idempotent.
- `sync-admin`: ensures the hive's internal admin matrix user exists
(used by `hive-c0re` for admin-room commands). Token persisted to the
admin token path. Safe to run again — idempotent.
- `promote-user`: promotes an already-registered user to homeserver
admin by an `!admin` command in `#admins`. ⚠️ Needs an admin **sender**,
which `@hive-<hive>:` isn't — promotion is a swarm-level operation, rehomed to
the swarm tier rather than granted here, so it has no admin sender to
call it with from the hive.
- `reset-password`: asks the admin room to set a new random
password and prints it to stdout. ⚠️ Same admin-**sender** requirement,
and the same swarm-level rehoming, so it's unavailable from the hive
too. Useful if an agent or human lost credentials.
admin via the matrix admin API. Requires `sync-admin` to have run
first (needs a valid admin token).
- `reset-password`: calls the matrix admin API to set a new random
password and prints it to stdout. Useful if an agent or human lost
credentials.
- `invite`: invites a matrix user (full `@user:server` or a bare
localpart, qualified with the homeserver's `server_name`) to the hive
Space by default, or to a `--room` id / `#alias`. Uses the sender
token; the account must be a member of the target room with
Space by default, or to a `--room` id / `#alias`. Uses the hive admin
token; the admin account must be a member of the target room with
invite power (it owns the hive Space, so that case always works).
Idempotent — already-member / already-invited is a no-op.
@ -229,7 +225,7 @@ granting that group the one action `machinectl shell` needs); `choom`
checks this itself and refuses up front with a message naming the
requirement rather than letting systemd reject the exec later with an
opaque polkit prompt. Same caveat as the socket grant: a shell opened
before the operator added you to `hive-admin` doesn't see it until you log
before the operator added you to `hive-admin` won't see it until you log
back in — secondary group membership applies at login.
It also needs the daemon socket, unlike the other exec-into-a-container

View file

@ -94,20 +94,6 @@ it.
the grant per hive (and pay for the re-emission), or give each agent container
its own store identity so credentials never pass through a hive at all.
### Per-hive credentials live under the hive kind, not beside a service
`swarm_secret_client::policy` scopes the **hive** stanza narrowly (unlike the
agent and service stanzas above) because a hive's own path names it, so
scoping to the reader's own name costs nothing and drifts nowhere. That only
holds if a credential that must be one-per-hive is actually stored under the
hive kind — the matrix sender token was originally published to
`swarm/services/matrix/sender-token`, under the **service** kind, and so was
readable by every hive though it belonged to only one. Making it per-hive meant
moving its path under the hive kind, not narrowing the service stanza's grant.
**A credential that must be one-per-hive goes under `Kind::Hive`**; putting it
under `Kind::Service` and expecting the grant to scope it — that's the mistake
this note exists to stop.
### Threat model: prompt injection → confused deputy
The realistic adversary **never needs to breach the container**. They supply

View file

@ -156,9 +156,6 @@
services.hyperhive.deploy.nats.authPackage =
lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-nats-auth;
services.hyperhive.deploy.matrix.ctlPackage =
lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-matrix-ctl;
services.hyperhive.deploy.authelia.bridgePackage =
lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-authelia-bridge;

View file

@ -65,43 +65,18 @@ const HTTP_TIMEOUT_SECS: u64 = 10;
/// store it nowhere.
const PASSWORD_BYTES: usize = 32;
/// Matrix localpart this hive acts as. Not an agent; has no state dir.
///
/// **Per hive, derived from the hive's name** — `hive-<name>`, built by
/// `swarm_secret_client::matrix::hive_localpart`, which is the one place the
/// derivation lives. It used to be the bare constant `hive`, and a swarm runs
/// one homeserver: every hive on it logged in as the same `@hive:`, so the
/// homeserver could not attribute an action to a hive and revoking one hive's
/// standing revoked all of them.
/// Matrix localpart for the hive system admin account. Not an agent; has
/// no state dir.
///
/// Also the `sender_localpart` of the hive's appservice registration,
/// which is what creates this account on a homeserver that has never had
/// one: the homeserver creates a registration's sender user itself, at
/// startup, before it accepts a request. See
/// `nix/host-modules/hive-matrix.nix`, where the same string is rendered as
/// `hiveLocalpart`. **The two must match**; nothing wires an override
/// across.
///
/// An ordinary account, with no homeserver-admin standing: what it
/// provisions — the Space, the chat room, the invites — it provisions as
/// the creator of those rooms.
///
/// # Errors
/// When `HYPERHIVE_HIVE_NAME` is unset or empty. That is the same variable
/// [`stored_sender_token`] logs in with and `hive-c0re`'s meta flake asserts
/// for every hive, so its absence is a broken deployment rather than a
/// supported one — and there is no safe default to fall back to: the only one
/// available is the shared account this replaced.
pub fn hive_localpart() -> Result<String> {
let hive = std::env::var("HYPERHIVE_HIVE_NAME")
.ok()
.filter(|h| !h.is_empty())
.context(
"matrix: HYPERHIVE_HIVE_NAME is unset, so this hive has no matrix account of its own \
— the localpart is derived from the hive name and has no swarm-wide fallback",
)?;
Ok(swarm_secret_client::matrix::hive_localpart(&hive))
}
/// startup, before it accepts a request. The account's *admin rights*
/// then come from the `admin_execute` promotion beside that registration
/// — see `nix/host-modules/hive-matrix.nix`, where this same literal
/// appears as `adminLocalpart`. **The two must match**; nothing wires an
/// override across.
pub const HIVE_ADMIN_LOCALPART: &str = "hive";
/// Display name of the hive Space. Plain text, no special characters, so
/// the Space stays rediscoverable by name (no room alias needed) even when
@ -119,11 +94,11 @@ pub const HIVE_CHAT_ROOM_NAME: &str = "hive-chat";
const HIVE_CHAT_ROOM_TOPIC: &str =
"Hive-wide chat for all agents and the operator. Auto-provisioned by hive-c0re.";
/// Host path for the appservice sender account's matrix access token. Outside every
/// Host path for the hive admin matrix access token. Outside every
/// purgeable path — not deleted by `destroy --purge` on any agent.
#[must_use]
pub fn sender_token_path() -> PathBuf {
crate::paths::matrix_sender_token()
pub fn admin_token_path() -> PathBuf {
crate::paths::matrix_admin_token()
}
/// Token file inside the agent's bind-mounted state dir (visible as
@ -407,26 +382,21 @@ async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Re
extract_access_token(&json)
}
/// Auto-recovery helper: reset a user's matrix password through the admin
/// room when the locally stored password is missing. Returns the new
/// password (already persisted to [`password_path`]) on success.
///
/// ⚠️ Needs an **admin sender**, which `@hive-<hive>:` is not — the reset is a
/// `!admin` command and tuwunel only treats a message as a command when
/// its sender is an admin in that room. So this recovery path fails until
/// the two admin operations are rehomed at swarm level; the ordinary
/// route (the stored password, or an appservice login) is unaffected.
/// Auto-recovery helper: reset a user's matrix password via the admin API
/// when the locally stored password is missing. Requires a valid hive admin
/// token at [`admin_token_path()`]. Returns the new password (already
/// persisted to [`password_path`]) on success.
///
/// Called by [`ensure_user_for`] when registration returns `M_USER_IN_USE`
/// but the password file is absent — covers the case where agent state dirs
/// were wiped but the homeserver still has the accounts.
async fn auto_reset_password(client: &reqwest::Client, name: &str) -> anyhow::Result<String> {
let sender_token = read_sender_token()
.context("matrix: the matrix sender token is unavailable for auto-recovery")?;
let admin_token = read_admin_token()
.context("matrix: admin token unavailable for auto-recovery; provision hive admin first")?;
let server_name = discover_server_name(client)
.await
.context("matrix: discover_server_name for auto-recovery")?;
let effective_password = reset_user_password(client, &sender_token, name, &server_name)
let effective_password = reset_user_password(client, &admin_token, name, &server_name)
.await
.with_context(|| format!("matrix: admin-room password reset for {name} (auto-recovery)"))?;
tracing::info!(%name, "matrix: auto-recovered password via admin-room reset");
@ -446,7 +416,7 @@ fn encode_room_id_for_url(room_id: &str) -> String {
/// Look up the room ID for the `#admins:<server>` alias.
async fn discover_admin_room_id(
client: &reqwest::Client,
sender_token: &str,
admin_token: &str,
server_name: &str,
) -> Result<String> {
let base = matrix_base()?;
@ -455,7 +425,7 @@ async fn discover_admin_room_id(
let url = format!("{base}/_matrix/client/v3/directory/room/{encoded_alias}");
let resp = client
.get(&url)
.bearer_auth(sender_token)
.bearer_auth(admin_token)
.send()
.await
.context("matrix: GET admin room alias")?;
@ -583,7 +553,7 @@ mod extract_new_password_tests {
/// Generic over `T` so both password-returning and `()` callers share the loop.
async fn admin_room_send_and_poll<T>(
client: &reqwest::Client,
sender_token: &str,
admin_token: &str,
server_name: &str,
room_url: &str,
command: &str,
@ -596,7 +566,7 @@ async fn admin_room_send_and_poll<T>(
format!("{base}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}");
let send_resp = client
.put(&send_url)
.bearer_auth(sender_token)
.bearer_auth(admin_token)
.json(&serde_json::json!({"msgtype": "m.text", "body": command}))
.send()
.await
@ -617,13 +587,13 @@ async fn admin_room_send_and_poll<T>(
// Poll for bot response: fetch the 20 most recent events (newest-first)
// on each tick. Walk the list until we hit our own command event_id;
// everything *before* that marker arrived after our command.
let own_user_id = format!("@{}:{server_name}", hive_localpart()?);
let own_user_id = format!("@{HIVE_ADMIN_LOCALPART}:{server_name}");
let poll_url = format!("{base}/_matrix/client/v3/rooms/{room_url}/messages?dir=b&limit=20");
for _ in 0..15_u8 {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let poll_json = client
.get(&poll_url)
.bearer_auth(sender_token)
.bearer_auth(admin_token)
.send()
.await
.context("matrix: admin room poll")?
@ -671,22 +641,22 @@ async fn admin_room_send_and_poll<T>(
}
/// Reset a user's password via the Matrix admin room (`#admins:<server>`).
/// Sends `!admin users reset-password @<localpart>:<server>` as `@hive-<hive>:`, polls for
/// the bot's response containing the new password.
/// Sends `!admin users reset-password @<localpart>:<server>` as @hive, polls for the bot's
/// response containing the new password.
///
/// Returns the new password; caller is responsible for persisting it.
async fn admin_room_reset_password(
client: &reqwest::Client,
sender_token: &str,
admin_token: &str,
server_name: &str,
localpart: &str,
) -> Result<String> {
let room_id = discover_admin_room_id(client, sender_token, server_name).await?;
let room_id = discover_admin_room_id(client, admin_token, server_name).await?;
let room_url = encode_room_id_for_url(&room_id);
let command = format!("!admin users reset-password @{localpart}:{server_name}");
admin_room_send_and_poll(
client,
sender_token,
admin_token,
server_name,
&room_url,
&command,
@ -791,15 +761,13 @@ pub async fn ensure_user_for(client: &reqwest::Client, name: &str, as_token: &st
{
pw
} else {
// Password file missing — attempt auto-recovery through the
// admin room.
// Password file missing — attempt auto-recovery via admin API.
// This covers the case where agent state dirs were wiped but the
// homeserver still has the accounts. Requires the hive's sender
// token at /var/lib/hyperhive/matrix/access-token, and an admin
// sender, which `@hive-<hive>:` is not.
// homeserver still has the accounts. Requires the hive admin
// token at /var/lib/hyperhive/matrix/admin-token.
tracing::info!(
%name,
"matrix: stored password missing, attempting admin-room auto-recovery"
"matrix: stored password missing, attempting admin-API auto-recovery"
);
match auto_reset_password(client, name).await {
Ok(new_pw) => new_pw,
@ -915,52 +883,42 @@ pub async fn sync_agent_standalone(name: &str) {
sync_agent(&client, name, &as_token).await;
}
/// Ensure the hive's `@hive-<hive>:` matrix user exists and that its access token is
/// persisted at [`sender_token_path()`].
/// Ensure the hive system admin matrix user exists, that its token is
/// persisted at [`admin_token_path()`], and that it actually holds admin
/// rights.
///
/// **Nothing here depends on registration order, and nothing here is
/// privileged.** The account used to have to be the first ever
/// registered, to win tuwunel's automatic first-user grant — a rule that
/// cannot fire for an appservice-created account at all. It is now an
/// ordinary account: the homeserver creates it because it is the
/// appservice registration's `sender_localpart`, and everything the hive
/// provisions with it, it provisions as the creator of those rooms.
/// **Nothing here depends on registration order any more.** The account
/// used to have to be the first ever registered, to win tuwunel's
/// automatic first-user grant — a rule that cannot fire for an
/// appservice-created account at all, and one that silently did nothing
/// on a homeserver that already had users. Admin rights now come from an
/// explicit `make_user_admin`: the `admin_execute` entry beside the
/// appservice registration performs it at homeserver startup, and
/// [`ensure_admin_rights`] checks the result and says so when it is
/// missing.
///
/// Idempotent — skips the account work when the token file already exists
/// and is non-empty.
///
/// The token is taken from the **swarm secret store** when it is there:
/// `swarm-matrix-ctl`, the oneshot inside the matrix container, publishes
/// it under an identity of its own, and taking it from there is what lets a
/// hive that holds no `as_token` have an admin at all. The mint ladder below
/// stays as the fallback for a store that is empty, unconfigured or
/// unreachable — which is every swarm whose matrix container predates that
/// binary.
pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Result<()> {
pub async fn ensure_admin_user(client: &reqwest::Client, as_token: &str) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let path = sender_token_path();
let path = admin_token_path();
if path.exists()
&& let Ok(existing) = std::fs::read_to_string(&path)
&& !existing.trim().is_empty()
{
tracing::debug!("matrix: the sender token is already present");
tracing::debug!("matrix: hive admin token already present");
return Ok(());
}
if let Some(token) = stored_sender_token().await {
return persist_sender_token(&path, &token);
}
// Per hive, and fatal when it cannot be derived: the fallback ladder below
// must not mint under some other hive's name.
let localpart = hive_localpart()?;
let password = random_password()?;
let access_token = match register_user(client, &localpart, as_token, &password).await {
let access_token = match register_user(client, HIVE_ADMIN_LOCALPART, as_token, &password).await
{
Ok(token) => {
let pw_path = password_path(&localpart);
let pw_path = password_path(HIVE_ADMIN_LOCALPART);
if let Some(parent) = pw_path.parent() {
std::fs::create_dir_all(parent).ok();
}
if let Err(e) = std::fs::write(&pw_path, format!("{password}\n")) {
tracing::warn!(error = ?e, %localpart, "matrix: failed to persist the sender account password");
tracing::warn!(error = ?e, "matrix: failed to persist hive admin password");
} else {
let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600));
}
@ -973,108 +931,125 @@ pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Resul
// hive-c0re gets a chance to ask. An appservice login needs
// no password, which is just as well since an account the
// homeserver created has none.
tracing::info!(%localpart, "matrix: the sender account already exists, logging in as the appservice");
match appservice_login(client, as_token, &localpart).await {
tracing::info!("matrix: hive admin user already exists, logging in as the appservice");
match appservice_login(client, as_token, HIVE_ADMIN_LOCALPART).await {
Ok(token) => token,
Err(e) => {
tracing::warn!(error = ?e, %localpart, "matrix: appservice login for the sender account failed; falling back to the stored password");
let pw_path = password_path(&localpart);
tracing::warn!(error = ?e, "matrix: appservice login for the hive admin failed; falling back to the stored password");
let pw_path = password_path(HIVE_ADMIN_LOCALPART);
let stored = std::fs::read_to_string(&pw_path)
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.with_context(|| {
format!(
"matrix: @{localpart}: exists, appservice login failed, and no \
"matrix: hive admin user exists, appservice login failed, and no \
password is stored at {} — check that the registration file's \
namespace covers @{localpart} and that the homeserver \
namespace covers @{HIVE_ADMIN_LOCALPART} and that the homeserver \
loaded it",
pw_path.display()
)
})?;
login_user(client, &localpart, &stored).await?
login_user(client, HIVE_ADMIN_LOCALPART, &stored).await?
}
}
}
Err(other) => return Err(other),
};
persist_sender_token(&path, &access_token)
}
/// Write the appservice sender account's access token to `path`, 0600, creating the
/// directory if it is not there.
///
/// Shared by both arms of [`ensure_hive_user`] rather than duplicated into
/// the store one: the file's mode is the only thing keeping an unprivileged
/// reader off the hive's matrix credential, and a second copy of that decision
/// is one that can be edited alone.
fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(path, format!("{access_token}\n"))
.with_context(|| format!("matrix: write the sender token to {}", path.display()))?;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
tracing::info!(path = %path.display(), "matrix: provisioned the sender token");
std::fs::write(&path, format!("{access_token}\n"))
.with_context(|| format!("matrix: write hive admin token to {}", path.display()))?;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
tracing::info!(path = %path.display(), "matrix: provisioned hive admin token");
Ok(())
}
/// Fetch the sender token `swarm-matrix-ctl` published, under
/// this hive's own store identity.
/// Check that the hive admin account holds admin rights, and repair it
/// through the admin room when it does not.
///
/// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` —
/// the same role string `workers::credential` logs in with, and already in
/// this process's environment, so the store read costs no plumbing through
/// [`ensure_all`]. That name is also the path's own hive segment, which is
/// what makes this read reach **this** hive's token and 403 on any other's:
/// the grant behind it is the hive's own `swarm/hives/<name>/*` stanza, not
/// the swarm-wide `swarm/services/*` one the shared token used to sit under.
/// Admin-ness in tuwunel is membership of the admin room, so that is what
/// this reads: the account's own joined-rooms list, which needs no
/// privileges to fetch. When the admin room is in it there is nothing to
/// do and nothing is sent — worth insisting on, because the repair is a
/// message in a room and this runs on every sweep.
///
/// `None`, never an error, for every way this can come up empty — no hive
/// name, no `BAO_*` identity, an unreachable store, nothing at the path. All
/// four mean the same thing to the caller ("mint it the old way"), and three
/// of them are the ordinary state of a swarm that has not deployed `swarm-matrix-ctl`
/// yet, so raising would turn a supported deployment into a warning every
/// sweep.
/// When it is absent, the repair is attempted anyway (a stale or failed
/// read is cheaper to retry than to reason about) and a failure is
/// reported rather than raised: agent accounts, the hive Space and the
/// chat room all work without an admin, so a hive with an unpromoted
/// admin is degraded, not broken. Only `hivectl matrix promote-user` /
/// `reset-password` need it.
///
/// 🩸 Logs the store **path** and never the value.
async fn stored_sender_token() -> Option<String> {
let hive = std::env::var("HYPERHIVE_HIVE_NAME")
.ok()
.filter(|h| !h.is_empty())?;
let path = match swarm_secret_client::matrix::sender_token_path(&hive) {
Ok(path) => path,
/// Returns whether the account holds admin rights now. A `false` has
/// already been logged, with what to do about it.
async fn ensure_admin_rights(
client: &reqwest::Client,
admin_token: &str,
server_name: &str,
) -> bool {
let room_id = match discover_admin_room_id(client, admin_token, server_name).await {
Ok(id) => id,
Err(e) => {
tracing::warn!(error = %e, "matrix: this hive's name does not form a store path");
return None;
tracing::warn!(error = ?e, "matrix: cannot resolve #admins — not checking the hive admin's rights");
return false;
}
};
let store = match swarm_secret_client::SecretStore::from_env(&hive).await {
Ok(store) => store,
Err(e) => {
tracing::debug!(error = %e, "matrix: no swarm secret store to read the sender token from");
return None;
match joined_rooms(client, admin_token).await {
Some(rooms) if rooms.iter().any(|r| r == &room_id) => {
tracing::debug!("matrix: hive admin is in the admin room");
return true;
}
};
match store
.read::<swarm_secret_client::matrix::Credential>(&path)
.await
Some(_) => {
tracing::warn!(
"matrix: hive admin is not in the admin room — attempting to promote it"
);
}
None => tracing::debug!(
"matrix: could not read the hive admin's joined rooms; attempting to promote it"
),
}
if let Err(e) =
promote_user_to_admin(client, admin_token, HIVE_ADMIN_LOCALPART, server_name).await
{
Ok(credential) if !credential.value.trim().is_empty() => {
tracing::info!(%path, "matrix: taking the sender token from the swarm store");
Some(credential.value)
}
Ok(_) => {
tracing::warn!(%path, "matrix: the stored sender token is empty; minting instead");
None
}
Err(e) => {
tracing::debug!(%path, error = %e, "matrix: no sender token in the store; minting instead");
None
// The honest message. Promoting through the admin room requires
// being admin already, so the one lever that works on a
// homeserver with no admin at all is the `admin_execute` entry in
// the hive-matrix module — which runs at startup, which means a
// restart is the fix rather than another sweep.
tracing::warn!(
error = ?e,
"matrix: hive admin '@{HIVE_ADMIN_LOCALPART}' holds no admin rights. \
The homeserver promotes it at startup (admin_execute in hive-matrix.nix), \
so `systemctl restart container@hive-matrix` grants it. Agent accounts and \
room provisioning are unaffected; `hivectl matrix promote-user` and \
`reset-password` need it."
);
return false;
}
tracing::info!("matrix: promoted the hive admin through the admin room");
true
}
/// The rooms `token`'s account has joined, or `None` when the list could
/// not be read. `None` is deliberately not an empty list: "no rooms" and
/// "no answer" lead to different decisions in [`ensure_admin_rights`].
async fn joined_rooms(client: &reqwest::Client, token: &str) -> Option<Vec<String>> {
let base = matrix_http()?;
let url = format!("{base}/_matrix/client/v3/joined_rooms");
let resp = client.get(&url).bearer_auth(token).send().await.ok()?;
if !resp.status().is_success() {
return None;
}
let body = resp.json::<serde_json::Value>().await.ok()?;
Some(
body["joined_rooms"]
.as_array()?
.iter()
.filter_map(|r| r.as_str().map(ToOwned::to_owned))
.collect(),
)
}
/// Whether an admin-room reply says a `make-user-admin` succeeded.
@ -1128,16 +1103,12 @@ mod is_make_admin_success_tests {
/// Promote a user to homeserver admin via the Matrix admin room
/// (`#admins:<server>`). Sends `!admin users make-user-admin @<localpart>:<server>` as
/// `@hive-<hive>:`, polls for the bot's success reply.
/// @hive, polls for the bot's success reply.
///
/// ⚠️ Requires the **sender** to be an admin already — tuwunel only
/// treats a message as a command when its sender is in the admin room.
/// `@hive-<hive>:` is an ordinary account (`hive-matrix.nix` grants it no
/// `admin_execute` promotion), so this call has no working sender from
/// the hive and fails with the admin room's refusal. Promotion is a
/// swarm-level operation and is being rehomed as such; this stays here,
/// failing loudly, rather than justifying an over-privileged token that
/// all 13 ordinary call sites would also carry.
/// So this promotes a *second* user; it cannot bootstrap the first one.
/// That is what the `admin_execute` entry in `hive-matrix.nix` is for.
///
/// Goes through the admin room rather than a direct HTTP call because
/// tuwunel implements parts of the Synapse admin API but not user
@ -1145,16 +1116,16 @@ mod is_make_admin_success_tests {
/// intended long-term mechanism, not a stopgap awaiting an upstream fix.
pub async fn promote_user_to_admin(
client: &reqwest::Client,
sender_token: &str,
admin_token: &str,
localpart: &str,
server_name: &str,
) -> Result<()> {
let room_id = discover_admin_room_id(client, sender_token, server_name).await?;
let room_id = discover_admin_room_id(client, admin_token, server_name).await?;
let room_url = encode_room_id_for_url(&room_id);
let command = format!("!admin users make-user-admin @{localpart}:{server_name}");
admin_room_send_and_poll(
client,
sender_token,
admin_token,
server_name,
&room_url,
&command,
@ -1172,25 +1143,19 @@ pub async fn promote_user_to_admin(
/// Reset a user's password via the Matrix admin room (`#admins:<server>`).
///
/// Sends `!admin users reset-password @<localpart>:<server>` to the admin room as
/// `@hive-<hive>:`, polls for the bot's response containing the new password, and persists
/// Sends `!admin users reset-password @<localpart>:<server>` to the admin room as @hive,
/// polls for the bot's response containing the new password, and persists
/// it to the non-purgeable creds path so [`ensure_user_for`] can re-login
/// on the next provisioning sweep.
///
/// ⚠️ Same admin-**sender** requirement as [`promote_user_to_admin`], and
/// the same consequence: `@hive-<hive>:` is an ordinary account with no admin
/// sender, and reset, like promotion, is a swarm-level operation rehomed
/// to the swarm tier rather than granted here — so it has no working
/// sender from the hive either.
///
/// Returns the new password for use in subsequent `login_user` calls.
pub async fn reset_user_password(
client: &reqwest::Client,
sender_token: &str,
admin_token: &str,
localpart: &str,
server_name: &str,
) -> Result<String> {
let pw = admin_room_reset_password(client, sender_token, server_name, localpart)
let pw = admin_room_reset_password(client, admin_token, server_name, localpart)
.await
.with_context(|| {
format!("matrix: admin-room password reset for @{localpart}:{server_name}")
@ -1240,19 +1205,19 @@ pub async fn discover_server_name(client: &reqwest::Client) -> Result<String> {
})
}
/// Read the sender token from disk. Returns an error if it
/// is absent — callers should gate their homeserver calls on this.
pub fn read_sender_token() -> Result<String> {
let path = sender_token_path();
/// Read the hive admin access token from disk. Returns an error if it
/// is absent — callers should gate their admin-API calls on this.
pub fn read_admin_token() -> Result<String> {
let path = admin_token_path();
std::fs::read_to_string(&path)
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.with_context(|| {
format!(
"the matrix sender token was not found at {} — \
"hive admin matrix token not found at {} — \
ensure hive-c0re has started at least once with matrix enabled \
(it provisions the hive's @hive-<hive>: account on boot)",
(it provisions the admin account on boot)",
path.display()
)
})
@ -1279,12 +1244,12 @@ fn persist_space_room_id(room_id: &str) -> Result<()> {
///
/// Name-based (not alias-based) rediscovery keeps the Space free of any
/// special-char room alias — the hardcoded plain name is the anchor.
async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Option<String> {
async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Option<String> {
let base = matrix_http()?;
let joined_url = format!("{base}/_matrix/client/v3/joined_rooms");
let joined: serde_json::Value = client
.get(&joined_url)
.bearer_auth(sender_token)
.bearer_auth(admin_token)
.send()
.await
.ok()?
@ -1301,7 +1266,7 @@ async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Opt
let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/");
let is_space = match client
.get(&create_url)
.bearer_auth(sender_token)
.bearer_auth(admin_token)
.send()
.await
{
@ -1317,7 +1282,7 @@ async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Opt
}
// …and named HIVE_SPACE_NAME (m.room.name `name`).
let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/");
let name_matches = match client.get(&name_url).bearer_auth(sender_token).send().await {
let name_matches = match client.get(&name_url).bearer_auth(admin_token).send().await {
Ok(r) if r.status().is_success() => r
.json::<serde_json::Value>()
.await
@ -1334,7 +1299,7 @@ async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Opt
/// Create (or recover) the hive Matrix Space and persist its room ID to
/// [`hive_space_room_id_path()`]. The Space is a private `m.space` owned by
/// `@hive-<hive>:`, identified by its hardcoded name [`HIVE_SPACE_NAME`] (no alias).
/// `@hive`, identified by its hardcoded name [`HIVE_SPACE_NAME`] (no alias).
///
/// Dedup strategy (single canonical space):
/// 1. If the room-id file exists, reuse it.
@ -1348,7 +1313,7 @@ async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Opt
///
/// Returns an error if the homeserver is unreachable, `createRoom` fails,
/// or the room-ID file cannot be written.
pub async fn ensure_hive_space(client: &reqwest::Client, sender_token: &str) -> Result<String> {
pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> Result<String> {
let base = matrix_base()?;
// 1. Stored room id wins (fast path).
if let Ok(existing) = std::fs::read_to_string(hive_space_room_id_path()) {
@ -1361,7 +1326,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, sender_token: &str) ->
// 2. No stored id — rediscover the existing space by its hardcoded name
// before creating a new one (prevents duplicate spaces after a wipe).
if let Some(room_id) = find_space_by_name(client, sender_token).await {
if let Some(room_id) = find_space_by_name(client, admin_token).await {
persist_space_room_id(&room_id)?;
tracing::info!(%room_id, "matrix: recovered hive space by name");
return Ok(room_id);
@ -1377,7 +1342,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, sender_token: &str) ->
});
let resp = client
.post(&url)
.bearer_auth(sender_token)
.bearer_auth(admin_token)
.json(&body)
.send()
.await
@ -1417,12 +1382,12 @@ fn persist_chat_room_id(room_id: &str) -> Result<()> {
/// [`find_space_by_name`] so a lost room-id file recovers the existing chat
/// room instead of spawning a duplicate. `None` if the homeserver is
/// unreachable or no match exists.
async fn find_chat_room_by_name(client: &reqwest::Client, sender_token: &str) -> Option<String> {
async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) -> Option<String> {
let base = matrix_http()?;
let joined_url = format!("{base}/_matrix/client/v3/joined_rooms");
let joined: serde_json::Value = client
.get(&joined_url)
.bearer_auth(sender_token)
.bearer_auth(admin_token)
.send()
.await
.ok()?
@ -1439,7 +1404,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, sender_token: &str) ->
let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/");
let is_space = match client
.get(&create_url)
.bearer_auth(sender_token)
.bearer_auth(admin_token)
.send()
.await
{
@ -1455,7 +1420,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, sender_token: &str) ->
}
// …and named HIVE_CHAT_ROOM_NAME (m.room.name `name`).
let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/");
let name_matches = match client.get(&name_url).bearer_auth(sender_token).send().await {
let name_matches = match client.get(&name_url).bearer_auth(admin_token).send().await {
Ok(r) if r.status().is_success() => r
.json::<serde_json::Value>()
.await
@ -1495,10 +1460,10 @@ fn state_needs_write(current: Option<&serde_json::Value>, desired: &serde_json::
/// too, loudly — and a 404 is the expected first-setup case.
async fn current_room_state(
client: &reqwest::Client,
sender_token: &str,
admin_token: &str,
url: &str,
) -> Option<serde_json::Value> {
let resp = match client.get(url).bearer_auth(sender_token).send().await {
let resp = match client.get(url).bearer_auth(admin_token).send().await {
Ok(resp) => resp,
Err(e) => {
tracing::debug!(error = ?e, url, "matrix: state read unreachable; writing");
@ -1526,7 +1491,7 @@ async fn current_room_state(
}
}
/// PUT a state event into `room_id` using the sender token, **skipping the
/// PUT a state event into `room_id` using the admin token, **skipping the
/// write when the room already carries identical content**.
///
/// The read is not an optimisation. A PUT of identical content is a no-op
@ -1541,7 +1506,7 @@ async fn current_room_state(
/// missing or divergent link still gets written.
async fn set_room_state(
client: &reqwest::Client,
sender_token: &str,
admin_token: &str,
room_id: &str,
event_type: &str,
state_key: &str,
@ -1552,7 +1517,7 @@ async fn set_room_state(
let encoded_key = encode_room_id_for_url(state_key);
let url =
format!("{base}/_matrix/client/v3/rooms/{encoded_room}/state/{event_type}/{encoded_key}");
let current = current_room_state(client, sender_token, &url).await;
let current = current_room_state(client, admin_token, &url).await;
if !state_needs_write(current.as_ref(), content) {
tracing::debug!(
%room_id,
@ -1563,7 +1528,7 @@ async fn set_room_state(
}
let resp = client
.put(&url)
.bearer_auth(sender_token)
.bearer_auth(admin_token)
.json(content)
.send()
.await
@ -1600,7 +1565,7 @@ async fn set_room_state(
/// link is logged but not fatal (the room still exists + is joinable).
pub async fn ensure_hive_chat_room(
client: &reqwest::Client,
sender_token: &str,
admin_token: &str,
space_room_id: &str,
server_name: &str,
) -> Result<String> {
@ -1614,7 +1579,7 @@ pub async fn ensure_hive_chat_room(
{
tracing::debug!(room_id = %id, "matrix: hive chat room already provisioned");
id
} else if let Some(id) = find_chat_room_by_name(client, sender_token).await {
} else if let Some(id) = find_chat_room_by_name(client, admin_token).await {
persist_chat_room_id(&id)?;
tracing::info!(room_id = %id, "matrix: recovered hive chat room by name");
id
@ -1654,7 +1619,7 @@ pub async fn ensure_hive_chat_room(
});
let resp = client
.post(&url)
.bearer_auth(sender_token)
.bearer_auth(admin_token)
.json(&body)
.send()
.await
@ -1685,7 +1650,7 @@ pub async fn ensure_hive_chat_room(
});
if let Err(e) = set_room_state(
client,
sender_token,
admin_token,
space_room_id,
"m.space.child",
&room_id,
@ -1703,21 +1668,21 @@ pub async fn ensure_hive_chat_room(
/// account. Idempotent — treats already-member responses as success.
async fn invite_to_room(
client: &reqwest::Client,
sender_token: &str,
admin_token: &str,
room_id: &str,
localpart: &str,
server_name: &str,
) -> Result<()> {
let user_id = format!("@{localpart}:{server_name}");
invite_user_id(client, sender_token, room_id, &user_id).await
invite_user_id(client, admin_token, room_id, &user_id).await
}
/// Fetch a user's current membership in a room via the sender token, or
/// Fetch a user's current membership in a room via the admin token, or
/// `None` if there is no membership event (never invited) or the lookup
/// fails. Returns the raw membership string (`invite`, `join`, `leave`, …).
async fn room_membership(
client: &reqwest::Client,
sender_token: &str,
admin_token: &str,
encoded_room_id: &str,
user_id: &str,
) -> Option<String> {
@ -1730,7 +1695,7 @@ async fn room_membership(
);
let resp = client
.get(&url)
.bearer_auth(sender_token)
.bearer_auth(admin_token)
.send()
.await
.ok()?;
@ -1744,13 +1709,13 @@ async fn room_membership(
}
/// Invite a fully-qualified Matrix user id (`@user:server`) to `room_id`
/// using the sender token. Idempotent: a user who is already a member or
/// using the admin token. Idempotent: a user who is already a member or
/// already has a pending invite is left untouched (no fresh invite is sent,
/// so they are not re-notified), and a 403 `M_FORBIDDEN` / `M_BAD_STATE`
/// from a racing invite is still treated as success.
async fn invite_user_id(
client: &reqwest::Client,
sender_token: &str,
admin_token: &str,
room_id: &str,
user_id: &str,
) -> Result<()> {
@ -1762,7 +1727,7 @@ async fn invite_user_id(
// Skip the invite entirely when the user is already invited or joined.
// Re-POSTing an invite to a pending member re-sends the invite event,
// which re-notifies the agent on every provisioning sweep.
if let Some(membership) = room_membership(client, sender_token, &encoded_room_id, user_id).await
if let Some(membership) = room_membership(client, admin_token, &encoded_room_id, user_id).await
&& matches!(membership.as_str(), "invite" | "join")
{
tracing::debug!(%user_id, %room_id, %membership, "matrix: invite skipped (already a member/invited)");
@ -1772,7 +1737,7 @@ async fn invite_user_id(
let url = format!("{base}/_matrix/client/v3/rooms/{encoded_room_id}/invite");
let resp = client
.post(&url)
.bearer_auth(sender_token)
.bearer_auth(admin_token)
.json(&serde_json::json!({ "user_id": user_id }))
.send()
.await
@ -1805,13 +1770,13 @@ async fn invite_user_id(
///
/// # Errors
///
/// Returns an error if the sender token or `server_name` can't be read,
/// Returns an error if the admin token or `server_name` can't be read,
/// the target room can't be resolved (no `--room` and no persisted hive
/// space), or the invite POST fails for a reason other than the user
/// already being a member / invited.
pub async fn invite_user(
client: &reqwest::Client,
sender_token: &str,
admin_token: &str,
user: &str,
room_override: Option<&str>,
server_name: &str,
@ -1825,7 +1790,7 @@ pub async fn invite_user(
// Resolve the room: explicit override (id or #alias) wins; otherwise
// the persisted hive Space.
let room_id = match room_override {
Some(r) if r.starts_with('#') => resolve_room_alias(client, sender_token, r).await?,
Some(r) if r.starts_with('#') => resolve_room_alias(client, admin_token, r).await?,
Some(r) if r.starts_with('!') => r.to_owned(),
Some(r) => anyhow::bail!(
"matrix: --room {r:?} is neither a room id nor an alias; \
@ -1839,14 +1804,14 @@ pub async fn invite_user(
(run the hive-c0re matrix sweep first)",
)?,
};
invite_user_id(client, sender_token, &room_id, &user_id).await?;
invite_user_id(client, admin_token, &room_id, &user_id).await?;
Ok(room_id)
}
/// Resolve a `#alias:server` to its room id via the directory API.
async fn resolve_room_alias(
client: &reqwest::Client,
sender_token: &str,
admin_token: &str,
alias: &str,
) -> Result<String> {
let base = matrix_base()?;
@ -1854,7 +1819,7 @@ async fn resolve_room_alias(
let url = format!("{base}/_matrix/client/v3/directory/room/{encoded}");
let resp = client
.get(&url)
.bearer_auth(sender_token)
.bearer_auth(admin_token)
.send()
.await
.with_context(|| format!("matrix: GET directory for {alias}"))?;
@ -1910,12 +1875,13 @@ pub async fn ensure_all() -> bool {
return false;
}
};
// The hive's `@hive-<hive>:` account first, because everything below provisions
// THROUGH it (the Space, the chat room and every invite are sent with
// its token) — as an ordinary user that created those rooms, not as a
// homeserver admin.
if let Err(e) = ensure_hive_user(&client, &as_token).await {
tracing::warn!(error = ?e, "matrix: ensure_hive_user failed");
// The hive admin first, because everything below provisions THROUGH
// it (the Space, the chat room and every invite are sent with its
// token). Not, any more, so that it wins a first-registered-user
// grant: it holds admin rights by explicit promotion, checked in
// `provision_space` once the server name is known.
if let Err(e) = ensure_admin_user(&client, &as_token).await {
tracing::warn!(error = ?e, "matrix: ensure_admin_user failed");
ok = false;
}
let Ok(containers) = crate::lifecycle::list().await else {
@ -1945,10 +1911,10 @@ pub async fn ensure_all() -> bool {
async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bool {
let mut ok = true;
// server_name is needed to form full Matrix user IDs for invites.
let sender_token = match read_sender_token() {
let admin_token = match read_admin_token() {
Ok(t) => t,
Err(e) => {
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no matrix sender token)");
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no admin token)");
return false;
}
};
@ -1960,55 +1926,60 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
return false;
}
};
let room_id = match ensure_hive_space(client, &sender_token).await {
// Before the rooms: the one thing in this sweep that is about the
// admin account itself rather than about what it provisions.
if !ensure_admin_rights(client, &admin_token, &server_name).await {
ok = false;
}
let room_id = match ensure_hive_space(client, &admin_token).await {
Ok(id) => id,
Err(e) => {
tracing::warn!(error = ?e, "matrix: ensure_hive_space failed");
return false;
}
};
// This hive's own account first, then all agents.
let localpart = match hive_localpart() {
Ok(l) => l,
Err(e) => {
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning");
return false;
}
};
if let Err(e) = invite_to_room(client, &sender_token, &room_id, &localpart, &server_name).await
// Invite @hive admin first, then all agents.
if let Err(e) = invite_to_room(
client,
&admin_token,
&room_id,
HIVE_ADMIN_LOCALPART,
&server_name,
)
.await
{
tracing::warn!(error = ?e, %localpart, "matrix: invite the hive account to the space failed");
tracing::warn!(error = ?e, "matrix: invite @hive to space failed");
ok = false;
}
for name in agent_names {
if let Err(e) = invite_to_room(client, &sender_token, &room_id, name, &server_name).await {
if let Err(e) = invite_to_room(client, &admin_token, &room_id, name, &server_name).await {
tracing::warn!(%name, error = ?e, "matrix: invite agent to space failed");
ok = false;
}
}
// Provision the default hive chat room as an m.space.child of the Space
// and invite `@hive-<hive>:` + every agent. Joining a Space alone surfaces no
// and invite @hive + every agent. Joining a Space alone surfaces no
// rooms to chat in (Matrix semantics — children aren't auto-joined), so
// without this the Space is empty. The restricted join rule additionally
// lets the operator (a Space member) join from the Space hierarchy.
match ensure_hive_chat_room(client, &sender_token, &room_id, &server_name).await {
match ensure_hive_chat_room(client, &admin_token, &room_id, &server_name).await {
Ok(chat_room_id) => {
if let Err(e) = invite_to_room(
client,
&sender_token,
&admin_token,
&chat_room_id,
&localpart,
HIVE_ADMIN_LOCALPART,
&server_name,
)
.await
{
tracing::warn!(error = ?e, %localpart, "matrix: invite the hive account to the chat room failed");
tracing::warn!(error = ?e, "matrix: invite @hive to chat room failed");
ok = false;
}
for name in agent_names {
if let Err(e) =
invite_to_room(client, &sender_token, &chat_room_id, name, &server_name).await
invite_to_room(client, &admin_token, &chat_room_id, name, &server_name).await
{
tracing::warn!(%name, error = ?e, "matrix: invite agent to chat room failed");
ok = false;

View file

@ -2,7 +2,7 @@
//! `/run/hyperhive` + `/run/hive-agent` runtime roots).
//!
//! Historically these were flat string literals scattered across many
//! modules (`broker.sqlite`, `matrix-sender-token`, `agent-sockets.json`,
//! modules (`broker.sqlite`, `matrix-admin-token`, `agent-sockets.json`,
//! …). This module is the **single Rust-side source** for every host
//! path — the strictly host-side ones grouped into subdirs (`db/`,
//! `forge/`, `matrix/`, `run/`), plus the **nix-coupled** roots
@ -128,7 +128,7 @@ pub fn agent_identity_dir(name: &str) -> PathBuf {
agent_identity_root().join(name)
}
/// `matrix/` — host-side matrix provisioning state (the appservice sender token, hive
/// `matrix/` — host-side matrix provisioning state (admin token, hive
/// Space room id, per-agent password creds). The shared registration
/// token is bind-mounted into the tuwunel container via nix and stays
/// at its own path (tracked separately).
@ -137,10 +137,10 @@ pub fn matrix_dir() -> PathBuf {
state_root().join("matrix")
}
/// `matrix/access-token` — the matrix appservice sender account's access token.
/// `matrix/admin-token` — hive system admin access token.
#[must_use]
pub fn matrix_sender_token() -> PathBuf {
matrix_dir().join("access-token")
pub fn matrix_admin_token() -> PathBuf {
matrix_dir().join("admin-token")
}
/// `matrix/space-room-id` — persisted hive Space room id.
@ -327,7 +327,7 @@ pub fn relocate_legacy_state() {
"forge-agent-configs-avatar-set",
forge_config_org_avatar_marker(),
),
("matrix-sender-token", matrix_sender_token()),
("matrix-admin-token", matrix_admin_token()),
("matrix-space-room-id", matrix_space_room_id()),
("matrix-creds", matrix_creds_dir()),
("agent-sockets.json", agent_sockets_file()),

View file

@ -455,7 +455,7 @@ async fn stream_agent_status(
// The `hivectl matrix` subcommands used to run these in-process, which forced
// the standalone CLI to link the whole daemon crate (matrix-sdk, reqwest, …).
// They now run daemon-side over the host socket: the daemon already holds the
// register + sender tokens and the matrix creds dir. Each op returns the
// register + admin tokens and the matrix creds dir. Each op returns the
// operator-facing lines hivectl used to `println!` in `HostResponse::messages`
// for the client to print verbatim.
// ---------------------------------------------------------------------------
@ -777,14 +777,14 @@ async fn handle_matrix_sync_admin() -> Result<HostResponse> {
let as_token =
crate::matrix::read_appservice_token().context("read matrix appservice token")?;
let client = matrix_http_client()?;
crate::matrix::ensure_hive_user(&client, &as_token)
crate::matrix::ensure_admin_user(&client, &as_token)
.await
.context("matrix sync-admin")?;
let path = crate::matrix::sender_token_path();
let path = crate::matrix::admin_token_path();
Ok(HostResponse::messages(vec![
format!(
"matrix: the @{}: user is provisioned",
crate::matrix::hive_localpart()?
"matrix: hive admin user '@{}' provisioned",
crate::matrix::HIVE_ADMIN_LOCALPART
),
format!("token persisted at: {}", path.display()),
]))
@ -792,12 +792,12 @@ async fn handle_matrix_sync_admin() -> Result<HostResponse> {
async fn handle_matrix_promote_user(name: &str) -> Result<HostResponse> {
require_matrix_present()?;
let sender_token = crate::matrix::read_sender_token()?;
let admin_token = crate::matrix::read_admin_token()?;
let client = matrix_http_client()?;
let server_name = crate::matrix::discover_server_name(&client)
.await
.context("discover matrix server_name")?;
crate::matrix::promote_user_to_admin(&client, &sender_token, name, &server_name)
crate::matrix::promote_user_to_admin(&client, &admin_token, name, &server_name)
.await
.with_context(|| format!("matrix promote-user {name}"))?;
Ok(HostResponse::messages(vec![format!(
@ -807,12 +807,12 @@ async fn handle_matrix_promote_user(name: &str) -> Result<HostResponse> {
async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResponse> {
require_matrix_present()?;
let sender_token = crate::matrix::read_sender_token()?;
let admin_token = crate::matrix::read_admin_token()?;
let client = matrix_http_client()?;
let server_name = crate::matrix::discover_server_name(&client)
.await
.context("discover matrix server_name")?;
let room_id = crate::matrix::invite_user(&client, &sender_token, user, room, &server_name)
let room_id = crate::matrix::invite_user(&client, &admin_token, user, room, &server_name)
.await
.with_context(|| format!("matrix invite {user}"))?;
let target = if user.starts_with('@') {
@ -827,12 +827,12 @@ async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResp
async fn handle_matrix_reset_password(name: &str) -> Result<HostResponse> {
require_matrix_present()?;
let sender_token = crate::matrix::read_sender_token()?;
let admin_token = crate::matrix::read_admin_token()?;
let client = matrix_http_client()?;
let server_name = crate::matrix::discover_server_name(&client)
.await
.context("discover matrix server_name")?;
crate::matrix::reset_user_password(&client, &sender_token, name, &server_name)
crate::matrix::reset_user_password(&client, &admin_token, name, &server_name)
.await
.with_context(|| format!("matrix reset-password {name}"))?;
// Password is persisted by reset_user_password.

View file

@ -335,10 +335,10 @@ pub enum MatrixCmd {
#[arg(long, conflicts_with = "password")]
password_stdin: bool,
},
/// Provision (or re-provision) the matrix appservice's sender account.
/// Provision (or re-provision) the hive system admin matrix account.
///
/// Runs automatically on startup; run manually to recover a missing
/// access token.
/// admin token.
SyncAdmin,
/// Promote a matrix user to homeserver admin.
PromoteUser {

View file

@ -1,5 +1,5 @@
//! `hivectl matrix` — matrix account provisioning verbs. hivectl forwards
//! each request to the daemon (which owns the register + sender tokens and
//! each request to the daemon (which owns the register + admin tokens and
//! the matrix creds dir) and renders the reply; it no longer links the
//! matrix machinery itself.
@ -28,7 +28,7 @@ pub(crate) async fn run_matrix_cmd(socket: &Path, cmd: MatrixCmd) -> Result<()>
/// Send a matrix provisioning request to the daemon and print the
/// operator-facing result lines it returns. The daemon owns the register +
/// sender tokens and the matrix creds dir, so hivectl no longer links the
/// admin tokens and the matrix creds dir, so hivectl no longer links the
/// matrix machinery — it just forwards the request and renders the reply.
async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Result<()> {
let resp = crate::client::request(socket, req)

View file

@ -27,7 +27,6 @@
./glue-controller-bao-identity.nix
./glue-grafana-oidc-client.nix
./glue-matrix-bao-token.nix
./glue-matrix-ctl-bao-identity.nix
./glue-queue-agent-credential.nix
./glue-secret-publisher-bao-identity.nix
./glue-swarm-otel-oidc-client.nix

View file

@ -147,19 +147,6 @@ in
# to be this one whenever the store has a host of its own.
[ -s ${pkiDir}/secret-publisher.pem ] || ${signLeaf} ${pkiDir} secret-publisher \
${lib.escapeShellArg deployCfg.bao.secretPublisherCommonName} "" clientAuth
# The matrix container's `swarm-matrix-ctl`. Minted unconditionally like the two
# above, and for the third variant of the same reason: the homeserver
# is a swarm singleton, so on every hive but the one running it this
# leaf is the file an operator copies rather than a file anything
# local reads.
#
# Its own CN, not the reader's: the reader's leaf carries this hive's
# name and its policy reads the whole store, while this principal may
# only write one path — which is the entire point of giving the
# container an identity instead of lending it the hive's.
[ -s ${pkiDir}/matrix-ctl.pem ] || ${signLeaf} ${pkiDir} matrix-ctl \
${lib.escapeShellArg deployCfg.bao.matrixCtlCommonName} "" clientAuth
'';
};
};

View file

@ -1,40 +0,0 @@
# Glue: point the matrix container's `swarm-matrix-ctl` at the bao leaf minted for it.
#
# ONE PAIRING PER FILE — matrix-ctl ← bao, and nothing else. Deleting this leaves a
# binary that takes operator-provided certificate paths, which is what any
# deployment not minting its own already does.
#
# ⚠️ The minting is NOT here. ./glue-bao-tls.nix holds the CA and signs the
# leaf, because the thing that owns a private key owns issuing from it. What
# belongs here is the pairing: which paths matrix-ctl presents.
#
# ⚠️ Gated on the leaf existing, not on the store being enabled — the same rule
# ./glue-secret-publisher-bao-identity.nix states, and it bites harder here: a
# swarm runs ONE homeserver, so the hive hosting it is the one least likely to
# also be the hive hosting the store.
#
# Everything is `mkDefault`. An operator naming their own paths wins.
{
lib,
config,
...
}:
let
hyperhiveCfg = config.services.hyperhive;
deployCfg = hyperhiveCfg.deploy;
baoDeploy = deployCfg.bao;
# Where ./glue-bao-tls.nix puts the leaves, derived from the reader's own path
# rather than repeating that file's directory literal: an operator who moves
# the PKI moves both, and the two cannot drift apart.
haveMintedPki = baoDeploy.clientCertFile != null;
pkiDir = if haveMintedPki then builtins.dirOf baoDeploy.clientCertFile else null;
in
{
config = lib.mkIf (hyperhiveCfg.enable && deployCfg.matrix.enable && haveMintedPki) {
services.hyperhive.deploy.matrix = {
ctlBaoClientCertFile = lib.mkDefault "${pkiDir}/matrix-ctl.pem";
ctlBaoClientKeyFile = lib.mkDefault "${pkiDir}/matrix-ctl-key.pem";
};
};
}

View file

@ -26,11 +26,6 @@ let
# `null/.well-known/…`.
autheliaCfg = config.services.hyperhive.swarm.authelia;
deployCfg = config.services.hyperhive.deploy;
# Where the swarm's secret store answers. Swarm-tier, identical on every
# host, which is what lets the container address it without knowing whether
# it stands here.
baoCfg = config.services.hyperhive.swarm.bao;
autheliaUrl = autheliaCfg.url;
# The all-local case: this host runs BOTH the homeserver and the swarm's
@ -64,30 +59,18 @@ let
# namespace below, with no shared registration secret in the picture.
appserviceId = "hyperhive";
# The appservice's own user, and the account the hive acts as. An
# ordinary user, not a homeserver admin: everything the hive does with
# it — the Space, the chat room, the invites — it does as the creator of
# those rooms. Loading a registration CREATES its `sender_localpart` user
# when absent (tuwunel `src/service/appservice/mod.rs`), on a zero-user
# database, inside `Services::start()` — before the HTTP listener accepts
# anything, so the account exists on the very first boot of a fresh
# homeserver without hive-c0re racing to register it.
# The appservice's own user, and deliberately the hive admin account.
# Loading a registration CREATES its `sender_localpart` user when absent
# (tuwunel `src/service/appservice/mod.rs`), on a zero-user database,
# inside `Services::start()` — before the HTTP listener accepts anything.
# That is what lets the `admin_execute` promotion below land on the very
# first boot of a fresh homeserver, instead of depending on hive-c0re
# racing to register the first account and win the auto-admin grant.
#
# 🩸 **Derived from the hive name, so it is one account per hive.** It was
# the bare `hive` and a swarm runs one homeserver: every hive on it acted
# as the same `@hive:`, which is one identity with no attribution and no
# way to revoke a single hive. The hives that do not run this container
# register their own `hive-<name>` through the appservice namespace below,
# which already admits it.
#
# ⚠️ Must equal `swarm_secret_client::matrix::hive_localpart`, which
# hive-c0re and swarm-matrix-ctl both derive from independently with
# nothing wiring an override across — same agreement, and same reason for
# saying so, as the token path below.
#
# `hiveName` has no fallback here for the reason ./glue-matrix-bao-token.nix
# gives at its own use of it: it is asserted set for every hyperhive host.
hiveLocalpart = "hive-${toString config.services.hyperhive.hiveName}";
# ⚠️ Must equal `matrix::HIVE_ADMIN_LOCALPART` in hive-c0re, which derives
# it independently with nothing wiring an override across — same
# agreement, and same reason for saying so, as the token path below.
adminLocalpart = "hive";
# The `as_token`, and the `hs_token` the spec requires alongside it. Both
# minted by the render script below, mode 0600; the `as_token` is the one
@ -124,62 +107,7 @@ let
appserviceCredentialId = "${appserviceId}-appservice.yaml";
appserviceCredentialDir = "/run/credentials/tuwunel.service";
# ── swarm-matrix-ctl ────────────────────────────────────────────────
#
# The oneshot that publishes the appservice sender account's access token to
# the swarm's secret store. It runs INSIDE the container, beside tuwunel,
# because the appservice token that authorises the mint is already in here —
# `appserviceDir` below is bound read-only precisely so the homeserver can
# load it — and minting anywhere else would create a second holder of that
# secret, which is the thing this whole arrangement exists to stop.
#
# Gated on the identity, not on `deploy.bao.enable`: a swarm's ONE homeserver
# is the host least likely to also be the host running the store, so
# "co-located with bao" would leave the intended deployment silently minting
# nothing. Same rule ./swarm-secret-publisher.nix's `haveClientIdentity`
# states, for a sharper reason.
ctlActive =
deployCfg.matrix.ctlBaoClientCertFile != null && deployCfg.matrix.ctlBaoClientKeyFile != null;
# The role on the store's `cert` auth mount, and so the single source of the
# string both ends must agree on: ./swarm-bao.nix writes the role under
# `matrixCtlPolicyName` and this hands it to the binary in the
# environment, so the binary itself spells no role at all.
ctlCertRole = "swarm-matrix-ctl";
# Every host directory matrix-ctl's bao identity is spread across. Normally
# one — ./glue-bao-tls.nix puts all three files in ./glue's PKI dir — but
# derived rather than assumed, because an operator naming paths by hand is
# exactly the deployment the gate above is written for.
#
# Directories rather than the files, for the reason `appserviceDir`'s own
# comment gives: a re-issued leaf is a new inode, and binding the file would
# pin the one the container saw when it started.
ctlPkiDirs = lib.optionals ctlActive (
lib.unique (
map builtins.dirOf (
[
deployCfg.matrix.ctlBaoClientCertFile
deployCfg.matrix.ctlBaoClientKeyFile
]
++ lib.optional (deployCfg.bao.serverCaFile != null) deployCfg.bao.serverCaFile
)
)
);
ctlBindMounts = lib.genAttrs ctlPkiDirs (dir: {
hostPath = dir;
isReadOnly = true;
});
# Where a reader of the published credential is told the token is good for.
# Empty when this hive serves no vhost: `matrix::Credential.homeserver` is an
# `Option`, and matrix-ctl reads an empty variable as absent rather than as
# the string "null" — which is what a hive with no gateway host actually
# knows about itself.
ctlHomeserverUrl = if cfg.gatewayHost == null then "" else "https://${toString cfg.gatewayHost}";
# Every local user this hive may provision — agents, `@hive-<hive>:` itself, and
# Every local user this hive may provision — agents, the hive admin, and
# the operator accounts `hivectl matrix create-user` makes, which is the
# whole matrix localpart charset.
#
@ -245,7 +173,7 @@ let
cat <<'REGISTRATION'
id: ${appserviceId}
url: null
sender_localpart: ${hiveLocalpart}
sender_localpart: ${adminLocalpart}
rate_limited: false
namespaces:
users:
@ -758,56 +686,6 @@ in
match authelia's register.
'';
};
ctlPackage = lib.mkOption {
type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-matrix-ctl";
description = ''
The `swarm-matrix-ctl` build run inside the matrix container.
⚠️ Named `ctlPackage`, not folded into `package` above: that one is
the homeserver, and this is a hyperhive binary that happens to run
beside it. Same split, and same reason, as
{option}`services.hyperhive.deploy.nats.authPackage`.
'';
};
ctlBaoClientCertFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Client certificate the matrix container's `swarm-matrix-ctl` presents to the
swarm's secret store. Its subject must be
{option}`services.hyperhive.deploy.bao.matrixCtlCommonName` — cert
auth matches on the CN, and the role accepts nothing else.
⚠️ **Not the hive's own leaf**, and that is the whole deliverable of
giving this container an identity: the hive's certificate reads every
secret in the store, while this one may write a single path. Pointing
this at `deploy.bao.clientCertFile` would evaluate, deploy and work —
and give away the separation in one line.
No default: a module that guessed would be holding the CA opinion
./swarm-bao.nix deliberately does not hold.
./glue-matrix-ctl-bao-identity.nix points it at the leaf
./glue-bao-tls.nix mints, where this host mints one.
The file and its key are bind-mounted into the container read-only.
Co-located in the hive's filesystem and therefore readable by hive
**root** — accepted: the boundary this buys is identity (no hive
*process* holds the appservice token), not physical isolation.
'';
};
ctlBaoClientKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Private key for
{option}`services.hyperhive.deploy.matrix.ctlBaoClientCertFile`.
Both or neither — the unit does not exist unless each is set.
'';
};
};
config = lib.mkIf deployCfg.matrix.enable {
@ -1204,11 +1082,7 @@ in
isReadOnly = true;
};
}
// caTrust.bindMount
# matrix-ctl's bao client identity, and nothing else of the hive's. See
# `ctlPkiDirs` above for why it is a derived set of directories
# rather than one literal.
// ctlBindMounts;
// caTrust.bindMount;
config =
{ ... }:
{
@ -1318,21 +1192,30 @@ in
# `Services::start()`, before the listener accepts anything.
appservice_dir = appserviceCredentialDir;
# No `admin_execute` promotion for `@${hiveLocalpart}`. The
# hive's account is an ordinary user: it creates the hive
# Space and chat room and invites agents into them, all of
# which ride on being the rooms' own creator at power level
# 100, and none of which is a homeserver-admin capability.
# Granting it server admin at boot would hand a credential
# that every hive reads far more than the work needs.
# The zero-user bootstrap, and the only thing here that needs
# no account to already exist. These run after startup and
# BEFORE the HTTP listener, with no sender and no permission
# check — which is what makes them the one lever that can
# promote the hive admin on a homeserver where nobody is admin
# yet. The appservice registration above creates
# `@${adminLocalpart}` as its sender user moments earlier;
# this joins it to the admin room at power level 100.
#
# The two operations that do need an admin sender —
# `!admin users make-user-admin` and
# `!admin users reset-password`, both messages into
# `#admins:${effectiveServerName}` — therefore have no
# working sender here. They are swarm-level operations and
# are being rehomed as such; until then they fail, loudly,
# rather than being served by an over-privileged token.
# Idempotent by upstream's own guard: `make_user_admin`
# short-circuits when the user is already joined at 100, so a
# hive that has had an admin for months emits nothing.
admin_execute = [
"users make-user-admin @${adminLocalpart}:${effectiveServerName}"
];
# ⚠️ Load-bearing, not tidiness. An `admin_execute` command
# that fails aborts startup outright when this is false — so
# the one boot where the promotion cannot work (a homeserver
# that has no `@${adminLocalpart}` and no appservice user yet,
# e.g. a registration file that arrived late) would take the
# homeserver down with it rather than converging on the next
# start.
admin_execute_errors_ignore = true;
# Server-side E2EE is opt-in (default off); the agent matrix
# client always supports decryption regardless.
allow_encryption = cfg.allowEncryption;
@ -1403,66 +1286,6 @@ in
"oidc_client_secret:${toString deployCfg.matrix.sso.clientSecretFile}"
];
# Publish the appservice sender account's access token to the swarm
# store, once, under an identity that belongs to this container and
# not to the hive. See `ctlActive` above for why it runs here.
#
# A `oneshot` with no timer and no retry loop of its own: the whole
# of "and only once" is the binary's first act, a read of the path it
# would write. `Restart=on-failure` covers a store that is sealed or
# a homeserver still starting; `RemainAfterExit` is deliberately NOT
# set, because the unit having succeeded is not the idempotency
# record — the store is, and it outlives this machine.
systemd.services.swarm-matrix-ctl = lib.mkIf ctlActive {
description = "publish the matrix sender token to the swarm secret store";
# Ordered after the homeserver because both of the ladder's arms
# are client-server API calls. `wants`, not `requires`: a run that
# finds the credential already published never touches tuwunel at
# all, so a homeserver that is slow to come up should delay this,
# not cancel it.
after = [ "tuwunel.service" ];
wants = [ "tuwunel.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
# The verb is part of the contract: `swarm-matrix-ctl` is a
# subcommand binary and refuses a bare invocation, so dropping
# `mint` here fails the unit rather than doing something else.
ExecStart = "${deployCfg.matrix.ctlPackage}/bin/swarm-matrix-ctl mint";
Restart = "on-failure";
RestartSec = 30;
# Bounded here rather than left to systemd's default, for the
# reason ./swarm-secret-publisher.nix states: a sealed store
# answers on the port and never answers the read.
TimeoutStartSec = 60;
SyslogIdentifier = "swarm-matrix-ctl";
};
environment = {
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
BAO_CLIENT_CERT = deployCfg.matrix.ctlBaoClientCertFile;
BAO_CLIENT_KEY = deployCfg.matrix.ctlBaoClientKeyFile;
MATRIX_MINT_CERT_ROLE = ctlCertRole;
# Loopback: this container shares the host netns, so the
# homeserver it must talk to is the one in this very unit's
# netns and needs no name, no vhost and no TLS.
MATRIX_MINT_API_URL = "http://127.0.0.1:${toString cfg.httpPort}";
# The bind-mounted registration, which IS the as_token. A path,
# never a value.
MATRIX_MINT_REGISTRATION = appserviceRegistrationPath;
MATRIX_MINT_LOCALPART = hiveLocalpart;
# The hive segment of the store path the token is published
# under, and so the thing that keeps this hive's token out of
# every other hive's reach: the grant that reaches it is the
# hive's own `swarm/hives/<name>/*` stanza. ./swarm-bao.nix
# spells the same name into matrix-ctl's write grant.
MATRIX_MINT_HIVE = toString config.services.hyperhive.hiveName;
MATRIX_MINT_HOMESERVER = ctlHomeserverUrl;
}
// lib.optionalAttrs (deployCfg.bao.serverCaFile != null) {
BAO_CACERT = deployCfg.bao.serverCaFile;
};
};
environment.systemPackages = [ deployCfg.matrix.package ];
};
};

View file

@ -236,54 +236,6 @@ let
}
'';
# The identity the matrix container's `swarm-matrix-ctl` presents. Named outside `hive-*`
# for the reason its two siblings above give — the controller may rewrite
# every policy under that prefix, and a policy it can rewrite constrains
# nothing.
matrixCtlPolicyName = "swarm-matrix-ctl";
matrixCtlCn = baoDeploy.matrixCtlCommonName;
# ONE path, and every narrowing in it is load-bearing.
#
# `secret/data/` is KV v2's ACL prefix, inserted by the engine rather than
# written by the caller — the same trap as the two grants above.
#
# Not `swarm/services/*` like the publisher's: this principal produces
# exactly one secret, its own hive's matrix sender account access token, and
# a homeserver is not entitled to overwrite Grafana's OIDC client. The path
# is spelled to the leaf for that reason, not for tidiness.
#
# 🩸 And the leaf now carries a HIVE segment, which is the narrowing that
# matters: the credential used to live at `swarm/services/matrix/sender-token`
# — one value for the whole swarm, under the `services/*` tree every hive's
# own policy grants read on. Under `swarm/hives/<name>/` the only read grant
# that reaches it is that hive's own stanza, so one hive cannot fetch
# another's. `matrixCtlHive` below is the name this principal may write, and
# it is one hive rather than a `hives/*` wildcard for the same reason.
#
# `read` as well as write, unlike either sibling, and it is what makes "and
# only once" mechanical: matrix-ctl's first act is to read this path back and
# stop if something is there, so without the capability every container
# restart would mint a second access token and invalidate the hive's. A read
# here recovers one secret this principal itself wrote, which is a much
# narrower grant than the publisher's would have been.
matrixCtlPolicyText = ''
path "${credentialMountPath}/data/swarm/hives/${matrixCtlHive}/matrix/sender-token" {
capabilities = ["create", "update", "read"]
}
'';
# Which hive matrix-ctl mints for. This host's own by default, which is right
# whenever the store and the homeserver are co-located and is the shape the
# `mkDefault` deployments produce; an operator running them apart names the
# homeserver's hive here, because the policy is written where the store is
# and the container runs where the homeserver is.
#
# A wrong value is loud rather than silent: matrix-ctl's write comes back 403
# with the store's own message and the hive falls back to minting its account
# locally, which is the same degrade a store that was never deployed gives.
matrixCtlHive = baoDeploy.matrixCtlHiveName;
# The KV v2 engine the controller writes agent credentials through. Named
# once because the grant above and the `secrets enable` in the bootstrap unit
# have to agree: a policy pointing at a mount nobody created is precisely the
@ -707,54 +659,6 @@ in
'';
};
matrixCtlCommonName = lib.mkOption {
type = lib.types.str;
default = "swarm-matrix-ctl";
example = "swarm-matrix-ctl.svc";
description = ''
Subject the store's matrix-ctl cert-auth role accepts — the
identity the oneshot inside the matrix container presents when it
publishes the appservice sender account's access token.
A **third** identity rather than reuse of either sibling above, and
the narrowest of the three: its grant is one path, that
credential itself. The point of the whole arrangement is that the
process holding the appservice token is not the hive, so handing it
the hive's own leaf — which reads every secret in the store — would
give the separation away in one line.
⚠️ Same collision as its siblings, and the same answer: ./swarm.nix
feeds this value into the guard on
{option}`services.hyperhive.swarm.hives`, so a hive named after it
fails evaluation rather than silently receiving matrix-ctl's grant.
'';
};
matrixCtlHiveName = lib.mkOption {
type = lib.types.str;
default = toString hyperhiveCfg.hiveName;
defaultText = lib.literalExpression "services.hyperhive.hiveName";
example = "pr1ma";
description = ''
Hive whose matrix sender token the store's matrix-ctl role may write.
The sender account's access token is **per hive**: it lives at
`swarm/hives/<name>/matrix/sender-token`, and the only read grant that
reaches it is that hive's own. So matrix-ctl's write grant names one
hive too — the hive whose homeserver container it runs in.
Defaults to this host's own {option}`services.hyperhive.hiveName`,
which is correct whenever the store and the homeserver are co-located.
Set it when they are not: the policy is written where the store runs,
and the oneshot runs where the homeserver does.
A wrong value degrades rather than breaks — matrix-ctl's write is
refused with the store's own message and the hive mints its account
locally instead, the same fallback a swarm that never deployed the
store already uses.
'';
};
serverCaFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
@ -1177,7 +1081,7 @@ in
# ⚠️ Deliberately EMPTY: nothing here generates an issuer into it, so
# the role below can be written but cannot yet issue. That is the
# whole shape of this step — the mount, its narrowing and its grant
# exist, and the binary that uses them arrives separately. Undoing it
# exist, and the minter that uses them arrives separately. Undoing it
# is `bao secrets disable ${servicesPkiMountPath}`.
case "$mounts" in
*'"${servicesPkiMountPath}/"'*) ;;
@ -1201,7 +1105,7 @@ in
# is a name type this issuer is unconstrained for.
# `server_flag` alone because that is what today's leaf carries
# (`extendedKeyUsage=serverAuth`, ./hive-tls.nix:144), and rsa:4096
# because that is the key the current implementation generates.
# because that is the key the current minter generates.
#
# An empty `swarm.serviceDomains` renders an empty `allowed_domains`,
# which with `allow_any_name=false` is a role that issues nothing —
@ -1318,54 +1222,6 @@ in
'';
};
# A THIRD sibling, for the reason the second one's comment gives: these
# unit names are operator-facing strings, and the grant written here
# belongs to a principal neither of the other two names. Same `after`
# rather than `requires`, and for the same stated reason — the first unit
# creates the mounts this one writes into, but a failed oneshot still
# counts as finished, so only ordering plus this unit's own retry
# converges.
systemd.services.swarm-bao-matrix-ctl-policy = lib.mkIf haveBootstrapToken {
description = "write swarm-matrix-ctl's bao policy and cert-auth role";
after = [
"container@${cfg.machine}.service"
"swarm-bao-controller-policy.service"
];
wantedBy = [ "multi-user.target" ];
path = [
baoCli
pkgs.coreutils
];
unitConfig.ConditionPathExists = baoDeploy.bootstrapTokenFile;
# Same unseal wait as its two siblings above, for the reason stated
# there: under `seal = "shamir"` a human unseals by hand.
startLimitBurst = 2880;
startLimitIntervalSec = 90000;
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
Restart = "on-failure";
RestartSec = 30;
};
script = ''
set -euo pipefail
BAO_TOKEN="$(cat ${lib.escapeShellArg baoDeploy.bootstrapTokenFile})"
export BAO_TOKEN
printf '%s' ${lib.escapeShellArg matrixCtlPolicyText} |
bao policy write ${lib.escapeShellArg matrixCtlPolicyName} -
''
+ lib.optionalString (baoDeploy.clientCaFile != null) ''
bao write auth/cert/certs/${lib.escapeShellArg matrixCtlPolicyName} \
certificate=@${tlsDir}/client-ca.pem \
allowed_common_names=${lib.escapeShellArg matrixCtlCn} \
token_policies=${lib.escapeShellArg matrixCtlPolicyName} \
display_name=${lib.escapeShellArg matrixCtlCn}
'';
};
containers.${cfg.machine} = {
autoStart = true;
ephemeral = false;

View file

@ -46,7 +46,6 @@ let
certAuthCns = [
deployCfg.bao.controllerCommonName
deployCfg.bao.secretPublisherCommonName
deployCfg.bao.matrixCtlCommonName
];
# Public hostnames of the swarm's own services, in declaration order.

Some files were not shown because too many files have changed in this diff Show more