The swarm gets an appservice identity of its own, separate from each hive's `hyperhive` registration. `swarm-matrix-ctl appservice render` mints its tokens inside the matrix container when they are absent and renders the registration tuwunel loads; `appservice publish` writes its as_token to `swarm/controller/swarm-controller/matrix/appservice-token`, the one kind no hive's policy grants. The homeserver calls move out of swarm-matrix-ctl into swarm-matrix-client, with a `whoami`, so swarm-controller can mint agents' accounts through the same pinned device id instead of a copy of them.
303 lines
12 KiB
Rust
303 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 swarm_matrix_client as homeserver;
|
|
|
|
use crate::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));
|
|
}
|
|
}
|