Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-09-29 23:49:57 +02:00 • committed by mara
commit ddb7d7196d
22 changed files with 187 additions and 1162 deletions

View file

@ -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"));
}
}

View file

@ -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 {

View file

@ -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()?;