matrix: mint the appservice sender token in the matrix container
A swarm runs one homeserver and a homeserver has one appservice sender account, so "mint it once" is a property of the thing being minted rather than something a lock has to enforce. That is what makes this account the one to move first: no trigger route, no controller change and no agent list — a boot-time oneshot beside tuwunel is the whole mechanism. `swarm-matrix-minter` runs inside `containers.hive-matrix`, which already holds the appservice token: the rendered registration is bound in read-only because that is how tuwunel is handed it. What the container lacked was an identity of its own, so this adds one — a leaf from the store's CA with a grant of exactly one path, not the hive's leaf, which reads every secret in the store. Both ends of the credential ship here. The minter reads the path it publishes to before it touches the homeserver, and returning on a non-empty read IS the "only once"; `hive-c0re`'s `ensure_hive_user` reads the same path, authenticating with the hive name already in `HYPERHIVE_HIVE_NAME`. The existing mint-then-`M_USER_IN_USE`-login ladder stays as the fallback for a store that is empty, unconfigured or unreachable, which is every swarm deployed before this — so nothing needs backfilling and nothing breaks if the rest of the sequence never lands. The credential is not an admin credential, and is not named like one. It is the access token of the appservice registration's own `sender_localpart` — `@hive:<server_name>`, an account the homeserver creates for itself when it loads the registration. The store path is `swarm/services/matrix/sender-token`, the host path is `matrix/access-token`, and the homeserver no longer runs an `admin_execute` promotion for that account at boot. Everything the hive provisions with it — the Space, the chat room, their hierarchy and join rules, the invites — rides on being the creator of those rooms at power level 100, not on homeserver admin; there is no Synapse admin API here to need, tuwunel has none. Two operations do need an admin *sender* and therefore stop working: `hivectl matrix promote-user` and `hivectl matrix reset-password`, both `!admin …` messages into `#admins:<server>`, plus the password-reset recovery path that an agent with a lost password file falls back to. They are swarm-level operations and are left failing loudly rather than served by an over-privileged token every other call site would also carry. The sweep's own admin-rights check and self-repair go with them: an account that is deliberately not an admin has nothing to check. `ephemeral = false` stays, and hive root can still read the container's filesystem. Accepted: what this buys is identity separation — no hive *process* holds or reads the appservice token — not physical isolation. Refs #4345
This commit is contained in:
parent
ff0da0b617
commit
f778122f5a
28 changed files with 1566 additions and 320 deletions
13
Cargo.lock
generated
13
Cargo.lock
generated
|
|
@ -4924,6 +4924,19 @@ dependencies = [
|
|||
"swarm-queue-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swarm-matrix-minter"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"swarm-secret-client",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swarm-nats-auth"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ members = [
|
|||
"swarm-authelia-bridge",
|
||||
"swarm-authelia-bridge-sock",
|
||||
"swarm-controller",
|
||||
"swarm-matrix-minter",
|
||||
"swarm-nats-auth",
|
||||
"swarm-queue-client",
|
||||
"swarm-logs",
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ control: [`swarm/ui.md`](../swarm/ui.md).
|
|||
### 6 · Matrix
|
||||
|
||||
```bash
|
||||
# Ensure the hive-internal admin account exists first
|
||||
# Ensure the hive's own `@hive:` 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, the matrix admin token) live on the host.
|
||||
creds (the core token, the `@hive:` matrix access token) live on the host.
|
||||
- All config changes (forge PRs on `agent-configs/<name>`) go through
|
||||
operator approval — agents can't unilaterally rebuild containers, by design.
|
||||
See [`boundary.md`](../trust-boundary/boundary.md) and [`security.md`](../trust-boundary/security.md).
|
||||
|
|
|
|||
|
|
@ -147,29 +147,31 @@ a token.
|
|||
(`hive-matrix-daemon.path` watching for `matrix-token` appearance)
|
||||
brings the daemon up on the same boot cycle anyway.
|
||||
|
||||
### The admin account, and why it needs no first-user luck
|
||||
### The `@hive:` account, and why it is not an admin
|
||||
|
||||
`@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. 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.
|
||||
anything. It is an **ordinary account**: nothing promotes it, and the
|
||||
homeserver runs no `admin_execute` for it.
|
||||
|
||||
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.
|
||||
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. (There is no Synapse admin API here either; tuwunel
|
||||
has none.)
|
||||
|
||||
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.
|
||||
Two operations do need an admin **sender**, and neither works today:
|
||||
`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
|
||||
are swarm-level operations and are being rehomed as such; until then
|
||||
they fail with the admin room's refusal rather than being served by an
|
||||
over-privileged credential that every other call site would also carry.
|
||||
The one hive-side path that depends on them is the password-reset
|
||||
auto-recovery for an agent whose stored password is gone — the ordinary
|
||||
appservice re-login above is unaffected.
|
||||
|
||||
<details><summary>Upgrading a hive that used the registration token</summary>
|
||||
|
||||
|
|
@ -185,10 +187,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 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.
|
||||
- **`@hive:` 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 simply no longer promotes it, so a hive built fresh has an
|
||||
ordinary account and an older one keeps whatever standing it acquired.
|
||||
- **`/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
|
||||
|
|
@ -237,7 +239,7 @@ 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 the admin
|
||||
creates a private **Matrix Space** named `"hive"` using the `@hive:`
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 hive system admin matrix account.
|
||||
Provision (or re-provision) the hive's own `@hive:` matrix 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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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 hive internal admin account
|
||||
hivectl matrix sync-admin # provision / refresh the hive's own `@hive:` 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,19 +74,21 @@ 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 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.
|
||||
- `sync-admin`: ensures the hive's own `@hive:` matrix user exists
|
||||
(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
|
||||
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
|
||||
admin by an `!admin` command in `#admins`. ⚠️ Needs an admin **sender**,
|
||||
which `@hive:` is not — this operation is being rehomed at swarm level
|
||||
and does not work from the hive today.
|
||||
- `reset-password`: asks the admin room to set a new random
|
||||
password and prints it to stdout. ⚠️ Needs an admin **sender** too, so
|
||||
it does not work from the hive today either. 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 hive admin
|
||||
token; the admin account must be a member of the target room with
|
||||
Space by default, or to a `--room` id / `#alias`. Uses the `@hive:`
|
||||
token; the 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@
|
|||
services.hyperhive.deploy.nats.authPackage =
|
||||
lib.mkDefault
|
||||
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-nats-auth;
|
||||
services.hyperhive.deploy.matrix.minterPackage =
|
||||
lib.mkDefault
|
||||
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-matrix-minter;
|
||||
services.hyperhive.deploy.authelia.bridgePackage =
|
||||
lib.mkDefault
|
||||
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-authelia-bridge;
|
||||
|
|
|
|||
|
|
@ -71,12 +71,15 @@ const PASSWORD_BYTES: usize = 32;
|
|||
/// 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. 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";
|
||||
/// startup, before it accepts a request. See
|
||||
/// `nix/host-modules/hive-matrix.nix`, where this same literal appears 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.
|
||||
pub const HIVE_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
|
||||
|
|
@ -94,11 +97,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 hive admin matrix access token. Outside every
|
||||
/// Host path for the `@hive:` account's matrix access token. Outside every
|
||||
/// purgeable path — not deleted by `destroy --purge` on any agent.
|
||||
#[must_use]
|
||||
pub fn admin_token_path() -> PathBuf {
|
||||
crate::paths::matrix_admin_token()
|
||||
pub fn hive_token_path() -> PathBuf {
|
||||
crate::paths::matrix_hive_token()
|
||||
}
|
||||
|
||||
/// Token file inside the agent's bind-mounted state dir (visible as
|
||||
|
|
@ -382,21 +385,26 @@ 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 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.
|
||||
/// 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:` is not — the reset is a
|
||||
/// `!admin` command and tuwunel only treats a message as a command when
|
||||
/// its sender is an admin in that room. So this recovery path fails until
|
||||
/// the two admin operations are rehomed at swarm level; the ordinary
|
||||
/// route (the stored password, or an appservice login) is unaffected.
|
||||
///
|
||||
/// Called by [`ensure_user_for`] when registration returns `M_USER_IN_USE`
|
||||
/// but the password file is absent — covers the case where agent state dirs
|
||||
/// were wiped but the homeserver still has the accounts.
|
||||
async fn auto_reset_password(client: &reqwest::Client, name: &str) -> anyhow::Result<String> {
|
||||
let admin_token = read_admin_token()
|
||||
.context("matrix: admin token unavailable for auto-recovery; provision hive admin first")?;
|
||||
let hive_token = read_hive_token()
|
||||
.context("matrix: the @hive: access token is unavailable for auto-recovery")?;
|
||||
let server_name = discover_server_name(client)
|
||||
.await
|
||||
.context("matrix: discover_server_name for auto-recovery")?;
|
||||
let effective_password = reset_user_password(client, &admin_token, name, &server_name)
|
||||
let effective_password = reset_user_password(client, &hive_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");
|
||||
|
|
@ -416,7 +424,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,
|
||||
admin_token: &str,
|
||||
hive_token: &str,
|
||||
server_name: &str,
|
||||
) -> Result<String> {
|
||||
let base = matrix_base()?;
|
||||
|
|
@ -425,7 +433,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(admin_token)
|
||||
.bearer_auth(hive_token)
|
||||
.send()
|
||||
.await
|
||||
.context("matrix: GET admin room alias")?;
|
||||
|
|
@ -553,7 +561,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,
|
||||
admin_token: &str,
|
||||
hive_token: &str,
|
||||
server_name: &str,
|
||||
room_url: &str,
|
||||
command: &str,
|
||||
|
|
@ -566,7 +574,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(admin_token)
|
||||
.bearer_auth(hive_token)
|
||||
.json(&serde_json::json!({"msgtype": "m.text", "body": command}))
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -587,13 +595,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!("@{HIVE_ADMIN_LOCALPART}:{server_name}");
|
||||
let own_user_id = format!("@{HIVE_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(admin_token)
|
||||
.bearer_auth(hive_token)
|
||||
.send()
|
||||
.await
|
||||
.context("matrix: admin room poll")?
|
||||
|
|
@ -647,16 +655,16 @@ async fn admin_room_send_and_poll<T>(
|
|||
/// Returns the new password; caller is responsible for persisting it.
|
||||
async fn admin_room_reset_password(
|
||||
client: &reqwest::Client,
|
||||
admin_token: &str,
|
||||
hive_token: &str,
|
||||
server_name: &str,
|
||||
localpart: &str,
|
||||
) -> Result<String> {
|
||||
let room_id = discover_admin_room_id(client, admin_token, server_name).await?;
|
||||
let room_id = discover_admin_room_id(client, hive_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,
|
||||
admin_token,
|
||||
hive_token,
|
||||
server_name,
|
||||
&room_url,
|
||||
&command,
|
||||
|
|
@ -761,13 +769,15 @@ pub async fn ensure_user_for(client: &reqwest::Client, name: &str, as_token: &st
|
|||
{
|
||||
pw
|
||||
} 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
|
||||
// homeserver still has the accounts. Requires the hive admin
|
||||
// token at /var/lib/hyperhive/matrix/admin-token.
|
||||
// homeserver still has the accounts. Requires the `@hive:`
|
||||
// token at /var/lib/hyperhive/matrix/access-token, and an admin
|
||||
// sender, which `@hive:` no longer is.
|
||||
tracing::info!(
|
||||
%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 {
|
||||
Ok(new_pw) => new_pw,
|
||||
|
|
@ -883,42 +893,49 @@ pub async fn sync_agent_standalone(name: &str) {
|
|||
sync_agent(&client, name, &as_token).await;
|
||||
}
|
||||
|
||||
/// Ensure the hive system admin matrix user exists, that its token is
|
||||
/// persisted at [`admin_token_path()`], and that it actually holds admin
|
||||
/// rights.
|
||||
/// Ensure the `@hive:` matrix user exists and that its access token is
|
||||
/// persisted at [`hive_token_path()`].
|
||||
///
|
||||
/// **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.
|
||||
/// **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.
|
||||
///
|
||||
/// Idempotent — skips the account work when the token file already exists
|
||||
/// 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-minter`, 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
|
||||
/// minter.
|
||||
pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let path = admin_token_path();
|
||||
let path = hive_token_path();
|
||||
if path.exists()
|
||||
&& let Ok(existing) = std::fs::read_to_string(&path)
|
||||
&& !existing.trim().is_empty()
|
||||
{
|
||||
tracing::debug!("matrix: hive admin token already present");
|
||||
tracing::debug!("matrix: the @hive: access token is already present");
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(token) = stored_hive_token().await {
|
||||
return persist_hive_token(&path, &token);
|
||||
}
|
||||
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, HIVE_LOCALPART, as_token, &password).await {
|
||||
Ok(token) => {
|
||||
let pw_path = password_path(HIVE_ADMIN_LOCALPART);
|
||||
let pw_path = password_path(HIVE_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, "matrix: failed to persist hive admin password");
|
||||
tracing::warn!(error = ?e, "matrix: failed to persist the @hive: account password");
|
||||
} else {
|
||||
let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
|
@ -931,125 +948,104 @@ pub async fn ensure_admin_user(client: &reqwest::Client, as_token: &str) -> Resu
|
|||
// 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!("matrix: hive admin user already exists, logging in as the appservice");
|
||||
match appservice_login(client, as_token, HIVE_ADMIN_LOCALPART).await {
|
||||
tracing::info!("matrix: the @hive: user already exists, logging in as the appservice");
|
||||
match appservice_login(client, as_token, HIVE_LOCALPART).await {
|
||||
Ok(token) => token,
|
||||
Err(e) => {
|
||||
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);
|
||||
tracing::warn!(error = ?e, "matrix: appservice login for @hive: failed; falling back to the stored password");
|
||||
let pw_path = password_path(HIVE_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: hive admin user exists, appservice login failed, and no \
|
||||
"matrix: the @hive: user exists, appservice login failed, and no \
|
||||
password is stored at {} — check that the registration file's \
|
||||
namespace covers @{HIVE_ADMIN_LOCALPART} and that the homeserver \
|
||||
namespace covers @{HIVE_LOCALPART} and that the homeserver \
|
||||
loaded it",
|
||||
pw_path.display()
|
||||
)
|
||||
})?;
|
||||
login_user(client, HIVE_ADMIN_LOCALPART, &stored).await?
|
||||
login_user(client, HIVE_LOCALPART, &stored).await?
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(other) => return Err(other),
|
||||
};
|
||||
persist_hive_token(&path, &access_token)
|
||||
}
|
||||
|
||||
/// Write the `@hive:` 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_hive_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 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");
|
||||
std::fs::write(path, format!("{access_token}\n")).with_context(|| {
|
||||
format!(
|
||||
"matrix: write the @hive: access token to {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||
tracing::info!(path = %path.display(), "matrix: provisioned the @hive: access token");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check that the hive admin account holds admin rights, and repair it
|
||||
/// through the admin room when it does not.
|
||||
/// Fetch the `@hive:` access token `swarm-matrix-minter` published, under
|
||||
/// this hive's own store identity.
|
||||
///
|
||||
/// 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.
|
||||
/// 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`]. No new grant either: a hive's policy already covers the
|
||||
/// whole `swarm/services/*` tree the minter writes into.
|
||||
///
|
||||
/// 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.
|
||||
/// `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 the minter
|
||||
/// yet, so raising would turn a supported deployment into a warning every
|
||||
/// sweep.
|
||||
///
|
||||
/// 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,
|
||||
/// 🩸 Logs the store **path** and never the value.
|
||||
async fn stored_hive_token() -> Option<String> {
|
||||
let hive = std::env::var("HYPERHIVE_HIVE_NAME")
|
||||
.ok()
|
||||
.filter(|h| !h.is_empty())?;
|
||||
let path = swarm_secret_client::matrix::hive_token_path();
|
||||
let store = match swarm_secret_client::SecretStore::from_env(&hive).await {
|
||||
Ok(store) => store,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "matrix: cannot resolve #admins — not checking the hive admin's rights");
|
||||
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() {
|
||||
tracing::debug!(error = %e, "matrix: no swarm secret store to read the @hive: access token from");
|
||||
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(),
|
||||
)
|
||||
};
|
||||
match store
|
||||
.read::<swarm_secret_client::matrix::Credential>(&path)
|
||||
.await
|
||||
{
|
||||
Ok(credential) if !credential.value.trim().is_empty() => {
|
||||
tracing::info!(%path, "matrix: taking the @hive: access token from the swarm store");
|
||||
Some(credential.value)
|
||||
}
|
||||
Ok(_) => {
|
||||
tracing::warn!(%path, "matrix: the stored @hive: credential is empty; minting instead");
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(%path, error = %e, "matrix: no @hive: credential in the store; minting instead");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an admin-room reply says a `make-user-admin` succeeded.
|
||||
|
|
@ -1116,16 +1112,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,
|
||||
admin_token: &str,
|
||||
hive_token: &str,
|
||||
localpart: &str,
|
||||
server_name: &str,
|
||||
) -> Result<()> {
|
||||
let room_id = discover_admin_room_id(client, admin_token, server_name).await?;
|
||||
let room_id = discover_admin_room_id(client, hive_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,
|
||||
admin_token,
|
||||
hive_token,
|
||||
server_name,
|
||||
&room_url,
|
||||
&command,
|
||||
|
|
@ -1151,11 +1147,11 @@ pub async fn promote_user_to_admin(
|
|||
/// Returns the new password for use in subsequent `login_user` calls.
|
||||
pub async fn reset_user_password(
|
||||
client: &reqwest::Client,
|
||||
admin_token: &str,
|
||||
hive_token: &str,
|
||||
localpart: &str,
|
||||
server_name: &str,
|
||||
) -> Result<String> {
|
||||
let pw = admin_room_reset_password(client, admin_token, server_name, localpart)
|
||||
let pw = admin_room_reset_password(client, hive_token, server_name, localpart)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("matrix: admin-room password reset for @{localpart}:{server_name}")
|
||||
|
|
@ -1205,19 +1201,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
|
||||
/// is absent — callers should gate their admin-API calls on this.
|
||||
pub fn read_admin_token() -> Result<String> {
|
||||
let path = admin_token_path();
|
||||
/// Read the `@hive:` access token from disk. Returns an error if it
|
||||
/// is absent — callers should gate their homeserver calls on this.
|
||||
pub fn read_hive_token() -> Result<String> {
|
||||
let path = hive_token_path();
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_owned())
|
||||
.filter(|s| !s.is_empty())
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"hive admin matrix token not found at {} — \
|
||||
"the @hive: matrix access token was not found at {} — \
|
||||
ensure hive-c0re has started at least once with matrix enabled \
|
||||
(it provisions the admin account on boot)",
|
||||
(it provisions the @hive: account on boot)",
|
||||
path.display()
|
||||
)
|
||||
})
|
||||
|
|
@ -1244,12 +1240,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, admin_token: &str) -> Option<String> {
|
||||
async fn find_space_by_name(client: &reqwest::Client, hive_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(admin_token)
|
||||
.bearer_auth(hive_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?
|
||||
|
|
@ -1264,12 +1260,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti
|
|||
let encoded = encode_room_id_for_url(room_id);
|
||||
// Must be an m.space (m.room.create `type`).
|
||||
let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/");
|
||||
let is_space = match client
|
||||
.get(&create_url)
|
||||
.bearer_auth(admin_token)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
let is_space = match client.get(&create_url).bearer_auth(hive_token).send().await {
|
||||
Ok(r) if r.status().is_success() => r
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
|
|
@ -1282,7 +1273,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti
|
|||
}
|
||||
// …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(admin_token).send().await {
|
||||
let name_matches = match client.get(&name_url).bearer_auth(hive_token).send().await {
|
||||
Ok(r) if r.status().is_success() => r
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
|
|
@ -1313,7 +1304,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti
|
|||
///
|
||||
/// 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, admin_token: &str) -> Result<String> {
|
||||
pub async fn ensure_hive_space(client: &reqwest::Client, hive_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()) {
|
||||
|
|
@ -1326,7 +1317,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
|
||||
// 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, hive_token).await {
|
||||
persist_space_room_id(&room_id)?;
|
||||
tracing::info!(%room_id, "matrix: recovered hive space by name");
|
||||
return Ok(room_id);
|
||||
|
|
@ -1342,7 +1333,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> R
|
|||
});
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(admin_token)
|
||||
.bearer_auth(hive_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -1382,12 +1373,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, admin_token: &str) -> Option<String> {
|
||||
async fn find_chat_room_by_name(client: &reqwest::Client, hive_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(admin_token)
|
||||
.bearer_auth(hive_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?
|
||||
|
|
@ -1402,12 +1393,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) ->
|
|||
let encoded = encode_room_id_for_url(room_id);
|
||||
// Skip the Space itself (and any other m.space).
|
||||
let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/");
|
||||
let is_space = match client
|
||||
.get(&create_url)
|
||||
.bearer_auth(admin_token)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
let is_space = match client.get(&create_url).bearer_auth(hive_token).send().await {
|
||||
Ok(r) if r.status().is_success() => r
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
|
|
@ -1420,7 +1406,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, admin_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(admin_token).send().await {
|
||||
let name_matches = match client.get(&name_url).bearer_auth(hive_token).send().await {
|
||||
Ok(r) if r.status().is_success() => r
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
|
|
@ -1460,10 +1446,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,
|
||||
admin_token: &str,
|
||||
hive_token: &str,
|
||||
url: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
let resp = match client.get(url).bearer_auth(admin_token).send().await {
|
||||
let resp = match client.get(url).bearer_auth(hive_token).send().await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = ?e, url, "matrix: state read unreachable; writing");
|
||||
|
|
@ -1491,7 +1477,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 `@hive:` 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
|
||||
|
|
@ -1506,7 +1492,7 @@ async fn current_room_state(
|
|||
/// missing or divergent link still gets written.
|
||||
async fn set_room_state(
|
||||
client: &reqwest::Client,
|
||||
admin_token: &str,
|
||||
hive_token: &str,
|
||||
room_id: &str,
|
||||
event_type: &str,
|
||||
state_key: &str,
|
||||
|
|
@ -1517,7 +1503,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, admin_token, &url).await;
|
||||
let current = current_room_state(client, hive_token, &url).await;
|
||||
if !state_needs_write(current.as_ref(), content) {
|
||||
tracing::debug!(
|
||||
%room_id,
|
||||
|
|
@ -1528,7 +1514,7 @@ async fn set_room_state(
|
|||
}
|
||||
let resp = client
|
||||
.put(&url)
|
||||
.bearer_auth(admin_token)
|
||||
.bearer_auth(hive_token)
|
||||
.json(content)
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -1565,7 +1551,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,
|
||||
admin_token: &str,
|
||||
hive_token: &str,
|
||||
space_room_id: &str,
|
||||
server_name: &str,
|
||||
) -> Result<String> {
|
||||
|
|
@ -1579,7 +1565,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, admin_token).await {
|
||||
} else if let Some(id) = find_chat_room_by_name(client, hive_token).await {
|
||||
persist_chat_room_id(&id)?;
|
||||
tracing::info!(room_id = %id, "matrix: recovered hive chat room by name");
|
||||
id
|
||||
|
|
@ -1619,7 +1605,7 @@ pub async fn ensure_hive_chat_room(
|
|||
});
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(admin_token)
|
||||
.bearer_auth(hive_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -1650,7 +1636,7 @@ pub async fn ensure_hive_chat_room(
|
|||
});
|
||||
if let Err(e) = set_room_state(
|
||||
client,
|
||||
admin_token,
|
||||
hive_token,
|
||||
space_room_id,
|
||||
"m.space.child",
|
||||
&room_id,
|
||||
|
|
@ -1668,21 +1654,21 @@ pub async fn ensure_hive_chat_room(
|
|||
/// account. Idempotent — treats already-member responses as success.
|
||||
async fn invite_to_room(
|
||||
client: &reqwest::Client,
|
||||
admin_token: &str,
|
||||
hive_token: &str,
|
||||
room_id: &str,
|
||||
localpart: &str,
|
||||
server_name: &str,
|
||||
) -> Result<()> {
|
||||
let user_id = format!("@{localpart}:{server_name}");
|
||||
invite_user_id(client, admin_token, room_id, &user_id).await
|
||||
invite_user_id(client, hive_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 `@hive:` 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,
|
||||
admin_token: &str,
|
||||
hive_token: &str,
|
||||
encoded_room_id: &str,
|
||||
user_id: &str,
|
||||
) -> Option<String> {
|
||||
|
|
@ -1693,12 +1679,7 @@ async fn room_membership(
|
|||
let url = format!(
|
||||
"{base}/_matrix/client/v3/rooms/{encoded_room_id}/state/m.room.member/{encoded_user}"
|
||||
);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(admin_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
let resp = client.get(&url).bearer_auth(hive_token).send().await.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
// 404 = no membership event yet; anything else we treat as "unknown"
|
||||
// and let the caller fall through to the invite attempt.
|
||||
|
|
@ -1709,13 +1690,13 @@ async fn room_membership(
|
|||
}
|
||||
|
||||
/// 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 `@hive:` 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,
|
||||
admin_token: &str,
|
||||
hive_token: &str,
|
||||
room_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<()> {
|
||||
|
|
@ -1727,7 +1708,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, admin_token, &encoded_room_id, user_id).await
|
||||
if let Some(membership) = room_membership(client, hive_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)");
|
||||
|
|
@ -1737,7 +1718,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(admin_token)
|
||||
.bearer_auth(hive_token)
|
||||
.json(&serde_json::json!({ "user_id": user_id }))
|
||||
.send()
|
||||
.await
|
||||
|
|
@ -1770,13 +1751,13 @@ async fn invite_user_id(
|
|||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the admin token or `server_name` can't be read,
|
||||
/// Returns an error if the `@hive:` 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,
|
||||
admin_token: &str,
|
||||
hive_token: &str,
|
||||
user: &str,
|
||||
room_override: Option<&str>,
|
||||
server_name: &str,
|
||||
|
|
@ -1790,7 +1771,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, admin_token, r).await?,
|
||||
Some(r) if r.starts_with('#') => resolve_room_alias(client, hive_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; \
|
||||
|
|
@ -1804,14 +1785,14 @@ pub async fn invite_user(
|
|||
(run the hive-c0re matrix sweep first)",
|
||||
)?,
|
||||
};
|
||||
invite_user_id(client, admin_token, &room_id, &user_id).await?;
|
||||
invite_user_id(client, hive_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,
|
||||
admin_token: &str,
|
||||
hive_token: &str,
|
||||
alias: &str,
|
||||
) -> Result<String> {
|
||||
let base = matrix_base()?;
|
||||
|
|
@ -1819,7 +1800,7 @@ async fn resolve_room_alias(
|
|||
let url = format!("{base}/_matrix/client/v3/directory/room/{encoded}");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(admin_token)
|
||||
.bearer_auth(hive_token)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("matrix: GET directory for {alias}"))?;
|
||||
|
|
@ -1875,13 +1856,12 @@ pub async fn ensure_all() -> bool {
|
|||
return false;
|
||||
}
|
||||
};
|
||||
// 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");
|
||||
// The `@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");
|
||||
ok = false;
|
||||
}
|
||||
let Ok(containers) = crate::lifecycle::list().await else {
|
||||
|
|
@ -1911,10 +1891,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 admin_token = match read_admin_token() {
|
||||
let hive_token = match read_hive_token() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no admin token)");
|
||||
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no @hive: access token)");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
@ -1926,33 +1906,22 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
|
|||
return false;
|
||||
}
|
||||
};
|
||||
// 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 {
|
||||
let room_id = match ensure_hive_space(client, &hive_token).await {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "matrix: ensure_hive_space failed");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// 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
|
||||
// Invite @hive first, then all agents.
|
||||
if let Err(e) =
|
||||
invite_to_room(client, &hive_token, &room_id, HIVE_LOCALPART, &server_name).await
|
||||
{
|
||||
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, &admin_token, &room_id, name, &server_name).await {
|
||||
if let Err(e) = invite_to_room(client, &hive_token, &room_id, name, &server_name).await {
|
||||
tracing::warn!(%name, error = ?e, "matrix: invite agent to space failed");
|
||||
ok = false;
|
||||
}
|
||||
|
|
@ -1963,13 +1932,13 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
|
|||
// 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, &admin_token, &room_id, &server_name).await {
|
||||
match ensure_hive_chat_room(client, &hive_token, &room_id, &server_name).await {
|
||||
Ok(chat_room_id) => {
|
||||
if let Err(e) = invite_to_room(
|
||||
client,
|
||||
&admin_token,
|
||||
&hive_token,
|
||||
&chat_room_id,
|
||||
HIVE_ADMIN_LOCALPART,
|
||||
HIVE_LOCALPART,
|
||||
&server_name,
|
||||
)
|
||||
.await
|
||||
|
|
@ -1979,7 +1948,7 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
|
|||
}
|
||||
for name in agent_names {
|
||||
if let Err(e) =
|
||||
invite_to_room(client, &admin_token, &chat_room_id, name, &server_name).await
|
||||
invite_to_room(client, &hive_token, &chat_room_id, name, &server_name).await
|
||||
{
|
||||
tracing::warn!(%name, error = ?e, "matrix: invite agent to chat room failed");
|
||||
ok = false;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//! `/run/hyperhive` + `/run/hive-agent` runtime roots).
|
||||
//!
|
||||
//! Historically these were flat string literals scattered across many
|
||||
//! modules (`broker.sqlite`, `matrix-admin-token`, `agent-sockets.json`,
|
||||
//! modules (`broker.sqlite`, `matrix-hive-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 (admin token, hive
|
||||
/// `matrix/` — host-side matrix provisioning state (the `@hive:` access 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/admin-token` — hive system admin access token.
|
||||
/// `matrix/access-token` — the `@hive:` account's matrix access token.
|
||||
#[must_use]
|
||||
pub fn matrix_admin_token() -> PathBuf {
|
||||
matrix_dir().join("admin-token")
|
||||
pub fn matrix_hive_token() -> PathBuf {
|
||||
matrix_dir().join("access-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-admin-token", matrix_admin_token()),
|
||||
("matrix-hive-token", matrix_hive_token()),
|
||||
("matrix-space-room-id", matrix_space_room_id()),
|
||||
("matrix-creds", matrix_creds_dir()),
|
||||
("agent-sockets.json", agent_sockets_file()),
|
||||
|
|
|
|||
|
|
@ -455,7 +455,7 @@ async fn stream_agent_status(
|
|||
// The `hivectl matrix` subcommands used to run these in-process, which forced
|
||||
// the standalone CLI to link the whole daemon crate (matrix-sdk, reqwest, …).
|
||||
// They now run daemon-side over the host socket: the daemon already holds the
|
||||
// register + admin tokens and the matrix creds dir. Each op returns the
|
||||
// register + `@hive:` 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_admin_user(&client, &as_token)
|
||||
crate::matrix::ensure_hive_user(&client, &as_token)
|
||||
.await
|
||||
.context("matrix sync-admin")?;
|
||||
let path = crate::matrix::admin_token_path();
|
||||
let path = crate::matrix::hive_token_path();
|
||||
Ok(HostResponse::messages(vec![
|
||||
format!(
|
||||
"matrix: hive admin user '@{}' provisioned",
|
||||
crate::matrix::HIVE_ADMIN_LOCALPART
|
||||
"matrix: the @{}: user is provisioned",
|
||||
crate::matrix::HIVE_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 admin_token = crate::matrix::read_admin_token()?;
|
||||
let hive_token = crate::matrix::read_hive_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, &admin_token, name, &server_name)
|
||||
crate::matrix::promote_user_to_admin(&client, &hive_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 admin_token = crate::matrix::read_admin_token()?;
|
||||
let hive_token = crate::matrix::read_hive_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, &admin_token, user, room, &server_name)
|
||||
let room_id = crate::matrix::invite_user(&client, &hive_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 admin_token = crate::matrix::read_admin_token()?;
|
||||
let hive_token = crate::matrix::read_hive_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, &admin_token, name, &server_name)
|
||||
crate::matrix::reset_user_password(&client, &hive_token, name, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix reset-password {name}"))?;
|
||||
// Password is persisted by reset_user_password.
|
||||
|
|
|
|||
|
|
@ -335,10 +335,10 @@ pub enum MatrixCmd {
|
|||
#[arg(long, conflicts_with = "password")]
|
||||
password_stdin: bool,
|
||||
},
|
||||
/// Provision (or re-provision) the hive system admin matrix account.
|
||||
/// Provision (or re-provision) the hive's own `@hive:` matrix account.
|
||||
///
|
||||
/// Runs automatically on startup; run manually to recover a missing
|
||||
/// admin token.
|
||||
/// access token.
|
||||
SyncAdmin,
|
||||
/// Promote a matrix user to homeserver admin.
|
||||
PromoteUser {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! `hivectl matrix` — matrix account provisioning verbs. hivectl forwards
|
||||
//! each request to the daemon (which owns the register + admin tokens and
|
||||
//! each request to the daemon (which owns the register + `@hive:` 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 +
|
||||
/// admin tokens and the matrix creds dir, so hivectl no longer links the
|
||||
/// `@hive:` tokens and the matrix creds dir, so hivectl no longer links the
|
||||
/// matrix machinery — it just forwards the request and renders the reply.
|
||||
async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Result<()> {
|
||||
let resp = crate::client::request(socket, req)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
./glue-controller-bao-identity.nix
|
||||
./glue-grafana-oidc-client.nix
|
||||
./glue-matrix-bao-token.nix
|
||||
./glue-matrix-minter-bao-identity.nix
|
||||
./glue-queue-agent-credential.nix
|
||||
./glue-secret-publisher-bao-identity.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.
|
||||
[ -s ${pkiDir}/secret-publisher.pem ] || ${signLeaf} ${pkiDir} secret-publisher \
|
||||
${lib.escapeShellArg deployCfg.bao.secretPublisherCommonName} "" clientAuth
|
||||
|
||||
# The matrix container's minter. 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-minter.pem ] || ${signLeaf} ${pkiDir} matrix-minter \
|
||||
${lib.escapeShellArg deployCfg.bao.matrixMinterCommonName} "" clientAuth
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
|
|
|||
40
nix/host-modules/glue-matrix-minter-bao-identity.nix
Normal file
40
nix/host-modules/glue-matrix-minter-bao-identity.nix
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Glue: point the matrix container's minter at the bao leaf minted for it.
|
||||
#
|
||||
# ONE PAIRING PER FILE — minter ← bao, and nothing else. Deleting this leaves a
|
||||
# minter 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 the minter 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 = {
|
||||
minterBaoClientCertFile = lib.mkDefault "${pkiDir}/matrix-minter.pem";
|
||||
minterBaoClientKeyFile = lib.mkDefault "${pkiDir}/matrix-minter-key.pem";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -26,6 +26,11 @@ 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
|
||||
|
|
@ -59,18 +64,19 @@ let
|
|||
# namespace below, with no shared registration secret in the picture.
|
||||
appserviceId = "hyperhive";
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
#
|
||||
# ⚠️ Must equal `matrix::HIVE_ADMIN_LOCALPART` in hive-c0re, which derives
|
||||
# ⚠️ Must equal `matrix::HIVE_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";
|
||||
hiveLocalpart = "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
|
||||
|
|
@ -107,7 +113,62 @@ let
|
|||
appserviceCredentialId = "${appserviceId}-appservice.yaml";
|
||||
appserviceCredentialDir = "/run/credentials/tuwunel.service";
|
||||
|
||||
# Every local user this hive may provision — agents, the hive admin, and
|
||||
# ── swarm-matrix-minter ────────────────────────────────────────────────
|
||||
#
|
||||
# The oneshot that publishes the `@hive:` 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.
|
||||
minterActive =
|
||||
deployCfg.matrix.minterBaoClientCertFile != null && deployCfg.matrix.minterBaoClientKeyFile != 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
|
||||
# `matrixMinterPolicyName` and this hands it to the binary in the
|
||||
# environment, so the binary itself spells no role at all.
|
||||
minterCertRole = "swarm-matrix-minter";
|
||||
|
||||
# Every host directory the minter'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.
|
||||
minterPkiDirs = lib.optionals minterActive (
|
||||
lib.unique (
|
||||
map builtins.dirOf (
|
||||
[
|
||||
deployCfg.matrix.minterBaoClientCertFile
|
||||
deployCfg.matrix.minterBaoClientKeyFile
|
||||
]
|
||||
++ lib.optional (deployCfg.bao.serverCaFile != null) deployCfg.bao.serverCaFile
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
minterBindMounts = lib.genAttrs minterPkiDirs (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 the minter 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.
|
||||
minterHomeserverUrl = if cfg.gatewayHost == null then "" else "https://${toString cfg.gatewayHost}";
|
||||
|
||||
# Every local user this hive may provision — agents, `@hive:` itself, and
|
||||
# the operator accounts `hivectl matrix create-user` makes, which is the
|
||||
# whole matrix localpart charset.
|
||||
#
|
||||
|
|
@ -173,7 +234,7 @@ let
|
|||
cat <<'REGISTRATION'
|
||||
id: ${appserviceId}
|
||||
url: null
|
||||
sender_localpart: ${adminLocalpart}
|
||||
sender_localpart: ${hiveLocalpart}
|
||||
rate_limited: false
|
||||
namespaces:
|
||||
users:
|
||||
|
|
@ -686,6 +747,56 @@ in
|
|||
match authelia's register.
|
||||
'';
|
||||
};
|
||||
|
||||
minterPackage = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-matrix-minter";
|
||||
description = ''
|
||||
The `swarm-matrix-minter` build run inside the matrix container.
|
||||
|
||||
⚠️ Named `minterPackage`, 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`.
|
||||
'';
|
||||
};
|
||||
|
||||
minterBaoClientCertFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = ''
|
||||
Client certificate the matrix container's minter presents to the
|
||||
swarm's secret store. Its subject must be
|
||||
{option}`services.hyperhive.deploy.bao.matrixMinterCommonName` — 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-minter-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.
|
||||
'';
|
||||
};
|
||||
|
||||
minterBaoClientKeyFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = ''
|
||||
Private key for
|
||||
{option}`services.hyperhive.deploy.matrix.minterBaoClientCertFile`.
|
||||
Both or neither — the minter unit does not exist unless each is set.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf deployCfg.matrix.enable {
|
||||
|
|
@ -1082,7 +1193,11 @@ in
|
|||
isReadOnly = true;
|
||||
};
|
||||
}
|
||||
// caTrust.bindMount;
|
||||
// caTrust.bindMount
|
||||
# The minter's bao client identity, and nothing else of the hive's. See
|
||||
# `minterPkiDirs` above for why it is a derived set of directories
|
||||
# rather than one literal.
|
||||
// minterBindMounts;
|
||||
config =
|
||||
{ ... }:
|
||||
{
|
||||
|
|
@ -1192,30 +1307,21 @@ in
|
|||
# `Services::start()`, before the listener accepts anything.
|
||||
appservice_dir = appserviceCredentialDir;
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
#
|
||||
# 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;
|
||||
# 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.
|
||||
# Server-side E2EE is opt-in (default off); the agent matrix
|
||||
# client always supports decryption regardless.
|
||||
allow_encryption = cfg.allowEncryption;
|
||||
|
|
@ -1286,6 +1392,57 @@ in
|
|||
"oidc_client_secret:${toString deployCfg.matrix.sso.clientSecretFile}"
|
||||
];
|
||||
|
||||
# Publish the `@hive:` account's access token to the swarm
|
||||
# store, once, under an identity that belongs to this container and
|
||||
# not to the hive. See `minterActive` 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-minter = lib.mkIf minterActive {
|
||||
description = "publish the @hive: matrix credential 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";
|
||||
ExecStart = "${deployCfg.matrix.minterPackage}/bin/swarm-matrix-minter";
|
||||
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-minter";
|
||||
};
|
||||
environment = {
|
||||
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
|
||||
BAO_CLIENT_CERT = deployCfg.matrix.minterBaoClientCertFile;
|
||||
BAO_CLIENT_KEY = deployCfg.matrix.minterBaoClientKeyFile;
|
||||
MATRIX_MINTER_CERT_ROLE = minterCertRole;
|
||||
# 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_MINTER_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_MINTER_REGISTRATION = appserviceRegistrationPath;
|
||||
MATRIX_MINTER_LOCALPART = hiveLocalpart;
|
||||
MATRIX_MINTER_HOMESERVER = minterHomeserverUrl;
|
||||
}
|
||||
// lib.optionalAttrs (deployCfg.bao.serverCaFile != null) {
|
||||
BAO_CACERT = deployCfg.bao.serverCaFile;
|
||||
};
|
||||
};
|
||||
|
||||
environment.systemPackages = [ deployCfg.matrix.package ];
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -236,6 +236,35 @@ let
|
|||
}
|
||||
'';
|
||||
|
||||
# The identity the matrix container's minter 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.
|
||||
matrixMinterPolicyName = "swarm-matrix-minter";
|
||||
matrixMinterCn = baoDeploy.matrixMinterCommonName;
|
||||
|
||||
# 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, the `@hive:` account's 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.
|
||||
#
|
||||
# `read` as well as write, unlike either sibling, and it is what makes "and
|
||||
# only once" mechanical: the minter'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.
|
||||
matrixMinterPolicyText = ''
|
||||
path "${credentialMountPath}/data/swarm/services/matrix/hive-access-token" {
|
||||
capabilities = ["create", "update", "read"]
|
||||
}
|
||||
'';
|
||||
|
||||
# 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
|
||||
|
|
@ -659,6 +688,29 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
matrixMinterCommonName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "swarm-matrix-minter";
|
||||
example = "swarm-matrix-minter.svc";
|
||||
description = ''
|
||||
Subject the store's matrix-minter cert-auth role accepts — the
|
||||
identity the oneshot inside the matrix container presents when it
|
||||
publishes the `@hive:` 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 the minter's grant.
|
||||
'';
|
||||
};
|
||||
|
||||
serverCaFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
|
|
@ -1222,6 +1274,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-minter-policy = lib.mkIf haveBootstrapToken {
|
||||
description = "write the swarm matrix minter'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 matrixMinterPolicyText} |
|
||||
bao policy write ${lib.escapeShellArg matrixMinterPolicyName} -
|
||||
''
|
||||
+ lib.optionalString (baoDeploy.clientCaFile != null) ''
|
||||
|
||||
bao write auth/cert/certs/${lib.escapeShellArg matrixMinterPolicyName} \
|
||||
certificate=@${tlsDir}/client-ca.pem \
|
||||
allowed_common_names=${lib.escapeShellArg matrixMinterCn} \
|
||||
token_policies=${lib.escapeShellArg matrixMinterPolicyName} \
|
||||
display_name=${lib.escapeShellArg matrixMinterCn}
|
||||
'';
|
||||
};
|
||||
|
||||
containers.${cfg.machine} = {
|
||||
autoStart = true;
|
||||
ephemeral = false;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ let
|
|||
certAuthCns = [
|
||||
deployCfg.bao.controllerCommonName
|
||||
deployCfg.bao.secretPublisherCommonName
|
||||
deployCfg.bao.matrixMinterCommonName
|
||||
];
|
||||
|
||||
# Public hostnames of the swarm's own services, in declaration order.
|
||||
|
|
|
|||
|
|
@ -140,6 +140,56 @@ let
|
|||
baoGrantHere.systemd.services.swarm-bao-secret-publisher-policy.after
|
||||
);
|
||||
}
|
||||
{
|
||||
# The third principal's grant, and the narrowest of the three: ONE path,
|
||||
# spelled to the leaf. The negative arms are the property — a homeserver
|
||||
# is not entitled to overwrite Grafana's OIDC client, so widening this to
|
||||
# the `services/` prefix the publisher holds would be a real loss even
|
||||
# though it would read as tidier.
|
||||
#
|
||||
# ⚠️ `services` is PLURAL, because the path segment comes from
|
||||
# `Kind::Service`'s `#[strum(serialize = "services")]` and not from
|
||||
# `Kind::label`, which renders the singular for error text. The singular
|
||||
# spelling evaluates, deploys, and 403s every read with "permission
|
||||
# denied" and nothing else.
|
||||
name = "the matrix minter's grant is the hive credential's path and nothing else";
|
||||
ok =
|
||||
let
|
||||
s = baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.script;
|
||||
in
|
||||
lib.hasInfix "path \"secret/data/swarm/services/matrix/hive-access-token\" {" s
|
||||
&& !(lib.hasInfix "secret/data/swarm/services/*" s)
|
||||
&& !(lib.hasInfix "secret/data/swarm/agents" s)
|
||||
&& !(lib.hasInfix "secret/data/swarm/hives" s)
|
||||
&& !(lib.hasInfix "sys/policies/acl" s);
|
||||
}
|
||||
{
|
||||
# 🩸 `read` is load-bearing here and is the one capability neither
|
||||
# sibling has. The minter's first act is to read this path back and stop
|
||||
# if something is there — that read IS "and only once", so without the
|
||||
# capability every container restart would mint a second access token and
|
||||
# invalidate the hive's.
|
||||
name = "the matrix minter may read back the one path it writes";
|
||||
ok =
|
||||
let
|
||||
s = baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.script;
|
||||
in
|
||||
lib.hasInfix "capabilities = [\"create\", \"update\", \"read\"]" s
|
||||
&& lib.hasInfix "auth/cert/certs/swarm-matrix-minter" s
|
||||
&& lib.hasInfix "allowed_common_names=swarm-matrix-minter" s;
|
||||
}
|
||||
{
|
||||
# Same two controls its siblings carry: ordered after the unit that makes
|
||||
# the mounts it writes into, and rendered on the HOST rather than inside
|
||||
# the store's container, where it would have neither an identity nor a
|
||||
# route to the store.
|
||||
name = "the minter's granting unit is ordered after the mounts and rendered on the host";
|
||||
ok =
|
||||
lib.elem "swarm-bao-controller-policy.service" (
|
||||
baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.after
|
||||
)
|
||||
&& !(baoGrantHere.containers.swarm-bao.config.systemd.services ? swarm-bao-matrix-minter-policy);
|
||||
}
|
||||
{
|
||||
# The policy authorising this route lives in another file, and nothing
|
||||
# else relates the grants to the paths the code actually writes.
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ let
|
|||
deploy.bao.clientCertFile = "/etc/pki/bao-client.pem";
|
||||
deploy.bao.clientKeyFile = "/etc/pki/bao-client-key.pem";
|
||||
};
|
||||
|
||||
# A homeserver on a hive with NO store identity at all — neither a local
|
||||
# store nor a hand-placed leaf. The absence arm for the minter cases below
|
||||
# needs it, and defining it here rather than importing keeps each group's
|
||||
# fixture set its own, as ./lib.nix asks.
|
||||
matrixNoBaoIdentity = hive { deploy.matrix.enable = true; };
|
||||
cases = [
|
||||
{
|
||||
# A login failure is the store being unreachable, sealed, or not yet
|
||||
|
|
@ -219,6 +225,110 @@ let
|
|||
&& (s.hive-c0re.environment.BAO_CACERT or null) == "%d/bao-ca.pem"
|
||||
&& lib.any (c: lib.hasPrefix "bao-ca.pem:" c) s.hive-c0re.serviceConfig.LoadCredential;
|
||||
}
|
||||
{
|
||||
# The same hole a third time, and the leaf whose absence is hardest to
|
||||
# see from outside: it is consumed by a unit INSIDE a container, so a
|
||||
# missing pairing renders as a container that comes up fine and publishes
|
||||
# nothing.
|
||||
name = "the store mints a leaf for the matrix minter, and the container is pointed at it";
|
||||
ok =
|
||||
let
|
||||
m = baoWithMatrix;
|
||||
p = m.services.hyperhive.deploy.matrix;
|
||||
in
|
||||
lib.hasInfix "matrix-minter.pem" m.systemd.services.swarm-bao-pki.script
|
||||
&& p.minterBaoClientCertFile == "/var/lib/swarm-bao-pki/matrix-minter.pem"
|
||||
&& p.minterBaoClientKeyFile == "/var/lib/swarm-bao-pki/matrix-minter-key.pem";
|
||||
}
|
||||
{
|
||||
# 🩸 The identity separation this whole arrangement buys, stated as the
|
||||
# one thing that would silently undo it. The container gets the MINTER's
|
||||
# leaf — whose grant is a single path — and not the hive's, which reads
|
||||
# every secret in the store. Both files exist in the same directory and
|
||||
# both would evaluate, deploy and work.
|
||||
name = "the matrix minter presents its own leaf, never the hive's store-wide one";
|
||||
ok =
|
||||
let
|
||||
env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-minter.environment;
|
||||
hiveLeaf = baoWithMatrix.services.hyperhive.deploy.bao.clientCertFile;
|
||||
in
|
||||
env.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/matrix-minter.pem"
|
||||
&& env.BAO_CLIENT_CERT != hiveLeaf;
|
||||
}
|
||||
{
|
||||
# The bind mount is what makes the environment above resolvable: without
|
||||
# it the unit names two paths the container does not have, and fails at
|
||||
# the TLS handshake naming no cause. Read off the mount table rather than
|
||||
# the option, so a pairing that stops reaching `bindMounts` still fails.
|
||||
#
|
||||
# The second arm is the shape guard: `bindMounts` is one literal plus two
|
||||
# merges, and a rewrite that dropped the appservice registration would
|
||||
# take the homeserver's own credential with it.
|
||||
name = "the matrix container binds the minter's PKI read-only, without losing the appservice registration";
|
||||
ok =
|
||||
let
|
||||
mounts = baoWithMatrix.containers.hive-matrix.bindMounts;
|
||||
in
|
||||
mounts ? "/var/lib/swarm-bao-pki"
|
||||
&& mounts."/var/lib/swarm-bao-pki".isReadOnly
|
||||
&& mounts ? "/var/lib/hyperhive/matrix-appservice";
|
||||
}
|
||||
{
|
||||
# What the unit is for, read as the two agreements it cannot get wrong:
|
||||
# the cert role ./host-modules/swarm-bao.nix writes, and a homeserver
|
||||
# address that is loopback because the container shares the host netns. A
|
||||
# vhost here would be a request out through the gateway and back.
|
||||
name = "the matrix minter is handed the store role and the loopback homeserver";
|
||||
ok =
|
||||
let
|
||||
m = baoWithMatrix;
|
||||
u = m.containers.hive-matrix.config.systemd.services.swarm-matrix-minter;
|
||||
port = m.services.hyperhive.swarm.matrix.httpPort;
|
||||
in
|
||||
u.environment.MATRIX_MINTER_CERT_ROLE == "swarm-matrix-minter"
|
||||
&& u.environment.MATRIX_MINTER_API_URL == "http://127.0.0.1:${toString port}"
|
||||
&& u.environment.MATRIX_MINTER_REGISTRATION == "/var/lib/hyperhive/matrix-appservice/hyperhive.yaml"
|
||||
&& u.serviceConfig.Type == "oneshot";
|
||||
}
|
||||
{
|
||||
# 🩸 A secret is a path, never a value — checked on the one unit in this
|
||||
# tree whose whole job is an `as_token`. Every variable it is given names
|
||||
# a file or an address; the token itself is read out of the bind-mounted
|
||||
# registration at runtime, so nothing here can be a token and an
|
||||
# environment block is world-readable through `systemctl show`.
|
||||
name = "the matrix minter's environment carries paths and addresses, never a token";
|
||||
ok =
|
||||
let
|
||||
env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-minter.environment;
|
||||
in
|
||||
!(lib.any (v: lib.hasInfix "as_token" v || lib.hasInfix "syt_" v) (lib.attrValues env));
|
||||
}
|
||||
{
|
||||
# The absence arm, and the deployment it protects: a homeserver on a hive
|
||||
# with no store identity at all. Without it the unit would exist naming
|
||||
# `null` as its certificate, which nixos renders as the literal string.
|
||||
name = "a matrix container with no store identity runs no minter and binds no PKI";
|
||||
ok =
|
||||
let
|
||||
units = matrixNoBaoIdentity.containers.hive-matrix.config.systemd.services;
|
||||
in
|
||||
!(units ? swarm-matrix-minter)
|
||||
&& !(matrixNoBaoIdentity.containers.hive-matrix.bindMounts ? "/var/lib/swarm-bao-pki");
|
||||
}
|
||||
{
|
||||
# 🩸 The privilege arm of the credential this slice publishes: the
|
||||
# account it belongs to must not be a homeserver admin. Read on the
|
||||
# rendered homeserver settings rather than on an option, because the
|
||||
# grant was never an option — it was a boot command in `admin_execute`,
|
||||
# and a command list is exactly the shape a later edit re-adds without
|
||||
# anything noticing.
|
||||
name = "the homeserver promotes no account to admin at boot";
|
||||
ok =
|
||||
let
|
||||
g = baoWithMatrix.containers.hive-matrix.config.services.matrix-tuwunel.settings.global;
|
||||
in
|
||||
!(g ? admin_execute) || g.admin_execute == [ ];
|
||||
}
|
||||
];
|
||||
in
|
||||
runGroup "bao-matrix-reader" cases
|
||||
|
|
|
|||
|
|
@ -52,6 +52,17 @@ let
|
|||
swarm.hives.pubctl.domain = "p.t.local";
|
||||
};
|
||||
|
||||
# The THIRD element of the same list, colliding on its own so neither of the
|
||||
# two above can carry it. The minter's grant is one path rather than a whole
|
||||
# prefix, which is exactly why a dead entry here would be easy to miss: a
|
||||
# hive that inherited it would not obviously break anything, it would
|
||||
# silently gain the ability to overwrite the swarm's matrix credential.
|
||||
hiveNamedAfterMinterSubject = hive {
|
||||
deploy.swarm-otel.enable = false;
|
||||
deploy.bao.matrixMinterCommonName = "mintctl";
|
||||
swarm.hives.mintctl.domain = "m.t.local";
|
||||
};
|
||||
|
||||
hiveNameWithComposedWord = hive {
|
||||
deploy.swarm-otel.enable = false;
|
||||
swarm.hives."h1-agent".domain = "a.t.local";
|
||||
|
|
@ -88,6 +99,18 @@ let
|
|||
a: !a.assertion && lib.hasInfix "'pubctl'" a.message
|
||||
) hiveNamedAfterPublisherSubject.assertions;
|
||||
}
|
||||
{
|
||||
# And the third, for the reason the second one's comment gives one list
|
||||
# element earlier. `certAuthCns` is where a role added beside the others
|
||||
# has to register itself, and nothing but a case per element notices when
|
||||
# one forgets.
|
||||
name = "a hive named after the matrix minter's subject is refused too";
|
||||
ok =
|
||||
equalityGuardFired hiveNamedAfterMinterSubject
|
||||
&& lib.any (
|
||||
a: !a.assertion && lib.hasInfix "'mintctl'" a.message
|
||||
) hiveNamedAfterMinterSubject.assertions;
|
||||
}
|
||||
{
|
||||
# Without this the case above proves nothing: an arm that fires for every
|
||||
# roster is not a guard, and `hives` is non-empty in both fixtures.
|
||||
|
|
|
|||
|
|
@ -163,6 +163,13 @@ in
|
|||
# rather than every hive's.
|
||||
swarm-nats-auth = mkBinPackage "swarm-nats-auth" "hyperhive swarm queue auth-callout responder";
|
||||
|
||||
# The matrix admin credential's minter. Out of `daemonBins` for the same
|
||||
# "runs *inside* a container, not on the host" reason as the responder
|
||||
# above, and with a second one: putting it in the core bundle would place
|
||||
# the binary that reads the appservice token on every hive's filesystem,
|
||||
# which is the arrangement it exists to end.
|
||||
swarm-matrix-minter = mkBinPackage "swarm-matrix-minter" "hyperhive matrix admin-credential minter";
|
||||
|
||||
# The only process allowed to write swarm-authelia's users database —
|
||||
# same "runs *inside* a container, not on the host" placement as
|
||||
# `swarm-nats-auth` above (this one lives in `swarm-authelia`'s
|
||||
|
|
|
|||
23
swarm-matrix-minter/Cargo.toml
Normal file
23
swarm-matrix-minter/Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[package]
|
||||
name = "swarm-matrix-minter"
|
||||
version.workspace = true
|
||||
readme = "README.md"
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "swarm-matrix-minter"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde_json.workspace = true
|
||||
# The agreement this binary is one end of: where the credential lives, what the
|
||||
# object at that path holds, and the `BAO_*` spellings the unit sets.
|
||||
swarm-secret-client.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
44
swarm-matrix-minter/README.md
Normal file
44
swarm-matrix-minter/README.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# swarm-matrix-minter
|
||||
|
||||
A boot-time oneshot that runs **inside `containers.hive-matrix`**, beside the
|
||||
homeserver, and puts the `@hive:` account's access token into the swarm's secret
|
||||
store under an identity of its own.
|
||||
|
||||
## Why it lives in the matrix container
|
||||
|
||||
The credential it mints is authorised by the appservice `as_token`, and the
|
||||
container already holds that: `nix/host-modules/hive-matrix.nix` bind-mounts the
|
||||
rendered appservice registration into it read-only, because that is how tuwunel
|
||||
itself is handed the registration. Minting anywhere else would mean copying the
|
||||
`as_token` to a second holder — and the point of this component is that the hive
|
||||
stops being one.
|
||||
|
||||
It is not the swarm controller for the same reason, plus a structural one: a
|
||||
homeserver has exactly **one** `@hive:` account and a swarm runs one
|
||||
homeserver, so "mint it once" needs no lock, no lease and no trigger surface — it
|
||||
is a property of the thing being minted.
|
||||
|
||||
## Idempotency
|
||||
|
||||
The **store** is the key, not the homeserver. A run reads
|
||||
`swarm/services/matrix/hive-access-token` first and returns without touching the
|
||||
homeserver when something is already there. Only an empty path reaches the mint
|
||||
ladder:
|
||||
|
||||
1. `POST /_matrix/client/v3/register` with `"type": "m.login.application_service"`
|
||||
— one round trip, no UIAA.
|
||||
2. `M_USER_IN_USE` (the expected arm on a homeserver that has already loaded the
|
||||
registration, since the account is the appservice's own `sender_localpart`) →
|
||||
`POST /_matrix/client/v3/login` as the appservice, same pinned `device_id`, so
|
||||
the old device is replaced rather than duplicated.
|
||||
3. Write the result to the store.
|
||||
|
||||
A crash between the homeserver call and the store write is recoverable: the next
|
||||
run takes arm 2.
|
||||
|
||||
## 🩸 A secret is a path, never a value
|
||||
|
||||
Nothing here logs, prints or interpolates a token. The mint ladder's errors are
|
||||
built from the homeserver's _status_ and its `errcode`, never its body, because a
|
||||
`/login` response body is an access token. The one identifier this binary logs is
|
||||
the store path it wrote.
|
||||
286
swarm-matrix-minter/src/homeserver.rs
Normal file
286
swarm-matrix-minter/src/homeserver.rs
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
//! The two calls the mint ladder is made of, against the homeserver next door.
|
||||
//!
|
||||
//! 🩸 **Every error in this module is built from the response's `status` and
|
||||
//! its `errcode`, never its body.** A successful `/register` or `/login` body
|
||||
//! *is* an access token, and an error body is one malformed response away from
|
||||
//! being the same bytes — so a `body: {json}` in a message here would put the
|
||||
//! `@hive:` credential in the journal.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
/// Client-server API calls are one round trip each against a homeserver in the
|
||||
/// same netns; a slow one is a broken one.
|
||||
const TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
/// Bytes of the throwaway password `/register` is given.
|
||||
///
|
||||
/// Protocol overhead, and stored nowhere: this account authenticates by access
|
||||
/// token, and the recovery path when that token is lost is the appservice login
|
||||
/// below rather than anything a password could open.
|
||||
const PASSWORD_BYTES: usize = 32;
|
||||
|
||||
/// What the homeserver said about a registration attempt.
|
||||
///
|
||||
/// An enum rather than a string match on the error text: `M_USER_IN_USE` is the
|
||||
/// *expected* answer here — the `@hive:` account is the appservice registration's
|
||||
/// own `sender_localpart`, so the homeserver creates it at startup, before
|
||||
/// anything gets to ask — and an expected answer should not have to be
|
||||
/// recovered from a formatted message.
|
||||
pub enum Registered {
|
||||
/// A fresh account, and the access token minted with it.
|
||||
Token(String),
|
||||
/// The account is already there; it has to be logged into instead.
|
||||
AlreadyExists,
|
||||
}
|
||||
|
||||
/// An HTTP client with this module's timeout.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the TLS backend will not initialise, which is the only way building a
|
||||
/// client fails.
|
||||
pub fn client() -> Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(TIMEOUT_SECS))
|
||||
.build()
|
||||
.context("building the HTTP client for the homeserver")
|
||||
}
|
||||
|
||||
/// Create `localpart`'s account as the appservice and return its access token.
|
||||
///
|
||||
/// One round trip: an appservice-typed registration needs no UIAA stage, so
|
||||
/// there is no session to carry and no shared registration secret in the
|
||||
/// picture. The `device_id` is pinned so that a later [`appservice_login`]
|
||||
/// replaces this device rather than accumulating one per run.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the request cannot be sent, the response will not decode, or the
|
||||
/// homeserver refuses with anything other than `M_USER_IN_USE` — which is
|
||||
/// [`Registered::AlreadyExists`] rather than an error.
|
||||
pub async fn register(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
localpart: &str,
|
||||
as_token: &str,
|
||||
) -> Result<Registered> {
|
||||
let body = serde_json::json!({
|
||||
// What makes this an appservice registration rather than an ordinary
|
||||
// one: without it the homeserver asks for a UIAA flow even though the
|
||||
// request carries the as_token.
|
||||
"type": "m.login.application_service",
|
||||
"username": localpart,
|
||||
"password": random_password()?,
|
||||
"device_id": device_id(localpart),
|
||||
"initial_device_display_name": format!("hyperhive ({localpart})"),
|
||||
"inhibit_login": false,
|
||||
});
|
||||
let (status, json) = post(
|
||||
client,
|
||||
&format!("{base}/_matrix/client/v3/register?kind=user"),
|
||||
as_token,
|
||||
&body,
|
||||
)
|
||||
.await
|
||||
.context("POST /register as the appservice")?;
|
||||
|
||||
if status.is_success() {
|
||||
return Ok(Registered::Token(access_token(&json)?));
|
||||
}
|
||||
if errcode(&json) == Some("M_USER_IN_USE") {
|
||||
return Ok(Registered::AlreadyExists);
|
||||
}
|
||||
bail!(
|
||||
"the homeserver refused to register @{localpart}: {}",
|
||||
why(status, &json)
|
||||
);
|
||||
}
|
||||
|
||||
/// Log in as an **existing** account using the appservice's authority, and
|
||||
/// return a fresh access token for it.
|
||||
///
|
||||
/// No password: the appservice is authorised for every localpart in its
|
||||
/// namespace, so it mints a session for one without knowing anything about the
|
||||
/// account — which is just as well, since an account the homeserver created for
|
||||
/// its own registration has none.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the request cannot be sent, the response will not decode, or the
|
||||
/// homeserver refuses.
|
||||
pub async fn appservice_login(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
localpart: &str,
|
||||
as_token: &str,
|
||||
) -> Result<String> {
|
||||
let body = serde_json::json!({
|
||||
"type": "m.login.application_service",
|
||||
"identifier": {
|
||||
"type": "m.id.user",
|
||||
"user": localpart,
|
||||
},
|
||||
// Matching `register`'s, so a re-login REPLACES that device's token
|
||||
// rather than leaving a second live device behind.
|
||||
"device_id": device_id(localpart),
|
||||
"initial_device_display_name": format!("hyperhive ({localpart})"),
|
||||
});
|
||||
let (status, json) = post(
|
||||
client,
|
||||
&format!("{base}/_matrix/client/v3/login"),
|
||||
as_token,
|
||||
&body,
|
||||
)
|
||||
.await
|
||||
.context("POST /login as the appservice")?;
|
||||
|
||||
if !status.is_success() {
|
||||
bail!(
|
||||
"the homeserver refused to log in @{localpart}: {}",
|
||||
why(status, &json)
|
||||
);
|
||||
}
|
||||
access_token(&json)
|
||||
}
|
||||
|
||||
/// The device every token this binary mints is pinned to.
|
||||
fn device_id(localpart: &str) -> String {
|
||||
format!("hyperhive-{localpart}")
|
||||
}
|
||||
|
||||
/// One authenticated JSON POST, returning the status beside the decoded body.
|
||||
async fn post(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
as_token: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<(reqwest::StatusCode, serde_json::Value)> {
|
||||
let resp = client
|
||||
.post(url)
|
||||
.bearer_auth(as_token)
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.context("sending the request")?;
|
||||
let status = resp.status();
|
||||
let json = resp
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.context("decoding the response as JSON")?;
|
||||
Ok((status, json))
|
||||
}
|
||||
|
||||
/// Pull `access_token` out of a successful response.
|
||||
fn access_token(json: &serde_json::Value) -> Result<String> {
|
||||
json["access_token"]
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
// Not `{json}`: on the success path this object holds the credential,
|
||||
// and a response missing the field is exactly when a reflex to print it
|
||||
// would fire.
|
||||
.context("the homeserver's response carried no `access_token`")
|
||||
}
|
||||
|
||||
/// The matrix error code, when the body is a standard error object.
|
||||
fn errcode(json: &serde_json::Value) -> Option<&str> {
|
||||
json["errcode"].as_str()
|
||||
}
|
||||
|
||||
/// Everything about a refusal that is safe to put in a message.
|
||||
///
|
||||
/// The `errcode` is a closed vocabulary from the spec and the status is a
|
||||
/// number; between them they say which of the ladder's arms was taken. The
|
||||
/// `error` string beside them is free-form homeserver text, so it stays out.
|
||||
fn why(status: reqwest::StatusCode, json: &serde_json::Value) -> String {
|
||||
match errcode(json) {
|
||||
Some(code) => format!("HTTP {status}, errcode {code}"),
|
||||
None => format!("HTTP {status}, no errcode"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A throwaway password for [`register`], as hex.
|
||||
///
|
||||
/// From `/dev/urandom` directly rather than through an RNG crate: this is the
|
||||
/// one random value the binary needs, and the kernel is already the source any
|
||||
/// such crate would reach for here.
|
||||
///
|
||||
/// # Errors
|
||||
/// When `/dev/urandom` cannot be read.
|
||||
fn random_password() -> Result<String> {
|
||||
use std::io::Read as _;
|
||||
|
||||
let mut buf = [0u8; PASSWORD_BYTES];
|
||||
std::fs::File::open("/dev/urandom")
|
||||
.context("opening /dev/urandom")?
|
||||
.read_exact(&mut buf)
|
||||
.context("reading from /dev/urandom")?;
|
||||
Ok(buf.iter().fold(String::new(), |mut acc, b| {
|
||||
use std::fmt::Write as _;
|
||||
// Infallible: `write!` into a `String` only fails if the formatter
|
||||
// does, and `{:02x}` of a `u8` has nothing to fail at.
|
||||
let _ = write!(acc, "{b:02x}");
|
||||
acc
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn both_calls_pin_the_same_device_so_a_relogin_replaces_it() {
|
||||
// The whole reason the recovery arm is safe to take repeatedly: an
|
||||
// unpinned login mints a NEW device each time, and a homeserver
|
||||
// accumulating devices for this account is one where revoking the credential
|
||||
// means finding all of them.
|
||||
assert_eq!(device_id("hive"), "hyperhive-hive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_is_described_by_its_errcode_and_never_by_its_body() {
|
||||
// 🩸 The invariant this module exists to keep. `error` is free-form
|
||||
// homeserver text and `access_token` is the credential itself; a
|
||||
// message built from the body would carry whichever of them the
|
||||
// response happened to hold.
|
||||
let json = serde_json::json!({
|
||||
"errcode": "M_FORBIDDEN",
|
||||
"error": "some free-form text",
|
||||
"access_token": "syt_the_actual_secret",
|
||||
});
|
||||
let message = why(reqwest::StatusCode::FORBIDDEN, &json);
|
||||
assert!(message.contains("M_FORBIDDEN"), "{message}");
|
||||
assert!(!message.contains("syt_the_actual_secret"), "{message}");
|
||||
assert!(!message.contains("free-form"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_with_no_errcode_still_produces_a_message() {
|
||||
// A homeserver behind a proxy answers with HTML, not a matrix error
|
||||
// object. The status is then the only thing there is to say, and
|
||||
// saying it is better than an empty report.
|
||||
let message = why(reqwest::StatusCode::BAD_GATEWAY, &serde_json::json!({}));
|
||||
assert!(message.contains("502"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_expected_already_exists_answer_is_recognised_by_its_errcode() {
|
||||
// Matched on the spec's code rather than on message text, because this
|
||||
// is the arm a healthy homeserver takes every time: the `@hive:` account
|
||||
// is the appservice registration's own sender, created at startup.
|
||||
let json = serde_json::json!({ "errcode": "M_USER_IN_USE", "error": "User ID taken" });
|
||||
assert_eq!(errcode(&json), Some("M_USER_IN_USE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_response_without_a_token_is_an_error_that_does_not_quote_it() {
|
||||
let e = access_token(&serde_json::json!({ "user_id": "@hive:t.local" }))
|
||||
.expect_err("no access_token in this object");
|
||||
assert!(!format!("{e}").contains("@hive:t.local"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_minted_password_is_hex_of_the_declared_length() {
|
||||
// The control on the hex fold: a short or non-hex password would be
|
||||
// accepted by the homeserver and only surface much later, if at all.
|
||||
let pw = random_password().expect("/dev/urandom is readable");
|
||||
assert_eq!(pw.len(), PASSWORD_BYTES * 2);
|
||||
assert!(pw.bytes().all(|b| b.is_ascii_hexdigit()), "not hex");
|
||||
}
|
||||
}
|
||||
249
swarm-matrix-minter/src/main.rs
Normal file
249
swarm-matrix-minter/src/main.rs
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
//! Mint the `@hive:` account's homeserver access token, once, and publish it to the
|
||||
//! swarm's secret store.
|
||||
//!
|
||||
//! A oneshot inside `containers.hive-matrix`, not a daemon and not part of the
|
||||
//! swarm controller. It is in the container because the appservice `as_token`
|
||||
//! that authorises the mint is *already* there — the registration tuwunel loads
|
||||
//! is bind-mounted in — so no second holder of that secret is created. It is
|
||||
//! this account rather than an agent's because a homeserver has **one** `@hive:`
|
||||
//! account and a swarm runs one homeserver: "only once" is a property of what
|
||||
//! is being minted, so there is nothing to lock and no trigger to serve.
|
||||
//!
|
||||
//! The store, not the homeserver, is the idempotency key — see
|
||||
//! [`already_published`]. On the hive side `hive-c0re`'s
|
||||
//! `matrix::ensure_hive_user` reads exactly the path written here, which is how
|
||||
//! a hive that holds no `as_token` still gets its matrix account.
|
||||
//!
|
||||
//! 🩸 A secret is a path, never a value. The only identifier this binary logs is
|
||||
//! the store path; see `homeserver`'s module doc for the same rule applied to
|
||||
//! error messages.
|
||||
|
||||
mod homeserver;
|
||||
mod registration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use swarm_secret_client::{
|
||||
SecretStore,
|
||||
client::{DEFAULT_CERT_MOUNT, Settings},
|
||||
matrix,
|
||||
};
|
||||
|
||||
/// Role on the store's `cert` auth mount to log in with. Its policy is what
|
||||
/// allows the write below; the certificate the `BAO_*` variables name has to
|
||||
/// carry the CN that role accepts.
|
||||
const ENV_CERT_ROLE: &str = "MATRIX_MINTER_CERT_ROLE";
|
||||
/// Client-server API base of the homeserver beside us — loopback, since the
|
||||
/// container shares the host netns.
|
||||
const ENV_API_URL: &str = "MATRIX_MINTER_API_URL";
|
||||
/// The bind-mounted appservice registration, which is where the `as_token`
|
||||
/// comes from. A path, never a value.
|
||||
const ENV_REGISTRATION: &str = "MATRIX_MINTER_REGISTRATION";
|
||||
/// Localpart of the hive's account. The appservice registration's own
|
||||
/// `sender_localpart`, and `hive-c0re`'s `matrix::HIVE_LOCALPART`.
|
||||
const ENV_LOCALPART: &str = "MATRIX_MINTER_LOCALPART";
|
||||
/// Public base URL of the homeserver, stored beside the token so a reader can
|
||||
/// reconstruct where it is good for. Optional: a swarm with no gateway vhost
|
||||
/// has no such URL, and `matrix::Credential` types the field to say so.
|
||||
const ENV_HOMESERVER: &str = "MATRIX_MINTER_HOMESERVER";
|
||||
|
||||
/// Everything the unit tells this process, checked before anything is opened.
|
||||
///
|
||||
/// Separate from the work for the reason `swarm_secret_client::client::Settings`
|
||||
/// is: every arm is a misconfiguration an operator reads an error about, and
|
||||
/// none of them needs a reachable homeserver or store to happen.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Config {
|
||||
cert_role: String,
|
||||
api_url: String,
|
||||
registration: String,
|
||||
localpart: String,
|
||||
homeserver: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Read the `MATRIX_MINTER_*` variables from the process environment.
|
||||
///
|
||||
/// # Errors
|
||||
/// Naming the first variable that is unset or empty.
|
||||
fn from_env() -> Result<Self> {
|
||||
Self::from_lookup(|k| std::env::var(k).ok())
|
||||
}
|
||||
|
||||
/// [`Config::from_env`] against an arbitrary lookup.
|
||||
///
|
||||
/// # Errors
|
||||
/// Naming the first variable that is unset or empty.
|
||||
fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
|
||||
let required = |var: &'static str| -> Result<String> {
|
||||
get(var)
|
||||
.filter(|v| !v.is_empty())
|
||||
.with_context(|| format!("{var} is unset or empty"))
|
||||
};
|
||||
Ok(Self {
|
||||
cert_role: required(ENV_CERT_ROLE)?,
|
||||
api_url: required(ENV_API_URL)?,
|
||||
registration: required(ENV_REGISTRATION)?,
|
||||
localpart: required(ENV_LOCALPART)?,
|
||||
// Empty is absent: systemd renders an unset nix option as
|
||||
// `Environment=VAR=`, so that is the shape this arrives in.
|
||||
homeserver: get(ENV_HOMESERVER).filter(|v| !v.is_empty()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the credential already in the store?
|
||||
///
|
||||
/// **This read is the "and only once".** The homeserver is not asked — a
|
||||
/// re-run of the container, or of this unit, costs one store read and stops.
|
||||
/// It is also the read-back of what a previous run wrote, so the path published
|
||||
/// and the path consulted cannot drift apart: they are one function call.
|
||||
///
|
||||
/// A failure to read is reported and treated as absent rather than raised. The
|
||||
/// two cases that reach it are a path that has never been written (the first
|
||||
/// run, which must go on to mint) and a token whose policy does not cover the
|
||||
/// path — and the second fails again, loudly and with the store's own message,
|
||||
/// at the write below.
|
||||
async fn already_published(store: &SecretStore, path: &str) -> bool {
|
||||
match store.read::<matrix::Credential>(path).await {
|
||||
Ok(credential) => !credential.value.trim().is_empty(),
|
||||
Err(e) => {
|
||||
tracing::info!(%path, error = %e, "nothing readable in the store yet");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = Config::from_env()?;
|
||||
// Explicitly, rather than through `SecretStore::from_env`: a missing or
|
||||
// misspelled `BAO_*` variable is the most likely thing to be wrong with a
|
||||
// freshly deployed unit, and this reports it before the homeserver is
|
||||
// touched at all.
|
||||
let settings = Settings::from_env().context("reading the store's BAO_* environment")?;
|
||||
let store = SecretStore::connect(&settings, &config.cert_role, DEFAULT_CERT_MOUNT)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"logging in to the swarm secret store as cert role {}",
|
||||
config.cert_role
|
||||
)
|
||||
})?;
|
||||
|
||||
let path = matrix::hive_token_path();
|
||||
if already_published(&store, &path).await {
|
||||
tracing::info!(%path, "the @hive: credential is already published; not minting");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let as_token = registration::as_token(&config.registration)?;
|
||||
let http = homeserver::client()?;
|
||||
let token =
|
||||
match homeserver::register(&http, &config.api_url, &config.localpart, &as_token).await? {
|
||||
homeserver::Registered::Token(token) => token,
|
||||
homeserver::Registered::AlreadyExists => {
|
||||
// The expected arm, not an edge case: this account is the
|
||||
// appservice's own `sender_localpart`, so the homeserver creates it
|
||||
// when it loads the registration — before anything gets to ask.
|
||||
tracing::info!("the @hive: account exists; logging in as the appservice instead");
|
||||
homeserver::appservice_login(&http, &config.api_url, &config.localpart, &as_token)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
store
|
||||
.write(
|
||||
&path,
|
||||
&matrix::Credential {
|
||||
value: token,
|
||||
homeserver: config.homeserver,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("writing the @hive: credential to {path}"))?;
|
||||
tracing::info!(%path, "published the @hive: credential");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A lookup standing in for a fully-configured unit's environment.
|
||||
fn full(k: &str) -> Option<String> {
|
||||
match k {
|
||||
ENV_CERT_ROLE => Some("swarm-matrix-minter".to_owned()),
|
||||
ENV_API_URL => Some("http://127.0.0.1:8008".to_owned()),
|
||||
ENV_REGISTRATION => {
|
||||
Some("/var/lib/hyperhive/matrix-appservice/hyperhive.yaml".to_owned())
|
||||
}
|
||||
ENV_LOCALPART => Some("hive".to_owned()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_complete_environment_is_accepted() {
|
||||
// The control: without it every assertion below could be passing
|
||||
// because `from_lookup` rejects everything.
|
||||
let c = Config::from_lookup(full).expect("every required variable is set");
|
||||
assert_eq!(c.localpart, "hive");
|
||||
assert_eq!(c.homeserver, None, "an absent public URL is not an error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_required_variable_is_named_when_it_is_the_missing_one() {
|
||||
for var in [ENV_CERT_ROLE, ENV_API_URL, ENV_REGISTRATION, ENV_LOCALPART] {
|
||||
let e = Config::from_lookup(|k| if k == var { None } else { full(k) })
|
||||
.expect_err("one required variable is absent");
|
||||
assert!(
|
||||
format!("{e}").contains(var),
|
||||
"dropping {var} should name {var}, got {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_variable_is_as_absent_as_an_unset_one() {
|
||||
// systemd writes `Environment=VAR=` for an unset nix option, so empty
|
||||
// is the shape these actually arrive in.
|
||||
let e = Config::from_lookup(|k| {
|
||||
if k == ENV_CERT_ROLE {
|
||||
Some(String::new())
|
||||
} else {
|
||||
full(k)
|
||||
}
|
||||
})
|
||||
.expect_err("an empty role is not a role");
|
||||
assert!(format!("{e}").contains(ENV_CERT_ROLE), "{e}");
|
||||
|
||||
let c = Config::from_lookup(|k| {
|
||||
if k == ENV_HOMESERVER {
|
||||
Some(String::new())
|
||||
} else {
|
||||
full(k)
|
||||
}
|
||||
})
|
||||
.expect("an empty public URL is optional, not fatal");
|
||||
assert_eq!(c.homeserver, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_published_path_is_the_one_the_hive_reads() {
|
||||
// Both ends of this slice's loop resolve the same function, so there is
|
||||
// no second spelling to drift — this pins that the loop exists at all,
|
||||
// and names the literal so a move of the path is a deliberate edit on
|
||||
// both sides rather than a silent 404 on the reading one.
|
||||
assert_eq!(
|
||||
matrix::hive_token_path(),
|
||||
"swarm/services/matrix/hive-access-token"
|
||||
);
|
||||
}
|
||||
}
|
||||
104
swarm-matrix-minter/src/registration.rs
Normal file
104
swarm-matrix-minter/src/registration.rs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
//! Reading the `as_token` out of the appservice registration the matrix
|
||||
//! container already has.
|
||||
//!
|
||||
//! No new credential is delivered for this. `nix/host-modules/hive-matrix.nix`
|
||||
//! binds the registration directory into the container read-only so tuwunel can
|
||||
//! load it, and the registration **is** the `as_token` — so the file this
|
||||
//! module opens is one this process could already read, and one the homeserver
|
||||
//! beside it reads too.
|
||||
//!
|
||||
//! Scanned line-by-line rather than parsed as YAML. The file has exactly one
|
||||
//! renderer (`appserviceRegistrationScript` in that same module, a `printf` of
|
||||
//! `as_token: <hex>`), so a parser would be a second, looser reading of a shape
|
||||
//! this repo writes itself — and it would pull a YAML crate into a binary whose
|
||||
//! only other input is JSON.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
/// The key the token is stored under, and the whole of the agreement with the
|
||||
/// renderer.
|
||||
const KEY: &str = "as_token:";
|
||||
|
||||
/// Read the registration at `path` and return its `as_token`.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the file cannot be read, or holds no `as_token` with a value — which is
|
||||
/// what a registration rendered by something other than this repo looks like
|
||||
/// from here.
|
||||
pub fn as_token(path: &str) -> Result<String> {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("reading the appservice registration at {path}"))?;
|
||||
// The path, not the file's contents: every line of it is either a secret or
|
||||
// a shape this module already knows.
|
||||
extract(&text).with_context(|| format!("no `as_token` in the registration at {path}"))
|
||||
}
|
||||
|
||||
/// [`as_token`] over text already in hand, so the agreement with the renderer
|
||||
/// can be tested without a file.
|
||||
fn extract(text: &str) -> Result<String> {
|
||||
for line in text.lines() {
|
||||
if let Some(rest) = line.strip_prefix(KEY) {
|
||||
let token = rest.trim();
|
||||
if token.is_empty() {
|
||||
bail!("the registration's `as_token` is empty");
|
||||
}
|
||||
return Ok(token.to_owned());
|
||||
}
|
||||
}
|
||||
bail!("the registration carries no `as_token` line")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The registration exactly as `appserviceRegistrationScript` renders it —
|
||||
/// the quoted heredoc, then the `printf` of the two tokens. Reproduced
|
||||
/// verbatim because that script is the other end of this agreement and
|
||||
/// lives in a file no Rust test can reach.
|
||||
const RENDERED: &str = "id: hyperhive\n\
|
||||
url: null\n\
|
||||
sender_localpart: hive\n\
|
||||
rate_limited: false\n\
|
||||
namespaces:\n \
|
||||
users:\n \
|
||||
- exclusive: false\n \
|
||||
regex: '@[a-z0-9._=/+-]+:example\\.test$'\n \
|
||||
aliases: []\n \
|
||||
rooms: []\n\
|
||||
as_token: deadbeef\n\
|
||||
hs_token: cafebabe\n";
|
||||
|
||||
#[test]
|
||||
fn the_token_is_taken_from_the_registration_this_repo_renders() {
|
||||
assert_eq!(
|
||||
extract(RENDERED).expect("the rendered shape parses"),
|
||||
"deadbeef"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_homeservers_own_token_is_not_mistaken_for_the_appservices() {
|
||||
// `hs_token` authenticates the homeserver TO the appservice and is a
|
||||
// different secret with a confusingly similar name; a substring search
|
||||
// would find it inside neither, but a `contains("s_token")`-shaped one
|
||||
// would. The control is that the line order in `RENDERED` puts
|
||||
// `as_token` first, so this arm needs the reverse to mean anything.
|
||||
let reversed = "hs_token: cafebabe\nas_token: deadbeef\n";
|
||||
assert_eq!(
|
||||
extract(reversed).expect("order does not matter"),
|
||||
"deadbeef"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_registration_with_no_token_is_an_error_rather_than_an_empty_string() {
|
||||
// An empty token authenticates nothing, and a homeserver answers a
|
||||
// request carrying one with a 403 that names the account rather than
|
||||
// the credential — so failing here is the only report an operator can
|
||||
// act on.
|
||||
for bad in ["id: hyperhive\n", "as_token:\n", "as_token: \n"] {
|
||||
assert!(extract(bad).is_err(), "{bad:?} must not yield a token");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use crate::{
|
||||
Error,
|
||||
path::{Kind, checked_segment, principal_prefix},
|
||||
path::{Kind, ROOT, checked_segment, principal_prefix},
|
||||
};
|
||||
|
||||
/// The path holding `agent`'s token for the external matrix account `account`.
|
||||
|
|
@ -26,6 +26,32 @@ pub fn account_path(agent: &str, account: &str) -> Result<String, Error> {
|
|||
Ok(format!("{prefix}/matrix/{account}"))
|
||||
}
|
||||
|
||||
/// The swarm service the homeserver is, as the name segment under
|
||||
/// [`Kind::Service`].
|
||||
///
|
||||
/// A swarm runs one homeserver, so this is a constant rather than a parameter —
|
||||
/// and that is the whole of what makes the credential below mintable
|
||||
/// "only once" without a lock.
|
||||
pub const HOMESERVER_SERVICE: &str = "matrix";
|
||||
|
||||
/// The path holding the `@hive:` account's homeserver access token.
|
||||
///
|
||||
/// Keyed per **homeserver**, not per hive and not per agent: a homeserver has one
|
||||
/// `@hive:` account (hive-c0re's `matrix::HIVE_LOCALPART`), so a
|
||||
/// per-hive copy would be several names for one secret. That is also why this
|
||||
/// takes no argument and cannot fail — there is no caller-supplied segment in
|
||||
/// it to reject.
|
||||
///
|
||||
/// Reachable by every hive without a new grant: [`crate::policy::render`]
|
||||
/// already grants a hive read on the whole [`Kind::Service`] tree.
|
||||
#[must_use]
|
||||
pub fn hive_token_path() -> String {
|
||||
format!(
|
||||
"{ROOT}/{}/{HOMESERVER_SERVICE}/hive-access-token",
|
||||
<&str>::from(Kind::Service)
|
||||
)
|
||||
}
|
||||
|
||||
/// The path holding `hive`'s matrix appservice token (`as_token`).
|
||||
///
|
||||
/// Keyed per **hive**, not per agent, like [`crate::queue::agent_client_path`]
|
||||
|
|
@ -86,6 +112,28 @@ mod tests {
|
|||
assert_eq!(p, "swarm/agents/atlas/matrix/ops-relay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hive_credential_lands_under_the_service_prefix_the_grant_covers() {
|
||||
// Spelled out rather than rebuilt from the same pieces the code uses,
|
||||
// for the reason above — and with a second job here: the hive's own
|
||||
// policy grants read on `secret/data/swarm/services/*`, so this exact
|
||||
// string is what makes the path reachable at all. The `services`
|
||||
// segment is PLURAL; `Kind::label` renders the singular and is for
|
||||
// error text only, so reading it as the path segment produces a
|
||||
// 403 the store explains as "permission denied" and nothing else.
|
||||
assert_eq!(hive_token_path(), "swarm/services/matrix/hive-access-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hive_credential_sits_where_a_service_principal_would() {
|
||||
// The constant path above must be the same one the general builder
|
||||
// produces, or the grant covering `Kind::Service` would cover a
|
||||
// neighbouring tree instead of this one.
|
||||
let prefix =
|
||||
principal_prefix(Kind::Service, HOMESERVER_SERVICE).expect("a plain name is legal");
|
||||
assert_eq!(hive_token_path(), format!("{prefix}/hive-access-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_appservice_token_lands_under_the_hive_prefix_the_grant_covers() {
|
||||
// Spelled out for the same reason as above, and with a second job here:
|
||||
|
|
|
|||
Loading…
Reference in a new issue