swarm-controller: stop minting the per-hive matrix sender account and token nothing reads
This commit is contained in:
parent
cb5fb3c730
commit
c66832febf
16 changed files with 40 additions and 556 deletions
|
|
@ -119,9 +119,8 @@ authelia and federation peers by name.
|
||||||
|
|
||||||
The homeserver refuses registration. **The swarm's appservice** creates every account:
|
The homeserver refuses registration. **The swarm's appservice** creates every account:
|
||||||
agents never see an appservice token, and an agent only ever receives its own
|
agents never see an appservice token, and an agent only ever receives its own
|
||||||
`access_token`. `swarm-controller` creates each agent's accounts and one sender
|
`access_token`. `swarm-controller` creates each agent's accounts. A hive has
|
||||||
account per hive, `@hive-<hive>:<server_name>`, an ordinary account that
|
no matrix account, because hive-c0re doesn't talk to matrix.
|
||||||
nothing promotes.
|
|
||||||
|
|
||||||
How the registration gets rendered, where its tokens live and how the
|
How the registration gets rendered, where its tokens live and how the
|
||||||
controller keeps an agent's token: [`matrix-provisioning.md`](../../4-implementation/swarm/matrix-provisioning.md).
|
controller keeps an agent's token: [`matrix-provisioning.md`](../../4-implementation/swarm/matrix-provisioning.md).
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ the rest of the cell says how.
|
||||||
| `swarm/agents/<agent>/github-token` | `swarm-controller`, when you link a GitHub account in the swarm UI | the agent | ❌ your token; delete it and link again | ✅ every 2 minutes |
|
| `swarm/agents/<agent>/github-token` | `swarm-controller`, when you link a GitHub account in the swarm UI | the agent | ❌ your token; delete it and link again | ✅ every 2 minutes |
|
||||||
| `swarm/agents/<agent>/backend-env` | `hive-agent-backend-env`, in the agent's container, from the agent's `backendEnvironmentFile` when the leaf holds nothing (field `env`, the file's exact text); or `swarm-controller`, when you set the API-key environment in the swarm UI | the agent | ❌ nothing replaces it without you; a save in the swarm UI overwrites it, including text an agent wrote into an empty leaf; the agent's file stays as it was | ❌ the file in `/run` refreshes every 2 minutes, and the harness and the subagent daemon load it at their next start |
|
| `swarm/agents/<agent>/backend-env` | `hive-agent-backend-env`, in the agent's container, from the agent's `backendEnvironmentFile` when the leaf holds nothing (field `env`, the file's exact text); or `swarm-controller`, when you set the API-key environment in the swarm UI | the agent | ❌ nothing replaces it without you; a save in the swarm UI overwrites it, including text an agent wrote into an empty leaf; the agent's file stays as it was | ❌ the file in `/run` refreshes every 2 minutes, and the harness and the subagent daemon load it at their next start |
|
||||||
| `swarm/agents/<agent>/claude-credentials` | `swarm-claude-login`, when you log the agent in from its card; one version per login | the agent, while it waits for a login | ❌ log in again | ✅ every 30 seconds while waiting |
|
| `swarm/agents/<agent>/claude-credentials` | `swarm-claude-login`, when you log the agent in from its card; one version per login | the agent, while it waits for a login | ❌ log in again | ✅ every 30 seconds while waiting |
|
||||||
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, every 5 minutes | `swarm-controller` and the hive | ✅ when missing or rejected by the homeserver | ✅ the hive re-reads on every sweep |
|
|
||||||
| `swarm/hives/<hive>/forge/token` | `swarm-controller`, every 5 minutes | `swarm-controller` and the hive | ✅ when missing or no longer matching the forge | ✅ read on every forge call |
|
| `swarm/hives/<hive>/forge/token` | `swarm-controller`, every 5 minutes | `swarm-controller` and the hive | ✅ when missing or no longer matching the forge | ✅ read on every forge call |
|
||||||
| `swarm/agents/<agent>/proxy-credential` | `swarm-controller`: 32 random bytes, at start and every 5 minutes for every agent that holds a store identity | the hive (`hive-c0re`) and the agent, which fetches it | ❌ never replaced: written only while the path holds nothing | ✅ `hive-c0re` re-reads it every 30 seconds |
|
| `swarm/agents/<agent>/proxy-credential` | `swarm-controller`: 32 random bytes, at start and every 5 minutes for every agent that holds a store identity | the hive (`hive-c0re`) and the agent, which fetches it | ❌ never replaced: written only while the path holds nothing | ✅ `hive-c0re` re-reads it every 30 seconds |
|
||||||
| `swarm/hives/<hive>/squid-selfcheck` | `swarm-controller`, in the same pass as the agents' credentials | the hive: `hive-c0re` and `squid-selfcheck-credential` (root) | ❌ never replaced: written only while the path holds nothing | ✅ `hive-c0re` every 30 seconds, the root unit every 10 minutes |
|
| `swarm/hives/<hive>/squid-selfcheck` | `swarm-controller`, in the same pass as the agents' credentials | the hive: `hive-c0re` and `squid-selfcheck-credential` (root) | ❌ never replaced: written only while the path holds nothing | ✅ `hive-c0re` every 30 seconds, the root unit every 10 minutes |
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,7 @@ a token.
|
||||||
not talk to matrix.
|
not talk to matrix.
|
||||||
5. **`swarm-controller` creates every account**: each agent's, with its
|
5. **`swarm-controller` creates every account**: each agent's, with its
|
||||||
token stored at `swarm/agents/<agent>/matrix/main` for the agent's
|
token stored at `swarm/agents/<agent>/matrix/main` for the agent's
|
||||||
`hive-matrix-daemon` to read as the agent itself, and each hive's
|
`hive-matrix-daemon` to read as the agent itself.
|
||||||
sender account (below).
|
|
||||||
|
|
||||||
A homeserver whose `swarm-matrix-ctl` has no store identity
|
A homeserver whose `swarm-matrix-ctl` has no store identity
|
||||||
(`services.hyperhive.deploy.matrix.ctlBaoClientCertFile`) loads no
|
(`services.hyperhive.deploy.matrix.ctlBaoClientCertFile`) loads no
|
||||||
|
|
@ -60,8 +59,7 @@ Until you do, `swarm-bao-matrix-oidc` fails naming both.
|
||||||
|
|
||||||
The registration's id is `swarm`, its sender `@swarm`, and its user
|
The registration's id is `swarm`, its sender `@swarm`, and its user
|
||||||
namespace non-exclusive. It's the swarm's identity on the homeserver:
|
namespace non-exclusive. It's the swarm's identity on the homeserver:
|
||||||
`swarm-controller` creates every agent's account and every hive's sender
|
`swarm-controller` creates every agent's account with it.
|
||||||
account with it.
|
|
||||||
|
|
||||||
The swarm secret store holds the registration's tokens, at
|
The swarm secret store holds the registration's tokens, at
|
||||||
`swarm/controller/swarm-controller/matrix/appservice-registration`.
|
`swarm/controller/swarm-controller/matrix/appservice-registration`.
|
||||||
|
|
@ -95,26 +93,14 @@ when it's missing, unknown to the homeserver, or someone else's. An
|
||||||
agent's daemon picks a re-minted token up on its next start, and a timer
|
agent's daemon picks a re-minted token up on its next start, and a timer
|
||||||
restarts it while it's down.
|
restarts it while it's down.
|
||||||
|
|
||||||
### A hive's sender account, and why it isn't an admin
|
### Hives have no matrix account
|
||||||
|
|
||||||
`@hive-<hive>:<server_name>` is the account a hive acts as.
|
Earlier versions created an `@hive-<hive>:<server_name>` sender account per
|
||||||
`swarm-controller` creates it with the swarm's appservice token, for every
|
hive and stored its token at `swarm/hives/<hive>/matrix/sender-token`.
|
||||||
hive in its directory. It's an **ordinary account**: nothing promotes it;
|
hive-c0re no longer talks to matrix, so `swarm-controller` no longer creates
|
||||||
the homeserver's `admin_execute` promotes only `@swarm`.
|
or refreshes either. Existing accounts and the stored tokens are harmless
|
||||||
|
leftovers: nothing reads them, and the `swarm-controller` policy has no grant
|
||||||
**One account per hive.** The localpart carries the hive's name, so a swarm
|
on the path.
|
||||||
whose hives share a homeserver gives each of them its own identity: the
|
|
||||||
homeserver attributes an action to the hive that took it, and revoking one
|
|
||||||
hive's standing leaves the others alone.
|
|
||||||
|
|
||||||
Its access token is the **sender token**. `swarm-controller`, its only
|
|
||||||
minter, mints it for every hive with the swarm's appservice token and
|
|
||||||
publishes it to `swarm/hives/<hive>/matrix/sender-token`, inside the hive's own
|
|
||||||
read grant (`swarm/hives/<hive>/*`). Nothing on the hive uses it any more:
|
|
||||||
hive-c0re used to present it for its Space and invite calls and no longer
|
|
||||||
makes any. The name says what it authenticates as — an account the
|
|
||||||
swarm's appservice creates — rather than any privilege level, because it
|
|
||||||
carries none.
|
|
||||||
|
|
||||||
Promoting a user to homeserver admin and resetting a password both need an
|
Promoting a user to homeserver admin and resetting a password both need an
|
||||||
admin **sender**: `!admin …` messages into `#admins:<server_name>`, and
|
admin **sender**: `!admin …` messages into `#admins:<server_name>`, and
|
||||||
|
|
@ -136,7 +122,7 @@ boot has both halves. With the store down or sealed, tuwunel waits for it.
|
||||||
`login_with_password` stays on, so the password fallback works
|
`login_with_password` stays on, so the password fallback works
|
||||||
too.
|
too.
|
||||||
- **The homeserver's `admin_execute` promotes only `@swarm` at boot**
|
- **The homeserver's `admin_execute` promotes only `@swarm` at boot**
|
||||||
(above); a hive's sender account is never promoted.
|
(above); no other swarm account is promoted.
|
||||||
- **The value that matters lives at
|
- **The value that matters lives at
|
||||||
`swarm/controller/swarm-controller/matrix/appservice-token`.**
|
`swarm/controller/swarm-controller/matrix/appservice-token`.**
|
||||||
`appservice sync` writes it there whenever the store's differs from the
|
`appservice sync` writes it there whenever the store's differs from the
|
||||||
|
|
|
||||||
|
|
@ -161,8 +161,8 @@ let
|
||||||
# ── the swarm's own appservice ──────────────────────────────────────
|
# ── the swarm's own appservice ──────────────────────────────────────
|
||||||
#
|
#
|
||||||
# The homeserver's one appservice registration, and the swarm's identity on
|
# The homeserver's one appservice registration, and the swarm's identity on
|
||||||
# it: `swarm-controller` creates every agent's account and every hive's
|
# it: `swarm-controller` creates every agent's account with its token. The
|
||||||
# `@hive-<hive>` sender with its token. The store holds its tokens;
|
# store holds its tokens;
|
||||||
# `swarm-matrix-ctl appservice render` fetches them into this container's
|
# `swarm-matrix-ctl appservice render` fetches them into this container's
|
||||||
# tmpfs before each homeserver start, and `appservice sync` keeps that copy
|
# tmpfs before each homeserver start, and `appservice sync` keeps that copy
|
||||||
# in step with them. Both are gated on matrix-ctl's store identity: with
|
# in step with them. Both are gated on matrix-ctl's store identity: with
|
||||||
|
|
@ -184,8 +184,9 @@ let
|
||||||
swarmAppserviceLegacyDir = "/var/lib/swarm-matrix-appservice";
|
swarmAppserviceLegacyDir = "/var/lib/swarm-matrix-appservice";
|
||||||
swarmAppserviceCredentialId = "swarm-appservice.yaml";
|
swarmAppserviceCredentialId = "swarm-appservice.yaml";
|
||||||
|
|
||||||
# Every local user the swarm appservice may create — agents and every
|
# Every local user the swarm appservice may create — agents, and the
|
||||||
# hive's `@hive-<hive>:` sender — which is the whole matrix localpart charset.
|
# `@hive-<hive>:` senders earlier versions made — which is the whole matrix
|
||||||
|
# localpart charset.
|
||||||
#
|
#
|
||||||
# ⚠️ Anchored deliberately: tuwunel compiles a namespace into a `RegexSet`
|
# ⚠️ Anchored deliberately: tuwunel compiles a namespace into a `RegexSet`
|
||||||
# and asks it for a MATCH, not a full match, so an unanchored
|
# and asks it for a MATCH, not a full match, so an unanchored
|
||||||
|
|
@ -1022,10 +1023,8 @@ in
|
||||||
}
|
}
|
||||||
# Exactly one account is promoted to homeserver admin at boot:
|
# Exactly one account is promoted to homeserver admin at boot:
|
||||||
# the swarm appservice's sender. Its token is read by
|
# the swarm appservice's sender. Its token is read by
|
||||||
# matrix-ctl and swarm-controller only. Each `@hive-<hive>`
|
# matrix-ctl and swarm-controller only. Every other account,
|
||||||
# stays an ordinary user, because its hive reads its
|
# agents' included, stays an ordinary user.
|
||||||
# credential; what it does (the invites) rides on its
|
|
||||||
# power in the room, not on admin.
|
|
||||||
#
|
#
|
||||||
# The registration load creates the sender inside
|
# The registration load creates the sender inside
|
||||||
# `Services::start()`, and `admin_execute` runs after that and
|
# `Services::start()`, and `admin_execute` runs after that and
|
||||||
|
|
|
||||||
|
|
@ -382,8 +382,8 @@ let
|
||||||
# instead of rotating it. `metadata/` is the revocation half: `delete` on
|
# instead of rotating it. `metadata/` is the revocation half: `delete` on
|
||||||
# `data/` only soft-deletes the newest version, and `+` being one path segment
|
# `data/` only soft-deletes the newest version, and `+` being one path segment
|
||||||
# keeps this to the queue leaf alone, and each linked account to its one leaf.
|
# keeps this to the queue leaf alone, and each linked account to its one leaf.
|
||||||
# Each hive's matrix sender token (`matrix_account::hive_sender`) grants `read`
|
# Each hive's forge token (`forge::hive_token`) grants `read` for the same
|
||||||
# for the same keep-if-live reason, and `+` for the same glob-only-as-last-segment reason.
|
# keep-if-live reason, and `+` for the same glob-only-as-last-segment reason.
|
||||||
#
|
#
|
||||||
# Four leaves it reads and never writes: the swarm appservice token (matrix-ctl),
|
# Four leaves it reads and never writes: the swarm appservice token (matrix-ctl),
|
||||||
# its OIDC client secret (the publisher), its forge token (the forge's host)
|
# its OIDC client secret (the publisher), its forge token (the forge's host)
|
||||||
|
|
@ -498,10 +498,6 @@ let
|
||||||
capabilities = ["delete"]
|
capabilities = ["delete"]
|
||||||
}
|
}
|
||||||
|
|
||||||
path "${credentialMountPath}/data/swarm/hives/+/matrix/sender-token" {
|
|
||||||
capabilities = ["create", "read", "update"]
|
|
||||||
}
|
|
||||||
|
|
||||||
path "${credentialMountPath}/data/swarm/hives/+/forge/token" {
|
path "${credentialMountPath}/data/swarm/hives/+/forge/token" {
|
||||||
capabilities = ["create", "read", "update"]
|
capabilities = ["create", "read", "update"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1862,21 +1862,19 @@ let
|
||||||
covers "hive-hives" && covers "hive-agent" && !(covers "swarm-hives");
|
covers "hive-hives" && covers "hive-agent" && !(covers "swarm-hives");
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
# `matrix_account::hive_sender` writes every hive's sender token and
|
# `forge::hive_token` writes every hive's forge token, and nothing else
|
||||||
# `forge::hive_token` every hive's forge token, and nothing else under
|
# under `hives/`, where each hive's queue credential also lives. `proxy_credential`
|
||||||
# `hives/`, where each hive's queue credential also lives. `proxy_credential`
|
|
||||||
# creates each hive's squid self-check credential and reads it back, and
|
# creates each hive's squid self-check credential and reads it back, and
|
||||||
# has no `update`, so it cannot replace one. `+` is one
|
# has no `update`, so it cannot replace one. `+` is one
|
||||||
# segment, which keeps each stanza to its one leaf; a `*` is a glob only
|
# segment, which keeps each stanza to its one leaf; a `*` is a glob only
|
||||||
# at the end of a path. Pinned as whole stanzas, so an added capability
|
# at the end of a path. Pinned as whole stanzas, so an added capability
|
||||||
# fails.
|
# fails.
|
||||||
name = "the controller writes each hive's sender and forge tokens, creates its squid self-check credential, manages its archive grants, and nothing else under hives";
|
name = "the controller writes each hive's forge token, creates its squid self-check credential, manages its archive grants, and nothing else under hives";
|
||||||
ok =
|
ok =
|
||||||
let
|
let
|
||||||
s = baoGrantHere.systemd.services.swarm-bao-controller-policy.script;
|
s = baoGrantHere.systemd.services.swarm-bao-controller-policy.script;
|
||||||
in
|
in
|
||||||
lib.hasInfix "path \"secret/data/swarm/hives/+/matrix/sender-token\" {\n capabilities = [\"create\", \"read\", \"update\"]\n}" s
|
lib.hasInfix "path \"secret/data/swarm/hives/+/forge/token\" {\n capabilities = [\"create\", \"read\", \"update\"]\n}" s
|
||||||
&& lib.hasInfix "path \"secret/data/swarm/hives/+/forge/token\" {\n capabilities = [\"create\", \"read\", \"update\"]\n}" s
|
|
||||||
&& lib.hasInfix "path \"secret/data/swarm/hives/+/squid-selfcheck\" {\n capabilities = [\"create\", \"read\"]\n}" s
|
&& lib.hasInfix "path \"secret/data/swarm/hives/+/squid-selfcheck\" {\n capabilities = [\"create\", \"read\"]\n}" s
|
||||||
&& !(lib.hasInfix "secret/data/swarm/hives/*" s)
|
&& !(lib.hasInfix "secret/data/swarm/hives/*" s)
|
||||||
# The only metadata it reaches under hives is the archive grants: list and delete.
|
# The only metadata it reaches under hives is the archive grants: list and delete.
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,7 @@
|
||||||
//! stored at `swarm/hives/<hive>/forge/token`, where hive-c0re reads it under
|
//! stored at `swarm/hives/<hive>/forge/token`, where hive-c0re reads it under
|
||||||
//! the hive's own store identity.
|
//! the hive's own store identity.
|
||||||
//!
|
//!
|
||||||
//! The roster is the controller's hive directory, as for
|
//! The roster is the controller's hive directory. The token decision is
|
||||||
//! `matrix_account::hive_sender`. The token decision is
|
|
||||||
//! [`super::agent_token`]'s, under this module's name and scopes, so a stored
|
//! [`super::agent_token`]'s, under this module's name and scopes, so a stored
|
||||||
//! token the forge still lists with the same last eight characters and scopes
|
//! token the forge still lists with the same last eight characters and scopes
|
||||||
//! is kept, and a pass whose reads fail queues nothing.
|
//! is kept, and a pass whose reads fail queues nothing.
|
||||||
|
|
|
||||||
|
|
@ -91,10 +91,6 @@ pub(crate) enum SwarmNodeKind {
|
||||||
/// Carries no hive: the path it writes has no hive segment, so an agent
|
/// Carries no hive: the path it writes has no hive segment, so an agent
|
||||||
/// that moves between hives keeps one matrix identity.
|
/// that moves between hives keeps one matrix identity.
|
||||||
MintAgentMatrixAccount { agent: String },
|
MintAgentMatrixAccount { agent: String },
|
||||||
/// Make sure `hive`'s sender account (`@hive-<hive>:`) holds a live token
|
|
||||||
/// in the swarm store, where that hive's matrix sweep reads it. See
|
|
||||||
/// `matrix_account::hive_sender`.
|
|
||||||
MintHiveSenderToken { hive: String },
|
|
||||||
/// Make sure `hive`'s read-only forge account (`hive-<hive>`) exists, is in
|
/// Make sure `hive`'s read-only forge account (`hive-<hive>`) exists, is in
|
||||||
/// the `hives` team, and holds a live token in the swarm store, where
|
/// the `hives` team, and holds a live token in the swarm store, where
|
||||||
/// that hive's hive-c0re reads it. See `forge::hive_token`.
|
/// that hive's hive-c0re reads it. See `forge::hive_token`.
|
||||||
|
|
@ -189,7 +185,6 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
|
||||||
SwarmNodeKind::MintAgentIdentity { .. } => "mint_agent_identity".to_owned(),
|
SwarmNodeKind::MintAgentIdentity { .. } => "mint_agent_identity".to_owned(),
|
||||||
SwarmNodeKind::MintAgentForgeToken { .. } => "mint_agent_forge_token".to_owned(),
|
SwarmNodeKind::MintAgentForgeToken { .. } => "mint_agent_forge_token".to_owned(),
|
||||||
SwarmNodeKind::MintAgentMatrixAccount { .. } => "mint_agent_matrix_account".to_owned(),
|
SwarmNodeKind::MintAgentMatrixAccount { .. } => "mint_agent_matrix_account".to_owned(),
|
||||||
SwarmNodeKind::MintHiveSenderToken { .. } => "mint_hive_sender_token".to_owned(),
|
|
||||||
SwarmNodeKind::MintHiveForgeToken { .. } => "mint_hive_forge_token".to_owned(),
|
SwarmNodeKind::MintHiveForgeToken { .. } => "mint_hive_forge_token".to_owned(),
|
||||||
SwarmNodeKind::MintClassifierForgeToken => "mint_classifier_forge_token".to_owned(),
|
SwarmNodeKind::MintClassifierForgeToken => "mint_classifier_forge_token".to_owned(),
|
||||||
SwarmNodeKind::MintBuilderForgeToken => "mint_builder_forge_token".to_owned(),
|
SwarmNodeKind::MintBuilderForgeToken => "mint_builder_forge_token".to_owned(),
|
||||||
|
|
@ -234,9 +229,9 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
|
||||||
SwarmNodeKind::SeedArchetypeConfig { agent, archetype } => {
|
SwarmNodeKind::SeedArchetypeConfig { agent, archetype } => {
|
||||||
serde_json::json!({ "agent": agent, "archetype": archetype.name })
|
serde_json::json!({ "agent": agent, "archetype": archetype.name })
|
||||||
}
|
}
|
||||||
SwarmNodeKind::MintHiveSenderToken { hive }
|
SwarmNodeKind::MintHiveForgeToken { hive } | SwarmNodeKind::CopyAtticFacts { hive } => {
|
||||||
| SwarmNodeKind::MintHiveForgeToken { hive }
|
serde_json::json!({ "hive": hive })
|
||||||
| SwarmNodeKind::CopyAtticFacts { hive } => serde_json::json!({ "hive": hive }),
|
}
|
||||||
SwarmNodeKind::MintClassifierForgeToken
|
SwarmNodeKind::MintClassifierForgeToken
|
||||||
| SwarmNodeKind::MintBuilderForgeToken
|
| SwarmNodeKind::MintBuilderForgeToken
|
||||||
| SwarmNodeKind::MintCiMetricsForgeToken
|
| SwarmNodeKind::MintCiMetricsForgeToken
|
||||||
|
|
@ -438,9 +433,6 @@ async fn run_node_work(
|
||||||
SwarmNodeKind::MintAgentMatrixAccount { agent } => {
|
SwarmNodeKind::MintAgentMatrixAccount { agent } => {
|
||||||
mint_matrix_account(deps.matrix_homeserver.as_deref(), &agent).await
|
mint_matrix_account(deps.matrix_homeserver.as_deref(), &agent).await
|
||||||
}
|
}
|
||||||
SwarmNodeKind::MintHiveSenderToken { hive } => {
|
|
||||||
mint_hive_sender(deps.matrix_homeserver.as_deref(), &hive).await
|
|
||||||
}
|
|
||||||
SwarmNodeKind::MintHiveForgeToken { hive } => mint_hive_forge_token(forge, &hive).await,
|
SwarmNodeKind::MintHiveForgeToken { hive } => mint_hive_forge_token(forge, &hive).await,
|
||||||
SwarmNodeKind::MintClassifierForgeToken => mint_classifier_forge_token(forge).await,
|
SwarmNodeKind::MintClassifierForgeToken => mint_classifier_forge_token(forge).await,
|
||||||
SwarmNodeKind::MintBuilderForgeToken => mint_builder_forge_token(forge).await,
|
SwarmNodeKind::MintBuilderForgeToken => mint_builder_forge_token(forge).await,
|
||||||
|
|
@ -1119,23 +1111,6 @@ async fn mint_matrix_account(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `MintHiveSenderToken` arm, lifted out for the same reason.
|
|
||||||
async fn mint_hive_sender(homeserver: Option<&str>, hive: &str) -> hive_jobq::scheduler::Outcome {
|
|
||||||
use hive_jobq::scheduler::Outcome;
|
|
||||||
|
|
||||||
let Some(base) = homeserver else {
|
|
||||||
return Outcome::Failed(format!(
|
|
||||||
"no matrix homeserver configured on this host ({} unset), so no hive \
|
|
||||||
sender token can be minted",
|
|
||||||
matrix_account::DEFAULT_HOMESERVER_ENV
|
|
||||||
));
|
|
||||||
};
|
|
||||||
match matrix_account::hive_sender::ensure_hive_sender_token(base, hive).await {
|
|
||||||
Ok(()) => Outcome::Done,
|
|
||||||
Err(e) => Outcome::Failed(format!("{e:#}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `SetAgentWanted` arm, lifted out so `run_node_work` stays under the line
|
/// The `SetAgentWanted` arm, lifted out so `run_node_work` stays under the line
|
||||||
/// limit: it fails without a wanted-state writer.
|
/// limit: it fails without a wanted-state writer.
|
||||||
async fn set_agent_wanted(
|
async fn set_agent_wanted(
|
||||||
|
|
@ -1724,27 +1699,6 @@ pub(crate) fn queue_matrix_account_mints(
|
||||||
Ok(ids)
|
Ok(ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert one `MintHiveSenderToken` job per hive and return the nodes' ids.
|
|
||||||
/// `matrix_account::hive_sender::spawn`'s periodic pass is the one caller.
|
|
||||||
pub(crate) fn queue_hive_sender_mints(
|
|
||||||
sched: &Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>,
|
|
||||||
hives: Vec<String>,
|
|
||||||
) -> Result<Vec<hive_jobq::NodeId>> {
|
|
||||||
let mut sched = sched
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
let mut ids = Vec::with_capacity(hives.len());
|
|
||||||
for hive in hives {
|
|
||||||
let queued = sched
|
|
||||||
.insert_job(None, |b| {
|
|
||||||
vec![b.node(SwarmNodeKind::MintHiveSenderToken { hive }).guid()]
|
|
||||||
})
|
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
||||||
ids.extend(queued);
|
|
||||||
}
|
|
||||||
Ok(ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert one `MintHiveForgeToken` job per hive and return the nodes' ids.
|
/// Insert one `MintHiveForgeToken` job per hive and return the nodes' ids.
|
||||||
/// `forge::hive_token::spawn`'s periodic pass is the one caller.
|
/// `forge::hive_token::spawn`'s periodic pass is the one caller.
|
||||||
pub(crate) fn queue_hive_forge_token_mints(
|
pub(crate) fn queue_hive_forge_token_mints(
|
||||||
|
|
@ -3970,37 +3924,6 @@ mod tests {
|
||||||
assert_eq!(agents, ["a", "b"]);
|
assert_eq!(agents, ["a", "b"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The hive sender-token pass's queueing: one mint node per hive, and
|
|
||||||
/// the node names the hive rather than an agent.
|
|
||||||
#[test]
|
|
||||||
fn a_queued_hive_sender_mint_is_one_node_per_hive() {
|
|
||||||
use hive_jobq_wire::WireNode as _;
|
|
||||||
|
|
||||||
let sched = std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
|
||||||
hive_jobq::Graph::new(),
|
|
||||||
hive_jobq::resources::ResourceTable::new(),
|
|
||||||
));
|
|
||||||
let ids =
|
|
||||||
super::queue_hive_sender_mints(&sched, vec!["alpha".to_owned(), "beta".to_owned()])
|
|
||||||
.expect("two jobs insert");
|
|
||||||
assert_eq!(ids.len(), 2);
|
|
||||||
let guard = sched
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
let mut hives: Vec<String> = guard
|
|
||||||
.graph()
|
|
||||||
.nodes()
|
|
||||||
.map(|n| {
|
|
||||||
assert_eq!(n.payload.label(), "mint_hive_sender_token");
|
|
||||||
let data = n.payload.data(n.id.get());
|
|
||||||
assert!(data.get("agent").is_none(), "{data}");
|
|
||||||
data["hive"].as_str().expect("hive is a string").to_owned()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
hives.sort();
|
|
||||||
assert_eq!(hives, ["alpha", "beta"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The hive forge-token pass's queueing: one node per hive, naming the
|
/// The hive forge-token pass's queueing: one node per hive, naming the
|
||||||
/// hive rather than an agent.
|
/// hive rather than an agent.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ async fn main() -> Result<()> {
|
||||||
hive_jobq::resources::ResourceTable::new(),
|
hive_jobq::resources::ResourceTable::new(),
|
||||||
)));
|
)));
|
||||||
let hives = load_hives();
|
let hives = load_hives();
|
||||||
spawn_matrix_account_backfill(&jobq, deps.matrix_homeserver.clone(), &hives, auth.clone());
|
spawn_matrix_account_backfill(&jobq, deps.matrix_homeserver.clone(), auth.clone());
|
||||||
spawn_jobq_worker(Arc::clone(&jobq), deps);
|
spawn_jobq_worker(Arc::clone(&jobq), deps);
|
||||||
// Bound to a named variable, not `_` — dropping the provider stops its
|
// Bound to a named variable, not `_` — dropping the provider stops its
|
||||||
// `PeriodicReader`, so it must live as long as `main` does (which it
|
// `PeriodicReader`, so it must live as long as `main` does (which it
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
//! the swarm's homeserver, minted with the swarm's appservice token and stored
|
//! the swarm's homeserver, minted with the swarm's appservice token and stored
|
||||||
//! where the agent's daemon reads it. See docs/4-implementation/swarm/credentials-reference.md for why
|
//! where the agent's daemon reads it. See docs/4-implementation/swarm/credentials-reference.md for why
|
||||||
//! `main` is the one name [`put_matrix_account`] refuses to write.
|
//! `main` is the one name [`put_matrix_account`] refuses to write.
|
||||||
//! [`hive_sender`] mints each **hive's** sender account the same way, sharing this module's helpers with [`agent_token`].
|
|
||||||
//! [`operator_admin`] makes authelia's operators homeserver admins with the same
|
//! [`operator_admin`] makes authelia's operators homeserver admins with the same
|
||||||
//! appservice token.
|
//! appservice token.
|
||||||
//! [`recovery`] mints the passphrase that opens each account's server-side key
|
//! [`recovery`] mints the passphrase that opens each account's server-side key
|
||||||
|
|
@ -38,7 +37,6 @@ use crate::routes::agents::swarm_hive;
|
||||||
use crate::routes::{AppState, error_problem};
|
use crate::routes::{AppState, error_problem};
|
||||||
|
|
||||||
pub mod agent_token;
|
pub mod agent_token;
|
||||||
pub mod hive_sender;
|
|
||||||
pub mod operator_admin;
|
pub mod operator_admin;
|
||||||
pub mod recovery;
|
pub mod recovery;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,195 +0,0 @@
|
||||||
//! Each hive's sender account, `@hive-<hive>:`, on the swarm's homeserver:
|
|
||||||
//! created here with the **swarm's** appservice token and stored at
|
|
||||||
//! `swarm/hives/<hive>/matrix/sender-token`, where hive-c0re's matrix sweep
|
|
||||||
//! reads it under the hive's own store identity.
|
|
||||||
//!
|
|
||||||
//! Runs for every hive in the directory, local or remote, and decides the
|
|
||||||
//! same way [`super::agent_token`] does: a stored token that `whoami`
|
|
||||||
//! confirms as `@hive-<hive>:` is kept, so this writes only when the path is
|
|
||||||
//! empty or its token is dead.
|
|
||||||
//!
|
|
||||||
//! This is the only writer of that path: hive-c0re never mints, it takes
|
|
||||||
//! whatever the store holds.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use swarm_matrix_client as homeserver;
|
|
||||||
use swarm_secret_client::matrix;
|
|
||||||
|
|
||||||
use super::agent_token::{Decision, Observed, appservice_token, classify, mint_at, plan, probe_at};
|
|
||||||
|
|
||||||
/// How often [`spawn`] re-checks every hive's sender token.
|
|
||||||
const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5);
|
|
||||||
|
|
||||||
/// Decide what to do about `hive`'s sender token from what the stored one is.
|
|
||||||
fn classify_hive(hive: &str, probe: &super::agent_token::Probe) -> Decision {
|
|
||||||
classify(&matrix::hive_localpart(hive), probe)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Make sure `hive`'s sender account holds a live token in the store,
|
|
||||||
/// creating the account or logging in to it when it does not. The whole job
|
|
||||||
/// of the `MintHiveSenderToken` node.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// When the store or the homeserver refuses a step, the swarm appservice
|
|
||||||
/// token has not been published, or the new token authenticates as someone
|
|
||||||
/// else.
|
|
||||||
pub async fn ensure_hive_sender_token(base: &str, hive: &str) -> Result<()> {
|
|
||||||
let store = crate::store::connect()
|
|
||||||
.await
|
|
||||||
.context("logging in to the swarm secret store")?;
|
|
||||||
let as_token = appservice_token(&store).await?;
|
|
||||||
let http = homeserver::client()?;
|
|
||||||
let path = matrix::sender_token_path(hive)?;
|
|
||||||
let reason = match classify_hive(hive, &probe_at(&store, &http, base, &path).await?) {
|
|
||||||
Decision::Keep => {
|
|
||||||
tracing::debug!(hive, "hive sender token is current; left as it is");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
Decision::Mint(reason) => reason,
|
|
||||||
};
|
|
||||||
let localpart = matrix::hive_localpart(hive);
|
|
||||||
mint_at(&store, &http, base, &localpart, &as_token, &path).await?;
|
|
||||||
tracing::info!(hive, ?reason, %path, "hive sender token minted and stored");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One pass: every hive in `hives`, observed.
|
|
||||||
///
|
|
||||||
/// Fails as a whole when the swarm appservice token is not published, for
|
|
||||||
/// the reason `agent_token`'s pass does.
|
|
||||||
async fn observe_all(base: &str, hives: &[String]) -> Result<Vec<(String, Observed)>> {
|
|
||||||
let store = crate::store::connect()
|
|
||||||
.await
|
|
||||||
.context("logging in to the swarm secret store")?;
|
|
||||||
appservice_token(&store).await?;
|
|
||||||
let http = homeserver::client()?;
|
|
||||||
let mut observed = Vec::with_capacity(hives.len());
|
|
||||||
for hive in hives {
|
|
||||||
let o = match matrix::sender_token_path(hive) {
|
|
||||||
Ok(path) => match probe_at(&store, &http, base, &path).await {
|
|
||||||
Ok(p) => Observed::Decided(classify_hive(hive, &p)),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(hive, error = %format!("{e:#}"), "hive sender token: read failed");
|
|
||||||
Observed::Unknown
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(hive, error = %e, "hive sender token: the hive name forms no store path");
|
|
||||||
Observed::Unknown
|
|
||||||
}
|
|
||||||
};
|
|
||||||
observed.push((hive.clone(), o));
|
|
||||||
}
|
|
||||||
Ok(observed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check every hive's sender token now and every [`RECONCILE_INTERVAL`]
|
|
||||||
/// after, and hand the hives that need one to `enqueue`, which inserts a
|
|
||||||
/// `MintHiveSenderToken` node for each.
|
|
||||||
///
|
|
||||||
/// The roster is the controller's hive directory. A pass that fails is
|
|
||||||
/// logged and retried on the next tick; it never stops the daemon.
|
|
||||||
pub fn spawn(base: Arc<str>, hives: Vec<String>, enqueue: impl Fn(Vec<String>) + Send + 'static) {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
|
|
||||||
loop {
|
|
||||||
ticker.tick().await;
|
|
||||||
match observe_all(&base, &hives).await {
|
|
||||||
Ok(observed) => {
|
|
||||||
let minting = plan(&observed);
|
|
||||||
if minting.is_empty() {
|
|
||||||
tracing::debug!(
|
|
||||||
checked = observed.len(),
|
|
||||||
"hive sender tokens: all current"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
tracing::info!(
|
|
||||||
checked = observed.len(),
|
|
||||||
minting = minting.len(),
|
|
||||||
"hive sender tokens: queueing mints"
|
|
||||||
);
|
|
||||||
enqueue(minting);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => tracing::warn!(
|
|
||||||
error = %format!("{e:#}"),
|
|
||||||
retry_in_s = RECONCILE_INTERVAL.as_secs(),
|
|
||||||
"hive sender tokens: pass failed; retrying next tick"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::super::agent_token::{MintReason, Probe};
|
|
||||||
use super::*;
|
|
||||||
use swarm_matrix_client::Whoami;
|
|
||||||
|
|
||||||
fn user(id: &str) -> Probe {
|
|
||||||
Probe::Whoami(Whoami::User(id.to_owned()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_live_token_for_the_hive_account_is_kept() {
|
|
||||||
assert_eq!(
|
|
||||||
classify_hive("pr1ma", &user("@hive-pr1ma:t.local")),
|
|
||||||
Decision::Keep
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nothing_stored_mints() {
|
|
||||||
// A hive whose homeserver is remote: nothing has ever written its path.
|
|
||||||
assert_eq!(
|
|
||||||
classify_hive("pr1ma", &Probe::NotStored),
|
|
||||||
Decision::Mint(MintReason::NotStored)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_dead_token_mints() {
|
|
||||||
// A token the homeserver revoked, or a device a manual login replaced.
|
|
||||||
assert_eq!(
|
|
||||||
classify_hive("pr1ma", &Probe::Whoami(Whoami::UnknownToken)),
|
|
||||||
Decision::Mint(MintReason::Revoked)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_token_for_another_account_mints() {
|
|
||||||
// The bare hive name is an agent's localpart, not the hive's account,
|
|
||||||
// and another hive's account is not this one's.
|
|
||||||
for other in ["@pr1ma:t.local", "@hive-beta:t.local", "@hive:t.local"] {
|
|
||||||
assert_eq!(
|
|
||||||
classify_hive("pr1ma", &user(other)),
|
|
||||||
Decision::Mint(MintReason::OtherUser),
|
|
||||||
"{other}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_outage_plans_nothing() {
|
|
||||||
// A mint replaces the token the hive is running on, so a pass that
|
|
||||||
// could not read must not mint for every hive.
|
|
||||||
let observed = [
|
|
||||||
("alpha".to_owned(), Observed::Unknown),
|
|
||||||
("beta".to_owned(), Observed::Unknown),
|
|
||||||
];
|
|
||||||
assert!(plan(&observed).is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_published_path_is_the_one_the_hive_reads() {
|
|
||||||
// hive-c0re's `stored_sender_token` resolves this path; the literal is
|
|
||||||
// what the bao grant names.
|
|
||||||
assert_eq!(
|
|
||||||
matrix::sender_token_path("pr1ma").expect("a plain name is legal"),
|
|
||||||
"swarm/hives/pr1ma/matrix/sender-token"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -21,8 +21,7 @@ use utoipa_axum::{router::OpenApiRouter, routes};
|
||||||
use crate::jobq::{
|
use crate::jobq::{
|
||||||
Late, SwarmNodeKind, SwarmResourceKind, queue_agent_renewals, queue_builder_forge_token_mint,
|
Late, SwarmNodeKind, SwarmResourceKind, queue_agent_renewals, queue_builder_forge_token_mint,
|
||||||
queue_ci_metrics_forge_token_mint, queue_classifier_forge_token_mint, queue_docs_grants,
|
queue_ci_metrics_forge_token_mint, queue_classifier_forge_token_mint, queue_docs_grants,
|
||||||
queue_forge_token_mints, queue_hive_forge_token_mints, queue_hive_sender_mints,
|
queue_forge_token_mints, queue_hive_forge_token_mints, queue_matrix_account_mints,
|
||||||
queue_matrix_account_mints,
|
|
||||||
};
|
};
|
||||||
use crate::routes::hives::{HiveEntry, ServiceLink};
|
use crate::routes::hives::{HiveEntry, ServiceLink};
|
||||||
use crate::routes::{self, AppState, agents, config_prs, forge_admin, hives, jobq_graph};
|
use crate::routes::{self, AppState, agents, config_prs, forge_admin, hives, jobq_graph};
|
||||||
|
|
@ -229,13 +228,12 @@ pub(crate) fn configured_matrix_homeserver() -> Option<Arc<str>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the recovery passphrase pass, which needs only the store; the
|
/// Start the recovery passphrase pass, which needs only the store; the
|
||||||
/// matrix-account backfill and the hive sender-token pass when a homeserver is
|
/// matrix-account backfill when a homeserver is configured; and the operator
|
||||||
/// configured; and the operator admin pass when the bridge is too. Lifted out
|
/// admin pass when the bridge is too. Lifted out of `main` for
|
||||||
/// of `main` for `clippy::too_many_lines`.
|
/// `clippy::too_many_lines`.
|
||||||
pub(crate) fn spawn_matrix_account_backfill(
|
pub(crate) fn spawn_matrix_account_backfill(
|
||||||
jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
|
jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
|
||||||
homeserver: Option<Arc<str>>,
|
homeserver: Option<Arc<str>>,
|
||||||
hives: &[HiveEntry],
|
|
||||||
auth: Option<Arc<auth::AuthBridge>>,
|
auth: Option<Arc<auth::AuthBridge>>,
|
||||||
) {
|
) {
|
||||||
matrix_account::recovery::spawn();
|
matrix_account::recovery::spawn();
|
||||||
|
|
@ -246,18 +244,11 @@ pub(crate) fn spawn_matrix_account_backfill(
|
||||||
matrix_account::operator_admin::spawn(bridge, Arc::clone(&base));
|
matrix_account::operator_admin::spawn(bridge, Arc::clone(&base));
|
||||||
}
|
}
|
||||||
let sched = Arc::clone(jobq);
|
let sched = Arc::clone(jobq);
|
||||||
matrix_account::agent_token::spawn(Arc::clone(&base), move |agents| {
|
matrix_account::agent_token::spawn(base, move |agents| {
|
||||||
if let Err(e) = queue_matrix_account_mints(&sched, agents) {
|
if let Err(e) = queue_matrix_account_mints(&sched, agents) {
|
||||||
tracing::warn!(error = %format!("{e:#}"), "agent matrix accounts: queueing failed");
|
tracing::warn!(error = %format!("{e:#}"), "agent matrix accounts: queueing failed");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let sched = Arc::clone(jobq);
|
|
||||||
let hive_names = hives.iter().map(|h| h.name.clone()).collect();
|
|
||||||
matrix_account::hive_sender::spawn(base, hive_names, move |hives| {
|
|
||||||
if let Err(e) = queue_hive_sender_mints(&sched, hives) {
|
|
||||||
tracing::warn!(error = %format!("{e:#}"), "hive sender tokens: queueing failed");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the forge's periodic passes — the swarm-wide objects, the runner
|
/// Start the forge's periodic passes — the swarm-wide objects, the runner
|
||||||
|
|
|
||||||
|
|
@ -480,37 +480,6 @@ fn whoami_session_verdict(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The ids of the rooms `token`'s user has joined.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// When the request cannot be sent, the response will not decode, the
|
|
||||||
/// homeserver refuses, or the answer carries no `joined_rooms`.
|
|
||||||
pub async fn joined_rooms(
|
|
||||||
client: &reqwest::Client,
|
|
||||||
base: &str,
|
|
||||||
token: &str,
|
|
||||||
) -> Result<Vec<String>> {
|
|
||||||
let (status, json) = get(
|
|
||||||
client,
|
|
||||||
&format!("{base}/_matrix/client/v3/joined_rooms"),
|
|
||||||
token,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.context("GET /joined_rooms")?;
|
|
||||||
if !status.is_success() {
|
|
||||||
bail!(
|
|
||||||
"the homeserver refused to list joined rooms: {}",
|
|
||||||
why(status, &json)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(json["joined_rooms"]
|
|
||||||
.as_array()
|
|
||||||
.context("the homeserver's answer carried no `joined_rooms`")?
|
|
||||||
.iter()
|
|
||||||
.filter_map(|room| room.as_str().map(str::to_owned))
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The content of `room_id`'s `event_type` state event under `state_key`
|
/// The content of `room_id`'s `event_type` state event under `state_key`
|
||||||
/// (empty for most), or `None` when the room has none.
|
/// (empty for most), or `None` when the room has none.
|
||||||
///
|
///
|
||||||
|
|
@ -546,37 +515,6 @@ pub async fn room_state(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set `room_id`'s `event_type` state event under `state_key` to `content`.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// When the request cannot be sent, the response will not decode, or the
|
|
||||||
/// homeserver refuses.
|
|
||||||
pub async fn put_state(
|
|
||||||
client: &reqwest::Client,
|
|
||||||
base: &str,
|
|
||||||
token: &str,
|
|
||||||
room_id: &str,
|
|
||||||
event_type: &str,
|
|
||||||
state_key: &str,
|
|
||||||
content: &serde_json::Value,
|
|
||||||
) -> Result<()> {
|
|
||||||
let (status, json) = exchange(
|
|
||||||
client
|
|
||||||
.put(state_url(base, room_id, event_type, state_key))
|
|
||||||
.bearer_auth(token)
|
|
||||||
.json(content),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("PUT {event_type} state into {room_id}"))?;
|
|
||||||
if !status.is_success() {
|
|
||||||
bail!(
|
|
||||||
"the homeserver refused to set {event_type} in {room_id}: {}",
|
|
||||||
why(status, &json)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The state-event URL for `room_id`, `event_type` and `state_key`.
|
/// The state-event URL for `room_id`, `event_type` and `state_key`.
|
||||||
fn state_url(base: &str, room_id: &str, event_type: &str, state_key: &str) -> String {
|
fn state_url(base: &str, room_id: &str, event_type: &str, state_key: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
|
|
@ -586,37 +524,6 @@ fn state_url(base: &str, room_id: &str, event_type: &str, state_key: &str) -> St
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a room from the `createRoom` request `body` and return its id.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// When the request cannot be sent, the response will not decode, the
|
|
||||||
/// homeserver refuses, or the answer carries no `room_id`.
|
|
||||||
pub async fn create_room(
|
|
||||||
client: &reqwest::Client,
|
|
||||||
base: &str,
|
|
||||||
token: &str,
|
|
||||||
body: &serde_json::Value,
|
|
||||||
) -> Result<String> {
|
|
||||||
let (status, json) = post(
|
|
||||||
client,
|
|
||||||
&format!("{base}/_matrix/client/v3/createRoom"),
|
|
||||||
token,
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.context("POST /createRoom")?;
|
|
||||||
if !status.is_success() {
|
|
||||||
bail!(
|
|
||||||
"the homeserver refused to create the room: {}",
|
|
||||||
why(status, &json)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
json["room_id"]
|
|
||||||
.as_str()
|
|
||||||
.map(str::to_owned)
|
|
||||||
.context("the homeserver's createRoom answer carried no `room_id`")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What the homeserver did with an invite.
|
/// What the homeserver did with an invite.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Invited {
|
pub enum Invited {
|
||||||
|
|
@ -1153,23 +1060,11 @@ mod tests {
|
||||||
async fn every_room_call_refuses_without_quoting_the_body() {
|
async fn every_room_call_refuses_without_quoting_the_body() {
|
||||||
let base = any_stub(500, LEAKY).await;
|
let base = any_stub(500, LEAKY).await;
|
||||||
let c = client().expect("a client");
|
let c = client().expect("a client");
|
||||||
let body = serde_json::json!({});
|
|
||||||
assert_refusal_hides_body(&joined_rooms(&c, &base, "t").await.expect_err("refused"));
|
|
||||||
assert_refusal_hides_body(
|
assert_refusal_hides_body(
|
||||||
&room_state(&c, &base, "t", "!r:x", "m.room.name", "")
|
&room_state(&c, &base, "t", "!r:x", "m.room.name", "")
|
||||||
.await
|
.await
|
||||||
.expect_err("refused"),
|
.expect_err("refused"),
|
||||||
);
|
);
|
||||||
assert_refusal_hides_body(
|
|
||||||
&put_state(&c, &base, "t", "!r:x", "m.room.name", "", &body)
|
|
||||||
.await
|
|
||||||
.expect_err("refused"),
|
|
||||||
);
|
|
||||||
assert_refusal_hides_body(
|
|
||||||
&create_room(&c, &base, "t", &body)
|
|
||||||
.await
|
|
||||||
.expect_err("refused"),
|
|
||||||
);
|
|
||||||
assert_refusal_hides_body(
|
assert_refusal_hides_body(
|
||||||
&invite(&c, &base, "t", "!r:x", "@a:x")
|
&invite(&c, &base, "t", "!r:x", "@a:x")
|
||||||
.await
|
.await
|
||||||
|
|
@ -1204,29 +1099,6 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn joined_rooms_and_create_room_read_their_fields() {
|
|
||||||
let c = client().expect("a client");
|
|
||||||
let base = any_stub(200, r#"{"joined_rooms":["!a:x","!b:x"]}"#).await;
|
|
||||||
assert_eq!(
|
|
||||||
joined_rooms(&c, &base, "t").await.expect("rooms"),
|
|
||||||
["!a:x", "!b:x"]
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
create_room(&c, &base, "t", &serde_json::json!({}))
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
let base = any_stub(200, r#"{"room_id":"!new:x"}"#).await;
|
|
||||||
assert_eq!(
|
|
||||||
create_room(&c, &base, "t", &serde_json::json!({}))
|
|
||||||
.await
|
|
||||||
.expect("room"),
|
|
||||||
"!new:x"
|
|
||||||
);
|
|
||||||
assert!(joined_rooms(&c, &base, "t").await.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_state_url_encodes_room_and_key_and_keeps_an_empty_key_slash() {
|
fn a_state_url_encodes_room_and_key_and_keeps_an_empty_key_slash() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,7 @@ pub fn agent_token_path(agent: &str) -> Result<String, Error> {
|
||||||
/// basic-auth header that token travels in.
|
/// basic-auth header that token travels in.
|
||||||
///
|
///
|
||||||
/// The `hive-` prefix keeps the account reading as a hive's rather than an
|
/// The `hive-` prefix keeps the account reading as a hive's rather than an
|
||||||
/// agent's, as [`crate::matrix::hive_localpart`] does on the homeserver.
|
/// agent's. `swarm-controller` refuses a new agent named with this prefix, and tells an
|
||||||
/// `swarm-controller` refuses a new agent named with this prefix, and tells an
|
|
||||||
/// older one apart from the hive's account by the account's email.
|
/// older one apart from the hive's account by the account's email.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn hive_login(hive: &str) -> String {
|
pub fn hive_login(hive: &str) -> String {
|
||||||
|
|
|
||||||
|
|
@ -70,44 +70,6 @@ impl std::fmt::Debug for Recovery {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The localpart `hive` acts as on the homeserver: the account
|
|
||||||
/// `swarm-controller` creates for it with the swarm's appservice token, and
|
|
||||||
/// the one hive-c0re's sender token belongs to.
|
|
||||||
///
|
|
||||||
/// **Derived from the hive's name, which is what makes it one account per
|
|
||||||
/// hive.** It used to be the bare constant `hive`: one swarm runs one
|
|
||||||
/// homeserver, so every hive on it logged in as the same `@hive:` and the
|
|
||||||
/// homeserver could not tell them apart — no attribution, and no way to revoke
|
|
||||||
/// one hive without revoking all of them.
|
|
||||||
///
|
|
||||||
/// The prefix is kept so the account still reads as a hive's rather than an
|
|
||||||
/// agent's. Both `swarm-controller` and hive-c0re derive it here, so the two
|
|
||||||
/// cannot disagree on which account a hive is.
|
|
||||||
///
|
|
||||||
/// Infallible on purpose: a hive name is `[a-z0-9-]` (`hive_types::Ident`) and
|
|
||||||
/// the appservice namespace regex admits that charset, so there is no
|
|
||||||
/// rejection arm to write. [`sender_token_path`] below does the checking, and
|
|
||||||
/// it is the one that interpolates into a store path.
|
|
||||||
#[must_use]
|
|
||||||
pub fn hive_localpart(hive: &str) -> String {
|
|
||||||
format!("hive-{hive}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The path holding `hive`'s matrix sender account's homeserver access token.
|
|
||||||
///
|
|
||||||
/// Keyed per **hive**, like [`crate::queue::hive_client_path`]. Under
|
|
||||||
/// [`Kind::Hive`] the grant that reaches it is the hive's **own** stanza,
|
|
||||||
/// `secret/data/swarm/hives/<this hive>/*`, which interpolates the name: hive
|
|
||||||
/// `alpha` reads its own token and gets a 403 on `beta`'s.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// [`Error::PathSegment`] when `hive` contains anything but `[A-Za-z0-9_-]`,
|
|
||||||
/// which is what keeps one hive's name from addressing another hive's secret.
|
|
||||||
pub fn sender_token_path(hive: &str) -> Result<String, Error> {
|
|
||||||
let prefix = principal_prefix(Kind::Hive, hive)?;
|
|
||||||
Ok(format!("{prefix}/matrix/sender-token"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The name segment of the controller's own subtree, and the cert-auth role it
|
/// The name segment of the controller's own subtree, and the cert-auth role it
|
||||||
/// logs in under (`swarm-controller`'s `store::CERT_ROLE`).
|
/// logs in under (`swarm-controller`'s `store::CERT_ROLE`).
|
||||||
pub(crate) const CONTROLLER: &str = "swarm-controller";
|
pub(crate) const CONTROLLER: &str = "swarm-controller";
|
||||||
|
|
@ -254,48 +216,6 @@ mod tests {
|
||||||
assert!(recovery_path("a-b_C9", "d-e_F0").is_ok());
|
assert!(recovery_path("a-b_C9", "d-e_F0").is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_sender_token_lands_under_the_hive_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/hives/<this hive>/*`, so
|
|
||||||
// this exact string is what makes the path reachable at all.
|
|
||||||
assert_eq!(
|
|
||||||
sender_token_path("pr1ma").expect("a plain name is legal"),
|
|
||||||
"swarm/hives/pr1ma/matrix/sender-token"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn one_hives_sender_token_is_not_another_hives() {
|
|
||||||
// The property the per-hive path exists for: two hives never name the
|
|
||||||
// same object, so the `hives/<name>/*` grant that reaches one cannot
|
|
||||||
// reach the other.
|
|
||||||
let a = sender_token_path("alpha").expect("legal");
|
|
||||||
let b = sender_token_path("beta").expect("legal");
|
|
||||||
assert_ne!(a, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_traversal_in_the_hive_name_is_refused_by_the_sender_path_too() {
|
|
||||||
let e = sender_token_path("../beta").expect_err("a traversal is not legal");
|
|
||||||
assert!(matches!(e, Error::PathSegment { kind: "hive", .. }), "{e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_localpart_is_derived_from_the_hive_name() {
|
|
||||||
// The literal is the point: an existing hive's account on the
|
|
||||||
// homeserver carries this localpart, so a change here strands it.
|
|
||||||
assert_eq!(hive_localpart("pr1ma"), "hive-pr1ma");
|
|
||||||
assert_ne!(
|
|
||||||
hive_localpart("pr1ma"),
|
|
||||||
hive_localpart("secunda"),
|
|
||||||
"two hives must not land on one account"
|
|
||||||
);
|
|
||||||
// And it is never the bare `hive` that used to be shared swarm-wide.
|
|
||||||
assert_ne!(hive_localpart("pr1ma"), "hive");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_swarm_appservice_token_lands_under_the_controller() {
|
fn the_swarm_appservice_token_lands_under_the_controller() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
|
|
@ -377,15 +377,15 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 🩸 The per-hive matrix sender-token scoping, asserted on the rendered
|
/// 🩸 The per-hive forge-token scoping, asserted on the rendered
|
||||||
/// document rather than on the path function alone: it is the policy text
|
/// document rather than on the path function alone: it is the policy text
|
||||||
/// that decides what a hive may fetch, so "the path is per-hive" only
|
/// that decides what a hive may fetch, so "the path is per-hive" only
|
||||||
/// means something if the stanza that reaches it is too.
|
/// means something if the stanza that reaches it is too.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_hive_reaches_its_own_matrix_sender_token_and_no_other_hives() {
|
fn a_hive_reaches_its_own_forge_token_and_no_other_hives() {
|
||||||
let alpha = effective_hive("alpha");
|
let alpha = effective_hive("alpha");
|
||||||
let own = crate::matrix::sender_token_path("alpha").expect("legal");
|
let own = crate::forge::hive_token_path("alpha").expect("legal");
|
||||||
let other = crate::matrix::sender_token_path("beta").expect("legal");
|
let other = crate::forge::hive_token_path("beta").expect("legal");
|
||||||
|
|
||||||
// Reachable: the hive's own stanza is the prefix of its own path.
|
// Reachable: the hive's own stanza is the prefix of its own path.
|
||||||
assert!(
|
assert!(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue