swarm-matrix-ctl: one control binary for the matrix container, not one per job

Renames `swarm-matrix-minter` and reshapes it around subcommands. Minting
is now `swarm-matrix-ctl mint`.

Running rust inside `containers.hive-matrix` is not free: it needs its own
store identity, its own cert role and its own bind mounts, and every one of
those is per-*container*, not per-task. A second single-purpose crate would
have had to duplicate that plumbing to add one action, so the next thing
that has to run in there should be a verb here rather than a new crate.
The old name guaranteed the opposite.

`main.rs` is clap dispatch; the minting logic moves to `mint.rs` unchanged.
A bare invocation is refused: `mint` writes a credential, so "no verb"
defaulting to it would make a typo in the unit mint rather than fail.

The environment prefix moves with it, `MATRIX_MINTER_*` → `MATRIX_MINT_*`.
Scoped to the verb and not to the binary, because a binary-scoped prefix is
one the next verb has to share or widen, and a widened one never narrows
again. A test asserts every variable carries the verb's prefix.

The principal renames too. The cert role, bao policy, granting unit, leaf
filename and `certAuthCns` entry all have to spell one string the same way,
so leaving them as `swarm-matrix-minter` would have rebuilt the naming
split this branch exists to remove. Renaming the nix options alongside is
free here: every one of them is introduced by this PR and has never been
released, so no operator config names them yet.

`ExecStart` now names the verb, which is a contract between a nix string
and a clap enum that fails at deploy time with no local signal. Both ends
assert it: `mint_is_spelled_the_way_the_unit_invokes_it` in the crate, and
a new module-eval arm reading the rendered `ExecStart`.

docs/getting-started/setup.md drops the sender token from its "live on the
host" list: setup does not touch this credential, so a setup guide has no
reason to name it.
This commit is contained in:
atlas 2026-09-20 14:29:45 +02:00 committed by mara
commit 67ba28448f
23 changed files with 319 additions and 172 deletions

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