Watch
0
0
Fork
You've already forked hyperhive
0

swarm-controller: make authelia's operators matrix homeserver admins

swarm-authelia-bridge gains ListOperators, the usernames in authelia's
operator group (`operatorGroup = "admins"`, passed in from
swarm-authelia.nix). Unlike the two agent verbs, which admit any active
token, it answers only the client named by
SWARM_AUTHELIA_BRIDGE_OPERATOR_READER_CLIENT_ID (swarm-controller's
queueClientId) and returns 403 to every other active token: authelia
issues machine tokens to every hive and its agents as well. The bridge's
introspection now models `client_id` to make that check, the same way
swarm-nats-auth's does.

swarm-controller runs a grant-only pass at start and every five minutes:
read the group, map each username to the account an SSO login lands on
(tuwunel lowercases `preferred_username`), skip anyone already joined to
`#admins:<server>` (tuwunel's definition of admin) or without a matrix
account, and send `!admin users make-user-admin <user>` there as @swarm
with the swarm appservice token. Nothing is ever revoked: tuwunel has no
command for it (#4799).

The admin-room calls (alias, joined members, profile, send) go into
swarm-matrix-client next to whoami, keeping its no-body-in-errors rule.

Closes #4585
This commit is contained in:
atlas 2026-10-04 15:21:41 +02:00 • committed by mara
commit 9e7fdb55ae
15 changed files with 787 additions and 33 deletions

View file

@ -1,5 +1,7 @@
//! The appservice calls against the swarm's homeserver: register, log in, and
//! ask a token who it is. Shared by `swarm-matrix-ctl` and `swarm-controller`;
//! The appservice calls against the swarm's homeserver: register, log in, ask
//! a token who it is, and the room calls `swarm-controller`'s admin-room
//! commands need (resolve an alias, list members, check an account exists,
//! send a message). Shared by `swarm-matrix-ctl` and `swarm-controller`;
//! see the README for why both mint through the same device id.
//!
//! 🩸 **Every error in this module is built from the response's `status` and
@ -188,6 +190,183 @@ fn whoami_verdict(status: reqwest::StatusCode, json: &serde_json::Value) -> Resu
bail!("the homeserver refused whoami: {}", why(status, json))
}
/// The room id `alias` names.
///
/// # Errors
/// When the request cannot be sent, the response will not decode, or the
/// homeserver refuses.
pub async fn resolve_alias(
client: &reqwest::Client,
base: &str,
token: &str,
alias: &str,
) -> Result<String> {
let (status, json) = get(
client,
&format!(
"{base}/_matrix/client/v3/directory/room/{}",
path_segment(alias)
),
token,
)
.await
.with_context(|| format!("GET /directory/room for {alias}"))?;
if !status.is_success() {
bail!(
"the homeserver refused to resolve {alias}: {}",
why(status, &json)
);
}
json["room_id"]
.as_str()
.map(str::to_owned)
.with_context(|| format!("the homeserver's answer for {alias} carried no `room_id`"))
}
/// The user ids joined to `room_id`. The token's own user must be one of them.
///
/// # Errors
/// When the request cannot be sent, the response will not decode, or the
/// homeserver refuses.
pub async fn joined_members(
client: &reqwest::Client,
base: &str,
token: &str,
room_id: &str,
) -> Result<std::collections::BTreeSet<String>> {
let (status, json) = get(
client,
&format!(
"{base}/_matrix/client/v3/rooms/{}/joined_members",
path_segment(room_id)
),
token,
)
.await
.with_context(|| format!("GET /rooms/{room_id}/joined_members"))?;
if !status.is_success() {
bail!(
"the homeserver refused to list {room_id}'s members: {}",
why(status, &json)
);
}
json["joined"]
.as_object()
.map(|joined| joined.keys().cloned().collect())
.with_context(|| format!("the homeserver's member list for {room_id} carried no `joined`"))
}
/// Whether `user_id` has an account on this homeserver.
///
/// Asked through the profile endpoint, which answers `M_NOT_FOUND` for a
/// local user id with no account.
///
/// # Errors
/// When the request cannot be sent, the response will not decode, or the
/// homeserver refuses with anything other than `M_NOT_FOUND`.
pub async fn user_exists(
client: &reqwest::Client,
base: &str,
token: &str,
user_id: &str,
) -> Result<bool> {
let (status, json) = get(
client,
&format!("{base}/_matrix/client/v3/profile/{}", path_segment(user_id)),
token,
)
.await
.with_context(|| format!("GET /profile/{user_id}"))?;
if status.is_success() {
return Ok(true);
}
if errcode(&json) == Some("M_NOT_FOUND") {
return Ok(false);
}
bail!(
"the homeserver refused {user_id}'s profile: {}",
why(status, &json)
)
}
/// Send `body` to `room_id` as an `m.text` message, and return its event id.
///
/// # Errors
/// When the request cannot be sent, the response will not decode, or the
/// homeserver refuses.
pub async fn send_text(
client: &reqwest::Client,
base: &str,
token: &str,
room_id: &str,
body: &str,
) -> Result<String> {
let resp = client
.put(format!(
"{base}/_matrix/client/v3/rooms/{}/send/m.room.message/{}",
path_segment(room_id),
random_hex(8)?
))
.bearer_auth(token)
.json(&serde_json::json!({ "msgtype": "m.text", "body": body }))
.send()
.await
.with_context(|| format!("PUT a message into {room_id}: sending the request"))?;
let status = resp.status();
let json = resp
.json::<serde_json::Value>()
.await
.with_context(|| format!("PUT a message into {room_id}: decoding the response"))?;
if !status.is_success() {
bail!(
"the homeserver refused a message into {room_id}: {}",
why(status, &json)
);
}
json["event_id"]
.as_str()
.map(str::to_owned)
.with_context(|| {
format!("the homeserver's answer to a message into {room_id} carried no `event_id`")
})
}
/// `value` percent-encoded for one URL path segment: room ids, aliases and
/// user ids carry `!`, `#`, `@` and `:`, and `#` would otherwise end the path.
fn path_segment(value: &str) -> String {
use std::fmt::Write as _;
value.bytes().fold(String::new(), |mut acc, b| {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
acc.push(char::from(b));
} else {
// Infallible, for `random_hex`'s reason.
let _ = write!(acc, "%{b:02X}");
}
acc
})
}
/// One authenticated GET, returning the status beside the decoded body.
async fn get(
client: &reqwest::Client,
url: &str,
token: &str,
) -> Result<(reqwest::StatusCode, serde_json::Value)> {
let resp = client
.get(url)
.bearer_auth(token)
.send()
.await
.context("sending the request")?;
let status = resp.status();
let json = resp
.json::<serde_json::Value>()
.await
.context("decoding the response as JSON")?;
Ok((status, json))
}
/// The device every token this binary mints is pinned to.
fn device_id(localpart: &str) -> String {
format!("hyperhive-{localpart}")
@ -360,6 +539,14 @@ mod tests {
}
}
#[test]
fn a_path_segment_escapes_what_would_end_or_split_the_path() {
assert_eq!(path_segment("#admins:t.local"), "%23admins%3At.local");
assert_eq!(path_segment("!abc:t.local"), "%21abc%3At.local");
assert_eq!(path_segment("@a.b_c-d:t.local"), "%40a.b_c-d%3At.local");
assert_eq!(path_segment("a/b"), "a%2Fb");
}
#[test]
fn a_minted_password_is_hex_of_the_declared_length() {
// The control on the hex fold: a short or non-hex password would be