matrix: mint the appservice sender token in the matrix container
A swarm runs one homeserver and a homeserver has one appservice sender account, so "mint it once" is a property of the thing being minted rather than something a lock has to enforce. That is what makes this account the one to move first: no trigger route, no controller change and no agent list — a boot-time oneshot beside tuwunel is the whole mechanism. `swarm-matrix-minter` runs inside `containers.hive-matrix`, which already holds the appservice token: the rendered registration is bound in read-only because that is how tuwunel is handed it. What the container lacked was an identity of its own, so this adds one — a leaf from the store's CA with a grant of exactly one path, not the hive's leaf, which reads every secret in the store. Both ends of the credential ship here. The minter reads the path it publishes to before it touches the homeserver, and returning on a non-empty read IS the "only once"; `hive-c0re`'s `ensure_hive_user` reads the same path, authenticating with the hive name already in `HYPERHIVE_HIVE_NAME`. The existing mint-then-`M_USER_IN_USE`-login ladder stays as the fallback for a store that is empty, unconfigured or unreachable, which is every swarm deployed before this — so nothing needs backfilling and nothing breaks if the rest of the sequence never lands. The credential is not an admin credential, and is not named like one. It is the access token of the appservice registration's own `sender_localpart` — `@hive:<server_name>`, an account the homeserver creates for itself when it loads the registration. The store path is `swarm/services/matrix/sender-token`, the host path is `matrix/access-token`, and the homeserver no longer runs an `admin_execute` promotion for that account at boot. Everything the hive provisions with it — the Space, the chat room, their hierarchy and join rules, the invites — rides on being the creator of those rooms at power level 100, not on homeserver admin; there is no Synapse admin API here to need, tuwunel has none. Two operations do need an admin *sender* and therefore stop working: `hivectl matrix promote-user` and `hivectl matrix reset-password`, both `!admin …` messages into `#admins:<server>`, plus the password-reset recovery path that an agent with a lost password file falls back to. They are swarm-level operations and are left failing loudly rather than served by an over-privileged token every other call site would also carry. The sweep's own admin-rights check and self-repair go with them: an account that is deliberately not an admin has nothing to check. `ephemeral = false` stays, and hive root can still read the container's filesystem. Accepted: what this buys is identity separation — no hive *process* holds or reads the appservice token — not physical isolation. Refs #4345
This commit is contained in:
parent
ff0da0b617
commit
f778122f5a
28 changed files with 1566 additions and 320 deletions
23
swarm-matrix-minter/Cargo.toml
Normal file
23
swarm-matrix-minter/Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[package]
|
||||
name = "swarm-matrix-minter"
|
||||
version.workspace = true
|
||||
readme = "README.md"
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "swarm-matrix-minter"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde_json.workspace = true
|
||||
# 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.
|
||||
swarm-secret-client.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
44
swarm-matrix-minter/README.md
Normal file
44
swarm-matrix-minter/README.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# swarm-matrix-minter
|
||||
|
||||
A boot-time oneshot that runs **inside `containers.hive-matrix`**, beside the
|
||||
homeserver, and puts the `@hive:` account's access token into the swarm's secret
|
||||
store under an identity of its own.
|
||||
|
||||
## Why it lives in the matrix container
|
||||
|
||||
The credential it mints is authorised by the appservice `as_token`, and the
|
||||
container already holds that: `nix/host-modules/hive-matrix.nix` bind-mounts the
|
||||
rendered appservice registration into it read-only, because that is how tuwunel
|
||||
itself is handed the registration. Minting anywhere else would mean copying the
|
||||
`as_token` to a second holder — and the point of this component is that the hive
|
||||
stops being one.
|
||||
|
||||
It is not the swarm controller for the same reason, plus a structural one: a
|
||||
homeserver has exactly **one** `@hive:` account and a swarm runs one
|
||||
homeserver, so "mint it once" needs no lock, no lease and no trigger surface — it
|
||||
is a property of the thing being minted.
|
||||
|
||||
## Idempotency
|
||||
|
||||
The **store** is the key, not the homeserver. A run reads
|
||||
`swarm/services/matrix/hive-access-token` first and returns without touching the
|
||||
homeserver when something is already there. Only an empty path reaches the mint
|
||||
ladder:
|
||||
|
||||
1. `POST /_matrix/client/v3/register` with `"type": "m.login.application_service"`
|
||||
— one round trip, no UIAA.
|
||||
2. `M_USER_IN_USE` (the expected arm on a homeserver that has already loaded the
|
||||
registration, since the account is the appservice's own `sender_localpart`) →
|
||||
`POST /_matrix/client/v3/login` as the appservice, same pinned `device_id`, so
|
||||
the old device is replaced rather than duplicated.
|
||||
3. Write the result to the store.
|
||||
|
||||
A crash between the homeserver call and the store write is recoverable: the next
|
||||
run takes arm 2.
|
||||
|
||||
## 🩸 A secret is a path, never a value
|
||||
|
||||
Nothing here logs, prints or interpolates a token. The mint ladder's errors are
|
||||
built from the homeserver's _status_ and its `errcode`, never its body, because a
|
||||
`/login` response body is an access token. The one identifier this binary logs is
|
||||
the store path it wrote.
|
||||
286
swarm-matrix-minter/src/homeserver.rs
Normal file
286
swarm-matrix-minter/src/homeserver.rs
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
//! The two calls the mint ladder is made of, against the homeserver next door.
|
||||
//!
|
||||
//! 🩸 **Every error in this module 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 — so a `body: {json}` in a message here would put the
|
||||
//! `@hive:` credential in the journal.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
/// Client-server API calls are one round trip each against a homeserver in the
|
||||
/// same netns; a slow one is a broken one.
|
||||
const TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
/// Bytes of the throwaway password `/register` is given.
|
||||
///
|
||||
/// Protocol overhead, and stored nowhere: this account authenticates by access
|
||||
/// token, and the recovery path when that token is lost is the appservice login
|
||||
/// below rather than anything a password could open.
|
||||
const PASSWORD_BYTES: usize = 32;
|
||||
|
||||
/// 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
|
||||
/// *expected* answer here — the `@hive:` account is the appservice registration's
|
||||
/// own `sender_localpart`, so the homeserver creates it at startup, before
|
||||
/// anything gets to ask — and an expected answer should not have to be
|
||||
/// recovered from a formatted message.
|
||||
pub enum Registered {
|
||||
/// A fresh account, and the access token minted with it.
|
||||
Token(String),
|
||||
/// The account is already there; it has to be logged into instead.
|
||||
AlreadyExists,
|
||||
}
|
||||
|
||||
/// An HTTP client with this module's timeout.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the TLS backend will not initialise, which is the only way building a
|
||||
/// client fails.
|
||||
pub fn client() -> Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(TIMEOUT_SECS))
|
||||
.build()
|
||||
.context("building the HTTP client for the homeserver")
|
||||
}
|
||||
|
||||
/// Create `localpart`'s account as the appservice and return its access token.
|
||||
///
|
||||
/// One round trip: an appservice-typed registration needs no UIAA stage, so
|
||||
/// there is no session to carry and no shared registration secret in the
|
||||
/// picture. The `device_id` is pinned so that a later [`appservice_login`]
|
||||
/// replaces this device rather than accumulating one per run.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the request cannot be sent, the response will not decode, or the
|
||||
/// homeserver refuses with anything other than `M_USER_IN_USE` — which is
|
||||
/// [`Registered::AlreadyExists`] rather than an error.
|
||||
pub async fn register(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
localpart: &str,
|
||||
as_token: &str,
|
||||
) -> Result<Registered> {
|
||||
let body = serde_json::json!({
|
||||
// What makes this an appservice registration rather than an ordinary
|
||||
// one: without it the homeserver asks for a UIAA flow even though the
|
||||
// request carries the as_token.
|
||||
"type": "m.login.application_service",
|
||||
"username": localpart,
|
||||
"password": random_password()?,
|
||||
"device_id": device_id(localpart),
|
||||
"initial_device_display_name": format!("hyperhive ({localpart})"),
|
||||
"inhibit_login": false,
|
||||
});
|
||||
let (status, json) = post(
|
||||
client,
|
||||
&format!("{base}/_matrix/client/v3/register?kind=user"),
|
||||
as_token,
|
||||
&body,
|
||||
)
|
||||
.await
|
||||
.context("POST /register as the appservice")?;
|
||||
|
||||
if status.is_success() {
|
||||
return Ok(Registered::Token(access_token(&json)?));
|
||||
}
|
||||
if errcode(&json) == Some("M_USER_IN_USE") {
|
||||
return Ok(Registered::AlreadyExists);
|
||||
}
|
||||
bail!(
|
||||
"the homeserver refused to register @{localpart}: {}",
|
||||
why(status, &json)
|
||||
);
|
||||
}
|
||||
|
||||
/// Log in as an **existing** account using the appservice's authority, and
|
||||
/// return a fresh access token for it.
|
||||
///
|
||||
/// No password: the appservice is authorised for every localpart in its
|
||||
/// namespace, so it mints a session for one without knowing anything about the
|
||||
/// account — which is just as well, since an account the homeserver created for
|
||||
/// its own registration has none.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the request cannot be sent, the response will not decode, or the
|
||||
/// homeserver refuses.
|
||||
pub async fn appservice_login(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
localpart: &str,
|
||||
as_token: &str,
|
||||
) -> Result<String> {
|
||||
let body = serde_json::json!({
|
||||
"type": "m.login.application_service",
|
||||
"identifier": {
|
||||
"type": "m.id.user",
|
||||
"user": localpart,
|
||||
},
|
||||
// Matching `register`'s, so a re-login REPLACES that device's token
|
||||
// rather than leaving a second live device behind.
|
||||
"device_id": device_id(localpart),
|
||||
"initial_device_display_name": format!("hyperhive ({localpart})"),
|
||||
});
|
||||
let (status, json) = post(
|
||||
client,
|
||||
&format!("{base}/_matrix/client/v3/login"),
|
||||
as_token,
|
||||
&body,
|
||||
)
|
||||
.await
|
||||
.context("POST /login as the appservice")?;
|
||||
|
||||
if !status.is_success() {
|
||||
bail!(
|
||||
"the homeserver refused to log in @{localpart}: {}",
|
||||
why(status, &json)
|
||||
);
|
||||
}
|
||||
access_token(&json)
|
||||
}
|
||||
|
||||
/// The device every token this binary mints is pinned to.
|
||||
fn device_id(localpart: &str) -> String {
|
||||
format!("hyperhive-{localpart}")
|
||||
}
|
||||
|
||||
/// One authenticated JSON POST, returning the status beside the decoded body.
|
||||
async fn post(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
as_token: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<(reqwest::StatusCode, serde_json::Value)> {
|
||||
let resp = client
|
||||
.post(url)
|
||||
.bearer_auth(as_token)
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.context("sending the request")?;
|
||||
let status = resp.status();
|
||||
let json = resp
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.context("decoding the response as JSON")?;
|
||||
Ok((status, json))
|
||||
}
|
||||
|
||||
/// Pull `access_token` out of a successful response.
|
||||
fn access_token(json: &serde_json::Value) -> Result<String> {
|
||||
json["access_token"]
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
// Not `{json}`: on the success path this object holds the credential,
|
||||
// and a response missing the field is exactly when a reflex to print it
|
||||
// would fire.
|
||||
.context("the homeserver's response carried no `access_token`")
|
||||
}
|
||||
|
||||
/// The matrix error code, when the body is a standard error object.
|
||||
fn errcode(json: &serde_json::Value) -> Option<&str> {
|
||||
json["errcode"].as_str()
|
||||
}
|
||||
|
||||
/// Everything about a refusal that is safe to put in a message.
|
||||
///
|
||||
/// The `errcode` is a closed vocabulary from the spec and the status is a
|
||||
/// number; between them they say which of the ladder's arms was taken. The
|
||||
/// `error` string beside them is free-form homeserver text, so it stays out.
|
||||
fn why(status: reqwest::StatusCode, json: &serde_json::Value) -> String {
|
||||
match errcode(json) {
|
||||
Some(code) => format!("HTTP {status}, errcode {code}"),
|
||||
None => format!("HTTP {status}, no errcode"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A throwaway password for [`register`], as hex.
|
||||
///
|
||||
/// From `/dev/urandom` directly rather than through an RNG crate: this is the
|
||||
/// one random value the binary needs, and the kernel is already the source any
|
||||
/// such crate would reach for here.
|
||||
///
|
||||
/// # Errors
|
||||
/// When `/dev/urandom` cannot be read.
|
||||
fn random_password() -> Result<String> {
|
||||
use std::io::Read as _;
|
||||
|
||||
let mut buf = [0u8; PASSWORD_BYTES];
|
||||
std::fs::File::open("/dev/urandom")
|
||||
.context("opening /dev/urandom")?
|
||||
.read_exact(&mut buf)
|
||||
.context("reading from /dev/urandom")?;
|
||||
Ok(buf.iter().fold(String::new(), |mut acc, b| {
|
||||
use std::fmt::Write as _;
|
||||
// Infallible: `write!` into a `String` only fails if the formatter
|
||||
// does, and `{:02x}` of a `u8` has nothing to fail at.
|
||||
let _ = write!(acc, "{b:02x}");
|
||||
acc
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn both_calls_pin_the_same_device_so_a_relogin_replaces_it() {
|
||||
// The whole reason the recovery arm is safe to take repeatedly: an
|
||||
// unpinned login mints a NEW device each time, and a homeserver
|
||||
// accumulating devices for this account is one where revoking the credential
|
||||
// means finding all of them.
|
||||
assert_eq!(device_id("hive"), "hyperhive-hive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_is_described_by_its_errcode_and_never_by_its_body() {
|
||||
// 🩸 The invariant this module exists to keep. `error` is free-form
|
||||
// homeserver text and `access_token` is the credential itself; a
|
||||
// message built from the body would carry whichever of them the
|
||||
// response happened to hold.
|
||||
let json = serde_json::json!({
|
||||
"errcode": "M_FORBIDDEN",
|
||||
"error": "some free-form text",
|
||||
"access_token": "syt_the_actual_secret",
|
||||
});
|
||||
let message = why(reqwest::StatusCode::FORBIDDEN, &json);
|
||||
assert!(message.contains("M_FORBIDDEN"), "{message}");
|
||||
assert!(!message.contains("syt_the_actual_secret"), "{message}");
|
||||
assert!(!message.contains("free-form"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_with_no_errcode_still_produces_a_message() {
|
||||
// A homeserver behind a proxy answers with HTML, not a matrix error
|
||||
// object. The status is then the only thing there is to say, and
|
||||
// saying it is better than an empty report.
|
||||
let message = why(reqwest::StatusCode::BAD_GATEWAY, &serde_json::json!({}));
|
||||
assert!(message.contains("502"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_expected_already_exists_answer_is_recognised_by_its_errcode() {
|
||||
// Matched on the spec's code rather than on message text, because this
|
||||
// is the arm a healthy homeserver takes every time: the `@hive:` account
|
||||
// is the appservice registration's own sender, created at startup.
|
||||
let json = serde_json::json!({ "errcode": "M_USER_IN_USE", "error": "User ID taken" });
|
||||
assert_eq!(errcode(&json), Some("M_USER_IN_USE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_response_without_a_token_is_an_error_that_does_not_quote_it() {
|
||||
let e = access_token(&serde_json::json!({ "user_id": "@hive:t.local" }))
|
||||
.expect_err("no access_token in this object");
|
||||
assert!(!format!("{e}").contains("@hive:t.local"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_minted_password_is_hex_of_the_declared_length() {
|
||||
// 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.
|
||||
let pw = random_password().expect("/dev/urandom is readable");
|
||||
assert_eq!(pw.len(), PASSWORD_BYTES * 2);
|
||||
assert!(pw.bytes().all(|b| b.is_ascii_hexdigit()), "not hex");
|
||||
}
|
||||
}
|
||||
249
swarm-matrix-minter/src/main.rs
Normal file
249
swarm-matrix-minter/src/main.rs
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
//! Mint the `@hive:` account's homeserver access token, once, and publish it to the
|
||||
//! swarm's secret store.
|
||||
//!
|
||||
//! A oneshot inside `containers.hive-matrix`, not a daemon and not part of the
|
||||
//! swarm controller. It is in the container because the appservice `as_token`
|
||||
//! that authorises the mint is *already* there — the registration tuwunel loads
|
||||
//! is bind-mounted in — so no second holder of that secret is created. It is
|
||||
//! this account rather than an agent's because a homeserver has **one** `@hive:`
|
||||
//! account and a swarm runs one homeserver: "only once" is a property of what
|
||||
//! is being minted, so there is nothing to lock and no trigger to serve.
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! 🩸 A secret is a path, never a value. The only identifier this binary logs is
|
||||
//! the store path; see `homeserver`'s module doc for the same rule applied to
|
||||
//! error messages.
|
||||
|
||||
mod homeserver;
|
||||
mod registration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use swarm_secret_client::{
|
||||
SecretStore,
|
||||
client::{DEFAULT_CERT_MOUNT, Settings},
|
||||
matrix,
|
||||
};
|
||||
|
||||
/// 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_MINTER_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_MINTER_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_MINTER_REGISTRATION";
|
||||
/// Localpart of the hive's account. The appservice registration's own
|
||||
/// `sender_localpart`, and `hive-c0re`'s `matrix::HIVE_LOCALPART`.
|
||||
const ENV_LOCALPART: &str = "MATRIX_MINTER_LOCALPART";
|
||||
/// 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_MINTER_HOMESERVER";
|
||||
|
||||
/// Everything the unit tells this process, 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,
|
||||
homeserver: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Read the `MATRIX_MINTER_*` 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)?,
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
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::hive_token_path();
|
||||
if already_published(&store, &path).await {
|
||||
tracing::info!(%path, "the @hive: credential 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 @hive: 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 @hive: credential to {path}"))?;
|
||||
tracing::info!(%path, "published the @hive: credential");
|
||||
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-minter".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".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");
|
||||
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] {
|
||||
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);
|
||||
}
|
||||
|
||||
#[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::hive_token_path(),
|
||||
"swarm/services/matrix/hive-access-token"
|
||||
);
|
||||
}
|
||||
}
|
||||
104
swarm-matrix-minter/src/registration.rs
Normal file
104
swarm-matrix-minter/src/registration.rs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
//! Reading the `as_token` out of the appservice registration the matrix
|
||||
//! container already has.
|
||||
//!
|
||||
//! No new credential is delivered for this. `nix/host-modules/hive-matrix.nix`
|
||||
//! binds the registration directory into the container read-only so tuwunel can
|
||||
//! load it, and the registration **is** the `as_token` — so the file this
|
||||
//! module opens is one this process could already read, and one the homeserver
|
||||
//! beside it reads too.
|
||||
//!
|
||||
//! Scanned line-by-line rather than parsed as YAML. The file has exactly one
|
||||
//! renderer (`appserviceRegistrationScript` in that same module, a `printf` of
|
||||
//! `as_token: <hex>`), so a parser would be a second, looser reading of a shape
|
||||
//! this repo writes itself — and it would pull a YAML crate into a binary whose
|
||||
//! only other input is JSON.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
/// The key the token is stored under, and the whole of the agreement with the
|
||||
/// renderer.
|
||||
const KEY: &str = "as_token:";
|
||||
|
||||
/// Read the registration at `path` and return its `as_token`.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the file cannot be read, or holds no `as_token` with a value — which is
|
||||
/// what a registration rendered by something other than this repo looks like
|
||||
/// from here.
|
||||
pub fn as_token(path: &str) -> Result<String> {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("reading the appservice registration at {path}"))?;
|
||||
// The path, not the file's contents: every line of it is either a secret or
|
||||
// a shape this module already knows.
|
||||
extract(&text).with_context(|| format!("no `as_token` in the registration at {path}"))
|
||||
}
|
||||
|
||||
/// [`as_token`] over text already in hand, so the agreement with the renderer
|
||||
/// can be tested without a file.
|
||||
fn extract(text: &str) -> Result<String> {
|
||||
for line in text.lines() {
|
||||
if let Some(rest) = line.strip_prefix(KEY) {
|
||||
let token = rest.trim();
|
||||
if token.is_empty() {
|
||||
bail!("the registration's `as_token` is empty");
|
||||
}
|
||||
return Ok(token.to_owned());
|
||||
}
|
||||
}
|
||||
bail!("the registration carries no `as_token` line")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The registration exactly as `appserviceRegistrationScript` renders it —
|
||||
/// the quoted heredoc, then the `printf` of the two tokens. Reproduced
|
||||
/// verbatim because that script is the other end of this agreement and
|
||||
/// lives in a file no Rust test can reach.
|
||||
const RENDERED: &str = "id: hyperhive\n\
|
||||
url: null\n\
|
||||
sender_localpart: hive\n\
|
||||
rate_limited: false\n\
|
||||
namespaces:\n \
|
||||
users:\n \
|
||||
- exclusive: false\n \
|
||||
regex: '@[a-z0-9._=/+-]+:example\\.test$'\n \
|
||||
aliases: []\n \
|
||||
rooms: []\n\
|
||||
as_token: deadbeef\n\
|
||||
hs_token: cafebabe\n";
|
||||
|
||||
#[test]
|
||||
fn the_token_is_taken_from_the_registration_this_repo_renders() {
|
||||
assert_eq!(
|
||||
extract(RENDERED).expect("the rendered shape parses"),
|
||||
"deadbeef"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_homeservers_own_token_is_not_mistaken_for_the_appservices() {
|
||||
// `hs_token` authenticates the homeserver TO the appservice and is a
|
||||
// different secret with a confusingly similar name; a substring search
|
||||
// would find it inside neither, but a `contains("s_token")`-shaped one
|
||||
// would. The control is that the line order in `RENDERED` puts
|
||||
// `as_token` first, so this arm needs the reverse to mean anything.
|
||||
let reversed = "hs_token: cafebabe\nas_token: deadbeef\n";
|
||||
assert_eq!(
|
||||
extract(reversed).expect("order does not matter"),
|
||||
"deadbeef"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_registration_with_no_token_is_an_error_rather_than_an_empty_string() {
|
||||
// An empty token authenticates nothing, and a homeserver answers a
|
||||
// request carrying one with a 403 that names the account rather than
|
||||
// the credential — so failing here is the only report an operator can
|
||||
// act on.
|
||||
for bad in ["id: hyperhive\n", "as_token:\n", "as_token: \n"] {
|
||||
assert!(extract(bad).is_err(), "{bad:?} must not yield a token");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue