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:
parent
fb9c6122df
commit
67ba28448f
23 changed files with 319 additions and 172 deletions
286
swarm-matrix-ctl/src/homeserver.rs
Normal file
286
swarm-matrix-ctl/src/homeserver.rs
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
//! 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");
|
||||
}
|
||||
}
|
||||
96
swarm-matrix-ctl/src/main.rs
Normal file
96
swarm-matrix-ctl/src/main.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
//! `swarm-matrix-ctl` — the rust that runs *inside* `containers.hive-matrix`.
|
||||
//!
|
||||
//! One binary with subcommands rather than one binary per job. The container
|
||||
//! is an awkward place to put code — it needs its own store identity, its own
|
||||
//! bind mounts and its own cert role — and all of that is per-*container*, not
|
||||
//! per-task. A second single-purpose crate would have had to duplicate the
|
||||
//! 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.
|
||||
//!
|
||||
//! 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
|
||||
//! applied to error messages.
|
||||
|
||||
mod homeserver;
|
||||
mod mint;
|
||||
mod registration;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "swarm-matrix-ctl",
|
||||
about = "Act on the swarm's matrix homeserver from inside its container"
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
/// Publish the appservice sender account's access token to the swarm
|
||||
/// secret store, once.
|
||||
///
|
||||
/// Configured entirely by the `MATRIX_MINT_*` environment the unit sets —
|
||||
/// no flags, because a systemd `Environment=` block is what a nix module
|
||||
/// can render and a command line full of paths is not.
|
||||
Mint,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
match Cli::parse().command {
|
||||
Command::Mint => mint::run().await,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::CommandFactory;
|
||||
|
||||
#[test]
|
||||
fn the_clap_tree_is_well_formed() {
|
||||
Cli::command().debug_assert();
|
||||
}
|
||||
|
||||
/// The unit's `ExecStart` names a verb, so a rename of it is a deploy-time
|
||||
/// failure with no local signal. This is that signal.
|
||||
#[test]
|
||||
fn mint_is_spelled_the_way_the_unit_invokes_it() {
|
||||
let cli = Cli::try_parse_from(["swarm-matrix-ctl", "mint"]).expect("`mint` is a verb");
|
||||
assert!(matches!(cli.command, Command::Mint));
|
||||
}
|
||||
|
||||
/// The control: without it the case above passes on a parser that accepts
|
||||
/// anything.
|
||||
#[test]
|
||||
fn an_unknown_verb_is_refused() {
|
||||
Cli::try_parse_from(["swarm-matrix-ctl", "conjure"])
|
||||
.expect_err("only declared verbs are accepted");
|
||||
}
|
||||
|
||||
/// A bare invocation must not silently do something. `mint` writes a
|
||||
/// credential, so "no verb" defaulting to it would make a typo in the unit
|
||||
/// mint rather than fail.
|
||||
#[test]
|
||||
fn no_verb_at_all_is_refused() {
|
||||
Cli::try_parse_from(["swarm-matrix-ctl"]).expect_err("a verb is required");
|
||||
}
|
||||
}
|
||||
260
swarm-matrix-ctl/src/mint.rs
Normal file
260
swarm-matrix-ctl/src/mint.rs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
//! `swarm-matrix-ctl mint` — publish the appservice sender account's
|
||||
//! homeserver access token to the swarm's secret store, once.
|
||||
//!
|
||||
//! A oneshot inside `containers.hive-matrix`, not a daemon and not part of the
|
||||
//! swarm controller. It is this account rather than an agent's because a
|
||||
//! homeserver has **one** appservice registration and so one sender account,
|
||||
//! and a swarm runs one homeserver: "only once" is a property of what is being
|
||||
//! minted, so there is nothing to lock and no trigger to serve.
|
||||
//!
|
||||
//! The store, not the homeserver, is the idempotency key — see
|
||||
//! [`already_published`]. On the hive side `hive-c0re`'s
|
||||
//! `matrix::ensure_hive_user` reads exactly the path written here, which is how
|
||||
//! a hive that holds no `as_token` still gets its matrix account.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use swarm_secret_client::{
|
||||
SecretStore,
|
||||
client::{DEFAULT_CERT_MOUNT, Settings},
|
||||
matrix,
|
||||
};
|
||||
|
||||
use crate::{homeserver, 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
|
||||
/// carry the CN that role accepts.
|
||||
const ENV_CERT_ROLE: &str = "MATRIX_MINT_CERT_ROLE";
|
||||
/// Client-server API base of the homeserver beside us — loopback, since the
|
||||
/// container shares the host netns.
|
||||
const ENV_API_URL: &str = "MATRIX_MINT_API_URL";
|
||||
/// The bind-mounted appservice registration, which is where the `as_token`
|
||||
/// comes from. A path, never a value.
|
||||
const ENV_REGISTRATION: &str = "MATRIX_MINT_REGISTRATION";
|
||||
/// Localpart of the appservice's sender account. The registration's own
|
||||
/// `sender_localpart`, and `hive-c0re`'s `matrix::HIVE_LOCALPART`.
|
||||
const ENV_LOCALPART: &str = "MATRIX_MINT_LOCALPART";
|
||||
/// Public base URL of the homeserver, stored beside the token so a reader can
|
||||
/// reconstruct where it is good for. Optional: a swarm with no gateway vhost
|
||||
/// has no such URL, and `matrix::Credential` types the field to say so.
|
||||
const ENV_HOMESERVER: &str = "MATRIX_MINT_HOMESERVER";
|
||||
|
||||
/// Everything the unit tells this verb, checked before anything is opened.
|
||||
///
|
||||
/// Separate from the work for the reason `swarm_secret_client::client::Settings`
|
||||
/// is: every arm is a misconfiguration an operator reads an error about, and
|
||||
/// none of them needs a reachable homeserver or store to happen.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Config {
|
||||
cert_role: String,
|
||||
api_url: String,
|
||||
registration: String,
|
||||
localpart: String,
|
||||
homeserver: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Read the `MATRIX_MINT_*` variables from the process environment.
|
||||
///
|
||||
/// # Errors
|
||||
/// Naming the first variable that is unset or empty.
|
||||
fn from_env() -> Result<Self> {
|
||||
Self::from_lookup(|k| std::env::var(k).ok())
|
||||
}
|
||||
|
||||
/// [`Config::from_env`] against an arbitrary lookup.
|
||||
///
|
||||
/// # Errors
|
||||
/// Naming the first variable that is unset or empty.
|
||||
fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
|
||||
let required = |var: &'static str| -> Result<String> {
|
||||
get(var)
|
||||
.filter(|v| !v.is_empty())
|
||||
.with_context(|| format!("{var} is unset or empty"))
|
||||
};
|
||||
Ok(Self {
|
||||
cert_role: required(ENV_CERT_ROLE)?,
|
||||
api_url: required(ENV_API_URL)?,
|
||||
registration: required(ENV_REGISTRATION)?,
|
||||
localpart: required(ENV_LOCALPART)?,
|
||||
// Empty is absent: systemd renders an unset nix option as
|
||||
// `Environment=VAR=`, so that is the shape this arrives in.
|
||||
homeserver: get(ENV_HOMESERVER).filter(|v| !v.is_empty()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the credential already in the store?
|
||||
///
|
||||
/// **This read is the "and only once".** The homeserver is not asked — a
|
||||
/// re-run of the container, or of this unit, costs one store read and stops.
|
||||
/// It is also the read-back of what a previous run wrote, so the path published
|
||||
/// and the path consulted cannot drift apart: they are one function call.
|
||||
///
|
||||
/// A failure to read is reported and treated as absent rather than raised. The
|
||||
/// two cases that reach it are a path that has never been written (the first
|
||||
/// run, which must go on to mint) and a token whose policy does not cover the
|
||||
/// path — and the second fails again, loudly and with the store's own message,
|
||||
/// at the write below.
|
||||
async fn already_published(store: &SecretStore, path: &str) -> bool {
|
||||
match store.read::<matrix::Credential>(path).await {
|
||||
Ok(credential) => !credential.value.trim().is_empty(),
|
||||
Err(e) => {
|
||||
tracing::info!(%path, error = %e, "nothing readable in the store yet");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the verb.
|
||||
///
|
||||
/// # Errors
|
||||
/// If the environment is incomplete, the store refuses the login or the write,
|
||||
/// the registration cannot be read, or the homeserver refuses both the
|
||||
/// registration and the appservice login.
|
||||
pub async fn run() -> Result<()> {
|
||||
let config = Config::from_env()?;
|
||||
// Explicitly, rather than through `SecretStore::from_env`: a missing or
|
||||
// misspelled `BAO_*` variable is the most likely thing to be wrong with a
|
||||
// freshly deployed unit, and this reports it before the homeserver is
|
||||
// touched at all.
|
||||
let settings = Settings::from_env().context("reading the store's BAO_* environment")?;
|
||||
let store = SecretStore::connect(&settings, &config.cert_role, DEFAULT_CERT_MOUNT)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"logging in to the swarm secret store as cert role {}",
|
||||
config.cert_role
|
||||
)
|
||||
})?;
|
||||
|
||||
let path = matrix::sender_token_path();
|
||||
if already_published(&store, &path).await {
|
||||
tracing::info!(%path, "the sender token is already published; not minting");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let as_token = registration::as_token(&config.registration)?;
|
||||
let http = homeserver::client()?;
|
||||
let token =
|
||||
match homeserver::register(&http, &config.api_url, &config.localpart, &as_token).await? {
|
||||
homeserver::Registered::Token(token) => token,
|
||||
homeserver::Registered::AlreadyExists => {
|
||||
// The expected arm, not an edge case: this account is the
|
||||
// appservice's own `sender_localpart`, so the homeserver creates it
|
||||
// when it loads the registration — before anything gets to ask.
|
||||
tracing::info!("the sender account exists; logging in as the appservice instead");
|
||||
homeserver::appservice_login(&http, &config.api_url, &config.localpart, &as_token)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
store
|
||||
.write(
|
||||
&path,
|
||||
&matrix::Credential {
|
||||
value: token,
|
||||
homeserver: config.homeserver,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("writing the sender token to {path}"))?;
|
||||
tracing::info!(%path, "published the sender token");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A lookup standing in for a fully-configured unit's environment.
|
||||
fn full(k: &str) -> Option<String> {
|
||||
match k {
|
||||
ENV_CERT_ROLE => Some("swarm-matrix-ctl".to_owned()),
|
||||
ENV_API_URL => Some("http://127.0.0.1:8008".to_owned()),
|
||||
ENV_REGISTRATION => {
|
||||
Some("/var/lib/hyperhive/matrix-appservice/hyperhive.yaml".to_owned())
|
||||
}
|
||||
ENV_LOCALPART => Some("hive".to_owned()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_complete_environment_is_accepted() {
|
||||
// The control: without it every assertion below could be passing
|
||||
// because `from_lookup` rejects everything.
|
||||
let c = Config::from_lookup(full).expect("every required variable is set");
|
||||
assert_eq!(c.localpart, "hive");
|
||||
assert_eq!(c.homeserver, None, "an absent public URL is not an error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_required_variable_is_named_when_it_is_the_missing_one() {
|
||||
for var in [ENV_CERT_ROLE, ENV_API_URL, ENV_REGISTRATION, ENV_LOCALPART] {
|
||||
let e = Config::from_lookup(|k| if k == var { None } else { full(k) })
|
||||
.expect_err("one required variable is absent");
|
||||
assert!(
|
||||
format!("{e}").contains(var),
|
||||
"dropping {var} should name {var}, got {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_variable_is_as_absent_as_an_unset_one() {
|
||||
// systemd writes `Environment=VAR=` for an unset nix option, so empty
|
||||
// is the shape these actually arrive in.
|
||||
let e = Config::from_lookup(|k| {
|
||||
if k == ENV_CERT_ROLE {
|
||||
Some(String::new())
|
||||
} else {
|
||||
full(k)
|
||||
}
|
||||
})
|
||||
.expect_err("an empty role is not a role");
|
||||
assert!(format!("{e}").contains(ENV_CERT_ROLE), "{e}");
|
||||
|
||||
let c = Config::from_lookup(|k| {
|
||||
if k == ENV_HOMESERVER {
|
||||
Some(String::new())
|
||||
} else {
|
||||
full(k)
|
||||
}
|
||||
})
|
||||
.expect("an empty public URL is optional, not fatal");
|
||||
assert_eq!(c.homeserver, None);
|
||||
}
|
||||
|
||||
/// The environment prefix is a contract with the nix unit, and the crate
|
||||
/// rename that produced it moved every one of these. A verb-scoped prefix
|
||||
/// is the point: the next verb brings its own, instead of widening a
|
||||
/// binary-scoped one nobody can then narrow.
|
||||
#[test]
|
||||
fn every_variable_is_scoped_to_the_verb() {
|
||||
for var in [
|
||||
ENV_CERT_ROLE,
|
||||
ENV_API_URL,
|
||||
ENV_REGISTRATION,
|
||||
ENV_LOCALPART,
|
||||
ENV_HOMESERVER,
|
||||
] {
|
||||
assert!(
|
||||
var.starts_with("MATRIX_MINT_"),
|
||||
"{var} is not scoped to the mint verb"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_published_path_is_the_one_the_hive_reads() {
|
||||
// Both ends of this slice's loop resolve the same function, so there is
|
||||
// no second spelling to drift — this pins that the loop exists at all,
|
||||
// and names the literal so a move of the path is a deliberate edit on
|
||||
// both sides rather than a silent 404 on the reading one.
|
||||
assert_eq!(
|
||||
matrix::sender_token_path(),
|
||||
"swarm/services/matrix/sender-token"
|
||||
);
|
||||
}
|
||||
}
|
||||
104
swarm-matrix-ctl/src/registration.rs
Normal file
104
swarm-matrix-ctl/src/registration.rs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
//! 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue