swarm-matrix-ctl: mint the swarm's own appservice registration

The swarm gets an appservice identity of its own, separate from each hive's
`hyperhive` registration. `swarm-matrix-ctl appservice render` mints its
tokens inside the matrix container when they are absent and renders the
registration tuwunel loads; `appservice publish` writes its as_token to
`swarm/controller/swarm-controller/matrix/appservice-token`, the one kind no
hive's policy grants.

The homeserver calls move out of swarm-matrix-ctl into swarm-matrix-client,
with a `whoami`, so swarm-controller can mint agents' accounts through the
same pinned device id instead of a copy of them.
This commit is contained in:
atlas 2026-09-24 23:42:36 +02:00 • committed by mara
commit 89aff8d613
10 changed files with 523 additions and 23 deletions

View file

@ -14,8 +14,10 @@ anyhow.workspace = true
# single-purpose binary: the next thing that has to run in the matrix container
# is a subcommand here, not a new crate.
clap.workspace = true
reqwest.workspace = true
serde_json.workspace = true
# The appservice calls, shared with `swarm-controller`, which mints agents'
# accounts through the same device id.
swarm-matrix-client.workspace = true
# The agreement this binary is one end of: where the credential lives, what the
# object at that path holds, and the `BAO_*` spellings the unit sets.
swarm-secret-client.workspace = true

View file

