Matrix: ask the server where its client API is
Closes #59. The address typed into the form was used verbatim in every request, which broke three ways that all look like a wrong password. **Delegation.** A homeserver is named by the domain in a user id, and that domain need not be where the client API lives - @you:example.com is commonly served from matrix.example.com. Servers publish where at /.well-known/matrix/client, and a client that never asks has to be told by hand. Confirmed live: matrix.org delegates to matrix-client.matrix.org. **Port 8448.** The federation port, which does not serve /_matrix/client/v3/* at all - so it fails with a bare 404 and a reconnect loop that retries it forever. Named rather than silently rewritten: guessing at 443 would be right most of the time and wrong on a server genuinely running the client API somewhere else. **A missing scheme.** "matrix.org" is not a URL, and reqwest refuses it with a builder error visible only as a backoff banner. Assumed https, since a homeserver that is not is not one to send a password to. Resolved once at sign-in and the resolved address is what gets stored, so a delegated server is asked where it lives once rather than on every request forever - and an account that works keeps working if the well-known later goes away. A server publishing nothing keeps the address as given, and a well-known naming something that is not a URL is ignored rather than believed: that would replace a working address with a broken one. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -19,6 +19,79 @@ pub fn http_client() -> &'static reqwest::Client {
|
||||
})
|
||||
}
|
||||
|
||||
/// Turns whatever somebody typed into a base URL the client API answers on.
|
||||
///
|
||||
/// Three things happen here, and each is a way people actually get this wrong.
|
||||
///
|
||||
/// **A missing scheme.** `matrix.org` is not a URL, and `reqwest` refuses it
|
||||
/// with a builder error that surfaces only as a reconnect banner. Assumed
|
||||
/// https, since a homeserver that is not is not one to send a password to.
|
||||
///
|
||||
/// **Delegation.** A homeserver is named by the domain in a user id, and that
|
||||
/// domain need not be where the client API lives - `@you:example.com` is
|
||||
/// commonly served from `matrix.example.com`. The server publishes where, at
|
||||
/// `/.well-known/matrix/client`, and a client that does not ask has to be told
|
||||
/// by hand. This asks.
|
||||
///
|
||||
/// **Port 8448.** That is the *federation* port. It does not serve
|
||||
/// `/_matrix/client/v3/*` at all, so pointing at it fails with a bare 404 and
|
||||
/// no hint what is wrong - and the reconnect loop then retries it forever.
|
||||
/// It is a common and reasonable-looking mistake, so it is named rather than
|
||||
/// silently rewritten: guessing that somebody meant 443 would be right most
|
||||
/// of the time and wrong on the servers that really do run the client API on
|
||||
/// an odd port.
|
||||
///
|
||||
/// A server that publishes nothing keeps the address as given, which is the
|
||||
/// right answer for a homeserver on its own domain.
|
||||
pub async fn resolve_homeserver(typed: &str) -> Result<String> {
|
||||
let with_scheme = normalise_homeserver(typed)?;
|
||||
// Best-effort by design: a server with no well-known is the ordinary case,
|
||||
// and so is one that answers with something that is not JSON. Either way
|
||||
// the address as given is the answer.
|
||||
match discover(&with_scheme).await {
|
||||
Some(delegated) => Ok(delegated),
|
||||
None => Ok(with_scheme),
|
||||
}
|
||||
}
|
||||
|
||||
/// The part of the above that needs no network: a scheme, and a refusal.
|
||||
fn normalise_homeserver(typed: &str) -> Result<String> {
|
||||
let typed = typed.trim().trim_end_matches('/');
|
||||
if typed.is_empty() {
|
||||
bail!("no homeserver address given");
|
||||
}
|
||||
let with_scheme = if typed.contains("://") { typed.to_string() } else { format!("https://{typed}") };
|
||||
|
||||
let parsed = url::Url::parse(&with_scheme).with_context(|| format!("{typed} is not an address"))?;
|
||||
if parsed.port() == Some(8448) {
|
||||
bail!(
|
||||
"{typed} is the federation port, which does not serve the client API - use the address without :8448, or whatever your server's own /.well-known/matrix/client points at"
|
||||
);
|
||||
}
|
||||
Ok(with_scheme)
|
||||
}
|
||||
|
||||
async fn discover(base: &str) -> Option<String> {
|
||||
let url = format!("{}/.well-known/matrix/client", base.trim_end_matches('/'));
|
||||
let resp = http_client().get(&url).send().await.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let body: Value = resp.json().await.ok()?;
|
||||
let delegated = body
|
||||
.get("m.homeserver")
|
||||
.and_then(|h| h.get("base_url"))
|
||||
.and_then(|u| u.as_str())?
|
||||
.trim()
|
||||
.trim_end_matches('/');
|
||||
// A well-known naming something that is not a URL is worse than one that
|
||||
// names nothing: it would replace a working address with a broken one.
|
||||
if delegated.is_empty() || url::Url::parse(delegated).is_err() {
|
||||
return None;
|
||||
}
|
||||
Some(delegated.to_string())
|
||||
}
|
||||
|
||||
/// The Matrix C-S API's standard error body shape (`{"errcode": "M_...",
|
||||
/// "error": "..."}`) - surfaced verbatim as the error message on a
|
||||
/// non-2xx response, since these are meant to be shown to the user
|
||||
@@ -97,3 +170,39 @@ async fn handle_response(resp: reqwest::Response) -> Result<Value> {
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn assumes_https_where_no_scheme_was_typed() {
|
||||
// Without this, reqwest refuses the address with a builder error that
|
||||
// surfaces only as a reconnect banner saying nothing useful.
|
||||
assert_eq!(normalise_homeserver("matrix.org").unwrap(), "https://matrix.org");
|
||||
assert_eq!(normalise_homeserver(" matrix.org/ ").unwrap(), "https://matrix.org");
|
||||
// Something already carrying one is left exactly as written, including
|
||||
// a plain-http server somebody is deliberately running locally.
|
||||
assert_eq!(normalise_homeserver("https://matrix.example.com").unwrap(), "https://matrix.example.com");
|
||||
assert_eq!(normalise_homeserver("http://localhost:8008").unwrap(), "http://localhost:8008");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_the_federation_port_by_name() {
|
||||
// 8448 does not serve the client API at all, so this fails with a bare
|
||||
// 404 and a reconnect loop that retries it forever. Named rather than
|
||||
// rewritten: guessing at 443 would be right most of the time and wrong
|
||||
// on a server genuinely running the client API on an odd port.
|
||||
let err = normalise_homeserver("https://example.com:8448").unwrap_err().to_string();
|
||||
assert!(err.contains("federation port"), "{err}");
|
||||
assert!(err.contains("well-known"), "{err}");
|
||||
// Any other port is somebody's own arrangement and is left alone.
|
||||
assert!(normalise_homeserver("https://example.com:8008").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn says_so_when_there_is_nothing_to_resolve() {
|
||||
assert!(normalise_homeserver("").is_err());
|
||||
assert!(normalise_homeserver(" ").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1584,6 +1584,14 @@ pub fn start_login(state: AppState, login_id: String, homeserver_url: String, us
|
||||
}
|
||||
|
||||
async fn try_login(state: &AppState, login_id: &str, homeserver_url: &str, username: &str, password: &str) -> Result<()> {
|
||||
// Where the client API actually is, before anything is stored or tried.
|
||||
//
|
||||
// Resolved here rather than at every call site, and the *resolved* address
|
||||
// is what gets saved - so a delegated server is asked once at sign-in
|
||||
// rather than on every request forever, and an account that works keeps
|
||||
// working if the well-known later goes away.
|
||||
state.events.emit("matrixLoginStatus", serde_json::json!({ "loginId": login_id, "detail": "finding the server..." }));
|
||||
let homeserver_url = &http::resolve_homeserver(homeserver_url).await?;
|
||||
state.events.emit("matrixLoginStatus", serde_json::json!({ "loginId": login_id, "detail": "logging in..." }));
|
||||
|
||||
// Saved before the login is attempted, so a failure leaves an account to
|
||||
|
||||
Reference in New Issue
Block a user