A swarm runs one homeserver and every hive on it logged in as the same `@hive:` localpart, holding the same access token out of one swarm-wide store path. That is one matrix identity for N hives: the homeserver cannot attribute an action to the hive that took it, and revoking one hive's standing revokes every hive's. Three changes, and the third is the one that makes the other two real: - **The localpart carries the hive's name** (`hive-<hive>`), derived in one place, `swarm_secret_client::matrix::hive_localpart`. `hive-matrix.nix` renders the same string as the appservice registration's `sender_localpart`, so the shared account stops being created rather than merely stops being used. - **The store path is templated by hive**, not a constant. The "a swarm runs one homeserver, so this is a constant rather than a parameter" rationale went with it; it stopped holding the moment two hives shared the homeserver it describes. - **The path moved out from under the grant every hive has.** It sat at `swarm/services/matrix/sender-token`, inside the `secret/data/swarm/services/*` read stanza `policy::render` gives every hive. It now sits under that hive's own stanza, `secret/data/swarm/hives/<hive>/*`, which interpolates the reader's name — so a hive reads its own token and is refused another's. The policy renderer itself is unchanged: narrowing the `services/*` grant would break the OIDC-secret read it exists for, and moving the credential is what this needed instead. A policy test walks the rendered stanzas and asserts none of hive alpha's covers hive beta's sender token, so a later stanza that widened it fails here. `swarm-matrix-ctl` takes a new required `MATRIX_MINT_HIVE` and writes that hive's path; its store grant in `swarm-bao.nix` follows, scoped to one hive's leaf via the new `deploy.bao.matrixCtlHiveName` (defaulting to this host's `hiveName`) rather than a `hives/*` wildcard, which would hand the matrix container every hive's token back. Migration: no outage at deploy. `ensure_hive_user` short-circuits on the local token file, so a hive keeps running on what it has; with no such file it reads the new per-hive path, finds nothing, and falls through to the existing register-or-appservice-login ladder against its own localpart — which needs only the per-hive `as_token` on local disk. The old shared object is read by nothing afterwards. Rooms do not follow the identity, and that is the one operator step; both ways out are written into `docs/integrations/matrix.md`. No admin standing is granted to the per-hive accounts: `admin_execute` stays empty and the assertion pinning it is untouched.
301 lines
12 KiB
Rust
301 lines
12 KiB
Rust
//! `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 mints for **one hive** — the hive this container runs
|
|
//! on, named by [`ENV_HIVE`] — and publishes to that hive's own path, so a
|
|
//! swarm whose hives share a homeserver gets one account and one token per
|
|
//! hive rather than one shared between all of them. "Only once" is therefore
|
|
//! once per hive, and it is still a property of what is being minted rather
|
|
//! than of a lock: nothing else writes that path.
|
|
//!
|
|
//! 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 this hive's sender account. The registration's own
|
|
/// `sender_localpart`, rendered by `hive-matrix.nix` from the hive name — the
|
|
/// same string `swarm_secret_client::matrix::hive_localpart` builds, which is
|
|
/// what `hive-c0re` derives its own copy with.
|
|
const ENV_LOCALPART: &str = "MATRIX_MINT_LOCALPART";
|
|
/// Name of the hive this container belongs to, and so the segment of the store
|
|
/// path the token is published under. It is what keeps one hive's token out of
|
|
/// another hive's reach — see `swarm_secret_client::matrix::sender_token_path`.
|
|
const ENV_HIVE: &str = "MATRIX_MINT_HIVE";
|
|
/// 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,
|
|
hive: 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)?,
|
|
hive: required(ENV_HIVE)?,
|
|
// 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(&config.hive)
|
|
.with_context(|| format!("building the store path for hive {}", config.hive))?;
|
|
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-pr1ma".to_owned()),
|
|
ENV_HIVE => Some("pr1ma".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-pr1ma");
|
|
assert_eq!(c.hive, "pr1ma");
|
|
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,
|
|
ENV_HIVE,
|
|
] {
|
|
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_HIVE,
|
|
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("pr1ma").expect("a plain name is legal"),
|
|
"swarm/hives/pr1ma/matrix/sender-token"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn two_hives_are_published_to_two_paths() {
|
|
// What the hive segment is FOR: this binary runs beside a homeserver
|
|
// several hives share, so a path without the hive name in it would
|
|
// have each run overwrite the last and leave every hive holding one
|
|
// identity — which is the shape this change exists to end.
|
|
let a = matrix::sender_token_path("alpha").expect("legal");
|
|
let b = matrix::sender_token_path("beta").expect("legal");
|
|
assert_ne!(a, b);
|
|
}
|
|
|
|
#[test]
|
|
fn the_localpart_the_unit_hands_over_is_the_one_derived_from_the_hive() {
|
|
// The nix unit renders both variables independently; this pins that
|
|
// the pair it is expected to render agrees with the shared derivation,
|
|
// so a unit still passing the old bare `hive` fails here rather than
|
|
// silently logging in as another hive's account.
|
|
let c = Config::from_lookup(full).expect("every required variable is set");
|
|
assert_eq!(c.localpart, matrix::hive_localpart(&c.hive));
|
|
}
|
|
}
|