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.
279 lines
10 KiB
Rust
279 lines
10 KiB
Rust
//! `swarm-matrix-ctl appservice` — the **swarm's** own appservice
|
|
//! registration: minted here, loaded by the homeserver beside us, and published
|
|
//! to the one store path `swarm-controller` reads it from.
|
|
//!
|
|
//! Two verbs, because they have opposite failure rules:
|
|
//!
|
|
//! - [`render`] runs before tuwunel and touches nothing but this container's
|
|
//! state dir. tuwunel loads the file it writes through `LoadCredential`, and
|
|
//! a missing credential source fails the homeserver's start, so this half
|
|
//! must not be able to fail on a network.
|
|
//! - [`publish`] runs after it and needs the store. A sealed store delays it
|
|
//! and nothing else.
|
|
//!
|
|
//! "Only once": the token file in the state dir is the record. [`render`]
|
|
//! mints only when it is absent and re-renders from it every time; [`publish`]
|
|
//! writes the store only when the store's copy differs.
|
|
//!
|
|
//! This registration's sender is promoted to homeserver admin at boot
|
|
//! (`nix/host-modules/hive-matrix.nix`), which is why its token goes to
|
|
//! `swarm_secret_client::matrix::swarm_appservice_token_path` — a path no
|
|
//! hive's policy reaches — and to nowhere else.
|
|
|
|
use std::io::Write as _;
|
|
use std::os::unix::fs::OpenOptionsExt as _;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{Context, Result};
|
|
use swarm_secret_client::{
|
|
SecretStore,
|
|
client::{DEFAULT_CERT_MOUNT, Settings},
|
|
matrix,
|
|
};
|
|
|
|
use crate::registration;
|
|
|
|
/// This container's state dir for the registration and its two tokens.
|
|
const ENV_DIR: &str = "MATRIX_APPSERVICE_DIR";
|
|
/// The registration's `sender_localpart`: the account the homeserver creates
|
|
/// for it and the one `admin_execute` promotes.
|
|
const ENV_SENDER: &str = "MATRIX_APPSERVICE_SENDER";
|
|
/// The user namespace regex, rendered by nix beside the hive registration's.
|
|
const ENV_USER_REGEX: &str = "MATRIX_APPSERVICE_USER_REGEX";
|
|
/// Role on the store's `cert` auth mount that [`publish`] logs in with.
|
|
const ENV_CERT_ROLE: &str = "MATRIX_APPSERVICE_CERT_ROLE";
|
|
|
|
/// The registration's `id`. Distinct from the hive registration's
|
|
/// (`hyperhive`): tuwunel refuses two registrations with one id.
|
|
const ID: &str = "swarm";
|
|
/// File names inside [`ENV_DIR`]. `REGISTRATION` is what tuwunel loads.
|
|
const AS_TOKEN: &str = "as-token";
|
|
const HS_TOKEN: &str = "hs-token";
|
|
const REGISTRATION: &str = "swarm.yaml";
|
|
/// Random bytes per token, as the hive registration's renderer uses.
|
|
const TOKEN_BYTES: usize = 32;
|
|
|
|
/// Read a required variable.
|
|
fn required(get: &impl Fn(&str) -> Option<String>, var: &'static str) -> Result<String> {
|
|
get(var)
|
|
.filter(|v| !v.is_empty())
|
|
.with_context(|| format!("{var} is unset or empty"))
|
|
}
|
|
|
|
/// Mint the tokens if absent and render the registration from them.
|
|
///
|
|
/// # Errors
|
|
/// If a variable is missing, or the state dir cannot be read or written.
|
|
pub fn render() -> Result<()> {
|
|
let get = |k: &str| std::env::var(k).ok();
|
|
let dir = PathBuf::from(required(&get, ENV_DIR)?);
|
|
let sender = required(&get, ENV_SENDER)?;
|
|
let regex = required(&get, ENV_USER_REGEX)?;
|
|
render_into(&dir, &sender, ®ex)?;
|
|
tracing::info!(path = %dir.join(REGISTRATION).display(), "rendered the swarm appservice registration");
|
|
Ok(())
|
|
}
|
|
|
|
/// [`render`] against an explicit directory, so a test can run it twice.
|
|
fn render_into(dir: &Path, sender: &str, regex: &str) -> Result<()> {
|
|
let as_token = existing_or_minted(&dir.join(AS_TOKEN))?;
|
|
let hs_token = existing_or_minted(&dir.join(HS_TOKEN))?;
|
|
write_secret(
|
|
&dir.join(REGISTRATION),
|
|
®istration_yaml(sender, regex, &as_token, &hs_token),
|
|
)
|
|
}
|
|
|
|
/// The token at `path`, minting and writing one first when there is none.
|
|
fn existing_or_minted(path: &Path) -> Result<String> {
|
|
match std::fs::read_to_string(path) {
|
|
Ok(t) if !t.trim().is_empty() => return Ok(t.trim().to_owned()),
|
|
Ok(_) => {}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
|
|
}
|
|
let token = swarm_matrix_client::random_hex(TOKEN_BYTES)?;
|
|
write_secret(path, &token)?;
|
|
tracing::info!(path = %path.display(), "minted a swarm appservice token");
|
|
Ok(token)
|
|
}
|
|
|
|
/// Write `contents` to `path` as a `0600` file, through a rename so a reader
|
|
/// never sees half of it.
|
|
fn write_secret(path: &Path, contents: &str) -> Result<()> {
|
|
let tmp = path.with_extension("tmp");
|
|
let _ = std::fs::remove_file(&tmp);
|
|
let mut f = std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.mode(0o600)
|
|
.open(&tmp)
|
|
.with_context(|| format!("creating {}", tmp.display()))?;
|
|
f.write_all(contents.as_bytes())
|
|
.and_then(|()| f.sync_all())
|
|
.with_context(|| format!("writing {}", tmp.display()))?;
|
|
std::fs::rename(&tmp, path).with_context(|| format!("renaming onto {}", path.display()))
|
|
}
|
|
|
|
/// The registration, in the shape `hive-matrix.nix` renders the hive's.
|
|
///
|
|
/// `exclusive: false` for that file's reason: an exclusive namespace does not
|
|
/// widen what this appservice may do, it refuses everyone else — and the hive
|
|
/// registration covers the same names until it is retired.
|
|
fn registration_yaml(sender: &str, regex: &str, as_token: &str, hs_token: &str) -> String {
|
|
format!(
|
|
"id: {ID}\n\
|
|
url: null\n\
|
|
sender_localpart: {sender}\n\
|
|
rate_limited: false\n\
|
|
namespaces:\n \
|
|
users:\n \
|
|
- exclusive: false\n \
|
|
regex: '{regex}'\n \
|
|
aliases: []\n \
|
|
rooms: []\n\
|
|
as_token: {as_token}\n\
|
|
hs_token: {hs_token}\n"
|
|
)
|
|
}
|
|
|
|
/// Whether the store needs the local token written to it.
|
|
fn needs_publish(stored: Option<&matrix::Credential>, local: &str) -> bool {
|
|
stored.is_none_or(|c| c.value.trim() != local)
|
|
}
|
|
|
|
/// Write the rendered registration's `as_token` to the store, unless the store
|
|
/// already holds it.
|
|
///
|
|
/// # Errors
|
|
/// If a variable is missing, the registration has not been rendered, or the
|
|
/// store refuses the login, the read or the write.
|
|
pub async fn publish() -> Result<()> {
|
|
let get = |k: &str| std::env::var(k).ok();
|
|
let dir = PathBuf::from(required(&get, ENV_DIR)?);
|
|
let cert_role = required(&get, ENV_CERT_ROLE)?;
|
|
let registration = dir.join(REGISTRATION);
|
|
let local = registration::as_token(®istration.to_string_lossy())?;
|
|
|
|
let settings = Settings::from_env().context("reading the store's BAO_* environment")?;
|
|
let store = SecretStore::connect(&settings, &cert_role, DEFAULT_CERT_MOUNT)
|
|
.await
|
|
.with_context(|| {
|
|
format!("logging in to the swarm secret store as cert role {cert_role}")
|
|
})?;
|
|
let path = matrix::swarm_appservice_token_path()?;
|
|
let stored: Option<matrix::Credential> = store
|
|
.read_optional(&path)
|
|
.await
|
|
.with_context(|| format!("reading {path}"))?;
|
|
if !needs_publish(stored.as_ref(), &local) {
|
|
tracing::info!(%path, "the swarm appservice token is already published");
|
|
return Ok(());
|
|
}
|
|
store
|
|
.write(
|
|
&path,
|
|
&matrix::Credential {
|
|
value: local,
|
|
homeserver: None,
|
|
},
|
|
)
|
|
.await
|
|
.with_context(|| format!("writing the swarm appservice token to {path}"))?;
|
|
tracing::info!(%path, "published the swarm appservice token");
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const REGEX: &str = "^@[a-z0-9._=/-]+:t\\.local$";
|
|
|
|
fn scratch() -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"swarm-appservice-{}-{}",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.expect("after the epoch")
|
|
.as_nanos()
|
|
));
|
|
std::fs::create_dir_all(&dir).expect("temp dir");
|
|
dir
|
|
}
|
|
|
|
#[test]
|
|
fn the_rendered_registration_carries_the_token_it_minted() {
|
|
let dir = scratch();
|
|
render_into(&dir, "swarm", REGEX).expect("renders");
|
|
let token = registration::as_token(&dir.join(REGISTRATION).to_string_lossy())
|
|
.expect("the as_token line parses");
|
|
assert_eq!(token.len(), TOKEN_BYTES * 2);
|
|
assert_eq!(
|
|
std::fs::read_to_string(dir.join(AS_TOKEN)).expect("minted"),
|
|
token
|
|
);
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn a_second_render_keeps_the_token() {
|
|
// "Only once": a re-mint on every boot would hand the controller a
|
|
// token the homeserver no longer loads until publish catches up.
|
|
let dir = scratch();
|
|
render_into(&dir, "swarm", REGEX).expect("first");
|
|
let first = std::fs::read_to_string(dir.join(REGISTRATION)).expect("rendered");
|
|
render_into(&dir, "swarm", REGEX).expect("second");
|
|
let second = std::fs::read_to_string(dir.join(REGISTRATION)).expect("rendered");
|
|
assert_eq!(first, second);
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn the_registration_is_the_swarms_and_not_the_hives() {
|
|
let y = registration_yaml("swarm", REGEX, "aa", "bb");
|
|
assert!(y.starts_with("id: swarm\n"), "{y}");
|
|
assert!(y.contains("\nsender_localpart: swarm\n"), "{y}");
|
|
assert!(y.contains("\n - exclusive: false\n"), "{y}");
|
|
assert!(y.contains(&format!("regex: '{REGEX}'")), "{y}");
|
|
assert!(y.contains("\nurl: null\n"), "{y}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_registration_and_tokens_are_owner_only() {
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
let dir = scratch();
|
|
render_into(&dir, "swarm", REGEX).expect("renders");
|
|
for f in [AS_TOKEN, HS_TOKEN, REGISTRATION] {
|
|
let mode = std::fs::metadata(dir.join(f))
|
|
.expect("exists")
|
|
.permissions()
|
|
.mode();
|
|
assert_eq!(mode & 0o777, 0o600, "{f}");
|
|
}
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn publish_writes_only_what_the_store_lacks() {
|
|
let same = matrix::Credential {
|
|
value: "aa".to_owned(),
|
|
homeserver: None,
|
|
};
|
|
assert!(!needs_publish(Some(&same), "aa"));
|
|
assert!(needs_publish(None, "aa"));
|
|
let other = matrix::Credential {
|
|
value: "bb".to_owned(),
|
|
homeserver: None,
|
|
};
|
|
assert!(needs_publish(Some(&other), "aa"));
|
|
}
|
|
|
|
#[test]
|
|
fn every_variable_is_scoped_to_the_verb() {
|
|
for var in [ENV_DIR, ENV_SENDER, ENV_USER_REGEX, ENV_CERT_ROLE] {
|
|
assert!(var.starts_with("MATRIX_APPSERVICE_"), "{var}");
|
|
}
|
|
}
|
|
}
|