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.
104 lines
4.1 KiB
Rust
104 lines
4.1 KiB
Rust
//! Reading the `as_token` out of the appservice registration the matrix
|
|
//! container already has.
|
|
//!
|
|
//! No new credential is delivered for this. `nix/host-modules/hive-matrix.nix`
|
|
//! binds the registration directory into the container read-only so tuwunel can
|
|
//! load it, and the registration **is** the `as_token` — so the file this
|
|
//! module opens is one this process could already read, and one the homeserver
|
|
//! beside it reads too.
|
|
//!
|
|
//! Scanned line-by-line rather than parsed as YAML. The file has exactly one
|
|
//! renderer (`appserviceRegistrationScript` in that same module, a `printf` of
|
|
//! `as_token: <hex>`), so a parser would be a second, looser reading of a shape
|
|
//! this repo writes itself — and it would pull a YAML crate into a binary whose
|
|
//! only other input is JSON.
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
|
|
/// The key the token is stored under, and the whole of the agreement with the
|
|
/// renderer.
|
|
const KEY: &str = "as_token:";
|
|
|
|
/// Read the registration at `path` and return its `as_token`.
|
|
///
|
|
/// # Errors
|
|
/// When the file cannot be read, or holds no `as_token` with a value — which is
|
|
/// what a registration rendered by something other than this repo looks like
|
|
/// from here.
|
|
pub fn as_token(path: &str) -> Result<String> {
|
|
let text = std::fs::read_to_string(path)
|
|
.with_context(|| format!("reading the appservice registration at {path}"))?;
|
|
// The path, not the file's contents: every line of it is either a secret or
|
|
// a shape this module already knows.
|
|
extract(&text).with_context(|| format!("no `as_token` in the registration at {path}"))
|
|
}
|
|
|
|
/// [`as_token`] over text already in hand, so the agreement with the renderer
|
|
/// can be tested without a file.
|
|
fn extract(text: &str) -> Result<String> {
|
|
for line in text.lines() {
|
|
if let Some(rest) = line.strip_prefix(KEY) {
|
|
let token = rest.trim();
|
|
if token.is_empty() {
|
|
bail!("the registration's `as_token` is empty");
|
|
}
|
|
return Ok(token.to_owned());
|
|
}
|
|
}
|
|
bail!("the registration carries no `as_token` line")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The registration exactly as `appserviceRegistrationScript` renders it —
|
|
/// the quoted heredoc, then the `printf` of the two tokens. Reproduced
|
|
/// verbatim because that script is the other end of this agreement and
|
|
/// lives in a file no Rust test can reach.
|
|
const RENDERED: &str = "id: hyperhive\n\
|
|
url: null\n\
|
|
sender_localpart: hive\n\
|
|
rate_limited: false\n\
|
|
namespaces:\n \
|
|
users:\n \
|
|
- exclusive: false\n \
|
|
regex: '@[a-z0-9._=/+-]+:example\\.test$'\n \
|
|
aliases: []\n \
|
|
rooms: []\n\
|
|
as_token: deadbeef\n\
|
|
hs_token: cafebabe\n";
|
|
|
|
#[test]
|
|
fn the_token_is_taken_from_the_registration_this_repo_renders() {
|
|
assert_eq!(
|
|
extract(RENDERED).expect("the rendered shape parses"),
|
|
"deadbeef"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_homeservers_own_token_is_not_mistaken_for_the_appservices() {
|
|
// `hs_token` authenticates the homeserver TO the appservice and is a
|
|
// different secret with a confusingly similar name; a substring search
|
|
// would find it inside neither, but a `contains("s_token")`-shaped one
|
|
// would. The control is that the line order in `RENDERED` puts
|
|
// `as_token` first, so this arm needs the reverse to mean anything.
|
|
let reversed = "hs_token: cafebabe\nas_token: deadbeef\n";
|
|
assert_eq!(
|
|
extract(reversed).expect("order does not matter"),
|
|
"deadbeef"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_registration_with_no_token_is_an_error_rather_than_an_empty_string() {
|
|
// An empty token authenticates nothing, and a homeserver answers a
|
|
// request carrying one with a 403 that names the account rather than
|
|
// the credential — so failing here is the only report an operator can
|
|
// act on.
|
|
for bad in ["id: hyperhive\n", "as_token:\n", "as_token: \n"] {
|
|
assert!(extract(bad).is_err(), "{bad:?} must not yield a token");
|
|
}
|
|
}
|
|
}
|