//! `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, var: &'static str) -> Result { 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 { 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 = 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}"); } } }