@ -0,0 +1,279 @@
//! `swarm-matrix-ctl appservice` — the **swarm's** own appservice
//! registration: minted here, loaded by the homeserver beside us, and published
//! to the one store path `swarm-controller` reads it from.
//!
//! Two verbs, because they have opposite failure rules:
//!
//! - [`render`] runs before tuwunel and touches nothing but this container's
//! state dir. tuwunel loads the file it writes through `LoadCredential`, and
//! a missing credential source fails the homeserver's start, so this half
//! must not be able to fail on a network.
//! - [`publish`] runs after it and needs the store. A sealed store delays it
//! and nothing else.
//!
//! "Only once": the token file in the state dir is the record. [`render`]
//! mints only when it is absent and re-renders from it every time; [`publish`]
//! writes the store only when the store's copy differs.
//!
//! This registration's sender is promoted to homeserver admin at boot
//! (`nix/host-modules/hive-matrix.nix`), which is why its token goes to
//! `swarm_secret_client::matrix::swarm_appservice_token_path` — a path no
//! hive's policy reaches — and to nowhere else.
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use swarm_secret_client::{
SecretStore,
client::{DEFAULT_CERT_MOUNT, Settings},
matrix,
};
use crate::registration;
/// This container's state dir for the registration and its two tokens.
const ENV_DIR: &str = "MATRIX_APPSERVICE_DIR";
/// The registration's `sender_localpart`: the account the homeserver creates
/// for it and the one `admin_execute` promotes.
const ENV_SENDER: &str = "MATRIX_APPSERVICE_SENDER";
/// The user namespace regex, rendered by nix beside the hive registration's.
const ENV_USER_REGEX: &str = "MATRIX_APPSERVICE_USER_REGEX";
/// Role on the store's `cert` auth mount that [`publish`] logs in with.
const ENV_CERT_ROLE: &str = "MATRIX_APPSERVICE_CERT_ROLE";
/// The registration's `id`. Distinct from the hive registration's
/// (`hyperhive`): tuwunel refuses two registrations with one id.
const ID: &str = "swarm";
/// File names inside [`ENV_DIR`]. `REGISTRATION` is what tuwunel loads.
const AS_TOKEN: &str = "as-token";
const HS_TOKEN: &str = "hs-token";
const REGISTRATION: &str = "swarm.yaml";
/// Random bytes per token, as the hive registration's renderer uses.
const TOKEN_BYTES: usize = 32;
/// Read a required variable.
fn required(get: &impl Fn(&str) -> Option<String>, var: &'static str) -> Result<String> {
get(var)
.filter(|v| !v.is_empty())
.with_context(|| format!("{var} is unset or empty"))
}
/// Mint the tokens if absent and render the registration from them.
///
/// # Errors
/// If a variable is missing, or the state dir cannot be read or written.
pub fn render() -> Result<()> {
let get = |k: &str| std::env::var(k).ok();
let dir = PathBuf::from(required(&get, ENV_DIR)?);
let sender = required(&get, ENV_SENDER)?;
let regex = required(&get, ENV_USER_REGEX)?;
render_into(&dir, &sender, &regex)?;
tracing::info!(path = %dir.join(REGISTRATION).display(), "rendered the swarm appservice registration");
Ok(())
}
/// [`render`] against an explicit directory, so a test can run it twice.
fn render_into(dir: &Path, sender: &str, regex: &str) -> Result<()> {
let as_token = existing_or_minted(&dir.join(AS_TOKEN))?;
let hs_token = existing_or_minted(&dir.join(HS_TOKEN))?;
write_secret(
&dir.join(REGISTRATION),
&registration_yaml(sender, regex, &as_token, &hs_token),
)
}
/// The token at `path`, minting and writing one first when there is none.
fn existing_or_minted(path: &Path) -> Result<String> {
match std::fs::read_to_string(path) {
Ok(t) if !t.trim().is_empty() => return Ok(t.trim().to_owned()),
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
}
let token = swarm_matrix_client::random_hex(TOKEN_BYTES)?;
write_secret(path, &token)?;
tracing::info!(path = %path.display(), "minted a swarm appservice token");
Ok(token)
}
/// Write `contents` to `path` as a `0600` file, through a rename so a reader
/// never sees half of it.
fn write_secret(path: &Path, contents: &str) -> Result<()> {
let tmp = path.with_extension("tmp");
let _ = std::fs::remove_file(&tmp);
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp)
.with_context(|| format!("creating {}", tmp.display()))?;
f.write_all(contents.as_bytes())
.and_then(|()| f.sync_all())
.with_context(|| format!("writing {}", tmp.display()))?;
std::fs::rename(&tmp, path).with_context(|| format!("renaming onto {}", path.display()))
}
/// The registration, in the shape `hive-matrix.nix` renders the hive's.
///
/// `exclusive: false` for that file's reason: an exclusive namespace does not
/// widen what this appservice may do, it refuses everyone else — and the hive
/// registration covers the same names until it is retired.
fn registration_yaml(sender: &str, regex: &str, as_token: &str, hs_token: &str) -> String {
format!(
"id: {ID}\n\
url: null\n\
sender_localpart: {sender}\n\
rate_limited: false\n\
namespaces:\n \
users:\n \
- exclusive: false\n \
regex: '{regex}'\n \
aliases: []\n \
rooms: []\n\
as_token: {as_token}\n\
hs_token: {hs_token}\n"
)
}
/// Whether the store needs the local token written to it.
fn needs_publish(stored: Option<&matrix::Credential>, local: &str) -> bool {
stored.is_none_or(|c| c.value.trim() != local)
}
/// Write the rendered registration's `as_token` to the store, unless the store
/// already holds it.
///
/// # Errors
/// If a variable is missing, the registration has not been rendered, or the
/// store refuses the login, the read or the write.
pub async fn publish() -> Result<()> {
let get = |k: &str| std::env::var(k).ok();
let dir = PathBuf::from(required(&get, ENV_DIR)?);
let cert_role = required(&get, ENV_CERT_ROLE)?;
let registration = dir.join(REGISTRATION);
let local = registration::as_token(&registration.to_string_lossy())?;
let settings = Settings::from_env().context("reading the store's BAO_* environment")?;
let store = SecretStore::connect(&settings, &cert_role, DEFAULT_CERT_MOUNT)
.await
.with_context(|| {
format!("logging in to the swarm secret store as cert role {cert_role}")
})?;
let path = matrix::swarm_appservice_token_path()?;
let stored: Option<matrix::Credential> = store
.read_optional(&path)
.await
.with_context(|| format!("reading {path}"))?;
if !needs_publish(stored.as_ref(), &local) {
tracing::info!(%path, "the swarm appservice token is already published");
return Ok(());
}
store
.write(
&path,
&matrix::Credential {
value: local,
homeserver: None,
},
)
.await
.with_context(|| format!("writing the swarm appservice token to {path}"))?;
tracing::info!(%path, "published the swarm appservice token");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const REGEX: &str = "^@[a-z0-9._=/-]+:t\\.local$";
fn scratch() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"swarm-appservice-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("after the epoch")
.as_nanos()
));
std::fs::create_dir_all(&dir).expect("temp dir");
dir
}
#[test]
fn the_rendered_registration_carries_the_token_it_minted() {
let dir = scratch();
render_into(&dir, "swarm", REGEX).expect("renders");
let token = registration::as_token(&dir.join(REGISTRATION).to_string_lossy())
.expect("the as_token line parses");
assert_eq!(token.len(), TOKEN_BYTES * 2);
assert_eq!(
std::fs::read_to_string(dir.join(AS_TOKEN)).expect("minted"),
token
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_second_render_keeps_the_token() {
// "Only once": a re-mint on every boot would hand the controller a
// token the homeserver no longer loads until publish catches up.
let dir = scratch();
render_into(&dir, "swarm", REGEX).expect("first");
let first = std::fs::read_to_string(dir.join(REGISTRATION)).expect("rendered");
render_into(&dir, "swarm", REGEX).expect("second");
let second = std::fs::read_to_string(dir.join(REGISTRATION)).expect("rendered");
assert_eq!(first, second);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_registration_is_the_swarms_and_not_the_hives() {
let y = registration_yaml("swarm", REGEX, "aa", "bb");
assert!(y.starts_with("id: swarm\n"), "{y}");
assert!(y.contains("\nsender_localpart: swarm\n"), "{y}");
assert!(y.contains("\n - exclusive: false\n"), "{y}");
assert!(y.contains(&format!("regex: '{REGEX}'")), "{y}");
assert!(y.contains("\nurl: null\n"), "{y}");
}
#[test]
fn the_registration_and_tokens_are_owner_only() {
use std::os::unix::fs::PermissionsExt as _;
let dir = scratch();
render_into(&dir, "swarm", REGEX).expect("renders");
for f in [AS_TOKEN, HS_TOKEN, REGISTRATION] {
let mode = std::fs::metadata(dir.join(f))
.expect("exists")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "{f}");
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn publish_writes_only_what_the_store_lacks() {
let same = matrix::Credential {
value: "aa".to_owned(),
homeserver: None,
};
assert!(!needs_publish(Some(&same), "aa"));
assert!(needs_publish(None, "aa"));
let other = matrix::Credential {
value: "bb".to_owned(),
homeserver: None,
};
assert!(needs_publish(Some(&other), "aa"));
}
#[test]
fn every_variable_is_scoped_to_the_verb() {
for var in [ENV_DIR, ENV_SENDER, ENV_USER_REGEX, ENV_CERT_ROLE] {
assert!(var.starts_with("MATRIX_APPSERVICE_"), "{var}");
}
}
}

View file

@ -1,286 +0,0 @@
//! The two calls the mint ladder is made of, against the homeserver next door.
//!
//! 🩸 **Every error in this module is built from the response's `status` and
//! its `errcode`, never its body.** A successful `/register` or `/login` body
//! *is* an access token, and an error body is one malformed response away from
//! being the same bytes — so a `body: {json}` in a message here would put the
//! sender token in the journal.
use anyhow::{Context, Result, bail};
/// Client-server API calls are one round trip each against a homeserver in the
/// same netns; a slow one is a broken one.
const TIMEOUT_SECS: u64 = 10;
/// Bytes of the throwaway password `/register` is given.
///
/// Protocol overhead, and stored nowhere: this account authenticates by access
/// token, and the recovery path when that token is lost is the appservice login
/// below rather than anything a password could open.
const PASSWORD_BYTES: usize = 32;
/// What the homeserver said about a registration attempt.
///
/// An enum rather than a string match on the error text: `M_USER_IN_USE` is the
/// *expected* answer here — the hive's `@hive-<hive>:` account is the appservice
/// registration's own `sender_localpart`, so the homeserver creates it at startup, before
/// anything gets to ask — and an expected answer should not have to be
/// recovered from a formatted message.
pub enum Registered {
/// A fresh account, and the access token minted with it.
Token(String),
/// The account is already there; it has to be logged into instead.
AlreadyExists,
}
/// An HTTP client with this module's timeout.
///
/// # Errors
/// When the TLS backend will not initialise, which is the only way building a
/// client fails.
pub fn client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(TIMEOUT_SECS))
.build()
.context("building the HTTP client for the homeserver")
}
/// Create `localpart`'s account as the appservice and return its access token.
///
/// One round trip: an appservice-typed registration needs no UIAA stage, so
/// there is no session to carry and no shared registration secret in the
/// picture. The `device_id` is pinned so that a later [`appservice_login`]
/// replaces this device rather than accumulating one per run.
///
/// # Errors
/// When the request cannot be sent, the response will not decode, or the
/// homeserver refuses with anything other than `M_USER_IN_USE` — which is
/// [`Registered::AlreadyExists`] rather than an error.
pub async fn register(
client: &reqwest::Client,
base: &str,
localpart: &str,
as_token: &str,
) -> Result<Registered> {
let body = serde_json::json!({
// What makes this an appservice registration rather than an ordinary
// one: without it the homeserver asks for a UIAA flow even though the
// request carries the as_token.
"type": "m.login.application_service",
"username": localpart,
"password": random_password()?,
"device_id": device_id(localpart),
"initial_device_display_name": format!("hyperhive ({localpart})"),
"inhibit_login": false,
});
let (status, json) = post(
client,
&format!("{base}/_matrix/client/v3/register?kind=user"),
as_token,
&body,
)
.await
.context("POST /register as the appservice")?;
if status.is_success() {
return Ok(Registered::Token(access_token(&json)?));
}
if errcode(&json) == Some("M_USER_IN_USE") {
return Ok(Registered::AlreadyExists);
}
bail!(
"the homeserver refused to register @{localpart}: {}",
why(status, &json)
);
}
/// Log in as an **existing** account using the appservice's authority, and
/// return a fresh access token for it.
///
/// No password: the appservice is authorised for every localpart in its
/// namespace, so it mints a session for one without knowing anything about the
/// account — which is just as well, since an account the homeserver created for
/// its own registration has none.
///
/// # Errors
/// When the request cannot be sent, the response will not decode, or the
/// homeserver refuses.
pub async fn appservice_login(
client: &reqwest::Client,
base: &str,
localpart: &str,
as_token: &str,
) -> Result<String> {
let body = serde_json::json!({
"type": "m.login.application_service",
"identifier": {
"type": "m.id.user",
"user": localpart,
},
// Matching `register`'s, so a re-login REPLACES that device's token
// rather than leaving a second live device behind.
"device_id": device_id(localpart),
"initial_device_display_name": format!("hyperhive ({localpart})"),
});
let (status, json) = post(
client,
&format!("{base}/_matrix/client/v3/login"),
as_token,
&body,
)
.await
.context("POST /login as the appservice")?;
if !status.is_success() {
bail!(
"the homeserver refused to log in @{localpart}: {}",
why(status, &json)
);
}
access_token(&json)
}
/// The device every token this binary mints is pinned to.
fn device_id(localpart: &str) -> String {
format!("hyperhive-{localpart}")
}
/// One authenticated JSON POST, returning the status beside the decoded body.
async fn post(
client: &reqwest::Client,
url: &str,
as_token: &str,
body: &serde_json::Value,
) -> Result<(reqwest::StatusCode, serde_json::Value)> {
let resp = client
.post(url)
.bearer_auth(as_token)
.json(body)
.send()
.await
.context("sending the request")?;
let status = resp.status();
let json = resp
.json::<serde_json::Value>()
.await
.context("decoding the response as JSON")?;
Ok((status, json))
}
/// Pull `access_token` out of a successful response.
fn access_token(json: &serde_json::Value) -> Result<String> {
json["access_token"]
.as_str()
.map(str::to_owned)
// Not `{json}`: on the success path this object holds the credential,
// and a response missing the field is exactly when a reflex to print it
// would fire.
.context("the homeserver's response carried no `access_token`")
}
/// The matrix error code, when the body is a standard error object.
fn errcode(json: &serde_json::Value) -> Option<&str> {
json["errcode"].as_str()
}
/// Everything about a refusal that is safe to put in a message.
///
/// The `errcode` is a closed vocabulary from the spec and the status is a
/// number; between them they say which of the ladder's arms was taken. The
/// `error` string beside them is free-form homeserver text, so it stays out.
fn why(status: reqwest::StatusCode, json: &serde_json::Value) -> String {
match errcode(json) {
Some(code) => format!("HTTP {status}, errcode {code}"),
None => format!("HTTP {status}, no errcode"),
}
}
/// A throwaway password for [`register`], as hex.
///
/// From `/dev/urandom` directly rather than through an RNG crate: this is the
/// one random value the binary needs, and the kernel is already the source any
/// such crate would reach for here.
///
/// # Errors
/// When `/dev/urandom` cannot be read.
fn random_password() -> Result<String> {
use std::io::Read as _;
let mut buf = [0u8; PASSWORD_BYTES];
std::fs::File::open("/dev/urandom")
.context("opening /dev/urandom")?
.read_exact(&mut buf)
.context("reading from /dev/urandom")?;
Ok(buf.iter().fold(String::new(), |mut acc, b| {
use std::fmt::Write as _;
// Infallible: `write!` into a `String` only fails if the formatter
// does, and `{:02x}` of a `u8` has nothing to fail at.
let _ = write!(acc, "{b:02x}");
acc
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn both_calls_pin_the_same_device_so_a_relogin_replaces_it() {
// The whole reason the recovery arm is safe to take repeatedly: an
// unpinned login mints a NEW device each time, and a homeserver
// accumulating devices for this account is one where revoking the credential
// means finding all of them.
assert_eq!(device_id("hive"), "hyperhive-hive");
}
#[test]
fn a_refusal_is_described_by_its_errcode_and_never_by_its_body() {
// 🩸 The invariant this module exists to keep. `error` is free-form
// homeserver text and `access_token` is the credential itself; a
// message built from the body would carry whichever of them the
// response happened to hold.
let json = serde_json::json!({
"errcode": "M_FORBIDDEN",
"error": "some free-form text",
"access_token": "syt_the_actual_secret",
});
let message = why(reqwest::StatusCode::FORBIDDEN, &json);
assert!(message.contains("M_FORBIDDEN"), "{message}");
assert!(!message.contains("syt_the_actual_secret"), "{message}");
assert!(!message.contains("free-form"), "{message}");
}
#[test]
fn a_refusal_with_no_errcode_still_produces_a_message() {
// A homeserver behind a proxy answers with HTML, not a matrix error
// object. The status is then the only thing there is to say, and
// saying it is better than an empty report.
let message = why(reqwest::StatusCode::BAD_GATEWAY, &serde_json::json!({}));
assert!(message.contains("502"), "{message}");
}
#[test]
fn the_expected_already_exists_answer_is_recognised_by_its_errcode() {
// Matched on the spec's code rather than on message text, because this
// is the arm a healthy homeserver takes every time: the hive's `@hive-<hive>:`
// account is the appservice registration's own sender, created at startup.
let json = serde_json::json!({ "errcode": "M_USER_IN_USE", "error": "User ID taken" });
assert_eq!(errcode(&json), Some("M_USER_IN_USE"));
}
#[test]
fn a_response_without_a_token_is_an_error_that_does_not_quote_it() {
let e = access_token(&serde_json::json!({ "user_id": "@hive:t.local" }))
.expect_err("no access_token in this object");
assert!(!format!("{e}").contains("@hive:t.local"), "{e}");
}
#[test]
fn a_minted_password_is_hex_of_the_declared_length() {
// The control on the hex fold: a short or non-hex password would be
// accepted by the homeserver and only surface much later, if at all.
let pw = random_password().expect("/dev/urandom is readable");
assert_eq!(pw.len(), PASSWORD_BYTES * 2);
assert!(pw.bytes().all(|b| b.is_ascii_hexdigit()), "not hex");
}
}

View file

@ -7,18 +7,19 @@
//! identity plumbing to add one action, so the next thing that has to run in
//! here is a verb below, not a new crate.
//!
//! Today that is one verb, [`mint`]: publish the appservice sender account's
//! homeserver access token to the swarm's secret store, once.
//! [`mint`] publishes a hive's appservice sender token to the swarm's secret
//! store, once. [`appservice`] mints the **swarm's** own appservice
//! registration and publishes its token for `swarm-controller`.
//!
//! It lives in the container because the appservice `as_token` that authorises
//! the mint is *already* there — the registration tuwunel loads is bind-mounted
//! in — so no second holder of that secret is created.
//!
//! 🩸 **A secret is a path, never a value.** The only identifier any verb here
//! logs is the store path; see `homeserver`'s module doc for the same rule
//! logs is the store path; see `swarm_matrix_client`'s module doc for the same rule
//! applied to error messages.
mod homeserver;
mod appservice;
mod mint;
mod registration;
@ -44,6 +45,20 @@ enum Command {
/// no flags, because a systemd `Environment=` block is what a nix module
/// can render and a command line full of paths is not.
Mint,
/// The swarm's own appservice registration, whose sender is the
/// homeserver's admin account. Configured by `MATRIX_APPSERVICE_*`.
#[command(subcommand)]
Appservice(Appservice),
}
#[derive(Debug, Subcommand)]
enum Appservice {
/// Mint the tokens when absent and render the registration tuwunel loads.
/// Local only: it runs before the homeserver and must not need a network.
Render,
/// Write the rendered `as_token` to the swarm secret store when the
/// store's copy differs.
Publish,
}
#[tokio::main]
@ -57,6 +72,8 @@ async fn main() -> Result<()> {
match Cli::parse().command {
Command::Mint => mint::run().await,
Command::Appservice(Appservice::Render) => appservice::render(),
Command::Appservice(Appservice::Publish) => appservice::publish().await,
}
}
@ -80,6 +97,25 @@ mod tests {
/// The control: without it the case above passes on a parser that accepts
/// anything.
/// The two units name these verbs, same reason as the test above.
#[test]
fn the_appservice_verbs_are_spelled_the_way_the_units_invoke_them() {
let cli =
Cli::try_parse_from(["swarm-matrix-ctl", "appservice", "render"]).expect("a verb");
assert!(matches!(
cli.command,
Command::Appservice(Appservice::Render)
));
let cli =
Cli::try_parse_from(["swarm-matrix-ctl", "appservice", "publish"]).expect("a verb");
assert!(matches!(
cli.command,
Command::Appservice(Appservice::Publish)
));
Cli::try_parse_from(["swarm-matrix-ctl", "appservice"])
.expect_err("a sub-verb is required");
}
#[test]
fn an_unknown_verb_is_refused() {
Cli::try_parse_from(["swarm-matrix-ctl", "conjure"])

View file

@ -21,7 +21,9 @@ use swarm_secret_client::{
matrix,
};
use crate::{homeserver, registration};
use swarm_matrix_client as homeserver;
use crate::registration;
/// Role on the store's `cert` auth mount to log in with. Its policy is what
/// allows the write below; the certificate the `BAO_*` variables name has to