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
|
|
@ -1,249 +0,0 @@
|
|||
//! Mint the matrix appservice sender account's homeserver access token, once, and publish it to the
|
||||
//! swarm's secret store.
|
||||
//!
|
||||
//! A oneshot inside `containers.hive-matrix`, not a daemon and not part of the
|
||||
//! swarm controller. It is 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. It is
|
||||
//! this account rather than an agent's because a homeserver has **one** `@hive:`
|
||||
//! 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.
|
||||
//!
|
||||
//! 🩸 A secret is a path, never a value. The only identifier this binary logs is
|
||||
//! the store path; see `homeserver`'s module doc for the same rule applied to
|
||||
//! error messages.
|
||||
|
||||
mod homeserver;
|
||||
mod registration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use swarm_secret_client::{
|
||||
SecretStore,
|
||||
client::{DEFAULT_CERT_MOUNT, Settings},
|
||||
matrix,
|
||||
};
|
||||
|
||||
/// 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_MINTER_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_MINTER_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_MINTER_REGISTRATION";
|
||||
/// Localpart of the hive's account. The appservice registration's own
|
||||
/// `sender_localpart`, and `hive-c0re`'s `matrix::HIVE_LOCALPART`.
|
||||
const ENV_LOCALPART: &str = "MATRIX_MINTER_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_MINTER_HOMESERVER";
|
||||
|
||||
/// Everything the unit tells this process, 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_MINTER_*` 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
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 @hive: 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-minter".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);
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue