network.md listed matrix among the shared-netns containers and the private-netns list named only agents and the CI runner; it now says every privateNetwork container keeps its own firewall. The authelia module no longer calls the matrix container shared, and the hive-c0re comment on having no homeserver fallback no longer names port 8008. Part of #4815.
1184 lines
47 KiB
Rust
1184 lines
47 KiB
Rust
//! Optional matrix-tuwunel wiring: the hive's `@hive-<hive>:` sender token,
|
|
//! taken from the swarm secret store, and the hive Space, chat room and
|
|
//! invites provisioned with it. No-op when no homeserver is configured, so
|
|
//! operators who haven't flipped `services.hyperhive.deploy.matrix.enable =
|
|
//! true` pay nothing.
|
|
//!
|
|
//! This module creates no accounts: `swarm-controller` mints every hive's
|
|
//! sender account and every agent's account with the swarm's appservice token.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use anyhow::{Context, Result};
|
|
use reqwest::StatusCode;
|
|
|
|
/// Client-server API base this daemon provisions against, from
|
|
/// `HIVE_MATRIX_API_URL` (set by the hyperhive NixOS module from
|
|
/// `services.hyperhive.swarm.matrix.apiUrl`).
|
|
///
|
|
/// `None` means **this hive has no homeserver to provision against** and
|
|
/// every matrix path no-ops — see [`is_present`]. There is deliberately no
|
|
/// fallback: a loopback default is right only when the homeserver happens to
|
|
/// share this daemon's netns, and an address baked into the binary is one
|
|
/// that builds fine and then talks to the wrong machine. The nix module
|
|
/// defaults it to `https://` on the homeserver's gateway vhost, on every
|
|
/// hive whichever host runs tuwunel.
|
|
///
|
|
/// Also not an agent-facing address either way. An agent has its own netns;
|
|
/// agents are handed the gateway vhost via `HIVE_MATRIX_URL`, and get
|
|
/// nothing at all when the hive has no vhost to offer.
|
|
fn matrix_http() -> Option<&'static str> {
|
|
static BASE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
|
BASE.get_or_init(|| std::env::var("HIVE_MATRIX_API_URL").ok())
|
|
.as_deref()
|
|
}
|
|
|
|
/// [`matrix_http`] for the call sites that propagate with `?`.
|
|
///
|
|
/// # Errors
|
|
/// When no homeserver is configured. Reaching one of these paths at all
|
|
/// means an [`is_present`] gate was skipped, so the message names that
|
|
/// rather than the missing variable.
|
|
fn matrix_base() -> Result<&'static str> {
|
|
matrix_http().context(
|
|
"matrix: no homeserver configured \
|
|
(services.hyperhive.swarm.matrix.apiUrl / HIVE_MATRIX_API_URL) — \
|
|
this path should have been gated on matrix::is_present()",
|
|
)
|
|
}
|
|
/// HTTP timeout for the sweep's homeserver round-trips.
|
|
const HTTP_TIMEOUT_SECS: u64 = 10;
|
|
|
|
/// Matrix localpart this hive acts as. Not an agent; has no state dir.
|
|
///
|
|
/// **Per hive, derived from the hive's name** — `hive-<name>`, built by
|
|
/// `swarm_secret_client::matrix::hive_localpart`, which is the one place the
|
|
/// derivation lives. It used to be the bare constant `hive`, and a swarm runs
|
|
/// one homeserver: every hive on it logged in as the same `@hive:`, so the
|
|
/// homeserver could not attribute an action to a hive and revoking one hive's
|
|
/// standing revoked all of them.
|
|
///
|
|
/// `swarm-controller` creates this account with the swarm's appservice token
|
|
/// (`matrix_account::hive_sender`) and stores its token where
|
|
/// [`stored_sender_token`] reads it; hive-c0re never creates it.
|
|
///
|
|
/// An ordinary account, with no homeserver-admin standing: what it
|
|
/// provisions — the Space, the chat room, the invites — it provisions as
|
|
/// the creator of those rooms.
|
|
///
|
|
/// # Errors
|
|
/// When `HYPERHIVE_HIVE_NAME` is unset or empty. That is the same variable
|
|
/// [`stored_sender_token`] logs in with and `hive-c0re`'s meta flake asserts
|
|
/// for every hive, so its absence is a broken deployment rather than a
|
|
/// supported one — and there is no safe default to fall back to: the only one
|
|
/// available is the shared account this replaced.
|
|
pub fn hive_localpart() -> Result<String> {
|
|
let hive = std::env::var("HYPERHIVE_HIVE_NAME")
|
|
.ok()
|
|
.filter(|h| !h.is_empty())
|
|
.context(
|
|
"matrix: HYPERHIVE_HIVE_NAME is unset, so this hive has no matrix account of its own \
|
|
— the localpart is derived from the hive name and has no swarm-wide fallback",
|
|
)?;
|
|
Ok(swarm_secret_client::matrix::hive_localpart(&hive))
|
|
}
|
|
|
|
/// Display name of the hive Space. Plain text, no special characters, so
|
|
/// the Space stays rediscoverable by name (no room alias needed) even when
|
|
/// the persisted room-id file is lost — preventing duplicate spaces from
|
|
/// being created on the next sweep.
|
|
pub const HIVE_SPACE_NAME: &str = "hive";
|
|
|
|
/// Display name of the default "hive chat" room — the `m.space.child` of
|
|
/// the hive Space that every agent + the operator can join. Plain text so
|
|
/// it stays rediscoverable by name (mirrors [`HIVE_SPACE_NAME`]) when the
|
|
/// persisted room-id file is lost, preventing duplicate chat rooms.
|
|
pub const HIVE_CHAT_ROOM_NAME: &str = "hive-chat";
|
|
|
|
/// Topic for the default hive chat room.
|
|
const HIVE_CHAT_ROOM_TOPIC: &str =
|
|
"Hive-wide chat for all agents and the operator. Auto-provisioned by hive-c0re.";
|
|
|
|
/// Host path for the appservice sender account's matrix access token. Outside every
|
|
/// purgeable path — not deleted by `destroy --purge` on any agent.
|
|
#[must_use]
|
|
pub fn sender_token_path() -> PathBuf {
|
|
crate::paths::matrix_sender_token()
|
|
}
|
|
|
|
/// Host path where the hive Matrix Space room ID is persisted.
|
|
/// Outside every purgeable path — not deleted by `destroy --purge`.
|
|
#[must_use]
|
|
pub fn hive_space_room_id_path() -> PathBuf {
|
|
crate::paths::matrix_space_room_id()
|
|
}
|
|
|
|
/// Host path where the default hive chat room ID is persisted. Outside
|
|
/// every purgeable path — not deleted by `destroy --purge`.
|
|
#[must_use]
|
|
pub fn hive_chat_room_id_path() -> PathBuf {
|
|
crate::paths::matrix_chat_room_id()
|
|
}
|
|
|
|
/// Whether this hive has a homeserver to provision against.
|
|
///
|
|
/// **A configured API URL, not a local container**: whether the homeserver
|
|
/// runs on this host is a different question, and a remote homeserver is
|
|
/// provisioned through the same URL.
|
|
#[must_use]
|
|
pub fn is_present() -> bool {
|
|
matrix_http().is_some()
|
|
}
|
|
|
|
/// Percent-encode a matrix room ID for use in a URL path segment.
|
|
/// Only `:` needs encoding; `!` and alphanumerics are path-safe.
|
|
fn encode_room_id_for_url(room_id: &str) -> String {
|
|
room_id.replace(':', "%3A")
|
|
}
|
|
|
|
/// Bring the hive's `@hive-<hive>:` sender token at [`sender_token_path()`] in
|
|
/// line with the swarm secret store.
|
|
///
|
|
/// `swarm-controller` is the only minter: it creates the account with the
|
|
/// swarm's appservice token, keeps the stored token while it is live and
|
|
/// replaces it when it is not. The file follows the store, and is kept as it
|
|
/// is when the store has nothing or cannot be reached, so a store outage does
|
|
/// not take the hive's matrix provisioning down with it.
|
|
///
|
|
/// # Errors
|
|
/// When neither the store nor the file holds a token, or the file write fails.
|
|
pub async fn ensure_hive_user() -> Result<()> {
|
|
let path = sender_token_path();
|
|
let on_disk = std::fs::read_to_string(&path).ok();
|
|
let stored = stored_sender_token().await;
|
|
match sender_source(stored.as_deref(), on_disk.as_deref()) {
|
|
SenderSource::Keep => {
|
|
tracing::debug!("matrix: the sender token is already present");
|
|
Ok(())
|
|
}
|
|
SenderSource::Store(token) => persist_sender_token(&path, token),
|
|
SenderSource::Unavailable => anyhow::bail!(
|
|
"matrix: no sender token in the swarm store or at {}; swarm-controller \
|
|
mints it into the store",
|
|
path.display()
|
|
),
|
|
}
|
|
}
|
|
|
|
/// What [`ensure_hive_user`] does about the sender token this sweep.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum SenderSource<'a> {
|
|
/// The file already holds the token to use.
|
|
Keep,
|
|
/// Write the store's token to the file.
|
|
Store(&'a str),
|
|
/// No token in the store or the file.
|
|
Unavailable,
|
|
}
|
|
|
|
/// Decide [`ensure_hive_user`]'s step from the store's token and the file's
|
|
/// content, each `None` when absent. Blank counts as absent.
|
|
fn sender_source<'a>(stored: Option<&'a str>, on_disk: Option<&str>) -> SenderSource<'a> {
|
|
let present = |s: &&str| !s.trim().is_empty();
|
|
let stored = stored.map(str::trim).filter(present);
|
|
let on_disk = on_disk.map(str::trim).filter(present);
|
|
match (stored, on_disk) {
|
|
(Some(s), Some(d)) if s == d => SenderSource::Keep,
|
|
(Some(s), _) => SenderSource::Store(s),
|
|
(None, Some(_)) => SenderSource::Keep,
|
|
(None, None) => SenderSource::Unavailable,
|
|
}
|
|
}
|
|
|
|
/// Write the appservice sender account's access token to `path`, 0600, creating the
|
|
/// directory if it is not there. The file's mode is the only thing keeping an
|
|
/// unprivileged reader off the hive's matrix credential.
|
|
fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()> {
|
|
crate::secret_file::write_secret_0600(path, format!("{access_token}\n").as_bytes())
|
|
.with_context(|| format!("matrix: write the sender token to {}", path.display()))?;
|
|
tracing::info!(path = %path.display(), "matrix: provisioned the sender token");
|
|
Ok(())
|
|
}
|
|
|
|
/// Fetch the sender token `swarm-controller` published, under this hive's own
|
|
/// store identity.
|
|
///
|
|
/// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` —
|
|
/// the same role string `lifecycle::agent_identity` logs in with, and already in
|
|
/// this process's environment, so the store read costs no plumbing through
|
|
/// [`ensure_all`]. That name is also the path's own hive segment, which is
|
|
/// what makes this read reach **this** hive's token and 403 on any other's:
|
|
/// the grant behind it is the hive's own `swarm/hives/<name>/*` stanza.
|
|
///
|
|
/// `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 ("keep the file"), and three of
|
|
/// them are the ordinary state of a swarm with no store token for this hive
|
|
/// yet, so raising would turn a supported deployment into a warning every
|
|
/// sweep.
|
|
///
|
|
/// 🩸 Logs the store **path** and never the value.
|
|
async fn stored_sender_token() -> Option<String> {
|
|
let hive = std::env::var("HYPERHIVE_HIVE_NAME")
|
|
.ok()
|
|
.filter(|h| !h.is_empty())?;
|
|
let path = match swarm_secret_client::matrix::sender_token_path(&hive) {
|
|
Ok(path) => path,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "matrix: this hive's name does not form a store path");
|
|
return None;
|
|
}
|
|
};
|
|
let store = match swarm_secret_client::SecretStore::from_env(&hive).await {
|
|
Ok(store) => store,
|
|
Err(e) => {
|
|
tracing::debug!(error = %e, "matrix: no swarm secret store to read the sender token from");
|
|
return None;
|
|
}
|
|
};
|
|
match store
|
|
.read::<swarm_secret_client::matrix::Credential>(&path)
|
|
.await
|
|
{
|
|
Ok(credential) if !credential.value.trim().is_empty() => {
|
|
tracing::debug!(%path, "matrix: read the sender token from the swarm store");
|
|
Some(credential.value)
|
|
}
|
|
Ok(_) => {
|
|
tracing::warn!(%path, "matrix: the stored sender token is empty");
|
|
None
|
|
}
|
|
Err(e) => {
|
|
tracing::debug!(%path, error = %e, "matrix: no sender token in the store");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Discover the matrix `server_name` from the running homeserver via
|
|
/// `GET /_matrix/key/v2/server` (unauthenticated federation key endpoint).
|
|
/// The response JSON always includes `"server_name"` per the matrix spec.
|
|
pub async fn discover_server_name(client: &reqwest::Client) -> Result<String> {
|
|
let base = matrix_base()?;
|
|
let url = format!("{base}/_matrix/key/v2/server");
|
|
let resp = client
|
|
.get(&url)
|
|
.send()
|
|
.await
|
|
.context("matrix: GET /_matrix/key/v2/server")?;
|
|
let status = resp.status();
|
|
let body = resp
|
|
.json::<serde_json::Value>()
|
|
.await
|
|
.context("matrix: parse /_matrix/key/v2/server response")?;
|
|
if !status.is_success() {
|
|
anyhow::bail!("matrix: /_matrix/key/v2/server returned HTTP {status}, body: {body}");
|
|
}
|
|
body["server_name"]
|
|
.as_str()
|
|
.map(str::to_owned)
|
|
.with_context(|| {
|
|
format!("matrix: /_matrix/key/v2/server response missing server_name field: {body}")
|
|
})
|
|
}
|
|
|
|
/// Read the sender token from disk. Returns an error if it
|
|
/// is absent — callers should gate their homeserver calls on this.
|
|
pub fn read_sender_token() -> Result<String> {
|
|
let path = sender_token_path();
|
|
std::fs::read_to_string(&path)
|
|
.ok()
|
|
.map(|s| s.trim().to_owned())
|
|
.filter(|s| !s.is_empty())
|
|
.with_context(|| {
|
|
format!(
|
|
"the matrix sender token was not found at {} — \
|
|
ensure hive-c0re has started at least once with matrix enabled \
|
|
(it provisions the hive's @hive-<hive>: account on boot)",
|
|
path.display()
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Persist the hive Space room id to [`hive_space_room_id_path()`] (0600).
|
|
fn persist_space_room_id(room_id: &str) -> Result<()> {
|
|
let path = hive_space_room_id_path();
|
|
crate::secret_file::write_secret_0600(&path, format!("{room_id}\n").as_bytes())
|
|
.with_context(|| format!("matrix: write space room_id to {}", path.display()))
|
|
}
|
|
|
|
/// Scan the admin account's joined rooms for the canonical hive Space: the
|
|
/// `m.space` whose name is [`HIVE_SPACE_NAME`]. Returns the first match
|
|
/// (deterministic per the homeserver's joined-rooms order) so a lost
|
|
/// room-id file recovers the existing space instead of spawning a
|
|
/// duplicate. `None` if the homeserver is unreachable or no match exists.
|
|
///
|
|
/// Name-based (not alias-based) rediscovery keeps the Space free of any
|
|
/// special-char room alias — the hardcoded plain name is the anchor.
|
|
async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Option<String> {
|
|
let base = matrix_http()?;
|
|
let joined_url = format!("{base}/_matrix/client/v3/joined_rooms");
|
|
let joined: serde_json::Value = client
|
|
.get(&joined_url)
|
|
.bearer_auth(sender_token)
|
|
.send()
|
|
.await
|
|
.ok()?
|
|
.json()
|
|
.await
|
|
.ok()?;
|
|
let rooms = joined["joined_rooms"].as_array()?;
|
|
for room in rooms {
|
|
let Some(room_id) = room.as_str() else {
|
|
continue;
|
|
};
|
|
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(sender_token)
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(r) if r.status().is_success() => r
|
|
.json::<serde_json::Value>()
|
|
.await
|
|
.ok()
|
|
.is_some_and(|c| c["type"].as_str() == Some("m.space")),
|
|
_ => false,
|
|
};
|
|
if !is_space {
|
|
continue;
|
|
}
|
|
// …and named HIVE_SPACE_NAME (m.room.name `name`).
|
|
let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/");
|
|
let name_matches = match client.get(&name_url).bearer_auth(sender_token).send().await {
|
|
Ok(r) if r.status().is_success() => r
|
|
.json::<serde_json::Value>()
|
|
.await
|
|
.ok()
|
|
.is_some_and(|n| n["name"].as_str() == Some(HIVE_SPACE_NAME)),
|
|
_ => false,
|
|
};
|
|
if name_matches {
|
|
return Some(room_id.to_owned());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Create (or recover) the hive Matrix Space and persist its room ID to
|
|
/// [`hive_space_room_id_path()`]. The Space is a private `m.space` owned by
|
|
/// `@hive-<hive>:`, identified by its hardcoded name [`HIVE_SPACE_NAME`] (no alias).
|
|
///
|
|
/// Dedup strategy (single canonical space):
|
|
/// 1. If the room-id file exists, reuse it.
|
|
/// 2. Otherwise, rediscover by scanning the admin's joined rooms for the
|
|
/// `m.space` named [`HIVE_SPACE_NAME`] and adopt it (re-persisting the
|
|
/// file). This recovers the existing space after a state wipe instead
|
|
/// of creating a duplicate.
|
|
/// 3. Only if neither yields a room do we `createRoom`.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if the homeserver is unreachable, `createRoom` fails,
|
|
/// or the room-ID file cannot be written.
|
|
pub async fn ensure_hive_space(client: &reqwest::Client, sender_token: &str) -> Result<String> {
|
|
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()) {
|
|
let trimmed = existing.trim().to_owned();
|
|
if !trimmed.is_empty() {
|
|
tracing::debug!(room_id = %trimmed, "matrix: hive space already provisioned");
|
|
return Ok(trimmed);
|
|
}
|
|
}
|
|
|
|
// 2. No stored id — rediscover the existing space by its hardcoded name
|
|
// before creating a new one (prevents duplicate spaces after a wipe).
|
|
if let Some(room_id) = find_space_by_name(client, sender_token).await {
|
|
persist_space_room_id(&room_id)?;
|
|
tracing::info!(%room_id, "matrix: recovered hive space by name");
|
|
return Ok(room_id);
|
|
}
|
|
|
|
// 3. Create the space (plain hardcoded name, no alias).
|
|
let url = format!("{base}/_matrix/client/v3/createRoom");
|
|
let body = serde_json::json!({
|
|
"name": HIVE_SPACE_NAME,
|
|
"creation_content": { "type": "m.space" },
|
|
"preset": "private_chat",
|
|
"visibility": "private",
|
|
});
|
|
let resp = client
|
|
.post(&url)
|
|
.bearer_auth(sender_token)
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.context("matrix: POST /createRoom (hive space)")?;
|
|
let status = resp.status();
|
|
let json = resp
|
|
.json::<serde_json::Value>()
|
|
.await
|
|
.context("matrix: parse /createRoom response")?;
|
|
if !status.is_success() {
|
|
anyhow::bail!("matrix: createRoom HTTP {status}, body: {json}");
|
|
}
|
|
let room_id = json["room_id"]
|
|
.as_str()
|
|
.with_context(|| format!("matrix: createRoom missing room_id: {json}"))?
|
|
.to_owned();
|
|
persist_space_room_id(&room_id)?;
|
|
tracing::info!(%room_id, "matrix: created hive space");
|
|
Ok(room_id)
|
|
}
|
|
|
|
/// Persist the hive chat room id to [`hive_chat_room_id_path()`] (0600).
|
|
fn persist_chat_room_id(room_id: &str) -> Result<()> {
|
|
let path = hive_chat_room_id_path();
|
|
crate::secret_file::write_secret_0600(&path, format!("{room_id}\n").as_bytes())
|
|
.with_context(|| format!("matrix: write chat room_id to {}", path.display()))
|
|
}
|
|
|
|
/// Scan the admin account's joined rooms for the canonical hive chat room:
|
|
/// a non-space room named [`HIVE_CHAT_ROOM_NAME`]. Mirrors
|
|
/// [`find_space_by_name`] so a lost room-id file recovers the existing chat
|
|
/// room instead of spawning a duplicate. `None` if the homeserver is
|
|
/// unreachable or no match exists.
|
|
async fn find_chat_room_by_name(client: &reqwest::Client, sender_token: &str) -> Option<String> {
|
|
let base = matrix_http()?;
|
|
let joined_url = format!("{base}/_matrix/client/v3/joined_rooms");
|
|
let joined: serde_json::Value = client
|
|
.get(&joined_url)
|
|
.bearer_auth(sender_token)
|
|
.send()
|
|
.await
|
|
.ok()?
|
|
.json()
|
|
.await
|
|
.ok()?;
|
|
let rooms = joined["joined_rooms"].as_array()?;
|
|
for room in rooms {
|
|
let Some(room_id) = room.as_str() else {
|
|
continue;
|
|
};
|
|
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(sender_token)
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(r) if r.status().is_success() => r
|
|
.json::<serde_json::Value>()
|
|
.await
|
|
.ok()
|
|
.is_some_and(|c| c["type"].as_str() == Some("m.space")),
|
|
_ => false,
|
|
};
|
|
if is_space {
|
|
continue;
|
|
}
|
|
// …and named HIVE_CHAT_ROOM_NAME (m.room.name `name`).
|
|
let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/");
|
|
let name_matches = match client.get(&name_url).bearer_auth(sender_token).send().await {
|
|
Ok(r) if r.status().is_success() => r
|
|
.json::<serde_json::Value>()
|
|
.await
|
|
.ok()
|
|
.is_some_and(|n| n["name"].as_str() == Some(HIVE_CHAT_ROOM_NAME)),
|
|
_ => false,
|
|
};
|
|
if name_matches {
|
|
return Some(room_id.to_owned());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Whether the state event still has to be written.
|
|
///
|
|
/// `current` is the room's existing content for this `(type, state_key)`,
|
|
/// or `None` when it could not be read. **Unreadable means write**: the
|
|
/// re-apply exists so a missing link gets repaired, and one redundant
|
|
/// event is cheaper than a hierarchy that never reconverges.
|
|
///
|
|
/// Comparison is `serde_json::Value` equality, which compares objects as
|
|
/// maps — key order in the response does not matter.
|
|
fn state_needs_write(current: Option<&serde_json::Value>, desired: &serde_json::Value) -> bool {
|
|
current != Some(desired)
|
|
}
|
|
|
|
/// Read the room's current content for one state event. `None` on any
|
|
/// failure — absent state, transport error, or an unparseable body are
|
|
/// all "we don't know", and [`state_needs_write`] turns that into a write.
|
|
///
|
|
/// One failure is louder than the rest, and it is the only one that hides:
|
|
/// **a non-404 HTTP failure means the read broke while the write still
|
|
/// works**, so the guard degrades to writing every time and the sweep
|
|
/// resumes waking the hive with nothing else to show for it. A transport
|
|
/// error needs no warning of its own — the PUT immediately after it fails
|
|
/// too, loudly — and a 404 is the expected first-setup case.
|
|
async fn current_room_state(
|
|
client: &reqwest::Client,
|
|
sender_token: &str,
|
|
url: &str,
|
|
) -> Option<serde_json::Value> {
|
|
let resp = match client.get(url).bearer_auth(sender_token).send().await {
|
|
Ok(resp) => resp,
|
|
Err(e) => {
|
|
tracing::debug!(error = ?e, url, "matrix: state read unreachable; writing");
|
|
return None;
|
|
}
|
|
};
|
|
let status = resp.status();
|
|
if !status.is_success() {
|
|
if status != reqwest::StatusCode::NOT_FOUND {
|
|
tracing::warn!(
|
|
%status,
|
|
url,
|
|
"matrix: state read failed while writes still work — the \
|
|
skip-if-unchanged guard is off and every sweep will re-emit"
|
|
);
|
|
}
|
|
return None;
|
|
}
|
|
match resp.json::<serde_json::Value>().await {
|
|
Ok(body) => Some(body),
|
|
Err(e) => {
|
|
tracing::debug!(error = ?e, url, "matrix: state read body unparseable; writing");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// PUT a state event into `room_id` using the sender 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
|
|
/// on the room's *state*, and the homeserver still appends an event to the
|
|
/// *timeline* — so "idempotent" was true one level too high. Downstream,
|
|
/// an event is unread activity, which is a todo, which is a turn: a caller
|
|
/// re-applying a link on a periodic sweep wakes every agent in the room on
|
|
/// that sweep's cadence, forever. Measured at ~30 minutes per wake per
|
|
/// agent before this guard existed.
|
|
///
|
|
/// The self-healing property the re-apply exists for is unaffected: a
|
|
/// missing or divergent link still gets written.
|
|
async fn set_room_state(
|
|
client: &reqwest::Client,
|
|
sender_token: &str,
|
|
room_id: &str,
|
|
event_type: &str,
|
|
state_key: &str,
|
|
content: &serde_json::Value,
|
|
) -> Result<()> {
|
|
let base = matrix_base()?;
|
|
let encoded_room = encode_room_id_for_url(room_id);
|
|
let encoded_key = encode_room_id_for_url(state_key);
|
|
let url =
|
|
format!("{base}/_matrix/client/v3/rooms/{encoded_room}/state/{event_type}/{encoded_key}");
|
|
let current = current_room_state(client, sender_token, &url).await;
|
|
if !state_needs_write(current.as_ref(), content) {
|
|
tracing::debug!(
|
|
%room_id,
|
|
event_type,
|
|
"matrix: state already current, skipping PUT (no timeline event)"
|
|
);
|
|
return Ok(());
|
|
}
|
|
let resp = client
|
|
.put(&url)
|
|
.bearer_auth(sender_token)
|
|
.json(content)
|
|
.send()
|
|
.await
|
|
.with_context(|| format!("matrix: PUT state {event_type} into {room_id}"))?;
|
|
let status = resp.status();
|
|
if status.is_success() {
|
|
return Ok(());
|
|
}
|
|
let body = resp.json::<serde_json::Value>().await.unwrap_or_default();
|
|
anyhow::bail!("matrix: set state {event_type} in {room_id}: HTTP {status}, body: {body}")
|
|
}
|
|
|
|
/// Create (or recover) the default hive chat room and wire it as a child of
|
|
/// the hive Space, persisting its room id to [`hive_chat_room_id_path()`].
|
|
///
|
|
/// The room is a normal room (not an `m.space`) named [`HIVE_CHAT_ROOM_NAME`]
|
|
/// with a `restricted` join rule allowing any member of the hive Space to
|
|
/// join — so the operator (who is in the Space) and every agent can chat
|
|
/// without needing an explicit invite. It's linked bidirectionally to the
|
|
/// Space: `m.space.child` on the Space points at the room, `m.space.parent`
|
|
/// on the room points back. Joining a Space does NOT auto-join its children
|
|
/// (Matrix semantics), so this gives clients a concrete room to surface +
|
|
/// join instead of an empty Space.
|
|
///
|
|
/// Dedup mirrors [`ensure_hive_space`]: persisted id wins, else rediscover
|
|
/// by name, else create. The space-child link is re-checked on every call
|
|
/// so a recovered room reconverges its hierarchy link — and written only
|
|
/// when it differs, see [`set_room_state`].
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if the homeserver is unreachable, `createRoom` fails,
|
|
/// or the room-ID file cannot be written. A failure wiring the space-child
|
|
/// link is logged but not fatal (the room still exists + is joinable).
|
|
pub async fn ensure_hive_chat_room(
|
|
client: &reqwest::Client,
|
|
sender_token: &str,
|
|
space_room_id: &str,
|
|
server_name: &str,
|
|
) -> Result<String> {
|
|
let base = matrix_base()?;
|
|
// 1. Stored room id wins (fast path). 2. Rediscover by name before
|
|
// creating (prevents duplicates after a state wipe). 3. Create.
|
|
let room_id = if let Some(id) = std::fs::read_to_string(hive_chat_room_id_path())
|
|
.ok()
|
|
.map(|s| s.trim().to_owned())
|
|
.filter(|s| !s.is_empty())
|
|
{
|
|
tracing::debug!(room_id = %id, "matrix: hive chat room already provisioned");
|
|
id
|
|
} else if let Some(id) = find_chat_room_by_name(client, sender_token).await {
|
|
persist_chat_room_id(&id)?;
|
|
tracing::info!(room_id = %id, "matrix: recovered hive chat room by name");
|
|
id
|
|
} else {
|
|
// `initial_state` is applied after the preset-derived state, so the
|
|
// restricted join rule overrides private_chat's invite-only default.
|
|
let url = format!("{base}/_matrix/client/v3/createRoom");
|
|
let body = serde_json::json!({
|
|
"name": HIVE_CHAT_ROOM_NAME,
|
|
"topic": HIVE_CHAT_ROOM_TOPIC,
|
|
// Pin the room version explicitly: the `restricted` join rule
|
|
// below needs room version >= 8. tuwunel's default is currently
|
|
// higher, but pinning makes the dependency explicit so a future
|
|
// homeserver-default change can't silently invalidate the
|
|
// restricted rule (which would quietly fall back to invite-only
|
|
// and break the "operator joins from the Space" path).
|
|
"room_version": "10",
|
|
"preset": "private_chat",
|
|
"visibility": "private",
|
|
"initial_state": [
|
|
{
|
|
"type": "m.room.join_rules",
|
|
"state_key": "",
|
|
"content": {
|
|
"join_rule": "restricted",
|
|
"allow": [
|
|
{ "type": "m.room_membership", "room_id": space_room_id }
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"type": "m.space.parent",
|
|
"state_key": space_room_id,
|
|
"content": { "via": [server_name], "canonical": true }
|
|
}
|
|
]
|
|
});
|
|
let resp = client
|
|
.post(&url)
|
|
.bearer_auth(sender_token)
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.context("matrix: POST /createRoom (hive chat room)")?;
|
|
let status = resp.status();
|
|
let json = resp
|
|
.json::<serde_json::Value>()
|
|
.await
|
|
.context("matrix: parse /createRoom response (chat room)")?;
|
|
if !status.is_success() {
|
|
anyhow::bail!("matrix: createRoom (chat) HTTP {status}, body: {json}");
|
|
}
|
|
let id = json["room_id"]
|
|
.as_str()
|
|
.with_context(|| format!("matrix: createRoom (chat) missing room_id: {json}"))?
|
|
.to_owned();
|
|
persist_chat_room_id(&id)?;
|
|
tracing::info!(room_id = %id, "matrix: created hive chat room");
|
|
id
|
|
};
|
|
|
|
// Wire the Space → room child link. Without an `m.space.child` carrying
|
|
// a `via`, the room won't surface in the Space hierarchy. `suggested`
|
|
// hints clients to surface it prominently.
|
|
let child_content = serde_json::json!({
|
|
"via": [server_name],
|
|
"suggested": true,
|
|
});
|
|
if let Err(e) = set_room_state(
|
|
client,
|
|
sender_token,
|
|
space_room_id,
|
|
"m.space.child",
|
|
&room_id,
|
|
&child_content,
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(error = ?e, "matrix: set m.space.child on hive space failed");
|
|
}
|
|
|
|
Ok(room_id)
|
|
}
|
|
|
|
/// Invite `@{localpart}:{server_name}` to `room_id` using the admin
|
|
/// account. Idempotent — treats already-member responses as success.
|
|
async fn invite_to_room(
|
|
client: &reqwest::Client,
|
|
sender_token: &str,
|
|
room_id: &str,
|
|
localpart: &str,
|
|
server_name: &str,
|
|
) -> Result<()> {
|
|
let user_id = format!("@{localpart}:{server_name}");
|
|
invite_user_id(client, sender_token, room_id, &user_id).await
|
|
}
|
|
|
|
/// Fetch a user's current membership in a room via the sender token, or
|
|
/// `None` if there is no membership event (never invited) or the lookup
|
|
/// fails. Returns the raw membership string (`invite`, `join`, `leave`, …).
|
|
async fn room_membership(
|
|
client: &reqwest::Client,
|
|
sender_token: &str,
|
|
encoded_room_id: &str,
|
|
user_id: &str,
|
|
) -> Option<String> {
|
|
let base = matrix_http()?;
|
|
// `:` must be percent-encoded in both the room-id and user-id path
|
|
// segments; `@` and `!` are permitted path characters per RFC 3986.
|
|
let encoded_user = user_id.replace(':', "%3A");
|
|
let url = format!(
|
|
"{base}/_matrix/client/v3/rooms/{encoded_room_id}/state/m.room.member/{encoded_user}"
|
|
);
|
|
let resp = client
|
|
.get(&url)
|
|
.bearer_auth(sender_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.
|
|
return None;
|
|
}
|
|
let body = resp.json::<serde_json::Value>().await.ok()?;
|
|
body["membership"].as_str().map(ToOwned::to_owned)
|
|
}
|
|
|
|
/// Invite a fully-qualified Matrix user id (`@user:server`) to `room_id`
|
|
/// using the sender token. Idempotent: a user who is already a member or
|
|
/// already has a pending invite is left untouched (no fresh invite is sent,
|
|
/// so they are not re-notified). A refused invite is an error unless the
|
|
/// user turns out to be invited or joined anyway — see [`refused_invite`].
|
|
async fn invite_user_id(
|
|
client: &reqwest::Client,
|
|
sender_token: &str,
|
|
room_id: &str,
|
|
user_id: &str,
|
|
) -> Result<()> {
|
|
let base = matrix_base()?;
|
|
// `:` must be percent-encoded in the room-id path segment; `!` is
|
|
// permitted in URL path characters per RFC 3986.
|
|
let encoded_room_id = room_id.replace(':', "%3A");
|
|
|
|
// Skip the invite entirely when the user is already invited or joined.
|
|
// Re-POSTing an invite to a pending member re-sends the invite event,
|
|
// which re-notifies the agent on every provisioning sweep.
|
|
if let Some(membership) = room_membership(client, sender_token, &encoded_room_id, user_id).await
|
|
&& matches!(membership.as_str(), "invite" | "join")
|
|
{
|
|
tracing::debug!(%user_id, %room_id, %membership, "matrix: invite skipped (already a member/invited)");
|
|
return Ok(());
|
|
}
|
|
|
|
let url = format!("{base}/_matrix/client/v3/rooms/{encoded_room_id}/invite");
|
|
let resp = client
|
|
.post(&url)
|
|
.bearer_auth(sender_token)
|
|
.json(&serde_json::json!({ "user_id": user_id }))
|
|
.send()
|
|
.await
|
|
.with_context(|| format!("matrix: POST /rooms/.../invite for {user_id}"))?;
|
|
let status = resp.status();
|
|
if status.is_success() {
|
|
tracing::debug!(%user_id, %room_id, "matrix: invited to room");
|
|
return Ok(());
|
|
}
|
|
// The pre-check misses a member when its read failed or a concurrent
|
|
// invite landed after it, so a refusal re-reads the membership.
|
|
let membership = if status == StatusCode::FORBIDDEN {
|
|
room_membership(client, sender_token, &encoded_room_id, user_id).await
|
|
} else {
|
|
None
|
|
};
|
|
refused_invite(resp, membership.as_deref(), user_id, room_id).await
|
|
}
|
|
|
|
/// Outcome of an invite POST the homeserver did not accept. A 403 is success
|
|
/// only when `membership`, re-read after the refusal, shows the user invited
|
|
/// or joined: `M_FORBIDDEN` covers both "already in the room" and real
|
|
/// refusals (banned target, sender lacks power), so the errcode can't tell
|
|
/// them apart.
|
|
async fn refused_invite(
|
|
resp: reqwest::Response,
|
|
membership: Option<&str>,
|
|
user_id: &str,
|
|
room_id: &str,
|
|
) -> Result<()> {
|
|
let status = resp.status();
|
|
if status == StatusCode::FORBIDDEN && matches!(membership, Some("invite" | "join")) {
|
|
tracing::debug!(%user_id, %room_id, "matrix: invite refused, user already member/invited");
|
|
return Ok(());
|
|
}
|
|
let body = resp.json::<serde_json::Value>().await.unwrap_or_default();
|
|
anyhow::bail!("matrix: invite {user_id} to {room_id}: HTTP {status}, body: {body}")
|
|
}
|
|
|
|
/// Invite an arbitrary Matrix user to the hive Space (default) or an
|
|
/// explicit `room_id` / alias. `user` may be a fully-qualified id
|
|
/// (`@name:server`) or a bare localpart, which is qualified with the
|
|
/// homeserver's `server_name`. Returns the resolved room id the invite
|
|
/// targeted. Used by `hivectl matrix invite`.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if the sender token or `server_name` can't be read,
|
|
/// the target room can't be resolved (no `--room` and no persisted hive
|
|
/// space), or the invite POST fails for a reason other than the user
|
|
/// already being a member / invited.
|
|
pub async fn invite_user(
|
|
client: &reqwest::Client,
|
|
sender_token: &str,
|
|
user: &str,
|
|
room_override: Option<&str>,
|
|
server_name: &str,
|
|
) -> Result<String> {
|
|
// Qualify a bare localpart to a full user id on the hive homeserver.
|
|
let user_id = if user.starts_with('@') {
|
|
user.to_owned()
|
|
} else {
|
|
format!("@{user}:{server_name}")
|
|
};
|
|
// Resolve the room: explicit override (id or #alias) wins; otherwise
|
|
// the persisted hive Space.
|
|
let room_id = match room_override {
|
|
Some(r) if r.starts_with('#') => resolve_room_alias(client, sender_token, r).await?,
|
|
Some(r) if r.starts_with('!') => r.to_owned(),
|
|
Some(r) => anyhow::bail!(
|
|
"matrix: --room {r:?} is neither a room id nor an alias; \
|
|
prefix with '!' for a room id (!abc:server) or '#' for an \
|
|
alias (#name:server)"
|
|
),
|
|
None => std::fs::read_to_string(hive_space_room_id_path())
|
|
.map(|s| s.trim().to_owned())
|
|
.context(
|
|
"matrix: no --room given and no persisted hive space \
|
|
(run the hive-c0re matrix sweep first)",
|
|
)?,
|
|
};
|
|
invite_user_id(client, sender_token, &room_id, &user_id).await?;
|
|
Ok(room_id)
|
|
}
|
|
|
|
/// Resolve a `#alias:server` to its room id via the directory API.
|
|
async fn resolve_room_alias(
|
|
client: &reqwest::Client,
|
|
sender_token: &str,
|
|
alias: &str,
|
|
) -> Result<String> {
|
|
let base = matrix_base()?;
|
|
let encoded = alias.replace('#', "%23").replace(':', "%3A");
|
|
let url = format!("{base}/_matrix/client/v3/directory/room/{encoded}");
|
|
let resp = client
|
|
.get(&url)
|
|
.bearer_auth(sender_token)
|
|
.send()
|
|
.await
|
|
.with_context(|| format!("matrix: GET directory for {alias}"))?;
|
|
let status = resp.status();
|
|
let json = resp.json::<serde_json::Value>().await.unwrap_or_default();
|
|
if !status.is_success() {
|
|
anyhow::bail!("matrix: resolve alias {alias}: HTTP {status}, body: {json}");
|
|
}
|
|
json["room_id"]
|
|
.as_str()
|
|
.map(str::to_owned)
|
|
.ok_or_else(|| anyhow::anyhow!("matrix: alias {alias} response missing room_id: {json}"))
|
|
}
|
|
|
|
/// Make sure the hive's own `@hive-<hive>:` account exists, then provision
|
|
/// the hive Space and chat room and invite every agent container to both.
|
|
/// Agents' own accounts are not created here: `swarm-controller` mints them
|
|
/// with the swarm's appservice token. Runs only as a `MatrixSweep` job node
|
|
/// (see `job_queue::templates::matrix_sweep`). No-op when the
|
|
/// hive-matrix container isn't running. Per-step failures are logged
|
|
/// but don't abort the sweep.
|
|
///
|
|
/// Returns `true` when every step of the sweep succeeded, `false` when at
|
|
/// least one step failed — the caller feeds this into a
|
|
/// [`crate::stats::sweep_health::SweepHealth`] to raise a debounced
|
|
/// dashboard banner on persistent failure (this sweep re-runs every 30
|
|
/// minutes, so a one-off blip self-heals without ever bannering).
|
|
pub async fn ensure_all() -> bool {
|
|
if !is_present() {
|
|
tracing::debug!("matrix: hive-matrix container absent, skipping user sweep");
|
|
return true;
|
|
}
|
|
let mut ok = true;
|
|
// One HTTP client for the whole sweep.
|
|
let client = match reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
|
|
.build()
|
|
{
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
tracing::warn!(error = ?e, "matrix: build HTTP client failed; skipping sweep");
|
|
return false;
|
|
}
|
|
};
|
|
// The hive's `@hive-<hive>:` account first, because everything below provisions
|
|
// THROUGH it (the Space, the chat room and every invite are sent with
|
|
// its token) — as an ordinary user that created those rooms, not as a
|
|
// homeserver admin.
|
|
if let Err(e) = ensure_hive_user().await {
|
|
tracing::warn!(error = ?e, "matrix: ensure_hive_user failed");
|
|
ok = false;
|
|
}
|
|
let Ok(containers) = crate::lifecycle::list().await else {
|
|
tracing::warn!("matrix: nixos-container list failed; skipping user sweep");
|
|
return false;
|
|
};
|
|
let mut agent_names: Vec<String> = Vec::new();
|
|
for c in &containers {
|
|
let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else {
|
|
continue;
|
|
};
|
|
agent_names.push(name.to_owned());
|
|
}
|
|
|
|
if !provision_space(&client, &agent_names).await {
|
|
ok = false;
|
|
}
|
|
ok
|
|
}
|
|
|
|
/// Provision the hive Space + default chat room and invite every agent
|
|
/// (+ the admin account) to both. Split out of [`ensure_all`] purely to
|
|
/// keep that function under the `too_many_lines` threshold — this is the
|
|
/// tail half of the same sequential sweep and shares its aggregate-bool,
|
|
/// log-and-continue failure handling.
|
|
async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bool {
|
|
let mut ok = true;
|
|
// server_name is needed to form full Matrix user IDs for invites.
|
|
let sender_token = match read_sender_token() {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no matrix sender token)");
|
|
return false;
|
|
}
|
|
};
|
|
// server_name first — the agent invites need it (fully-qualified user ids).
|
|
let server_name = match discover_server_name(client).await {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
tracing::warn!(error = ?e, "matrix: discover_server_name failed; skipping space provisioning");
|
|
return false;
|
|
}
|
|
};
|
|
let room_id = match ensure_hive_space(client, &sender_token).await {
|
|
Ok(id) => id,
|
|
Err(e) => {
|
|
tracing::warn!(error = ?e, "matrix: ensure_hive_space failed");
|
|
return false;
|
|
}
|
|
};
|
|
// This hive's own account first, then all agents.
|
|
let localpart = match hive_localpart() {
|
|
Ok(l) => l,
|
|
Err(e) => {
|
|
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning");
|
|
return false;
|
|
}
|
|
};
|
|
if let Err(e) = invite_to_room(client, &sender_token, &room_id, &localpart, &server_name).await
|
|
{
|
|
tracing::warn!(error = ?e, %localpart, "matrix: invite the hive account to the space failed");
|
|
ok = false;
|
|
}
|
|
for name in agent_names {
|
|
if let Err(e) = invite_to_room(client, &sender_token, &room_id, name, &server_name).await {
|
|
tracing::warn!(%name, error = ?e, "matrix: invite agent to space failed");
|
|
ok = false;
|
|
}
|
|
}
|
|
|
|
// Provision the default hive chat room as an m.space.child of the Space
|
|
// and invite `@hive-<hive>:` + every agent. Joining a Space alone surfaces no
|
|
// rooms to chat in (Matrix semantics — children aren't auto-joined), so
|
|
// without this the Space is empty. The restricted join rule additionally
|
|
// lets the operator (a Space member) join from the Space hierarchy.
|
|
match ensure_hive_chat_room(client, &sender_token, &room_id, &server_name).await {
|
|
Ok(chat_room_id) => {
|
|
if let Err(e) = invite_to_room(
|
|
client,
|
|
&sender_token,
|
|
&chat_room_id,
|
|
&localpart,
|
|
&server_name,
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(error = ?e, %localpart, "matrix: invite the hive account to the chat room failed");
|
|
ok = false;
|
|
}
|
|
for name in agent_names {
|
|
if let Err(e) =
|
|
invite_to_room(client, &sender_token, &chat_room_id, name, &server_name).await
|
|
{
|
|
tracing::warn!(%name, error = ?e, "matrix: invite agent to chat room failed");
|
|
ok = false;
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(error = ?e, "matrix: ensure_hive_chat_room failed");
|
|
ok = false;
|
|
}
|
|
}
|
|
ok
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn with_no_file_the_store_token_is_taken() {
|
|
assert_eq!(sender_source(Some("tok"), None), SenderSource::Store("tok"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_store_token_replaces_a_different_file_token() {
|
|
// The swarm re-minted a dead token; the file must follow it.
|
|
assert_eq!(
|
|
sender_source(Some("new\n"), Some("old\n")),
|
|
SenderSource::Store("new")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_matching_the_store_is_kept() {
|
|
// Trailing newline on disk is how `persist_sender_token` writes it.
|
|
assert_eq!(
|
|
sender_source(Some("tok"), Some("tok\n")),
|
|
SenderSource::Keep
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn with_nothing_in_the_store_the_file_is_kept() {
|
|
assert_eq!(sender_source(None, Some("tok\n")), SenderSource::Keep);
|
|
}
|
|
|
|
#[test]
|
|
fn with_no_token_anywhere_nothing_is_available() {
|
|
assert_eq!(
|
|
sender_source(Some(""), Some(" \n")),
|
|
SenderSource::Unavailable
|
|
);
|
|
assert_eq!(sender_source(None, None), SenderSource::Unavailable);
|
|
}
|
|
|
|
/// The steady state. This is the whole point of the guard: the sweep
|
|
/// runs forever, and every write it makes is a wake for every agent
|
|
/// in the room.
|
|
#[test]
|
|
fn state_matching_the_room_is_not_rewritten() {
|
|
let desired = serde_json::json!({ "via": ["example.org"], "suggested": true });
|
|
let current = desired.clone();
|
|
assert!(!state_needs_write(Some(¤t), &desired));
|
|
}
|
|
|
|
/// Key order in the homeserver's response must not force a write —
|
|
/// the guard leans on `serde_json::Value` comparing objects as maps,
|
|
/// and a byte- or order-sensitive comparison would silently degrade
|
|
/// to writing every time while still looking correct.
|
|
#[test]
|
|
fn state_matching_but_reordered_is_not_rewritten() {
|
|
let desired = serde_json::json!({ "via": ["example.org"], "suggested": true });
|
|
let current = serde_json::json!({ "suggested": true, "via": ["example.org"] });
|
|
assert!(!state_needs_write(Some(¤t), &desired));
|
|
}
|
|
|
|
#[test]
|
|
fn diverged_state_is_rewritten() {
|
|
let desired = serde_json::json!({ "via": ["example.org"], "suggested": true });
|
|
let current = serde_json::json!({ "via": ["old.example.org"], "suggested": true });
|
|
assert!(state_needs_write(Some(¤t), &desired));
|
|
}
|
|
|
|
/// Fail-open, and deliberately so: an unreadable current state must
|
|
/// write. The re-apply exists to repair a missing link, so "we don't
|
|
/// know" has to behave like "it's missing", not like "it's fine".
|
|
#[test]
|
|
fn unknown_state_is_written() {
|
|
let desired = serde_json::json!({ "via": ["example.org"], "suggested": true });
|
|
assert!(state_needs_write(None, &desired));
|
|
}
|
|
|
|
/// A homeserver response built in memory, so no client (and no TLS
|
|
/// roots) is needed to drive the response-handling halves.
|
|
fn response(status: u16, body: &'static str) -> reqwest::Response {
|
|
axum::http::Response::builder()
|
|
.status(status)
|
|
.body(body)
|
|
.expect("valid mock response")
|
|
.into()
|
|
}
|
|
|
|
const FORBIDDEN_BODY: &str = r#"{"errcode":"M_FORBIDDEN","error":"refused"}"#;
|
|
|
|
/// A banned target or a sender without power gets exactly this 403;
|
|
/// with no membership behind it, the invite did not happen.
|
|
#[tokio::test]
|
|
async fn a_refused_invite_without_membership_is_an_error() {
|
|
for membership in [None, Some("ban"), Some("leave")] {
|
|
let outcome =
|
|
refused_invite(response(403, FORBIDDEN_BODY), membership, "@a:x", "!r:x").await;
|
|
assert!(outcome.is_err(), "membership {membership:?} must not pass");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_refused_invite_for_a_member_is_success() {
|
|
for membership in ["invite", "join"] {
|
|
refused_invite(
|
|
response(403, FORBIDDEN_BODY),
|
|
Some(membership),
|
|
"@a:x",
|
|
"!r:x",
|
|
)
|
|
.await
|
|
.expect("already invited/joined is the goal state");
|
|
}
|
|
}
|
|
|
|
/// Membership only excuses a 403; any other failure stays a failure.
|
|
#[tokio::test]
|
|
async fn a_non_403_invite_failure_or_garbage_body_is_an_error() {
|
|
for (status, body) in [(500, "not json"), (403, "not json"), (429, "{}")] {
|
|
let outcome = refused_invite(response(status, body), None, "@a:x", "!r:x").await;
|
|
assert!(outcome.is_err(), "HTTP {status} {body:?} must fail");
|
|
}
|
|
let outcome = refused_invite(response(500, "{}"), Some("join"), "@a:x", "!r:x").await;
|
|
assert!(outcome.is_err(), "a 500 is not excused by membership");
|
|
}
|
|
}
|