swarm-matrix-ctl: mint the swarm's own appservice registration
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.
This commit is contained in:
parent
cb176c7be7
commit
89aff8d613
10 changed files with 523 additions and 23 deletions
11
Cargo.lock
generated
11
Cargo.lock
generated
|
|
@ -4935,14 +4935,23 @@ dependencies = [
|
||||||
"swarm-queue-client",
|
"swarm-queue-client",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "swarm-matrix-client"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"reqwest",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swarm-matrix-ctl"
|
name = "swarm-matrix-ctl"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
"reqwest",
|
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"swarm-matrix-client",
|
||||||
"swarm-secret-client",
|
"swarm-secret-client",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ members = [
|
||||||
"swarm-authelia-bridge",
|
"swarm-authelia-bridge",
|
||||||
"swarm-authelia-bridge-sock",
|
"swarm-authelia-bridge-sock",
|
||||||
"swarm-controller",
|
"swarm-controller",
|
||||||
|
"swarm-matrix-client",
|
||||||
"swarm-matrix-ctl",
|
"swarm-matrix-ctl",
|
||||||
"swarm-nats-auth",
|
"swarm-nats-auth",
|
||||||
"swarm-queue-client",
|
"swarm-queue-client",
|
||||||
|
|
@ -101,6 +102,7 @@ hive-priv-sock = { path = "hive-priv-sock" }
|
||||||
hive-sock-client = { path = "hive-sock-client" }
|
hive-sock-client = { path = "hive-sock-client" }
|
||||||
hive-types = { path = "hive-types" }
|
hive-types = { path = "hive-types" }
|
||||||
swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" }
|
swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" }
|
||||||
|
swarm-matrix-client = { path = "swarm-matrix-client" }
|
||||||
swarm-queue-client = { path = "swarm-queue-client" }
|
swarm-queue-client = { path = "swarm-queue-client" }
|
||||||
swarm-secret-client = { path = "swarm-secret-client" }
|
swarm-secret-client = { path = "swarm-secret-client" }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
|
|
||||||
13
swarm-matrix-client/Cargo.toml
Normal file
13
swarm-matrix-client/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
[package]
|
||||||
|
name = "swarm-matrix-client"
|
||||||
|
version.workspace = true
|
||||||
|
readme = "README.md"
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
17
swarm-matrix-client/README.md
Normal file
17
swarm-matrix-client/README.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# swarm-matrix-client
|
||||||
|
|
||||||
|
The appservice calls against the swarm's homeserver, shared by the two binaries
|
||||||
|
that make them: `swarm-matrix-ctl` (inside the matrix container, for a hive's
|
||||||
|
sender account) and `swarm-controller` (for each agent's account, with the
|
||||||
|
swarm's own appservice token).
|
||||||
|
|
||||||
|
Three calls, all client-server API: register an account as the appservice, log
|
||||||
|
in to an existing one as the appservice, and ask a token who it is. Both mints
|
||||||
|
pin the device id `hyperhive-<localpart>`, so a second login **replaces** that
|
||||||
|
device's token instead of adding a device.
|
||||||
|
|
||||||
|
## 🩸 A secret is a path, never a value
|
||||||
|
|
||||||
|
Every error here is built from the response's status and its `errcode`, never
|
||||||
|
its body: a successful `/register` or `/login` body is an access token, and an
|
||||||
|
error body is one malformed response away from being the same bytes.
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
//! The two calls the mint ladder is made of, against the homeserver next door.
|
//! The appservice calls against the swarm's homeserver: register, log in, and
|
||||||
|
//! ask a token who it is. Shared by `swarm-matrix-ctl` and `swarm-controller`;
|
||||||
|
//! see the README for why both mint through the same device id.
|
||||||
//!
|
//!
|
||||||
//! 🩸 **Every error in this module is built from the response's `status` and
|
//! 🩸 **Every error in this module is built from the response's `status` and
|
||||||
//! its `errcode`, never its body.** A successful `/register` or `/login` body
|
//! its `errcode`, never its body.** A successful `/register` or `/login` body
|
||||||
|
|
@ -8,8 +10,8 @@
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
|
|
||||||
/// Client-server API calls are one round trip each against a homeserver in the
|
/// Every call here is one client-server API round trip; a slow one is a broken
|
||||||
/// same netns; a slow one is a broken one.
|
/// one.
|
||||||
const TIMEOUT_SECS: u64 = 10;
|
const TIMEOUT_SECS: u64 = 10;
|
||||||
|
|
||||||
/// Bytes of the throwaway password `/register` is given.
|
/// Bytes of the throwaway password `/register` is given.
|
||||||
|
|
@ -21,11 +23,11 @@ const PASSWORD_BYTES: usize = 32;
|
||||||
|
|
||||||
/// What the homeserver said about a registration attempt.
|
/// What the homeserver said about a registration attempt.
|
||||||
///
|
///
|
||||||
/// An enum rather than a string match on the error text: `M_USER_IN_USE` is the
|
/// An enum rather than a string match on the error text: `M_USER_IN_USE` is an
|
||||||
/// *expected* answer here — the hive's `@hive-<hive>:` account is the appservice
|
/// *expected* answer — a registration's own `sender_localpart` is created by
|
||||||
/// registration's own `sender_localpart`, so the homeserver creates it at startup, before
|
/// the homeserver at startup, and an agent minted once before already exists —
|
||||||
/// anything gets to ask — and an expected answer should not have to be
|
/// and an expected answer should not have to be recovered from a formatted
|
||||||
/// recovered from a formatted message.
|
/// message.
|
||||||
pub enum Registered {
|
pub enum Registered {
|
||||||
/// A fresh account, and the access token minted with it.
|
/// A fresh account, and the access token minted with it.
|
||||||
Token(String),
|
Token(String),
|
||||||
|
|
@ -68,7 +70,7 @@ pub async fn register(
|
||||||
// request carries the as_token.
|
// request carries the as_token.
|
||||||
"type": "m.login.application_service",
|
"type": "m.login.application_service",
|
||||||
"username": localpart,
|
"username": localpart,
|
||||||
"password": random_password()?,
|
"password": random_hex(PASSWORD_BYTES)?,
|
||||||
"device_id": device_id(localpart),
|
"device_id": device_id(localpart),
|
||||||
"initial_device_display_name": format!("hyperhive ({localpart})"),
|
"initial_device_display_name": format!("hyperhive ({localpart})"),
|
||||||
"inhibit_login": false,
|
"inhibit_login": false,
|
||||||
|
|
@ -140,6 +142,52 @@ pub async fn appservice_login(
|
||||||
access_token(&json)
|
access_token(&json)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the homeserver says a token is.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Whoami {
|
||||||
|
/// A live token, and the full user id it authenticates as.
|
||||||
|
User(String),
|
||||||
|
/// `M_UNKNOWN_TOKEN`: the homeserver does not know this token, because a
|
||||||
|
/// later login on the same device replaced it or it was revoked.
|
||||||
|
UnknownToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the homeserver who `token` is.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// When the request cannot be sent, the response will not decode, or the
|
||||||
|
/// homeserver refuses with anything other than `M_UNKNOWN_TOKEN`. That one is
|
||||||
|
/// [`Whoami::UnknownToken`], because it is a verdict about the token and the
|
||||||
|
/// rest are not.
|
||||||
|
pub async fn whoami(client: &reqwest::Client, base: &str, token: &str) -> Result<Whoami> {
|
||||||
|
let resp = client
|
||||||
|
.get(format!("{base}/_matrix/client/v3/account/whoami"))
|
||||||
|
.bearer_auth(token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("GET /account/whoami: sending the request")?;
|
||||||
|
let status = resp.status();
|
||||||
|
let json = resp
|
||||||
|
.json::<serde_json::Value>()
|
||||||
|
.await
|
||||||
|
.context("GET /account/whoami: decoding the response as JSON")?;
|
||||||
|
whoami_verdict(status, &json)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`whoami`]'s reading of a response, apart from the IO.
|
||||||
|
fn whoami_verdict(status: reqwest::StatusCode, json: &serde_json::Value) -> Result<Whoami> {
|
||||||
|
if status.is_success() {
|
||||||
|
return json["user_id"]
|
||||||
|
.as_str()
|
||||||
|
.map(|u| Whoami::User(u.to_owned()))
|
||||||
|
.context("the homeserver's whoami response carried no `user_id`");
|
||||||
|
}
|
||||||
|
if errcode(json) == Some("M_UNKNOWN_TOKEN") {
|
||||||
|
return Ok(Whoami::UnknownToken);
|
||||||
|
}
|
||||||
|
bail!("the homeserver refused whoami: {}", why(status, json))
|
||||||
|
}
|
||||||
|
|
||||||
/// The device every token this binary mints is pinned to.
|
/// The device every token this binary mints is pinned to.
|
||||||
fn device_id(localpart: &str) -> String {
|
fn device_id(localpart: &str) -> String {
|
||||||
format!("hyperhive-{localpart}")
|
format!("hyperhive-{localpart}")
|
||||||
|
|
@ -195,18 +243,18 @@ fn why(status: reqwest::StatusCode, json: &serde_json::Value) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A throwaway password for [`register`], as hex.
|
/// `bytes` random bytes from `/dev/urandom`, as hex: [`register`]'s
|
||||||
|
/// throwaway password, and the appservice tokens `swarm-matrix-ctl` mints.
|
||||||
///
|
///
|
||||||
/// From `/dev/urandom` directly rather than through an RNG crate: this is the
|
/// From `/dev/urandom` directly rather than through an RNG crate: the kernel is
|
||||||
/// one random value the binary needs, and the kernel is already the source any
|
/// already the source any such crate would reach for here.
|
||||||
/// such crate would reach for here.
|
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// When `/dev/urandom` cannot be read.
|
/// When `/dev/urandom` cannot be read.
|
||||||
fn random_password() -> Result<String> {
|
pub fn random_hex(bytes: usize) -> Result<String> {
|
||||||
use std::io::Read as _;
|
use std::io::Read as _;
|
||||||
|
|
||||||
let mut buf = [0u8; PASSWORD_BYTES];
|
let mut buf = vec![0u8; bytes];
|
||||||
std::fs::File::open("/dev/urandom")
|
std::fs::File::open("/dev/urandom")
|
||||||
.context("opening /dev/urandom")?
|
.context("opening /dev/urandom")?
|
||||||
.read_exact(&mut buf)
|
.read_exact(&mut buf)
|
||||||
|
|
@ -275,11 +323,48 @@ mod tests {
|
||||||
assert!(!format!("{e}").contains("@hive:t.local"), "{e}");
|
assert!(!format!("{e}").contains("@hive:t.local"), "{e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whoami_reads_the_user_of_a_live_token() {
|
||||||
|
let json =
|
||||||
|
serde_json::json!({ "user_id": "@atlas:t.local", "device_id": "hyperhive-atlas" });
|
||||||
|
assert_eq!(
|
||||||
|
whoami_verdict(reqwest::StatusCode::OK, &json).expect("a live token"),
|
||||||
|
Whoami::User("@atlas:t.local".to_owned())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_replaced_token_is_a_verdict_and_not_an_error() {
|
||||||
|
// The arm a mint decides on: this is how a token some other login on
|
||||||
|
// the same device replaced looks.
|
||||||
|
let json = serde_json::json!({ "errcode": "M_UNKNOWN_TOKEN", "error": "Unknown token" });
|
||||||
|
assert_eq!(
|
||||||
|
whoami_verdict(reqwest::StatusCode::UNAUTHORIZED, &json).expect("a verdict"),
|
||||||
|
Whoami::UnknownToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn any_other_refusal_is_an_error_so_nobody_mints_on_it() {
|
||||||
|
// The control for the arm above: a 401 with another code, or a 5xx,
|
||||||
|
// says nothing about the token, and a caller that minted on it would
|
||||||
|
// rotate every token during an outage.
|
||||||
|
for (status, json) in [
|
||||||
|
(
|
||||||
|
reqwest::StatusCode::UNAUTHORIZED,
|
||||||
|
serde_json::json!({ "errcode": "M_MISSING_TOKEN" }),
|
||||||
|
),
|
||||||
|
(reqwest::StatusCode::BAD_GATEWAY, serde_json::json!({})),
|
||||||
|
] {
|
||||||
|
assert!(whoami_verdict(status, &json).is_err(), "{status}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_minted_password_is_hex_of_the_declared_length() {
|
fn a_minted_password_is_hex_of_the_declared_length() {
|
||||||
// The control on the hex fold: a short or non-hex password would be
|
// The control on the hex fold: a short or non-hex password would be
|
||||||
// accepted by the homeserver and only surface much later, if at all.
|
// accepted by the homeserver and only surface much later, if at all.
|
||||||
let pw = random_password().expect("/dev/urandom is readable");
|
let pw = random_hex(PASSWORD_BYTES).expect("/dev/urandom is readable");
|
||||||
assert_eq!(pw.len(), PASSWORD_BYTES * 2);
|
assert_eq!(pw.len(), PASSWORD_BYTES * 2);
|
||||||
assert!(pw.bytes().all(|b| b.is_ascii_hexdigit()), "not hex");
|
assert!(pw.bytes().all(|b| b.is_ascii_hexdigit()), "not hex");
|
||||||
}
|
}
|
||||||
|
|
@ -14,8 +14,10 @@ anyhow.workspace = true
|
||||||
# single-purpose binary: the next thing that has to run in the matrix container
|
# single-purpose binary: the next thing that has to run in the matrix container
|
||||||
# is a subcommand here, not a new crate.
|
# is a subcommand here, not a new crate.
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
reqwest.workspace = true
|
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
# The appservice calls, shared with `swarm-controller`, which mints agents'
|
||||||
|
# accounts through the same device id.
|
||||||
|
swarm-matrix-client.workspace = true
|
||||||
# The agreement this binary is one end of: where the credential lives, what the
|
# The agreement this binary is one end of: where the credential lives, what the
|
||||||
# object at that path holds, and the `BAO_*` spellings the unit sets.
|
# object at that path holds, and the `BAO_*` spellings the unit sets.
|
||||||
swarm-secret-client.workspace = true
|
swarm-secret-client.workspace = true
|
||||||
|
|
|
||||||
279
swarm-matrix-ctl/src/appservice.rs
Normal file
279
swarm-matrix-ctl/src/appservice.rs
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
//! `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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,18 +7,19 @@
|
||||||
//! identity plumbing to add one action, so the next thing that has to run in
|
//! identity plumbing to add one action, so the next thing that has to run in
|
||||||
//! here is a verb below, not a new crate.
|
//! here is a verb below, not a new crate.
|
||||||
//!
|
//!
|
||||||
//! Today that is one verb, [`mint`]: publish the appservice sender account's
|
//! [`mint`] publishes a hive's appservice sender token to the swarm's secret
|
||||||
//! homeserver access token to the swarm's secret store, once.
|
//! store, once. [`appservice`] mints the **swarm's** own appservice
|
||||||
|
//! registration and publishes its token for `swarm-controller`.
|
||||||
//!
|
//!
|
||||||
//! It lives in the container because the appservice `as_token` that authorises
|
//! It lives in the container because the appservice `as_token` that authorises
|
||||||
//! the mint is *already* there — the registration tuwunel loads is bind-mounted
|
//! the mint is *already* there — the registration tuwunel loads is bind-mounted
|
||||||
//! in — so no second holder of that secret is created.
|
//! in — so no second holder of that secret is created.
|
||||||
//!
|
//!
|
||||||
//! 🩸 **A secret is a path, never a value.** The only identifier any verb here
|
//! 🩸 **A secret is a path, never a value.** The only identifier any verb here
|
||||||
//! logs is the store path; see `homeserver`'s module doc for the same rule
|
//! logs is the store path; see `swarm_matrix_client`'s module doc for the same rule
|
||||||
//! applied to error messages.
|
//! applied to error messages.
|
||||||
|
|
||||||
mod homeserver;
|
mod appservice;
|
||||||
mod mint;
|
mod mint;
|
||||||
mod registration;
|
mod registration;
|
||||||
|
|
||||||
|
|
@ -44,6 +45,20 @@ enum Command {
|
||||||
/// no flags, because a systemd `Environment=` block is what a nix module
|
/// no flags, because a systemd `Environment=` block is what a nix module
|
||||||
/// can render and a command line full of paths is not.
|
/// can render and a command line full of paths is not.
|
||||||
Mint,
|
Mint,
|
||||||
|
/// The swarm's own appservice registration, whose sender is the
|
||||||
|
/// homeserver's admin account. Configured by `MATRIX_APPSERVICE_*`.
|
||||||
|
#[command(subcommand)]
|
||||||
|
Appservice(Appservice),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
enum Appservice {
|
||||||
|
/// Mint the tokens when absent and render the registration tuwunel loads.
|
||||||
|
/// Local only: it runs before the homeserver and must not need a network.
|
||||||
|
Render,
|
||||||
|
/// Write the rendered `as_token` to the swarm secret store when the
|
||||||
|
/// store's copy differs.
|
||||||
|
Publish,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|
@ -57,6 +72,8 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
match Cli::parse().command {
|
match Cli::parse().command {
|
||||||
Command::Mint => mint::run().await,
|
Command::Mint => mint::run().await,
|
||||||
|
Command::Appservice(Appservice::Render) => appservice::render(),
|
||||||
|
Command::Appservice(Appservice::Publish) => appservice::publish().await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,6 +97,25 @@ mod tests {
|
||||||
|
|
||||||
/// The control: without it the case above passes on a parser that accepts
|
/// The control: without it the case above passes on a parser that accepts
|
||||||
/// anything.
|
/// anything.
|
||||||
|
/// The two units name these verbs, same reason as the test above.
|
||||||
|
#[test]
|
||||||
|
fn the_appservice_verbs_are_spelled_the_way_the_units_invoke_them() {
|
||||||
|
let cli =
|
||||||
|
Cli::try_parse_from(["swarm-matrix-ctl", "appservice", "render"]).expect("a verb");
|
||||||
|
assert!(matches!(
|
||||||
|
cli.command,
|
||||||
|
Command::Appservice(Appservice::Render)
|
||||||
|
));
|
||||||
|
let cli =
|
||||||
|
Cli::try_parse_from(["swarm-matrix-ctl", "appservice", "publish"]).expect("a verb");
|
||||||
|
assert!(matches!(
|
||||||
|
cli.command,
|
||||||
|
Command::Appservice(Appservice::Publish)
|
||||||
|
));
|
||||||
|
Cli::try_parse_from(["swarm-matrix-ctl", "appservice"])
|
||||||
|
.expect_err("a sub-verb is required");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unknown_verb_is_refused() {
|
fn an_unknown_verb_is_refused() {
|
||||||
Cli::try_parse_from(["swarm-matrix-ctl", "conjure"])
|
Cli::try_parse_from(["swarm-matrix-ctl", "conjure"])
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,9 @@ use swarm_secret_client::{
|
||||||
matrix,
|
matrix,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{homeserver, registration};
|
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
|
/// 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
|
/// allows the write below; the certificate the `BAO_*` variables name has to
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,28 @@ pub fn appservice_token_path(hive: &str) -> Result<String, Error> {
|
||||||
Ok(format!("{prefix}/matrix/appservice-token"))
|
Ok(format!("{prefix}/matrix/appservice-token"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The name segment of the controller's own subtree, and the cert-auth role it
|
||||||
|
/// logs in under (`swarm-controller`'s `store::CERT_ROLE`).
|
||||||
|
const CONTROLLER: &str = "swarm-controller";
|
||||||
|
|
||||||
|
/// The path holding the **swarm's** appservice token: the one registration
|
||||||
|
/// on the swarm's homeserver that is not a hive's, whose sender is promoted
|
||||||
|
/// to homeserver admin at boot.
|
||||||
|
///
|
||||||
|
/// The matrix container mints it and publishes it here; `swarm-controller`
|
||||||
|
/// reads it to create agents' accounts. Under [`Kind::Controller`] because
|
||||||
|
/// that is the one kind [`crate::policy::render`] grants no hive: every hive
|
||||||
|
/// reads `agents/*`, its own `hives/<hive>/*` and `services/*`, so under any
|
||||||
|
/// of those this admin credential would be readable by every hive.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Never in practice: the name segment is a constant. The `Result` is
|
||||||
|
/// [`principal_prefix`]'s.
|
||||||
|
pub fn swarm_appservice_token_path() -> Result<String, Error> {
|
||||||
|
let prefix = principal_prefix(Kind::Controller, CONTROLLER)?;
|
||||||
|
Ok(format!("{prefix}/matrix/appservice-token"))
|
||||||
|
}
|
||||||
|
|
||||||
/// What an account's path holds: the token, plus the homeserver it belongs to.
|
/// What an account's path holds: the token, plus the homeserver it belongs to.
|
||||||
///
|
///
|
||||||
/// The homeserver rides with the token rather than on the queue notice that
|
/// The homeserver rides with the token rather than on the queue notice that
|
||||||
|
|
@ -215,6 +237,39 @@ mod tests {
|
||||||
assert!(matches!(e, Error::PathSegment { kind: "hive", .. }), "{e}");
|
assert!(matches!(e, Error::PathSegment { kind: "hive", .. }), "{e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_swarm_appservice_token_lands_under_the_controller() {
|
||||||
|
assert_eq!(
|
||||||
|
swarm_appservice_token_path().expect("a constant segment"),
|
||||||
|
"swarm/controller/swarm-controller/matrix/appservice-token"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_hive_policy_reaches_the_swarm_appservice_token() {
|
||||||
|
// 🩸 The swarm's sender is a homeserver admin, so its token must not
|
||||||
|
// be under any stanza a hive's policy renders. Checked against the
|
||||||
|
// rendered document rather than against the prefix, because the
|
||||||
|
// document is what the store enforces.
|
||||||
|
let doc = crate::policy::render("pr1ma").expect("a plain name is legal");
|
||||||
|
// Every stanza is `path "secret/data/<prefix>*" { … }`.
|
||||||
|
let granted: Vec<&str> = doc
|
||||||
|
.lines()
|
||||||
|
.filter_map(|l| l.strip_prefix("path \"secret/data/"))
|
||||||
|
.filter_map(|p| p.strip_suffix("*\" {"))
|
||||||
|
.collect();
|
||||||
|
let reads = |path: &str| granted.iter().any(|g| path.starts_with(g));
|
||||||
|
let path = swarm_appservice_token_path().expect("a constant segment");
|
||||||
|
assert!(!reads(&path), "{path} is readable under {granted:?}");
|
||||||
|
// The control: an agent's account path IS under a hive stanza, so the
|
||||||
|
// assertion above can fail at all.
|
||||||
|
let agent = account_path("atlas", "main").expect("legal");
|
||||||
|
assert!(
|
||||||
|
reads(&agent),
|
||||||
|
"{agent} should be readable under {granted:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_segment_cannot_escape_its_own_directory() {
|
fn a_segment_cannot_escape_its_own_directory() {
|
||||||
// Each of these is a *different* way to address another agent's tree,
|
// Each of these is a *different* way to address another agent's tree,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue