matrix: swarm-controller is the only minter
Every hive is in a swarm and every swarm runs matrix, so every swarm has a swarm-controller, and since #4810 its hive_sender pass mints each hive's @hive-<hive>: sender token into the store every five minutes. The two other minters of that token go: - swarm-matrix-ctl mint: the systemd.services.swarm-matrix-ctl unit in the hive-matrix container, Command::Mint and src/mint.rs. The binary, its appservice render/publish verbs, ctlPackage, ctlActive and the ctl cert role stay. bao-matrix-reader's checks on the deleted unit are removed; the leaf-identity and no-token-in-env checks now look at swarm-matrix-appservice-publish, which runs under the same identity. - the hive-side mint ladder in hive-c0re's ensure_hive_user (register/appservice-login/password-login with the local as_token), with read_appservice_token, paths::matrix_appservice_token and the helpers only it used. ensure_hive_user now takes the store's token, keeps the file when the store has none or can't be reached, and fails otherwise. - hivectl matrix sync-admin: the verb, HostRequest::MatrixSyncAdmin and handle_matrix_sync_admin. The periodic MatrixSweep (ensure_all) is unchanged apart from no longer reading the local as_token. This removes the double-mint race #4810's review flagged: two minters logging in on one pinned device could leave a dead token in the store until the next pass. Closes #4813 Closes #4814
This commit is contained in:
parent
91e47732a6
commit
ddb7d7196d
22 changed files with 187 additions and 1162 deletions
|
|
@ -1,19 +1,11 @@
|
|||
//! Optional matrix-tuwunel wiring: the hive's appservice identity (host) +
|
||||
//! per-agent account creation → `<agent-state>/matrix-token`. No-op
|
||||
//! when the `hive-matrix` container isn't running, so operators who
|
||||
//! haven't flipped `services.hyperhive.deploy.matrix.enable = true` pay
|
||||
//! nothing.
|
||||
//! Optional matrix-tuwunel wiring: the hive's `@hive-<hive>:` sender token,
|
||||
//! taken from the swarm secret store, and the hive Space, chat room and
|
||||
//! invites provisioned with it. No-op when no homeserver is configured, so
|
||||
//! operators who haven't flipped `services.hyperhive.deploy.matrix.enable =
|
||||
//! true` pay nothing.
|
||||
//!
|
||||
//! Accounts are created **as the hive's appservice**, not by presenting a
|
||||
//! shared registration token in a UIAA flow. The difference that matters
|
||||
//! here is not the round-trip count: an appservice token is an *identity*
|
||||
//! the homeserver knows, so the secret never has to be the same on both
|
||||
//! sides of the wire, and account creation does not depend on registration
|
||||
//! being open to anyone who learns a token.
|
||||
//!
|
||||
//! See `docs/integrations/matrix.md::Provisioning flow (appservice)` for the
|
||||
//! registration file's shape, how its token reaches both halves, and the
|
||||
//! host/container bind-mount layout.
|
||||
//! This module creates no accounts: `swarm-controller` mints every hive's
|
||||
//! sender account and every agent's account with the swarm's appservice token.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
|
@ -54,14 +46,8 @@ fn matrix_base() -> Result<&'static str> {
|
|||
this path should have been gated on matrix::is_present()",
|
||||
)
|
||||
}
|
||||
/// HTTP timeout for registration round-trips. Account creation is one
|
||||
/// POST; even the slow path should finish well inside this budget.
|
||||
/// HTTP timeout for the sweep's homeserver round-trips.
|
||||
const HTTP_TIMEOUT_SECS: u64 = 10;
|
||||
/// Length (bytes) of the throwaway per-agent matrix password. Random
|
||||
/// 32-byte hex — agents never log in with the password (they
|
||||
/// authenticate by `access_token`), so it's protocol overhead. We
|
||||
/// store it nowhere.
|
||||
const PASSWORD_BYTES: usize = 32;
|
||||
|
||||
/// Matrix localpart this hive acts as. Not an agent; has no state dir.
|
||||
///
|
||||
|
|
@ -124,14 +110,6 @@ pub fn sender_token_path() -> PathBuf {
|
|||
crate::paths::matrix_sender_token()
|
||||
}
|
||||
|
||||
/// Password file for the hive's own `@hive-<hive>:` account. Stored OUTSIDE
|
||||
/// the purgeable `agent_state_root` tree so it survives `destroy --purge`.
|
||||
///
|
||||
/// Path: `/var/lib/hyperhive/matrix/creds/<name>-password`
|
||||
fn password_path(name: &str) -> PathBuf {
|
||||
crate::paths::matrix_creds_dir().join(format!("{name}-password"))
|
||||
}
|
||||
|
||||
/// Host path where the hive Matrix Space room ID is persisted.
|
||||
/// Outside every purgeable path — not deleted by `destroy --purge`.
|
||||
#[must_use]
|
||||
|
|
@ -159,325 +137,39 @@ pub fn is_present() -> bool {
|
|||
matrix_http().is_some()
|
||||
}
|
||||
|
||||
/// Read `n` cryptographic-quality bytes from `/dev/urandom` and return
|
||||
/// them hex-encoded. Avoids pulling a workspace `rand` dep just for
|
||||
/// 32 bytes of randomness; the kernel's CSPRNG is more than enough for
|
||||
/// a long-lived shared secret on the same host.
|
||||
fn random_hex(n: usize) -> Result<String> {
|
||||
use std::io::Read;
|
||||
let mut buf = vec![0_u8; n];
|
||||
let mut f = std::fs::File::open("/dev/urandom").context("open /dev/urandom")?;
|
||||
f.read_exact(&mut buf).context("read /dev/urandom")?;
|
||||
let mut hex = String::with_capacity(n * 2);
|
||||
for b in &buf {
|
||||
use std::fmt::Write as _;
|
||||
write!(hex, "{b:02x}").ok();
|
||||
}
|
||||
Ok(hex)
|
||||
}
|
||||
|
||||
/// Read the hive's appservice token — the `as_token` of the registration
|
||||
/// the homeserver loaded at boot. Every account this module creates is
|
||||
/// authorised by it.
|
||||
///
|
||||
/// **Reads, never mints**, unlike the registration token it replaced.
|
||||
/// That token was the whole agreement, so whichever side wrote it first
|
||||
/// was right; this one has a second half — the registration file naming
|
||||
/// it, which only the nix side writes. A token minted here would be a
|
||||
/// token the homeserver has never heard of, and the failure would surface
|
||||
/// as every request being refused rather than as a missing file.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the file is absent or empty. That means the host activation
|
||||
/// script has not run on this generation yet; callers log it and leave
|
||||
/// existing accounts alone rather than trying to proceed.
|
||||
pub fn read_appservice_token() -> Result<String> {
|
||||
let path = crate::paths::matrix_appservice_token();
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_owned())
|
||||
.filter(|s| !s.is_empty())
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"matrix appservice token not found at {} — it is minted by the \
|
||||
hive-matrix activation script, which also renders the registration \
|
||||
file naming it; deploy the hive-matrix module (or re-run \
|
||||
`nixos-rebuild switch`) before provisioning matrix users",
|
||||
path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the localpart of a matrix user id for `agent`. Matrix
|
||||
/// usernames are 1-255 chars from the `[a-z0-9._=-/]` alphabet; agent
|
||||
/// names already conform (hyperhive enforces a strict subset), so no
|
||||
/// escaping is needed at the boundary.
|
||||
fn user_localpart(agent: &str) -> &str {
|
||||
agent
|
||||
}
|
||||
|
||||
/// Send the registration POST as the appservice and parse the response.
|
||||
/// Returns `Ok((status, body))` on any completed HTTP round-trip
|
||||
/// (including the `M_USER_IN_USE` 400 the caller treats as "already
|
||||
/// exists"); errors only on transport failure.
|
||||
async fn register_post(
|
||||
client: &reqwest::Client,
|
||||
as_token: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> Result<(StatusCode, serde_json::Value)> {
|
||||
let base = matrix_base()?;
|
||||
let url = format!("{base}/_matrix/client/v3/register");
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(as_token)
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.context("matrix: POST /register")?;
|
||||
let status = resp.status();
|
||||
let json = resp
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.context("matrix: parse /register response")?;
|
||||
Ok((status, json))
|
||||
}
|
||||
|
||||
/// Generate a throwaway random password for matrix UIAA registration.
|
||||
/// `PASSWORD_BYTES` raw bytes ⇒ 64-char hex string. The hive sender
|
||||
/// account authenticates by `access_token`, so this password is
|
||||
/// protocol overhead never used for login.
|
||||
pub fn random_password() -> Result<String> {
|
||||
random_hex(PASSWORD_BYTES)
|
||||
}
|
||||
|
||||
/// Create the matrix account for `agent` as the hive's appservice and
|
||||
/// return an access token for it. One round-trip: an appservice-typed
|
||||
/// registration needs no UIAA stage at all, so there is no session to
|
||||
/// carry and no shared secret to present.
|
||||
///
|
||||
/// The account is created **by** the appservice but is an ordinary user
|
||||
/// afterwards — it gets its own device and its own access token, and the
|
||||
/// agent authenticates with that rather than with anything the hive
|
||||
/// holds. The `as_token` never leaves the host.
|
||||
///
|
||||
/// Its one caller, the hive sender account's provisioning, always passes
|
||||
/// a [`random_password`] throwaway — the account authenticates by
|
||||
/// `access_token`, never `m.login.password`.
|
||||
///
|
||||
/// # Errors
|
||||
/// Propagates the homeserver's own body, which is what the
|
||||
/// `M_USER_IN_USE` callers match on. A `M_EXCLUSIVE` body means the
|
||||
/// localpart falls outside the appservice's namespace — the registration
|
||||
/// file's `namespaces.users` regex is the place to look, not this call.
|
||||
async fn register_user(
|
||||
client: &reqwest::Client,
|
||||
agent: &str,
|
||||
as_token: &str,
|
||||
password: &str,
|
||||
) -> Result<String> {
|
||||
let localpart = user_localpart(agent);
|
||||
let body = serde_json::json!({
|
||||
// What makes this an appservice registration rather than an
|
||||
// ordinary one. Without it the homeserver treats the request as a
|
||||
// normal client's and asks for a UIAA flow — even holding the
|
||||
// as_token.
|
||||
"type": "m.login.application_service",
|
||||
"username": localpart,
|
||||
"password": password,
|
||||
// device_id stays stable across re-runs so a re-mint doesn't
|
||||
// strand orphan devices in tuwunel.
|
||||
"device_id": format!("hyperhive-{agent}"),
|
||||
"initial_device_display_name": format!("hyperhive ({agent})"),
|
||||
"inhibit_login": false,
|
||||
});
|
||||
let (status, body) = register_post(client, as_token, &body).await?;
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("matrix: /register as appservice HTTP {status}, body: {body}");
|
||||
}
|
||||
extract_access_token(&body)
|
||||
}
|
||||
|
||||
/// Log in as an **existing** account using the hive's appservice token,
|
||||
/// and return a fresh access token for it. No password involved: the
|
||||
/// appservice is authorised for every localpart in its namespace, so it
|
||||
/// can mint a session for one without knowing anything about the account.
|
||||
///
|
||||
/// This is the recovery path that used to need a stored password or an
|
||||
/// admin-room password reset — an account whose token file was lost is
|
||||
/// re-tokened from the hive's own identity instead. The device id matches
|
||||
/// [`register_user`]'s, so a re-login replaces that device's token rather
|
||||
/// than accumulating devices.
|
||||
async fn appservice_login(client: &reqwest::Client, as_token: &str, agent: &str) -> Result<String> {
|
||||
let base = matrix_base()?;
|
||||
let url = format!("{base}/_matrix/client/v3/login");
|
||||
let body = serde_json::json!({
|
||||
"type": "m.login.application_service",
|
||||
"identifier": {
|
||||
"type": "m.id.user",
|
||||
"user": user_localpart(agent),
|
||||
},
|
||||
"device_id": format!("hyperhive-{agent}"),
|
||||
"initial_device_display_name": format!("hyperhive ({agent})"),
|
||||
});
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(as_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("matrix: POST /login as appservice")?;
|
||||
let status = resp.status();
|
||||
let json = resp
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.context("matrix: parse appservice /login response")?;
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("matrix: appservice /login HTTP {status} for {agent}, body: {json}");
|
||||
}
|
||||
extract_access_token(&json)
|
||||
}
|
||||
|
||||
/// Pull `access_token` out of a successful /register response.
|
||||
fn extract_access_token(body: &serde_json::Value) -> Result<String> {
|
||||
body["access_token"]
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.with_context(|| format!("matrix: missing access_token in response: {body}"))
|
||||
}
|
||||
|
||||
/// Login with `m.login.password` and return the access token. Fallback
|
||||
/// for when registration fails with `M_USER_IN_USE` — the account
|
||||
/// already exists in the homeserver but the token file was lost. Fails
|
||||
/// if the stored password no longer matches (e.g. homeserver wiped);
|
||||
/// the only caller is the hive sender account's own recovery path
|
||||
/// (`hivectl matrix sync-admin`).
|
||||
async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Result<String> {
|
||||
let base = matrix_base()?;
|
||||
let url = format!("{base}/_matrix/client/v3/login");
|
||||
let body = serde_json::json!({
|
||||
"type": "m.login.password",
|
||||
"identifier": {
|
||||
"type": "m.id.user",
|
||||
"user": user_localpart(agent),
|
||||
},
|
||||
"password": password,
|
||||
"device_id": format!("hyperhive-{agent}"),
|
||||
"initial_device_display_name": format!("hyperhive ({agent})"),
|
||||
});
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("matrix: POST /login")?;
|
||||
let status = resp.status();
|
||||
let json = resp
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.context("matrix: parse /login response")?;
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("matrix: /login HTTP {status} for agent {agent}, body: {json}");
|
||||
}
|
||||
extract_access_token(&json)
|
||||
}
|
||||
|
||||
/// Percent-encode a matrix room ID for use in a URL path segment.
|
||||
/// Only `:` needs encoding; `!` and alphanumerics are path-safe.
|
||||
fn encode_room_id_for_url(room_id: &str) -> String {
|
||||
room_id.replace(':', "%3A")
|
||||
}
|
||||
|
||||
/// Ensure the hive's `@hive-<hive>:` matrix user exists and that its access token is
|
||||
/// persisted at [`sender_token_path()`].
|
||||
/// Bring the hive's `@hive-<hive>:` sender token at [`sender_token_path()`] in
|
||||
/// line with the swarm secret store.
|
||||
///
|
||||
/// **Nothing here depends on registration order, and nothing here is
|
||||
/// privileged.** The account used to have to be the first ever
|
||||
/// registered, to win tuwunel's automatic first-user grant — a rule that
|
||||
/// cannot fire for an appservice-created account at all. It is now an
|
||||
/// ordinary account: the homeserver creates it because it is the
|
||||
/// appservice registration's `sender_localpart`, and everything the hive
|
||||
/// provisions with it, it provisions as the creator of those rooms.
|
||||
///
|
||||
/// Where the token comes from is [`sender_source`]'s decision. The **swarm
|
||||
/// secret store** wins whenever it holds one: `swarm-controller` mints it
|
||||
/// there (and `swarm-matrix-ctl` on the homeserver's own host), keeps it while
|
||||
/// it is live and replaces it when it is not, so the file follows the store
|
||||
/// rather than outliving it. That is also what lets a hive that holds no
|
||||
/// `as_token` have an account at all. The file is kept when the store has
|
||||
/// nothing or cannot be reached, and the mint ladder below is the fallback
|
||||
/// when neither holds a token and this hive has an `as_token`.
|
||||
/// `swarm-controller` is the only minter: it creates the account with the
|
||||
/// swarm's appservice token, keeps the stored token while it is live and
|
||||
/// replaces it when it is not. The file follows the store, and is kept as it
|
||||
/// is when the store has nothing or cannot be reached, so a store outage does
|
||||
/// not take the hive's matrix provisioning down with it.
|
||||
///
|
||||
/// # Errors
|
||||
/// When no token can be had — nothing in the store or the file, and no
|
||||
/// `as_token` — or when a step of the mint ladder or the file write fails.
|
||||
pub async fn ensure_hive_user(client: &reqwest::Client, as_token: Option<&str>) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
/// When neither the store nor the file holds a token, or the file write fails.
|
||||
pub async fn ensure_hive_user() -> Result<()> {
|
||||
let path = sender_token_path();
|
||||
let on_disk = std::fs::read_to_string(&path).ok();
|
||||
let stored = stored_sender_token().await;
|
||||
let as_token = match sender_source(stored.as_deref(), on_disk.as_deref(), as_token) {
|
||||
match sender_source(stored.as_deref(), on_disk.as_deref()) {
|
||||
SenderSource::Keep => {
|
||||
tracing::debug!("matrix: the sender token is already present");
|
||||
return Ok(());
|
||||
Ok(())
|
||||
}
|
||||
SenderSource::Store(token) => return persist_sender_token(&path, token),
|
||||
SenderSource::Mint(as_token) => as_token,
|
||||
SenderSource::Store(token) => persist_sender_token(&path, token),
|
||||
SenderSource::Unavailable => anyhow::bail!(
|
||||
"matrix: no sender token in the swarm store or at {}, and no appservice \
|
||||
token on this host to mint one with",
|
||||
"matrix: no sender token in the swarm store or at {}; swarm-controller \
|
||||
mints it into the store",
|
||||
path.display()
|
||||
),
|
||||
};
|
||||
// Per hive, and fatal when it cannot be derived: the fallback ladder below
|
||||
// must not mint under some other hive's name.
|
||||
let localpart = hive_localpart()?;
|
||||
let password = random_password()?;
|
||||
let access_token = match register_user(client, &localpart, as_token, &password).await {
|
||||
Ok(token) => {
|
||||
let pw_path = password_path(&localpart);
|
||||
if let Some(parent) = pw_path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
if let Err(e) = std::fs::write(&pw_path, format!("{password}\n")) {
|
||||
tracing::warn!(error = ?e, %localpart, "matrix: failed to persist the sender account password");
|
||||
} else {
|
||||
let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
token
|
||||
}
|
||||
Err(reg_err) if reg_err.to_string().contains("M_USER_IN_USE") => {
|
||||
// The expected path, not an edge case: this account is the
|
||||
// appservice's own `sender_localpart`, so the homeserver
|
||||
// creates it when it loads the registration — before
|
||||
// hive-c0re gets a chance to ask. An appservice login needs
|
||||
// no password, which is just as well since an account the
|
||||
// homeserver created has none.
|
||||
tracing::info!(%localpart, "matrix: the sender account already exists, logging in as the appservice");
|
||||
match appservice_login(client, as_token, &localpart).await {
|
||||
Ok(token) => token,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, %localpart, "matrix: appservice login for the sender account failed; falling back to the stored password");
|
||||
let pw_path = password_path(&localpart);
|
||||
let stored = std::fs::read_to_string(&pw_path)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_owned())
|
||||
.filter(|s| !s.is_empty())
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"matrix: @{localpart}: exists, appservice login failed, and no \
|
||||
password is stored at {} — check that the registration file's \
|
||||
namespace covers @{localpart} and that the homeserver \
|
||||
loaded it",
|
||||
pw_path.display()
|
||||
)
|
||||
})?;
|
||||
login_user(client, &localpart, &stored).await?
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(other) => return Err(other),
|
||||
};
|
||||
persist_sender_token(&path, &access_token)
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`ensure_hive_user`] does about the sender token this sweep.
|
||||
|
|
@ -487,39 +179,27 @@ enum SenderSource<'a> {
|
|||
Keep,
|
||||
/// Write the store's token to the file.
|
||||
Store(&'a str),
|
||||
/// Mint one with this hive's own appservice token.
|
||||
Mint(&'a str),
|
||||
/// Nothing to take and nothing to mint with.
|
||||
/// No token in the store or the file.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Decide [`ensure_hive_user`]'s step from the store's token, the file's
|
||||
/// content and this hive's `as_token`, each `None` when absent. Blank counts
|
||||
/// as absent.
|
||||
fn sender_source<'a>(
|
||||
stored: Option<&'a str>,
|
||||
on_disk: Option<&str>,
|
||||
as_token: Option<&'a str>,
|
||||
) -> SenderSource<'a> {
|
||||
/// Decide [`ensure_hive_user`]'s step from the store's token and the file's
|
||||
/// content, each `None` when absent. Blank counts as absent.
|
||||
fn sender_source<'a>(stored: Option<&'a str>, on_disk: Option<&str>) -> SenderSource<'a> {
|
||||
let present = |s: &&str| !s.trim().is_empty();
|
||||
let stored = stored.map(str::trim).filter(present);
|
||||
let on_disk = on_disk.map(str::trim).filter(present);
|
||||
match (stored, on_disk, as_token.filter(present)) {
|
||||
(Some(s), Some(d), _) if s == d => SenderSource::Keep,
|
||||
(Some(s), _, _) => SenderSource::Store(s),
|
||||
(None, Some(_), _) => SenderSource::Keep,
|
||||
(None, None, Some(a)) => SenderSource::Mint(a),
|
||||
(None, None, None) => SenderSource::Unavailable,
|
||||
match (stored, on_disk) {
|
||||
(Some(s), Some(d)) if s == d => SenderSource::Keep,
|
||||
(Some(s), _) => SenderSource::Store(s),
|
||||
(None, Some(_)) => SenderSource::Keep,
|
||||
(None, None) => SenderSource::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the appservice sender account's access token to `path`, 0600, creating the
|
||||
/// directory if it is not there.
|
||||
///
|
||||
/// Shared by both arms of [`ensure_hive_user`] rather than duplicated into
|
||||
/// the store one: the file's mode is the only thing keeping an unprivileged
|
||||
/// reader off the hive's matrix credential, and a second copy of that decision
|
||||
/// is one that can be edited alone.
|
||||
/// directory if it is not there. The file's mode is the only thing keeping an
|
||||
/// unprivileged reader off the hive's matrix credential.
|
||||
fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
|
|
@ -533,8 +213,8 @@ fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch the sender token `swarm-controller` (or `swarm-matrix-ctl`)
|
||||
/// published, under this hive's own store identity.
|
||||
/// Fetch the sender token `swarm-controller` published, under this hive's own
|
||||
/// store identity.
|
||||
///
|
||||
/// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` —
|
||||
/// the same role string `workers::credential` logs in with, and already in
|
||||
|
|
@ -546,10 +226,10 @@ fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()
|
|||
///
|
||||
/// `None`, never an error, for every way this can come up empty — no hive
|
||||
/// name, no `BAO_*` identity, an unreachable store, nothing at the path. All
|
||||
/// four mean the same thing to the caller ("keep the file, or mint it the old
|
||||
/// way"), and three of them are the ordinary state of a swarm with no store
|
||||
/// token for this hive yet, so raising would turn a supported deployment into
|
||||
/// a warning every sweep.
|
||||
/// four mean the same thing to the caller ("keep the file"), and three of
|
||||
/// them are the ordinary state of a swarm with no store token for this hive
|
||||
/// yet, so raising would turn a supported deployment into a warning every
|
||||
/// sweep.
|
||||
///
|
||||
/// 🩸 Logs the store **path** and never the value.
|
||||
async fn stored_sender_token() -> Option<String> {
|
||||
|
|
@ -1279,12 +959,6 @@ pub async fn ensure_all() -> bool {
|
|||
return true;
|
||||
}
|
||||
let mut ok = true;
|
||||
// Absent on every hive whose homeserver runs elsewhere. Only the sender
|
||||
// token's mint fallback needs it; `ensure_hive_user` fails, and says so,
|
||||
// when the store has no token either.
|
||||
let as_token = read_appservice_token()
|
||||
.inspect_err(|e| tracing::debug!(error = ?e, "matrix: no local appservice token"))
|
||||
.ok();
|
||||
// One HTTP client for the whole sweep.
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
|
||||
|
|
@ -1300,7 +974,7 @@ pub async fn ensure_all() -> bool {
|
|||
// THROUGH it (the Space, the chat room and every invite are sent with
|
||||
// its token) — as an ordinary user that created those rooms, not as a
|
||||
// homeserver admin.
|
||||
if let Err(e) = ensure_hive_user(&client, as_token.as_deref()).await {
|
||||
if let Err(e) = ensure_hive_user().await {
|
||||
tracing::warn!(error = ?e, "matrix: ensure_hive_user failed");
|
||||
ok = false;
|
||||
}
|
||||
|
|
@ -1413,18 +1087,15 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_remote_hive_takes_the_store_token_without_an_appservice_token() {
|
||||
assert_eq!(
|
||||
sender_source(Some("tok"), None, None),
|
||||
SenderSource::Store("tok")
|
||||
);
|
||||
fn with_no_file_the_store_token_is_taken() {
|
||||
assert_eq!(sender_source(Some("tok"), None), SenderSource::Store("tok"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_store_token_replaces_a_different_file_token() {
|
||||
// The swarm re-minted a dead token; the file must follow it.
|
||||
assert_eq!(
|
||||
sender_source(Some("new\n"), Some("old\n"), Some("as")),
|
||||
sender_source(Some("new\n"), Some("old\n")),
|
||||
SenderSource::Store("new")
|
||||
);
|
||||
}
|
||||
|
|
@ -1433,40 +1104,23 @@ mod tests {
|
|||
fn a_file_matching_the_store_is_kept() {
|
||||
// Trailing newline on disk is how `persist_sender_token` writes it.
|
||||
assert_eq!(
|
||||
sender_source(Some("tok"), Some("tok\n"), None),
|
||||
sender_source(Some("tok"), Some("tok\n")),
|
||||
SenderSource::Keep
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_nothing_in_the_store_the_file_is_kept() {
|
||||
assert_eq!(
|
||||
sender_source(None, Some("tok\n"), Some("as")),
|
||||
SenderSource::Keep
|
||||
);
|
||||
assert_eq!(sender_source(None, Some("tok\n")), SenderSource::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_no_token_anywhere_the_appservice_token_mints() {
|
||||
fn with_no_token_anywhere_nothing_is_available() {
|
||||
assert_eq!(
|
||||
sender_source(None, Some(" \n"), Some("as")),
|
||||
SenderSource::Mint("as")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_no_token_and_no_appservice_token_nothing_is_available() {
|
||||
assert_eq!(
|
||||
sender_source(Some(""), None, Some("")),
|
||||
sender_source(Some(""), Some(" \n")),
|
||||
SenderSource::Unavailable
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_hex_is_well_formed_and_correct_length() {
|
||||
let h = random_hex(16).expect("/dev/urandom readable");
|
||||
assert_eq!(h.len(), 32);
|
||||
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
assert_eq!(sender_source(None, None), SenderSource::Unavailable);
|
||||
}
|
||||
|
||||
/// The steady state. This is the whole point of the guard: the sweep
|
||||
|
|
@ -1506,25 +1160,6 @@ mod tests {
|
|||
assert!(state_needs_write(None, &desired));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_hex_two_calls_differ() {
|
||||
// Sanity check — not a statistical claim, just guards
|
||||
// against ever accidentally returning a constant.
|
||||
let a = random_hex(16).expect("/dev/urandom readable");
|
||||
let b = random_hex(16).expect("/dev/urandom readable");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_access_token_pulls_from_success_body() {
|
||||
let body = serde_json::json!({
|
||||
"user_id": "@alice:matrix.example.org",
|
||||
"access_token": "syt_abc123",
|
||||
"device_id": "ABC",
|
||||
});
|
||||
assert_eq!(extract_access_token(&body).unwrap(), "syt_abc123");
|
||||
}
|
||||
|
||||
/// A homeserver response built in memory, so no client (and no TLS
|
||||
/// roots) is needed to drive the response-handling halves.
|
||||
fn response(status: u16, body: &'static str) -> reqwest::Response {
|
||||
|
|
@ -1572,11 +1207,4 @@ mod tests {
|
|||
let outcome = refused_invite(response(500, "{}"), Some("join"), "@a:x", "!r:x").await;
|
||||
assert!(outcome.is_err(), "a 500 is not excused by membership");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_access_token_errors_on_missing_field() {
|
||||
let body = serde_json::json!({"user_id": "@alice:matrix.example.org"});
|
||||
let err = extract_access_token(&body).unwrap_err();
|
||||
assert!(err.to_string().contains("missing access_token"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,14 +160,6 @@ pub fn matrix_chat_room_id() -> PathBuf {
|
|||
matrix_dir().join("chat-room-id")
|
||||
}
|
||||
|
||||
/// `matrix/creds/` — the hive sender account's throwaway matrix password
|
||||
/// (survives `destroy --purge`; it authenticates by token, this is
|
||||
/// recovery only).
|
||||
#[must_use]
|
||||
pub fn matrix_creds_dir() -> PathBuf {
|
||||
matrix_dir().join("creds")
|
||||
}
|
||||
|
||||
/// `run/` — runtime maps hive-c0re regenerates on every meta sync.
|
||||
#[must_use]
|
||||
pub fn run_dir() -> PathBuf {
|
||||
|
|
@ -284,17 +276,6 @@ pub fn gateway_agents_conf() -> PathBuf {
|
|||
// `nix/host-modules/hive-c0re/default.nix` and `nix/host-modules/hive-ci.nix` — must match.
|
||||
pub const FORGE_CORE_TOKEN: &str = "/var/lib/hyperhive/forge-core-token";
|
||||
|
||||
/// `matrix-appservice-token` — the `as_token` of the hive's appservice
|
||||
/// registration, which authorises every account this daemon creates.
|
||||
// nix: minted by the `hive-matrix-appservice` activation script in
|
||||
// `nix/host-modules/hive-matrix.nix`, which renders it into the registration
|
||||
// file the homeserver loads — must match. Read-only here on purpose: a token
|
||||
// minted on this side would not be the one in that file.
|
||||
#[must_use]
|
||||
pub fn matrix_appservice_token() -> PathBuf {
|
||||
state_root().join("matrix-appservice-token")
|
||||
}
|
||||
|
||||
/// `/run/hyperhive` — the runtime root (host admin socket + per-agent dirs).
|
||||
#[must_use]
|
||||
pub fn runtime_root() -> PathBuf {
|
||||
|
|
@ -324,13 +305,12 @@ pub fn agent_runtime_dir(name: &str) -> PathBuf {
|
|||
/// within the same filesystem is atomic.
|
||||
pub fn relocate_legacy_state() {
|
||||
let root = state_root();
|
||||
let moves: [(&str, PathBuf); 7] = [
|
||||
let moves: [(&str, PathBuf); 6] = [
|
||||
("broker.sqlite", db_dir().join("broker.sqlite")),
|
||||
("build_logs.sqlite", db_dir().join("build_logs.sqlite")),
|
||||
("forge-core-avatar-set", forge_core_avatar_marker()),
|
||||
("matrix-sender-token", matrix_sender_token()),
|
||||
("matrix-space-room-id", matrix_space_room_id()),
|
||||
("matrix-creds", matrix_creds_dir()),
|
||||
("agent-sockets.json", agent_sockets_file()),
|
||||
];
|
||||
for (old_rel, new) in &moves {
|
||||
|
|
|
|||
|
|
@ -206,7 +206,6 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
|||
)
|
||||
.await?
|
||||
}
|
||||
HostRequest::MatrixSyncAdmin => handle_matrix_sync_admin().await?,
|
||||
HostRequest::MatrixInvite { user, room } => {
|
||||
handle_matrix_invite(user, room.as_deref()).await?
|
||||
}
|
||||
|
|
@ -366,10 +365,9 @@ async fn stream_agent_status(
|
|||
//
|
||||
// The `hivectl matrix` subcommands used to run these in-process, which forced
|
||||
// the standalone CLI to link the whole daemon crate (matrix-sdk, reqwest, …).
|
||||
// They now run daemon-side over the host socket: the daemon already holds the
|
||||
// register + sender tokens and the matrix creds dir. Each op returns the
|
||||
// operator-facing lines hivectl used to `println!` in `HostResponse::messages`
|
||||
// for the client to print verbatim.
|
||||
// They now run daemon-side over the host socket, where the daemon already holds
|
||||
// the sender token. Each op returns the operator-facing lines hivectl used to
|
||||
// `println!` in `HostResponse::messages` for the client to print verbatim.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Shared reqwest client for the matrix admin HTTP calls (30s timeout,
|
||||
|
|
@ -588,24 +586,6 @@ async fn handle_push_snapshot(
|
|||
Ok(HostResponse::success())
|
||||
}
|
||||
|
||||
async fn handle_matrix_sync_admin() -> Result<HostResponse> {
|
||||
require_matrix_present()?;
|
||||
let as_token =
|
||||
crate::matrix::read_appservice_token().context("read matrix appservice token")?;
|
||||
let client = matrix_http_client()?;
|
||||
crate::matrix::ensure_hive_user(&client, Some(&as_token))
|
||||
.await
|
||||
.context("matrix sync-admin")?;
|
||||
let path = crate::matrix::sender_token_path();
|
||||
Ok(HostResponse::messages(vec![
|
||||
format!(
|
||||
"matrix: the @{}: user is provisioned",
|
||||
crate::matrix::hive_localpart()?
|
||||
),
|
||||
format!("token persisted at: {}", path.display()),
|
||||
]))
|
||||
}
|
||||
|
||||
async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResponse> {
|
||||
require_matrix_present()?;
|
||||
let sender_token = crate::matrix::read_sender_token()?;
|
||||
|
|
|
|||
Loading…
Reference in a new issue