feat(#2035): auto-discover dashboard-provisioned matrix accounts via token+homeserver sidecar
This commit is contained in:
parent
cc73bc7cd0
commit
3b0a914487
6 changed files with 112 additions and 11 deletions
|
|
@ -244,7 +244,9 @@ pub(super) async fn post_matrix_account_login(Form(f): Form<MatrixLoginForm>) ->
|
|||
}
|
||||
};
|
||||
|
||||
if let Err(e) = crate::priv_client::write_agent_matrix_token(agent, &token, Some(account)).await
|
||||
if let Err(e) =
|
||||
crate::priv_client::write_agent_matrix_token(agent, &token, Some(account), Some(homeserver))
|
||||
.await
|
||||
{
|
||||
return error_response(&format!("matrix-account-login: write token failed: {e:#}"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -709,7 +709,7 @@ pub async fn ensure_user_for(
|
|||
// unprivileged `hive-core` user and cannot write to agent-owned state
|
||||
// directories directly. hive-priv writes the file 0600 and chowns it
|
||||
// to the agent user so it is readable from inside the container.
|
||||
crate::priv_client::write_agent_matrix_token(name, &access_token, None)
|
||||
crate::priv_client::write_agent_matrix_token(name, &access_token, None, None)
|
||||
.await
|
||||
.with_context(|| format!("matrix: write matrix-token for {name} via hive-priv"))?;
|
||||
tracing::info!(%name, "matrix: provisioned access token");
|
||||
|
|
|
|||
|
|
@ -261,15 +261,22 @@ pub async fn write_agent_forge_token(agent_name: &str, token: &str) -> Result<()
|
|||
/// `<state>/matrix-token-<name>` for an extra (external) account. The file
|
||||
/// is written 0600 and chowned to the agent user so it is readable from
|
||||
/// inside the agent container. hive-priv validates the account suffix.
|
||||
///
|
||||
/// `homeserver: Some(url)` (only meaningful with `account: Some`) also
|
||||
/// writes the sidecar `<state>/matrix-account-<name>.json` so the daemon
|
||||
/// can auto-discover the extra account without a `matrixAccounts` config
|
||||
/// declaration (see issue tracker "external matrix account auto-discovery").
|
||||
pub async fn write_agent_matrix_token(
|
||||
agent_name: &str,
|
||||
token: &str,
|
||||
account: Option<&str>,
|
||||
homeserver: Option<&str>,
|
||||
) -> Result<()> {
|
||||
ok(call(&PrivRequest::WriteAgentMatrixToken {
|
||||
agent_name: agent_name.to_owned(),
|
||||
token: token.to_owned(),
|
||||
account: account.map(ToOwned::to_owned),
|
||||
homeserver: homeserver.map(ToOwned::to_owned),
|
||||
})
|
||||
.await?)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,14 +78,13 @@ pub fn configured() -> anyhow::Result<Vec<AccountCfg>> {
|
|||
state_dir: paths::matrix_state_dir(),
|
||||
homeserver: None,
|
||||
};
|
||||
let Some(raw) = std::env::var_os("HIVE_MATRIX_ACCOUNTS") else {
|
||||
return Ok(vec![hive]);
|
||||
};
|
||||
let extras: Vec<AccountCfg> = serde_json::from_str(&raw.to_string_lossy())
|
||||
.map_err(|e| anyhow::anyhow!("parse HIVE_MATRIX_ACCOUNTS as JSON array: {e}"))?;
|
||||
let mut accounts = Vec::with_capacity(extras.len() + 1);
|
||||
accounts.push(hive);
|
||||
accounts.extend(extras);
|
||||
let mut accounts = vec![hive];
|
||||
if let Some(raw) = std::env::var_os("HIVE_MATRIX_ACCOUNTS") {
|
||||
let extras: Vec<AccountCfg> = serde_json::from_str(&raw.to_string_lossy())
|
||||
.map_err(|e| anyhow::anyhow!("parse HIVE_MATRIX_ACCOUNTS as JSON array: {e}"))?;
|
||||
accounts.extend(extras);
|
||||
}
|
||||
// Reject duplicate names among the *configured* set (hive + extras).
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for a in &accounts {
|
||||
if !seen.insert(a.name.as_str()) {
|
||||
|
|
@ -95,9 +94,84 @@ pub fn configured() -> anyhow::Result<Vec<AccountCfg>> {
|
|||
);
|
||||
}
|
||||
}
|
||||
// Append dashboard-provisioned accounts (a `matrix-token-<name>` file +
|
||||
// its `matrix-account-<name>.json` homeserver sidecar) that aren't
|
||||
// already declared in config, so an account logged in via the dashboard
|
||||
// form works without a `matrixAccounts` edit + rebuild. Explicit config
|
||||
// wins on name collision.
|
||||
let configured: std::collections::HashSet<String> =
|
||||
accounts.iter().map(|a| a.name.clone()).collect();
|
||||
for disc in discover_token_accounts() {
|
||||
if !configured.contains(&disc.name) {
|
||||
accounts.push(disc);
|
||||
}
|
||||
}
|
||||
Ok(accounts)
|
||||
}
|
||||
|
||||
/// Scan the agent state dir for extra matrix accounts provisioned via the
|
||||
/// dashboard login form: each is a `matrix-token-<name>` file plus a
|
||||
/// `matrix-account-<name>.json` sidecar carrying the homeserver. Returns one
|
||||
/// [`AccountCfg`] per discovered account that has BOTH files — a token
|
||||
/// without a sidecar is skipped, because the homeserver is then unknown and
|
||||
/// defaulting to the hive homeserver would be wrong for an external account.
|
||||
/// Best-effort: an unreadable dir or malformed sidecar yields fewer
|
||||
/// accounts, never an error — explicit `HIVE_MATRIX_ACCOUNTS` config stays
|
||||
/// authoritative.
|
||||
fn discover_token_accounts() -> Vec<AccountCfg> {
|
||||
let token_path = paths::token_file();
|
||||
let Some(state_dir) = token_path.parent() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(rd) = std::fs::read_dir(state_dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for entry in rd.flatten() {
|
||||
let fname = entry.file_name();
|
||||
let Some(fname) = fname.to_str() else {
|
||||
continue;
|
||||
};
|
||||
// `matrix-token` (no suffix) is the hive account, handled separately;
|
||||
// only `matrix-token-<name>` files are extra accounts.
|
||||
let Some(name) = fname.strip_prefix("matrix-token-") else {
|
||||
continue;
|
||||
};
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let sidecar = state_dir.join(format!("matrix-account-{name}.json"));
|
||||
let Some(homeserver) = read_account_homeserver(&sidecar) else {
|
||||
tracing::warn!(
|
||||
account = name,
|
||||
sidecar = %sidecar.display(),
|
||||
"matrix: discovered token but no homeserver sidecar; skipping account \
|
||||
(re-login via the dashboard to write it)"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
out.push(AccountCfg {
|
||||
name: name.to_owned(),
|
||||
token_file: entry.path(),
|
||||
state_dir: state_dir.join(format!("matrix-sdk-state-{name}")),
|
||||
homeserver: Some(homeserver),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Read `{"homeserver": "<url>"}` from a sidecar file. `None` when the file
|
||||
/// is missing, unreadable, not valid JSON, or the `homeserver` field is
|
||||
/// absent / empty.
|
||||
fn read_account_homeserver(path: &Path) -> Option<String> {
|
||||
let raw = std::fs::read_to_string(path).ok()?;
|
||||
let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
|
||||
json.get("homeserver")?
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
/// Live status of one matrix account, as reported by [`Registry::list`]
|
||||
/// (the `list_accounts` daemon op). Only accounts that successfully
|
||||
/// restored a session appear, so `live` is always `true` today; the
|
||||
|
|
|
|||
|
|
@ -247,6 +247,7 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String,
|
|||
ref agent_name,
|
||||
ref token,
|
||||
ref account,
|
||||
ref homeserver,
|
||||
} => {
|
||||
validate_agent_name(agent_name)?;
|
||||
// Build the token filename. `None` → the hive account's
|
||||
|
|
@ -262,7 +263,17 @@ async fn exec(req: PrivRequest, writer: &mut OwnedWriteHalf) -> Result<(String,
|
|||
format!("matrix-token-{a}")
|
||||
}
|
||||
};
|
||||
write_agent_state_file(agent_name, &filename, &format!("{token}\n"))
|
||||
let res = write_agent_state_file(agent_name, &filename, &format!("{token}\n"))?;
|
||||
// For an extra account, persist its homeserver in a sidecar
|
||||
// (`matrix-account-<a>.json`) so the daemon can auto-discover the
|
||||
// account without a static `matrixAccounts` config entry. Only
|
||||
// when both `account` and `homeserver` are present; the account
|
||||
// suffix is already validated above.
|
||||
if let (Some(a), Some(hs)) = (account, homeserver) {
|
||||
let meta = serde_json::json!({ "homeserver": hs }).to_string();
|
||||
write_agent_state_file(agent_name, &format!("matrix-account-{a}.json"), &meta)?;
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
PrivRequest::RestartMatrixDaemon { ref agent_name } => {
|
||||
|
|
|
|||
|
|
@ -389,6 +389,13 @@ pub enum PrivRequest {
|
|||
/// Extra-account suffix. `None` → `matrix-token` (the hive account);
|
||||
/// `Some(name)` → `matrix-token-<name>` (validated as a plain ident).
|
||||
account: Option<String>,
|
||||
/// Homeserver URL for an extra account. When `Some` (only meaningful
|
||||
/// alongside `account: Some`), hive-priv also writes the sidecar
|
||||
/// `matrix-account-<name>.json` (`{"homeserver": <url>}`, 0600,
|
||||
/// chowned to the agent) so the daemon can auto-discover the account
|
||||
/// without a config declaration. `None` → no sidecar written.
|
||||
#[serde(default)]
|
||||
homeserver: Option<String>,
|
||||
},
|
||||
|
||||
/// Restart `hive-matrix-daemon.service` inside an agent container via
|
||||
|
|
|
|||
Loading…
Reference in a new issue