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:
2026-09-02 08:20:22 -04:00
co-authored by Claude Opus 5
parent 1f0d947825
commit 78019ca762
2 changed files with 117 additions and 0 deletions
+109
View File
@@ -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());
}
}
+8
View File
@@ -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