Port federation server name resolution and policies to Rust

The parts of the outbound federation agent that decide where to connect
and what to trust, each a port of the Python it replaces, with Rust unit
tests. Nothing uses them yet.

- `resolve`: `_matrix-fed._tcp`, then `_matrix._tcp`, then port 8448, as
  `MatrixHostnameEndpoint._resolve_server`. SRV records are ordered as
  `_sort_server_list` (RFC 2782), cached while every record is live, and
  the stale answer is used when a refetch fails. A lone `.` target means
  the service is unavailable. SRV lookups go through hickory, and A/AAAA
  through the system resolver, as Twisted uses getaddrinfo. Parts derive
  from matrix-hyper-federation-client (Apache-2.0).
- `well_known`: `WellKnownResolver`, with Python's cache periods, jitter,
  grace period, had-valid memory, size cap and retries, behind a
  `WellKnownFetcher` trait so the state machine is tested without HTTP.
  Two additions: each fetch attempt has a timeout (`client_timeout`), and
  concurrent lookups for one server name share a fetch. An `m.server`
  that is not a valid URI authority now counts as an invalid response.
- `policy`: the IP range lists, refusing a name if any of its addresses
  is blocked (the DNS rebinding defence), and the `https_proxy`
  settings with Python's `no_proxy` matching.
- `tls`: `FederationPolicyForHTTPS` on rustls: platform roots or the
  custom CA list, verification off (still sending SNI), the per-host
  verification whitelist. rustls has no TLS 1.0 or 1.1, so a minimum
  below 1.2 means 1.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
This commit is contained in:
Erik JohnstonandClaude Opus 5.5 committed 2026-10-15 14:54:15 +00:00
1 parent db4febd65a
commit 7f34eefa26
8 files changed
+2292 -5

No files matched your search

Generated
+272 -5
View File
@@ -208,6 +208,36 @@ dependencies = [
"libc",
]
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crossbeam-channel"
version = "0.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crypto-common"
version = "0.1.6"
@@ -263,6 +293,12 @@ dependencies = [
"syn 2.0.104",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "deadpool"
version = "0.12.3"
@@ -370,6 +406,18 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "enum-as-inner"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -526,6 +574,18 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.3"
@@ -535,7 +595,7 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
@@ -618,6 +678,52 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hickory-proto"
version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502"
dependencies = [
"async-trait",
"cfg-if",
"data-encoding",
"enum-as-inner",
"futures-channel",
"futures-io",
"futures-util",
"idna",
"ipnet",
"once_cell",
"rand 0.9.5",
"ring",
"thiserror",
"tinyvec",
"tokio",
"tracing",
"url",
]
[[package]]
name = "hickory-resolver"
version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a"
dependencies = [
"cfg-if",
"futures-util",
"hickory-proto",
"ipconfig",
"moka",
"once_cell",
"parking_lot",
"rand 0.9.5",
"resolv-conf",
"smallvec",
"thiserror",
"tokio",
"tracing",
]
[[package]]
name = "hmac"
version = "0.13.0"
@@ -905,6 +1011,19 @@ dependencies = [
"hashbrown 0.15.4",
]
[[package]]
name = "ipconfig"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
dependencies = [
"socket2 0.6.5",
"widestring",
"windows-registry",
"windows-result",
"windows-sys 0.61.2",
]
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -1053,6 +1172,23 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moka"
version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9"
dependencies = [
"crossbeam-channel",
"crossbeam-epoch",
"crossbeam-utils",
"equivalent",
"parking_lot",
"portable-atomic",
"smallvec",
"tagptr",
"uuid",
]
[[package]]
name = "num_cpus"
version = "1.17.0"
@@ -1086,6 +1222,10 @@ name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
dependencies = [
"critical-section",
"portable-atomic",
]
[[package]]
name = "openssl-probe"
@@ -1176,7 +1316,7 @@ dependencies = [
"hmac",
"md-5",
"memchr",
"rand",
"rand 0.10.2",
"sha2 0.11.0",
"stringprep",
]
@@ -1202,6 +1342,15 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.95"
@@ -1321,7 +1470,7 @@ dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
@@ -1357,12 +1506,28 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.2"
@@ -1374,6 +1539,16 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -1383,6 +1558,15 @@ dependencies = [
"getrandom 0.2.16",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
@@ -1478,6 +1662,12 @@ dependencies = [
"web-sys",
]
[[package]]
name = "resolv-conf"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
[[package]]
name = "ring"
version = "0.17.14"
@@ -1831,8 +2021,10 @@ dependencies = [
"futures",
"headers",
"hex",
"hickory-resolver",
"http",
"http-body-util",
"httpdate",
"icu_segmenter",
"ipnet",
"itertools",
@@ -1847,6 +2039,7 @@ dependencies = [
"pyo3",
"pyo3-log",
"pythonize",
"rand 0.10.2",
"regex",
"reqwest",
"rustc-hash",
@@ -1860,7 +2053,9 @@ dependencies = [
"tokio",
"tokio-postgres",
"tokio-postgres-rustls",
"tokio-rustls",
"ulid",
"url",
]
[[package]]
@@ -1883,6 +2078,12 @@ dependencies = [
"syn 2.0.104",
]
[[package]]
name = "tagptr"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]]
name = "target-lexicon"
version = "0.13.5"
@@ -2000,7 +2201,7 @@ dependencies = [
"pin-project-lite",
"postgres-protocol",
"postgres-types",
"rand",
"rand 0.10.2",
"socket2 0.6.5",
"tokio",
"tokio-util",
@@ -2126,7 +2327,7 @@ version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "947dde63b6d514cc5e044edad4e0ca7261afd1099d16c83d942cb2b2f348689c"
dependencies = [
"rand",
"rand 0.10.2",
"web-time",
]
@@ -2180,6 +2381,17 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
dependencies = [
"getrandom 0.4.3",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "version_check"
version = "0.9.5"
@@ -2330,12 +2542,47 @@ dependencies = [
"web-sys",
]
[[package]]
name = "widestring"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -2475,6 +2722,26 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]]
name = "zerofrom"
version = "0.1.6"
+18
View File
@@ -102,6 +102,24 @@ tokio-postgres-rustls = { version = "0.14.0", default-features = false, features
"ring",
] }
lru = "0.16.4"
# Outbound federation (`federation::agent`): the TLS policy.
tokio-rustls = { version = "0.26.2", default-features = false, features = [
"ring",
"logging",
"tls12",
] }
# SRV lookups. A/AAAA go through the system resolver, as in Python. 0.25
# rather than 0.26, which needs a newer rustc than our MSRV.
hickory-resolver = { version = "0.25.2", default-features = false, features = [
"tokio",
"system-config",
] }
# SRV record ordering and cache period jitter.
rand = "0.10.2"
# The `Expires` header of `.well-known` responses.
httpdate = "1.0.3"
# Resolving `.well-known` redirects.
url = "2.5.4"
# Signing outbound federation requests. 2.x rather than 3.x, which needs a
# newer rustc than our MSRV.
ed25519-dalek = "2.2.0"
+34
View File
@@ -0,0 +1,34 @@
/*
* This file is licensed under the Affero General Public License (AGPL) version 3.
*
* Copyright (C) 2026 Element Creations Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* See the GNU Affero General Public License for more details:
* <https://www.gnu.org/licenses/agpl-3.0.html>.
*
*/
//! The outbound federation agent: Python's `MatrixFederationAgent` and its
//! `HTTPConnectionPool`. This holds its parts so far.
//!
//! - [`resolve`]: SRV lookups and the order to try servers in.
//! - [`well_known`]: `.well-known` delegation and its cache.
//! - [`policy`]: the IP range lists and the proxy.
//! - [`tls`]: certificate verification.
use std::sync::Arc;
pub mod policy;
pub mod resolve;
pub mod tls;
pub mod well_known;
/// The current time in milliseconds since the epoch. Production reads the
/// homeserver's [`Clock`](crate::clock::Clock). Rust tests use their own,
/// since the virtual clock is process-global.
pub type Now = Arc<dyn Fn() -> u64 + Send + Sync>;
+312
View File
@@ -0,0 +1,312 @@
/*
* This file is licensed under the Affero General Public License (AGPL) version 3.
*
* Copyright (C) 2026 Element Creations Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* See the GNU Affero General Public License for more details:
* <https://www.gnu.org/licenses/agpl-3.0.html>.
*
*/
//! Which addresses outbound federation may connect to, and through which
//! proxy.
use std::{net::IpAddr, sync::Arc};
use anyhow::{anyhow, bail, Context};
use base64::{prelude::BASE64_STANDARD, Engine};
use log::info;
use super::resolve::{parse_ip_literal, DnsError, DnsResolver};
use crate::{
config::types::{IpRangeSet, ProxyConfig},
federation::transport::TransportError,
};
/// The IP range block and allow lists, applied to every address we connect
/// to. This is Python's `_IPBlockingResolver` (via
/// `BlocklistingReactorWrapper`).
#[derive(Clone)]
pub struct IpPolicy {
blocklist: IpRangeSet,
allowlist: Option<IpRangeSet>,
}
impl IpPolicy {
pub fn new(blocklist: IpRangeSet, allowlist: Option<IpRangeSet>) -> Self {
IpPolicy {
blocklist,
allowlist,
}
}
/// Blocked if in the blocklist and not in the allowlist.
pub fn is_blocked(&self, ip: &IpAddr) -> bool {
self.blocklist.contains(ip) && !self.allowlist.as_ref().is_some_and(|a| a.contains(ip))
}
/// Whether an IP literal is blocked by the blocklist alone, ignoring the
/// allowlist. This is how Python's `BlocklistingAgentWrapper` treats the
/// `.well-known` fetch, since it is not given the allowlist.
pub fn is_blocked_ignoring_allowlist(&self, ip: &IpAddr) -> bool {
self.blocklist.contains(ip)
}
/// Resolve `host` to the addresses we may connect to.
///
/// If any address is blocked, none are returned: an attacker controlling
/// DNS could otherwise mix a blocked address in with good ones (DNS
/// rebinding). Python reports this as a failed DNS lookup, and so do we.
pub async fn resolve(
&self,
dns: &dyn DnsResolver,
host: &str,
) -> Result<Vec<IpAddr>, TransportError> {
let ips = match dns.lookup_ip(host).await {
Ok(ips) => ips,
Err(DnsError::NotFound) => {
return Err(TransportError::Dns(anyhow!("no addresses for {host}")))
}
Err(DnsError::Failed(err)) => return Err(TransportError::Dns(err)),
};
if let Some(blocked) = ips.iter().find(|ip| self.is_blocked(ip)) {
info!("Blocked {blocked} from DNS resolution to {host}");
return Err(TransportError::Dns(anyhow!(
"{host} resolved to blocked address {blocked}"
)));
}
Ok(ips)
}
}
/// An `https_proxy` that federation connections tunnel through with
/// `CONNECT`, unless `no_proxy` exempts the host.
#[derive(Clone, Debug)]
pub struct Proxy {
/// Whether to speak TLS to the proxy itself.
pub tls: bool,
pub host: String,
pub port: u16,
/// The `Proxy-Authorization` header value, from credentials in the
/// proxy URL.
pub authorization: Option<String>,
no_proxy: Arc<NoProxy>,
}
impl Proxy {
/// The proxy to use, if `https_proxy` is set. Parses the URL the way
/// Python's `http_proxy_endpoint` does: scheme `http` (the default) or
/// `https`, port defaulting to 1080, optional `user:password@`.
pub fn from_config(config: &ProxyConfig) -> anyhow::Result<Option<Self>> {
let Some(https_proxy) = config.https_proxy.as_deref().filter(|p| !p.is_empty()) else {
return Ok(None);
};
let with_scheme = if https_proxy.contains("://") {
https_proxy.to_owned()
} else {
format!("http://{https_proxy}")
};
let url = url::Url::parse(&with_scheme)
.with_context(|| format!("parsing https_proxy {https_proxy:?}"))?;
let tls = match url.scheme() {
"http" => false,
"https" => true,
other => bail!("unsupported https_proxy scheme {other:?}"),
};
let host = url
.host_str()
.with_context(|| format!("https_proxy {https_proxy:?} has no host"))?
.to_owned();
let port = url.port().unwrap_or(1080);
// Credentials are only used when both parts are present, and are
// sent as written in the URL (Python does not percent-decode them).
let authorization = match (url.username(), url.password()) {
(user, Some(password)) if !user.is_empty() && !password.is_empty() => Some(format!(
"Basic {}",
BASE64_STANDARD.encode(format!("{user}:{password}"))
)),
_ => None,
};
Ok(Some(Proxy {
tls,
host,
port,
authorization,
no_proxy: Arc::new(NoProxy::new(
config.no_proxy_hosts.as_deref().unwrap_or(&[]),
)),
}))
}
/// Whether a connection to `host` should go through this proxy.
pub fn applies_to(&self, host: &str) -> bool {
!self.no_proxy.bypasses(host)
}
}
/// The `no_proxy` host list, matched as Python's
/// `urllib.request.proxy_bypass_environment` does: `*` exempts everything,
/// otherwise an entry matches the host itself and its subdomains. CIDR
/// entries are only compared as strings, as in Python.
#[derive(Debug)]
pub struct NoProxy {
all: bool,
names: Vec<String>,
}
impl NoProxy {
pub fn new(hosts: &[String]) -> Self {
// Python joins the list with commas and compares the whole string
// to `*`.
let all = hosts.len() == 1 && hosts[0] == "*";
let names = hosts
.iter()
.flat_map(|h| h.split(','))
.map(|name| name.trim().trim_start_matches('.').to_lowercase())
.filter(|name| !name.is_empty())
.collect();
NoProxy { all, names }
}
pub fn bypasses(&self, host: &str) -> bool {
if self.all {
return true;
}
let host = host.to_lowercase();
// Python splits a trailing `:port` off; our hosts carry no port, but
// an IPv6 literal must not be mistaken for one.
let host_only = match host.rsplit_once(':') {
Some((h, port))
if parse_ip_literal(&host).is_none()
&& port.bytes().all(|b| b.is_ascii_digit()) =>
{
h.to_owned()
}
_ => host.clone(),
};
self.names.iter().any(|name| {
let dotted = format!(".{name}");
host_only == *name
|| host == *name
|| host_only.ends_with(&dotted)
|| host.ends_with(&dotted)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::federation::agent::resolve::InMemoryResolver;
fn policy(block: &[&str], allow: Option<&[&str]>) -> IpPolicy {
let set =
|nets: &[&str]| IpRangeSet::new(nets.iter().map(|n| n.parse().unwrap()).collect());
IpPolicy::new(set(block), allow.map(set))
}
#[test]
fn allowlist_overrides_blocklist() {
let p = policy(&["10.0.0.0/8"], Some(&["10.1.0.0/16"]));
assert!(p.is_blocked(&"10.2.0.1".parse().unwrap()));
assert!(!p.is_blocked(&"10.1.0.1".parse().unwrap()));
assert!(!p.is_blocked(&"11.0.0.1".parse().unwrap()));
assert!(p.is_blocked_ignoring_allowlist(&"10.1.0.1".parse().unwrap()));
}
#[tokio::test]
async fn one_blocked_address_blocks_the_name() {
let dns = InMemoryResolver::new();
dns.add_host(
"mixed.example",
vec!["1.2.3.4".parse().unwrap(), "10.0.0.1".parse().unwrap()],
);
dns.add_host("good.example", vec!["1.2.3.4".parse().unwrap()]);
let p = policy(&["10.0.0.0/8"], None);
let err = p.resolve(&dns, "mixed.example").await.unwrap_err();
assert!(err.is_dns(), "{err}");
assert_eq!(
p.resolve(&dns, "good.example").await.unwrap(),
["1.2.3.4".parse::<IpAddr>().unwrap()]
);
// IP literals are checked too.
assert!(p.resolve(&dns, "10.0.0.1").await.unwrap_err().is_dns());
assert!(p
.resolve(&dns, "unknown.example")
.await
.unwrap_err()
.is_dns());
}
#[test]
fn no_proxy_matching() {
let no_proxy = NoProxy::new(&[
"example.com".to_owned(),
".other.org".to_owned(),
" spaced.net ".to_owned(),
]);
assert!(no_proxy.bypasses("example.com"));
assert!(no_proxy.bypasses("sub.example.com"));
assert!(no_proxy.bypasses("EXAMPLE.COM"));
assert!(!no_proxy.bypasses("notexample.com"));
assert!(no_proxy.bypasses("other.org"));
assert!(no_proxy.bypasses("a.other.org"));
assert!(no_proxy.bypasses("spaced.net"));
assert!(!no_proxy.bypasses("matrix.org"));
assert!(NoProxy::new(&["*".to_owned()]).bypasses("anything"));
assert!(!NoProxy::new(&[]).bypasses("anything"));
}
#[test]
fn proxy_url_parsing() {
let config = |p: &str| ProxyConfig {
http_proxy: None,
https_proxy: Some(p.to_owned()),
no_proxy_hosts: None,
};
let proxy = Proxy::from_config(&config("proxy.example"))
.unwrap()
.unwrap();
assert!(!proxy.tls);
assert_eq!((proxy.host.as_str(), proxy.port), ("proxy.example", 1080));
assert_eq!(proxy.authorization, None);
let proxy = Proxy::from_config(&config("https://bob:pa%20ss@proxy.example:3128"))
.unwrap()
.unwrap();
assert!(proxy.tls);
assert_eq!(proxy.port, 3128);
assert_eq!(
proxy.authorization.as_deref(),
Some(format!("Basic {}", BASE64_STANDARD.encode("bob:pa%20ss")).as_str())
);
// A user without a password sends no credentials.
let proxy = Proxy::from_config(&config("http://bob@proxy.example"))
.unwrap()
.unwrap();
assert_eq!(proxy.authorization, None);
assert!(Proxy::from_config(&config("socks5://proxy.example")).is_err());
assert!(Proxy::from_config(&ProxyConfig::default())
.unwrap()
.is_none());
}
}
+590
View File
@@ -0,0 +1,590 @@
/*
* This file is licensed under the Affero General Public License (AGPL) version 3.
*
* Copyright (C) 2026 Element Creations Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* See the GNU Affero General Public License for more details:
* <https://www.gnu.org/licenses/agpl-3.0.html>.
*
* Originally licensed under the Apache License, Version 2.0:
* <http://www.apache.org/licenses/LICENSE-2.0>.
*
* Derived from `src/server_resolver.rs` of
* <https://github.com/erikjohnston/matrix-hyper-federation-client>.
*
* [This file includes modifications made by Element Creations Ltd]
*/
//! Resolving a Matrix server name to the hosts and ports to connect to.
//!
//! This is `MatrixHostnameEndpoint._resolve_server` and `SrvResolver` from
//! Python. The `.well-known` lookup happens before this, in
//! [`super::well_known`], because it rewrites the request's `Host` header.
use std::{
collections::{BTreeMap, HashMap},
net::IpAddr,
sync::{Arc, Mutex},
};
use anyhow::anyhow;
use async_trait::async_trait;
use log::{debug, warn};
use rand::{seq::SliceRandom, Rng, RngExt};
use crate::federation::transport::TransportError;
/// The port federation uses when a server name gives none and has no SRV
/// record.
pub const DEFAULT_FEDERATION_PORT: u16 = 8448;
/// One SRV record.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SrvRecord {
pub priority: u16,
pub weight: u16,
pub port: u16,
/// The target host, without a trailing dot. `.` for the root, which a
/// lone record uses to say the service is unavailable.
pub target: String,
/// The record's TTL, in seconds.
pub ttl: u32,
}
/// Why a DNS lookup found nothing.
#[derive(Debug)]
pub enum DnsError {
/// The name does not exist, or has no records of this type.
NotFound,
/// The lookup itself failed (no answer, server failure, and so on).
Failed(anyhow::Error),
}
/// The DNS lookups the agent makes.
#[async_trait]
pub trait DnsResolver: Send + Sync + 'static {
async fn lookup_srv(&self, name: &str) -> Result<Vec<SrvRecord>, DnsError>;
/// The addresses of `host`. An IP literal resolves to itself.
async fn lookup_ip(&self, host: &str) -> Result<Vec<IpAddr>, DnsError>;
}
/// The production resolver. SRV records come from hickory, which reads the
/// system's `resolv.conf` as Twisted's DNS client does. Addresses come from
/// the system resolver (`getaddrinfo`), as Twisted's `HostnameEndpoint` uses,
/// so `/etc/hosts` and `nsswitch.conf` apply.
pub struct SystemResolver {
hickory: hickory_resolver::TokioResolver,
}
impl SystemResolver {
pub fn new() -> anyhow::Result<Self> {
let hickory = hickory_resolver::TokioResolver::builder_tokio()?.build();
Ok(SystemResolver { hickory })
}
}
#[async_trait]
impl DnsResolver for SystemResolver {
async fn lookup_srv(&self, name: &str) -> Result<Vec<SrvRecord>, DnsError> {
let lookup = match self.hickory.srv_lookup(name).await {
Ok(lookup) => lookup,
Err(err) if err.is_no_records_found() => return Err(DnsError::NotFound),
Err(err) => return Err(DnsError::Failed(err.into())),
};
let records = lookup
.as_lookup()
.record_iter()
.filter_map(|record| {
let srv = record.data().as_srv()?;
let target = srv.target();
let target = if target.is_root() {
".".to_owned()
} else {
target.to_ascii().trim_end_matches('.').to_owned()
};
Some(SrvRecord {
priority: srv.priority(),
weight: srv.weight(),
port: srv.port(),
target,
ttl: record.ttl(),
})
})
.collect();
Ok(records)
}
async fn lookup_ip(&self, host: &str) -> Result<Vec<IpAddr>, DnsError> {
if let Some(ip) = parse_ip_literal(host) {
return Ok(vec![ip]);
}
// `lookup_host` wants a port, which it ignores for our purposes.
let addrs = tokio::net::lookup_host((host, 0)).await.map_err(|err| {
// getaddrinfo's errors do not tell "no such name" apart from a
// failed lookup in a way std exposes, so treat all as failures.
DnsError::Failed(anyhow!(err).context(format!("resolving {host}")))
})?;
let ips: Vec<IpAddr> = addrs.map(|addr| addr.ip()).collect();
if ips.is_empty() {
return Err(DnsError::NotFound);
}
Ok(ips)
}
}
/// A resolver that answers from fixed tables, for tests.
#[derive(Default)]
pub struct InMemoryResolver {
srv: Mutex<HashMap<String, Result<Vec<SrvRecord>, String>>>,
ips: Mutex<HashMap<String, Vec<IpAddr>>>,
}
impl InMemoryResolver {
pub fn new() -> Self {
Self::default()
}
pub fn add_srv(&self, name: &str, records: Vec<SrvRecord>) {
self.srv
.lock()
.expect("poisoned")
.insert(name.to_owned(), Ok(records));
}
/// Make SRV lookups of `name` fail (rather than find nothing).
pub fn fail_srv(&self, name: &str, message: &str) {
self.srv
.lock()
.expect("poisoned")
.insert(name.to_owned(), Err(message.to_owned()));
}
pub fn add_host(&self, host: &str, ips: Vec<IpAddr>) {
self.ips
.lock()
.expect("poisoned")
.insert(host.to_owned(), ips);
}
}
#[async_trait]
impl DnsResolver for InMemoryResolver {
async fn lookup_srv(&self, name: &str) -> Result<Vec<SrvRecord>, DnsError> {
match self.srv.lock().expect("poisoned").get(name) {
Some(Ok(records)) if !records.is_empty() => Ok(records.clone()),
Some(Ok(_)) | None => Err(DnsError::NotFound),
Some(Err(message)) => Err(DnsError::Failed(anyhow!("{message}"))),
}
}
async fn lookup_ip(&self, host: &str) -> Result<Vec<IpAddr>, DnsError> {
if let Some(ip) = parse_ip_literal(host) {
return Ok(vec![ip]);
}
match self.ips.lock().expect("poisoned").get(host) {
Some(ips) if !ips.is_empty() => Ok(ips.clone()),
_ => Err(DnsError::NotFound),
}
}
}
/// Parse an IP literal, as a URI host gives it (IPv6 in brackets) or bare.
pub fn parse_ip_literal(host: &str) -> Option<IpAddr> {
host.strip_prefix('[')
.and_then(|h| h.strip_suffix(']'))
.unwrap_or(host)
.parse()
.ok()
}
/// A host and port to try connecting to.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Server {
pub host: String,
pub port: u16,
}
/// A cached SRV record, with the time it expires in seconds since the epoch.
#[derive(Clone, Debug)]
struct CachedRecord {
record: SrvRecord,
expires: u64,
}
/// Resolves server names via SRV, caching the answers.
///
/// The cache follows Python's `SrvResolver`: a cached answer is used only
/// while all its records are unexpired, and when a refetch fails the stale
/// answer is used instead. The cache belongs to the agent, where Python's is
/// module-global.
pub struct ServerResolver {
dns: Arc<dyn DnsResolver>,
cache: Mutex<HashMap<String, Vec<CachedRecord>>>,
}
impl ServerResolver {
pub fn new(dns: Arc<dyn DnsResolver>) -> Self {
ServerResolver {
dns,
cache: Mutex::new(HashMap::new()),
}
}
pub fn dns(&self) -> &Arc<dyn DnsResolver> {
&self.dns
}
/// The servers to try for `host` and optional `port`, in order.
///
/// An IP literal or an explicit port bypasses SRV. Otherwise we look up
/// `_matrix-fed._tcp`, then the deprecated `_matrix._tcp`, and fall back
/// to `host:8448`.
pub async fn resolve_server(
&self,
host: &str,
port: Option<u16>,
now_secs: u64,
) -> Result<Vec<Server>, TransportError> {
if port.is_some() || parse_ip_literal(host).is_some() {
return Ok(vec![Server {
host: host.to_owned(),
port: port.unwrap_or(DEFAULT_FEDERATION_PORT),
}]);
}
for service in ["_matrix-fed._tcp", "_matrix._tcp"] {
let servers = self
.resolve_service(&format!("{service}.{host}"), now_secs)
.await?;
if !servers.is_empty() {
debug!("Got {servers:?} from {service} SRV lookup for {host}");
return Ok(servers);
}
}
debug!("No SRV records for {host}");
Ok(vec![Server {
host: host.to_owned(),
port: DEFAULT_FEDERATION_PORT,
}])
}
/// Look up one SRV name, returning the servers in RFC 2782 order, or
/// nothing if there are no records.
async fn resolve_service(
&self,
service_name: &str,
now_secs: u64,
) -> Result<Vec<Server>, TransportError> {
let cached = self
.cache
.lock()
.expect("poisoned")
.get(service_name)
.cloned();
// An empty cached answer is refetched, as in Python.
if let Some(cached) = &cached {
if !cached.is_empty() && cached.iter().all(|c| c.expires > now_secs) {
let records = cached.iter().map(|c| c.record.clone()).collect();
return Ok(sort_srv_records(records, &mut rand::rng()));
}
}
let records = match self.dns.lookup_srv(service_name).await {
Ok(records) => records,
Err(DnsError::NotFound) => return Ok(Vec::new()),
Err(DnsError::Failed(err)) => {
return match cached {
Some(cached) if !cached.is_empty() => {
warn!("Failed to resolve {service_name}, falling back to cache: {err:#}");
let records = cached.into_iter().map(|c| c.record).collect();
Ok(sort_srv_records(records, &mut rand::rng()))
}
_ => Err(TransportError::Dns(
err.context(format!("SRV lookup of {service_name}")),
)),
};
}
};
if let [only] = records.as_slice() {
if only.target == "." {
return Err(TransportError::ServiceUnavailable(service_name.to_owned()));
}
}
let cached = records
.iter()
.map(|record| CachedRecord {
record: record.clone(),
expires: now_secs + u64::from(record.ttl),
})
.collect();
self.cache
.lock()
.expect("poisoned")
.insert(service_name.to_owned(), cached);
Ok(sort_srv_records(records, &mut rand::rng()))
}
}
/// Order SRV records as Python's `_sort_server_list` does: by priority, and
/// within a priority by a weighted random pick (RFC 2782, without its
/// off-by-one). Zero-weight records left over are shuffled onto the end.
pub fn sort_srv_records<R: Rng + ?Sized>(records: Vec<SrvRecord>, rng: &mut R) -> Vec<Server> {
let mut by_priority: BTreeMap<u16, Vec<SrvRecord>> = BTreeMap::new();
for record in records {
by_priority.entry(record.priority).or_default().push(record);
}
let mut results = Vec::new();
for (_, mut records) in by_priority {
let mut total_weight: u32 = records.iter().map(|r| u32::from(r.weight)).sum();
while !records.is_empty() && total_weight > 0 {
let mut target = rng.random_range(1..=total_weight);
let mut chosen = records.len() - 1;
for (i, record) in records.iter().enumerate() {
target = target.saturating_sub(u32::from(record.weight));
if target == 0 {
chosen = i;
break;
}
}
let record = records.remove(chosen);
total_weight -= u32::from(record.weight);
results.push(record);
}
records.shuffle(rng);
results.extend(records);
}
results
.into_iter()
.map(|record| Server {
host: record.target,
port: record.port,
})
.collect()
}
#[cfg(test)]
mod tests {
use rand::{rngs::StdRng, SeedableRng};
use super::*;
fn srv(priority: u16, weight: u16, target: &str, port: u16, ttl: u32) -> SrvRecord {
SrvRecord {
priority,
weight,
port,
target: target.to_owned(),
ttl,
}
}
fn server(host: &str, port: u16) -> Server {
Server {
host: host.to_owned(),
port,
}
}
#[test]
fn sort_orders_by_priority() {
let mut rng = StdRng::seed_from_u64(1);
let sorted = sort_srv_records(
vec![
srv(10, 0, "c", 1, 0),
srv(0, 0, "a", 1, 0),
srv(5, 0, "b", 1, 0),
],
&mut rng,
);
let hosts: Vec<_> = sorted.iter().map(|s| s.host.as_str()).collect();
assert_eq!(hosts, ["a", "b", "c"]);
}
/// Within a priority, a record is picked first in proportion to its
/// weight, and zero-weight records come last.
#[test]
fn sort_weights_within_a_priority() {
let mut rng = StdRng::seed_from_u64(2);
let mut heavy_first = 0;
for _ in 0..1000 {
let sorted = sort_srv_records(
vec![
srv(0, 0, "zero", 1, 0),
srv(0, 1, "light", 1, 0),
srv(0, 9, "heavy", 1, 0),
],
&mut rng,
);
assert_eq!(sorted.len(), 3);
assert_eq!(sorted[2].host, "zero");
if sorted[0].host == "heavy" {
heavy_first += 1;
}
}
// Expect ~900.
assert!((850..950).contains(&heavy_first), "{heavy_first}");
}
#[test]
fn sort_all_zero_weights_keeps_everything() {
let mut rng = StdRng::seed_from_u64(3);
let mut sorted =
sort_srv_records(vec![srv(0, 0, "a", 1, 0), srv(0, 0, "b", 1, 0)], &mut rng);
sorted.sort_by(|a, b| a.host.cmp(&b.host));
assert_eq!(sorted, [server("a", 1), server("b", 1)]);
}
fn resolver() -> (Arc<InMemoryResolver>, ServerResolver) {
let dns = Arc::new(InMemoryResolver::new());
let resolver = ServerResolver::new(dns.clone());
(dns, resolver)
}
#[tokio::test]
async fn literal_or_port_bypasses_srv() {
let (dns, resolver) = resolver();
dns.add_srv(
"_matrix-fed._tcp.example.com",
vec![srv(0, 0, "other", 1, 60)],
);
assert_eq!(
resolver
.resolve_server("example.com", Some(1234), 0)
.await
.unwrap(),
[server("example.com", 1234)]
);
assert_eq!(
resolver.resolve_server("1.2.3.4", None, 0).await.unwrap(),
[server("1.2.3.4", 8448)]
);
assert_eq!(
resolver.resolve_server("[::1]", None, 0).await.unwrap(),
[server("[::1]", 8448)]
);
}
#[tokio::test]
async fn srv_lookup_order() {
let (dns, resolver) = resolver();
// Nothing: host and the default port.
assert_eq!(
resolver
.resolve_server("example.com", None, 0)
.await
.unwrap(),
[server("example.com", 8448)]
);
// The deprecated record is used when it is the only one.
dns.add_srv("_matrix._tcp.example.com", vec![srv(0, 0, "old", 1111, 60)]);
assert_eq!(
resolver
.resolve_server("example.com", None, 0)
.await
.unwrap(),
[server("old", 1111)]
);
// `_matrix-fed._tcp` wins over it.
dns.add_srv(
"_matrix-fed._tcp.example.com",
vec![srv(0, 0, "new", 2222, 60)],
);
assert_eq!(
resolver
.resolve_server("example.com", None, 0)
.await
.unwrap(),
[server("new", 2222)]
);
}
#[tokio::test]
async fn lone_root_target_is_unavailable() {
let (dns, resolver) = resolver();
dns.add_srv("_matrix-fed._tcp.example.com", vec![srv(0, 0, ".", 0, 60)]);
let err = resolver
.resolve_server("example.com", None, 0)
.await
.unwrap_err();
assert!(
matches!(err, TransportError::ServiceUnavailable(_)),
"{err}"
);
}
#[tokio::test]
async fn cache_and_stale_fallback() {
let (dns, resolver) = resolver();
let name = "_matrix-fed._tcp.example.com";
dns.add_srv(name, vec![srv(0, 0, "first", 1, 60)]);
assert_eq!(
resolver
.resolve_server("example.com", None, 1000)
.await
.unwrap(),
[server("first", 1)]
);
// Still cached: a changed answer is not seen.
dns.add_srv(name, vec![srv(0, 0, "second", 2, 60)]);
assert_eq!(
resolver
.resolve_server("example.com", None, 1059)
.await
.unwrap(),
[server("first", 1)]
);
// Expired: refetched.
assert_eq!(
resolver
.resolve_server("example.com", None, 1060)
.await
.unwrap(),
[server("second", 2)]
);
// Expired and the lookup fails: the stale answer is used.
dns.fail_srv(name, "SERVFAIL");
assert_eq!(
resolver
.resolve_server("example.com", None, 5000)
.await
.unwrap(),
[server("second", 2)]
);
// A failure with nothing cached is a DNS error.
dns.fail_srv("_matrix-fed._tcp.other.com", "SERVFAIL");
let err = resolver
.resolve_server("other.com", None, 0)
.await
.unwrap_err();
assert!(err.is_dns(), "{err}");
}
}
+258
View File
@@ -0,0 +1,258 @@
/*
* This file is licensed under the Affero General Public License (AGPL) version 3.
*
* Copyright (C) 2026 Element Creations Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* See the GNU Affero General Public License for more details:
* <https://www.gnu.org/licenses/agpl-3.0.html>.
*
*/
//! The TLS policy for outbound federation: Python's
//! `FederationPolicyForHTTPS`.
//!
//! Certificates are checked against the platform roots, or the
//! `federation_custom_ca_list` instead when set. Verification can be turned
//! off entirely, or for hosts matching
//! `federation_certificate_verification_whitelist`. With verification off we
//! still send SNI.
//!
//! rustls has no TLS 1.0 or 1.1, so a `federation_client_minimum_tls_version`
//! below 1.2 means 1.2.
use std::sync::Arc;
use anyhow::{bail, Context};
use log::warn;
use tokio_rustls::rustls::{
self,
client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
crypto::{verify_tls12_signature, verify_tls13_signature, CryptoProvider},
pki_types::{CertificateDer, ServerName, UnixTime},
ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme,
};
use crate::config::{types::PythonRegex, TlsConfig};
pub struct TlsPolicy {
verifying: Arc<ClientConfig>,
not_verifying: Arc<ClientConfig>,
verify: bool,
verification_whitelist: Vec<PythonRegex>,
}
impl TlsPolicy {
pub fn new(config: &TlsConfig) -> anyhow::Result<Self> {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let versions: &[&rustls::SupportedProtocolVersion] =
match config.federation_client_minimum_tls_version.as_str() {
"1" | "1.1" | "1.2" => &[&rustls::version::TLS12, &rustls::version::TLS13],
"1.3" => &[&rustls::version::TLS13],
other => bail!("unknown federation_client_minimum_tls_version {other:?}"),
};
let roots = match &config.federation_custom_ca_pems {
Some(pems) => custom_roots(pems)?,
None => platform_roots(),
};
let verifying = ClientConfig::builder_with_provider(provider.clone())
.with_protocol_versions(versions)?
.with_root_certificates(roots)
.with_no_client_auth();
let not_verifying = ClientConfig::builder_with_provider(provider.clone())
.with_protocol_versions(versions)?
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoVerification(provider)))
.with_no_client_auth();
Ok(TlsPolicy {
verifying: Arc::new(verifying),
not_verifying: Arc::new(not_verifying),
verify: config.federation_verify_certificates,
verification_whitelist: config.federation_certificate_verification_whitelist.clone(),
})
}
/// The client config to connect to `host` with. `host` is the name we
/// verify the certificate against: the server name, or the delegated
/// name after a `.well-known` lookup, never the SRV target.
pub fn config_for(&self, host: &str) -> Arc<ClientConfig> {
let verify = self.verify
&& !self
.verification_whitelist
.iter()
.any(|regex| regex.is_match_at_start(host));
if verify {
self.verifying.clone()
} else {
self.not_verifying.clone()
}
}
}
/// The name to verify (and send as SNI) for `host`. IP literals verify
/// against the certificate's IP addresses and send no SNI.
pub fn server_name(host: &str) -> anyhow::Result<ServerName<'static>> {
let bare = host
.strip_prefix('[')
.and_then(|h| h.strip_suffix(']'))
.unwrap_or(host);
ServerName::try_from(bare.to_owned())
.with_context(|| format!("invalid TLS server name {host:?}"))
}
fn custom_roots(pems: &[String]) -> anyhow::Result<RootCertStore> {
let mut roots = RootCertStore::empty();
for pem in pems {
for cert in rustls_pemfile::certs(&mut pem.as_bytes()) {
let cert = cert.context("parsing federation_custom_ca_list")?;
roots
.add(cert)
.context("adding a federation_custom_ca_list certificate")?;
}
}
if roots.is_empty() {
bail!("federation_custom_ca_list contains no certificates");
}
Ok(roots)
}
fn platform_roots() -> RootCertStore {
let mut roots = RootCertStore::empty();
let loaded = rustls_native_certs::load_native_certs();
for err in &loaded.errors {
warn!("Error loading platform TLS roots: {err}");
}
let (added, ignored) = roots.add_parsable_certificates(loaded.certs);
if ignored > 0 {
warn!("Ignored {ignored} unparsable platform TLS roots ({added} loaded)");
}
roots
}
/// Accepts any certificate. The handshake signatures are still checked,
/// since they are part of TLS itself rather than of trusting the peer.
#[derive(Debug)]
struct NoVerification(Arc<CryptoProvider>);
impl ServerCertVerifier for NoVerification {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
verify_tls12_signature(
message,
cert,
dss,
&self.0.signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
verify_tls13_signature(
message,
cert,
dss,
&self.0.signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.0.signature_verification_algorithms.supported_schemes()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config(verify: bool, whitelist: &[&str]) -> TlsConfig {
TlsConfig {
federation_verify_certificates: verify,
federation_client_minimum_tls_version: "1".to_owned(),
federation_certificate_verification_whitelist: whitelist
.iter()
.map(|p| PythonRegex::from_python_pattern(p, true).unwrap())
.collect(),
federation_custom_ca_pems: None,
}
}
#[test]
fn whitelist_turns_off_verification_per_host() {
let policy = TlsPolicy::new(&config(true, &[r"\A(.{0,}\.insecure\.example)\Z"])).unwrap();
assert!(Arc::ptr_eq(
&policy.config_for("matrix.org"),
&policy.verifying
));
assert!(Arc::ptr_eq(
&policy.config_for("a.insecure.example"),
&policy.not_verifying
));
let policy = TlsPolicy::new(&config(false, &[])).unwrap();
assert!(Arc::ptr_eq(
&policy.config_for("matrix.org"),
&policy.not_verifying
));
}
#[test]
fn minimum_version() {
let mut c = config(true, &[]);
c.federation_client_minimum_tls_version = "1.3".to_owned();
assert!(TlsPolicy::new(&c).is_ok());
c.federation_client_minimum_tls_version = "2".to_owned();
assert!(TlsPolicy::new(&c).is_err());
}
#[test]
fn server_names() {
assert!(matches!(
server_name("example.com").unwrap(),
ServerName::DnsName(_)
));
assert!(matches!(
server_name("1.2.3.4").unwrap(),
ServerName::IpAddress(_)
));
assert!(matches!(
server_name("[::1]").unwrap(),
ServerName::IpAddress(_)
));
}
#[test]
fn custom_ca_list_must_hold_certificates() {
let mut c = config(true, &[]);
c.federation_custom_ca_pems = Some(vec!["not a pem".to_owned()]);
assert!(TlsPolicy::new(&c).is_err());
}
}
+805
View File
@@ -0,0 +1,805 @@
/*
* This file is licensed under the Affero General Public License (AGPL) version 3.
*
* Copyright (C) 2026 Element Creations Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* See the GNU Affero General Public License for more details:
* <https://www.gnu.org/licenses/agpl-3.0.html>.
*
*/
//! `.well-known/matrix/server` delegation, as Python's `WellKnownResolver`.
//!
//! The caching rules are Python's, constant for constant. Two things are
//! new: each fetch attempt has a timeout (Python's has none), and
//! concurrent lookups of one server name share a single fetch.
use std::{
collections::HashMap,
sync::{Arc, Mutex},
time::{Duration, UNIX_EPOCH},
};
use anyhow::anyhow;
use async_trait::async_trait;
use bytes::Bytes;
use futures::{
future::{BoxFuture, Shared},
FutureExt,
};
use http::{header, HeaderMap, StatusCode, Uri};
use log::{info, warn};
use rand::RngExt;
use super::Now;
use crate::clock::Sleeper;
/// How long to cache a `.well-known` result by default.
const DEFAULT_CACHE_PERIOD: f64 = 24.0 * 3600.0;
/// Jitter on the default and failure cache periods.
const CACHE_PERIOD_JITTER: f64 = 0.1;
/// How long to cache a failure to fetch.
const INVALID_CACHE_PERIOD: f64 = 3600.0;
/// How long to cache a failure to fetch if there was recently a valid result.
const DOWN_CACHE_PERIOD: f64 = 2.0 * 60.0;
/// How long to remember there was a valid result, after it expires.
const REMEMBER_DOMAIN_HAD_VALID: f64 = 2.0 * 3600.0;
/// Bounds on a cache period taken from the response headers.
const MAX_CACHE_PERIOD: f64 = 48.0 * 3600.0;
const MIN_CACHE_PERIOD: f64 = 5.0 * 60.0;
/// The largest `.well-known` body we accept.
pub const MAX_SIZE: usize = 50 * 1024;
/// Refetch this fraction of the TTL before a cached result expires.
const GRACE_PERIOD_FACTOR: f64 = 0.2;
/// Attempts to fetch for a server that recently had a valid result.
const RETRY_ATTEMPTS: u32 = 3;
/// The pause between those attempts.
const RETRY_DELAY: Duration = Duration::from_millis(500);
/// The most redirects we follow, as Twisted's `RedirectAgent`.
const MAX_REDIRECTS: usize = 20;
/// One HTTP response, with the body read in full.
#[derive(Clone, Debug)]
pub struct FetchResponse {
pub status: StatusCode,
pub headers: HeaderMap,
pub body: Bytes,
}
#[derive(Debug)]
pub enum FetchError {
/// The body was longer than [`MAX_SIZE`].
TooLarge,
Other(anyhow::Error),
}
/// Makes one `GET` request, without following redirects. The agent's
/// implementation goes through the agent's own connection pool.
#[async_trait]
pub trait WellKnownFetcher: Send + Sync + 'static {
async fn fetch(&self, uri: &Uri) -> Result<FetchResponse, FetchError>;
}
#[derive(Clone, Debug)]
struct CachedResult {
/// The delegated server name, if any.
delegated: Option<String>,
expiry: f64,
ttl: f64,
}
#[derive(Default)]
struct CacheState {
results: HashMap<String, CachedResult>,
/// Whether each server recently had a valid `.well-known`, and until
/// when we remember that.
had_valid: HashMap<String, (bool, f64)>,
}
impl CacheState {
fn result(&self, server_name: &str, now: f64) -> Option<&CachedResult> {
self.results.get(server_name).filter(|r| r.expiry > now)
}
fn had_valid(&self, server_name: &str, now: f64) -> bool {
self.had_valid
.get(server_name)
.is_some_and(|&(valid, expiry)| valid && expiry > now)
}
fn insert_result(&mut self, server_name: &str, result: CachedResult, now: f64) {
// Drop expired entries, as Python's `TTLCache` does on every access,
// so the maps only hold live entries.
self.results.retain(|_, r| r.expiry > now);
self.results.insert(server_name.to_owned(), result);
}
fn insert_had_valid(&mut self, server_name: &str, valid: bool, expiry: f64, now: f64) {
self.had_valid.retain(|_, &mut (_, e)| e > now);
self.had_valid
.insert(server_name.to_owned(), (valid, expiry));
}
}
/// Why a fetch failed. A temporary failure (no response, or a 5xx) keeps
/// serving a cached result. A permanent one (a bad response) does not.
#[derive(Debug)]
struct FetchFailure {
temporary: bool,
}
pub struct WellKnownResolver {
fetcher: Arc<dyn WellKnownFetcher>,
sleeper: Arc<dyn Sleeper>,
now: Now,
attempt_timeout: Duration,
state: Mutex<CacheState>,
in_flight: Mutex<HashMap<String, Shared<BoxFuture<'static, Option<String>>>>>,
}
impl WellKnownResolver {
pub fn new(
fetcher: Arc<dyn WellKnownFetcher>,
sleeper: Arc<dyn Sleeper>,
now: Now,
attempt_timeout: Duration,
) -> Self {
WellKnownResolver {
fetcher,
sleeper,
now,
attempt_timeout,
state: Mutex::new(CacheState::default()),
in_flight: Mutex::new(HashMap::new()),
}
}
fn now_secs(&self) -> f64 {
(self.now)() as f64 / 1000.0
}
/// The server `server_name` delegates federation to, if any.
pub async fn get_well_known(self: &Arc<Self>, server_name: &str) -> Option<String> {
let now = self.now_secs();
let prev = {
let state = self.state.lock().expect("poisoned");
match state.result(server_name, now) {
Some(cached) if now < cached.expiry - GRACE_PERIOD_FACTOR * cached.ttl => {
return cached.delegated.clone();
}
Some(cached) => cached.delegated.clone(),
None => None,
}
};
let lookup = {
let mut in_flight = self.in_flight.lock().expect("poisoned");
in_flight
.entry(server_name.to_owned())
.or_insert_with(|| {
let this = self.clone();
let server_name = server_name.to_owned();
async move {
let result = this.lookup(&server_name, prev).await;
this.in_flight
.lock()
.expect("poisoned")
.remove(&server_name);
result
}
.boxed()
.shared()
})
.clone()
};
lookup.await
}
/// Fetch, and cache what we learn. `prev` is the cached delegation we
/// are refreshing, if any.
async fn lookup(&self, server_name: &str, prev: Option<String>) -> Option<String> {
let (result, cache_period) = match self.fetch_well_known(server_name).await {
Ok(fetched) => fetched,
Err(failure) => {
if failure.temporary && prev.is_some() {
// Keep serving the cached result; hopefully the server
// is back next time we ask.
return prev;
}
let had_valid = self
.state
.lock()
.expect("poisoned")
.had_valid(server_name, self.now_secs());
let period = if had_valid {
DOWN_CACHE_PERIOD
} else {
INVALID_CACHE_PERIOD
};
(None, period * jitter())
}
};
if cache_period > 0.0 {
let now = self.now_secs();
self.state.lock().expect("poisoned").insert_result(
server_name,
CachedResult {
delegated: result.clone(),
expiry: now + cache_period,
ttl: cache_period,
},
now,
);
}
result
}
/// Fetch and parse, without consulting the result cache. Returns the
/// delegated server (if any) and how long to cache that for.
async fn fetch_well_known(
&self,
server_name: &str,
) -> Result<(Option<String>, f64), FetchFailure> {
let had_valid = self
.state
.lock()
.expect("poisoned")
.had_valid(server_name, self.now_secs());
let response = self.make_request(server_name, had_valid).await?;
let delegated = parse_response(&response).map_err(|err| {
info!("Error parsing well-known for {server_name}: {err:#}");
FetchFailure { temporary: false }
})?;
info!("Response from .well-known for {server_name}: {delegated:?}");
let now = self.now_secs();
let cache_period = match cache_period_from_headers(&response.headers, now) {
None => DEFAULT_CACHE_PERIOD * jitter(),
Some(period) => period.clamp(MIN_CACHE_PERIOD, MAX_CACHE_PERIOD),
};
self.state.lock().expect("poisoned").insert_had_valid(
server_name,
delegated.is_some(),
now + cache_period + REMEMBER_DOMAIN_HAD_VALID,
now,
);
Ok((delegated, cache_period))
}
/// Fetch the file, retrying on failure (no response or a 5xx) only if
/// `retry`. A non-5xx response is returned whatever its status.
async fn make_request(
&self,
server_name: &str,
retry: bool,
) -> Result<FetchResponse, FetchFailure> {
let uri: Uri = format!("https://{server_name}/.well-known/matrix/server")
.parse()
.map_err(|err| {
info!("Invalid well-known URI for {server_name}: {err}");
FetchFailure { temporary: true }
})?;
let mut attempt = 0;
loop {
attempt += 1;
info!("Fetching {uri}");
let error = match tokio::time::timeout(
self.attempt_timeout,
self.fetch_following_redirects(&uri),
)
.await
{
Ok(Ok(response)) if !response.status.is_server_error() => return Ok(response),
Ok(Ok(response)) => anyhow!("Non-200 response {}", response.status.as_u16()),
Ok(Err(FetchError::TooLarge)) => {
// Not worth fetching again, but may be temporary.
warn!("Requested .well-known file for {server_name} is too large > {MAX_SIZE} bytes");
return Err(FetchFailure { temporary: true });
}
Ok(Err(FetchError::Other(err))) => err,
Err(_) => anyhow!("timed out after {:?}", self.attempt_timeout),
};
if !retry || attempt >= RETRY_ATTEMPTS {
info!("Error fetching {uri}: {error:#}");
return Err(FetchFailure { temporary: true });
}
info!("Error fetching {uri}: {error:#}. Retrying");
// Pause briefly in the hope that it comes back up.
self.sleeper.sleep(RETRY_DELAY).await;
}
}
/// One `GET`, following redirects as Twisted's `RedirectAgent` does.
async fn fetch_following_redirects(&self, uri: &Uri) -> Result<FetchResponse, FetchError> {
let mut uri = uri.clone();
for _ in 0..=MAX_REDIRECTS {
let response = self.fetcher.fetch(&uri).await?;
if !matches!(response.status.as_u16(), 301 | 302 | 303 | 307 | 308) {
return Ok(response);
}
let location = response
.headers
.get(header::LOCATION)
.and_then(|l| l.to_str().ok())
.ok_or_else(|| FetchError::Other(anyhow!("redirect with no Location")))?;
let next = url::Url::parse(&uri.to_string())
.and_then(|base| base.join(location))
.map_err(|err| FetchError::Other(anyhow!("bad redirect {location:?}: {err}")))?;
uri = next
.as_str()
.parse()
.map_err(|err| FetchError::Other(anyhow!("bad redirect {location:?}: {err}")))?;
}
Err(FetchError::Other(anyhow!("too many redirects")))
}
}
fn jitter() -> f64 {
rand::rng().random_range((1.0 - CACHE_PERIOD_JITTER)..=(1.0 + CACHE_PERIOD_JITTER))
}
/// The delegated server name in a `.well-known` response.
///
/// Python also rejects a non-200 status and a missing, non-string or
/// non-ASCII `m.server`. We also reject a value that is not a valid URI
/// authority, which Python would only discover when the request built from
/// it failed. An empty `m.server` means no delegation.
fn parse_response(response: &FetchResponse) -> anyhow::Result<Option<String>> {
if response.status != StatusCode::OK {
anyhow::bail!("Non-200 response {}", response.status.as_u16());
}
let body: serde_json::Value = serde_json::from_slice(&response.body)?;
let server = body
.get("m.server")
.and_then(|s| s.as_str())
.ok_or_else(|| anyhow!("no string m.server"))?;
if server.is_empty() {
return Ok(None);
}
if !server.is_ascii() {
anyhow::bail!("non-ASCII m.server {server:?}");
}
server
.parse::<http::uri::Authority>()
.map_err(|err| anyhow!("invalid m.server {server:?}: {err}"))?;
Ok(Some(server.to_owned()))
}
/// The cache period the response headers ask for, in seconds: `no-store`
/// is zero, then `max-age`, then `Expires`. As Python's
/// `_cache_period_from_headers`.
fn cache_period_from_headers(headers: &HeaderMap, now_secs: f64) -> Option<f64> {
let cache_control = parse_cache_control(headers);
if cache_control.contains_key("no-store") {
return Some(0.0);
}
if let Some(Some(max_age)) = cache_control.get("max-age") {
if let Ok(max_age) = max_age.parse::<i64>() {
return Some(max_age as f64);
}
}
if let Some(expires) = headers.get_all(header::EXPIRES).iter().next_back() {
// An invalid date (notably "0") means already expired (RFC 7234).
return Some(
expires
.to_str()
.ok()
.and_then(|e| httpdate::parse_http_date(e).ok())
.map(|expires| {
let expires = expires
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64();
expires - now_secs
})
.unwrap_or(0.0),
);
}
None
}
fn parse_cache_control(headers: &HeaderMap) -> HashMap<String, Option<String>> {
let mut directives = HashMap::new();
for value in headers.get_all(header::CACHE_CONTROL) {
let Ok(value) = value.to_str() else { continue };
for directive in value.split(',') {
let mut parts = directive.splitn(2, '=').map(str::trim);
let key = parts.next().unwrap_or("").to_lowercase();
let value = parts.next().map(str::to_owned);
directives.insert(key, value);
}
}
directives
}
/// For tests, which need a fixed `SystemTime` for `Expires` headers.
#[cfg(test)]
fn http_date(secs: u64) -> String {
httpdate::fmt_http_date(UNIX_EPOCH + Duration::from_secs(secs))
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use http::HeaderValue;
use super::*;
use crate::clock::TokioSleeper;
type Handler = dyn Fn(&Uri) -> Result<FetchResponse, FetchError> + Send + Sync;
struct FakeFetcher {
handler: Mutex<Box<Handler>>,
calls: AtomicUsize,
}
impl FakeFetcher {
fn new(
handler: impl Fn(&Uri) -> Result<FetchResponse, FetchError> + Send + Sync + 'static,
) -> Arc<Self> {
Arc::new(FakeFetcher {
handler: Mutex::new(Box::new(handler)),
calls: AtomicUsize::new(0),
})
}
fn set(
&self,
handler: impl Fn(&Uri) -> Result<FetchResponse, FetchError> + Send + Sync + 'static,
) {
*self.handler.lock().unwrap() = Box::new(handler);
}
fn calls(&self) -> usize {
self.calls.swap(0, Ordering::SeqCst)
}
}
#[async_trait]
impl WellKnownFetcher for FakeFetcher {
async fn fetch(&self, uri: &Uri) -> Result<FetchResponse, FetchError> {
self.calls.fetch_add(1, Ordering::SeqCst);
// Yield, so concurrent lookups overlap.
tokio::task::yield_now().await;
(self.handler.lock().unwrap())(uri)
}
}
fn response(
status: u16,
body: &str,
headers: &[(&str, &str)],
) -> Result<FetchResponse, FetchError> {
let mut map = HeaderMap::new();
for (name, value) in headers {
map.append(
http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
HeaderValue::from_str(value).unwrap(),
);
}
Ok(FetchResponse {
status: StatusCode::from_u16(status).unwrap(),
headers: map,
body: Bytes::from(body.to_owned()),
})
}
/// A response delegating to `to`, cached for exactly two hours.
fn delegate(to: &'static str) -> impl Fn(&Uri) -> Result<FetchResponse, FetchError> {
move |_| {
response(
200,
&format!(r#"{{"m.server": "{to}"}}"#),
&[("cache-control", "max-age=7200")],
)
}
}
/// The same without cache headers, so cached for the jittered default.
fn delegate_default(to: &'static str) -> impl Fn(&Uri) -> Result<FetchResponse, FetchError> {
move |_| response(200, &format!(r#"{{"m.server": "{to}"}}"#), &[])
}
fn make_resolver(fetcher: Arc<FakeFetcher>) -> (Arc<WellKnownResolver>, Arc<AtomicU64>) {
let clock = Arc::new(AtomicU64::new(1_000_000_000));
let now_clock = clock.clone();
let resolver = Arc::new(WellKnownResolver::new(
fetcher,
Arc::new(TokioSleeper),
Arc::new(move || now_clock.load(Ordering::SeqCst)),
Duration::from_secs(60),
));
(resolver, clock)
}
fn advance(clock: &AtomicU64, secs: f64) {
clock.fetch_add((secs * 1000.0) as u64, Ordering::SeqCst);
}
#[tokio::test(start_paused = true)]
async fn valid_result_is_cached_with_jitter() {
let fetcher = FakeFetcher::new(delegate_default("target:1234"));
let (resolver, clock) = make_resolver(fetcher.clone());
let uri_seen = Arc::new(Mutex::new(None));
let seen = uri_seen.clone();
fetcher.set(move |uri| {
*seen.lock().unwrap() = Some(uri.to_string());
delegate_default("target:1234")(uri)
});
assert_eq!(
resolver.get_well_known("example.com").await.as_deref(),
Some("target:1234")
);
assert_eq!(fetcher.calls(), 1);
assert_eq!(
uri_seen.lock().unwrap().as_deref(),
Some("https://example.com/.well-known/matrix/server")
);
// The default period is 24h ±10%, refetched in its last 20%: so no
// refetch at 24h × 0.9 × 0.8, and one by 24h × 1.1.
advance(&clock, 24.0 * 3600.0 * 0.9 * 0.8 - 1.0);
resolver.get_well_known("example.com").await;
assert_eq!(fetcher.calls(), 0);
advance(&clock, 24.0 * 3600.0 * 0.4);
resolver.get_well_known("example.com").await;
assert_eq!(fetcher.calls(), 1);
}
#[tokio::test(start_paused = true)]
async fn missing_well_known_is_cached_for_an_hour() {
let fetcher = FakeFetcher::new(|_| response(404, "", &[]));
let (resolver, clock) = make_resolver(fetcher.clone());
assert_eq!(resolver.get_well_known("example.com").await, None);
assert_eq!(fetcher.calls(), 1);
// 1h ±10%, refetched in the last 20%.
advance(&clock, 3600.0 * 0.9 * 0.8 - 1.0);
assert_eq!(resolver.get_well_known("example.com").await, None);
assert_eq!(fetcher.calls(), 0);
advance(&clock, 3600.0 * 0.4);
assert_eq!(resolver.get_well_known("example.com").await, None);
assert_eq!(fetcher.calls(), 1);
}
#[tokio::test(start_paused = true)]
async fn headers_set_the_cache_period() {
let fetcher = FakeFetcher::new(|_| {
response(
200,
r#"{"m.server": "t"}"#,
&[("cache-control", "max-age=7200")],
)
});
let (resolver, clock) = make_resolver(fetcher.clone());
resolver.get_well_known("example.com").await;
assert_eq!(fetcher.calls(), 1);
// Refetch starts at 80% of 2h.
advance(&clock, 7200.0 * 0.8 - 1.0);
resolver.get_well_known("example.com").await;
assert_eq!(fetcher.calls(), 0);
advance(&clock, 2.0);
resolver.get_well_known("example.com").await;
assert_eq!(fetcher.calls(), 1);
}
#[test]
fn cache_period_parsing() {
let headers = |pairs: &[(&str, &str)]| response(200, "", pairs).unwrap().headers;
assert_eq!(cache_period_from_headers(&headers(&[]), 0.0), None);
assert_eq!(
cache_period_from_headers(&headers(&[("cache-control", "max-age=60, public")]), 0.0),
Some(60.0)
);
assert_eq!(
cache_period_from_headers(&headers(&[("Cache-Control", "No-Store, max-age=60")]), 0.0),
Some(0.0)
);
// A bad max-age falls through to Expires.
assert_eq!(
cache_period_from_headers(
&headers(&[
("cache-control", "max-age=abc"),
("expires", &http_date(1000))
]),
400.0
),
Some(600.0)
);
assert_eq!(
cache_period_from_headers(&headers(&[("expires", "0")]), 400.0),
Some(0.0)
);
}
/// A short (or zero) period from the headers is raised to the minimum.
#[tokio::test(start_paused = true)]
async fn short_periods_are_clamped() {
let fetcher = FakeFetcher::new(|_| {
response(
200,
r#"{"m.server": "t"}"#,
&[("cache-control", "no-store")],
)
});
let (resolver, clock) = make_resolver(fetcher.clone());
resolver.get_well_known("example.com").await;
advance(&clock, 5.0 * 60.0 * 0.8 - 1.0);
resolver.get_well_known("example.com").await;
assert_eq!(fetcher.calls(), 1);
}
/// Once a server has had a valid `.well-known`, a failure is cached for
/// only two minutes, fetching is retried, and a temporary failure keeps
/// the cached result.
#[tokio::test(start_paused = true)]
async fn failures_after_a_valid_result() {
let fetcher = FakeFetcher::new(delegate("target"));
let (resolver, clock) = make_resolver(fetcher.clone());
assert_eq!(
resolver.get_well_known("example.com").await.as_deref(),
Some("target")
);
assert_eq!(fetcher.calls(), 1);
// In the grace period, the server is down: three attempts, and the
// cached result is kept.
fetcher.set(|_| response(503, "", &[]));
advance(&clock, 7200.0 * 0.9);
assert_eq!(
resolver.get_well_known("example.com").await.as_deref(),
Some("target")
);
assert_eq!(fetcher.calls(), 3);
// After the result expires it is gone. We remember for another two
// hours that it was valid, so we retry, and cache the failure for
// 2m (±10%) rather than 1h.
advance(&clock, 7200.0 * 0.1 + 1.0);
assert_eq!(resolver.get_well_known("example.com").await, None);
assert_eq!(fetcher.calls(), 3);
fetcher.set(delegate("target"));
advance(&clock, 2.0 * 60.0 * 0.9 * 0.8 - 1.0);
assert_eq!(resolver.get_well_known("example.com").await, None);
assert_eq!(fetcher.calls(), 0);
advance(&clock, 2.0 * 60.0 * 0.4);
assert_eq!(
resolver.get_well_known("example.com").await.as_deref(),
Some("target")
);
assert_eq!(fetcher.calls(), 1);
}
/// Without a recent valid result, a failure is tried once.
#[tokio::test(start_paused = true)]
async fn failures_without_a_valid_result_are_not_retried() {
let fetcher = FakeFetcher::new(|_| response(503, "", &[]));
let (resolver, _) = make_resolver(fetcher.clone());
assert_eq!(resolver.get_well_known("example.com").await, None);
assert_eq!(fetcher.calls(), 1);
}
/// A bad response is a permanent failure: the cached result is dropped.
#[tokio::test(start_paused = true)]
async fn invalid_response_replaces_cached_result() {
let fetcher = FakeFetcher::new(delegate("target"));
let (resolver, clock) = make_resolver(fetcher.clone());
resolver.get_well_known("example.com").await;
fetcher.set(|_| response(200, "not json", &[]));
advance(&clock, 7200.0 * 0.9);
assert_eq!(resolver.get_well_known("example.com").await, None);
assert_eq!(fetcher.calls(), 2);
}
#[tokio::test(start_paused = true)]
async fn bad_bodies() {
for body in [
r#"{}"#,
r#"{"m.server": 1}"#,
r#"{"m.server": "bad host/name"}"#,
r#"{"m.server": "é.example"}"#,
r#"{"m.server": ""}"#,
] {
let fetcher = FakeFetcher::new(move |_| response(200, body, &[]));
let (resolver, _) = make_resolver(fetcher);
assert_eq!(resolver.get_well_known("example.com").await, None, "{body}");
}
}
#[tokio::test(start_paused = true)]
async fn too_large_is_not_retried() {
let fetcher = FakeFetcher::new(delegate("target"));
let (resolver, clock) = make_resolver(fetcher.clone());
resolver.get_well_known("example.com").await;
fetcher.calls();
// Even though the server had a valid result.
fetcher.set(|_| Err(FetchError::TooLarge));
advance(&clock, 7200.0 * 0.9);
assert_eq!(
resolver.get_well_known("example.com").await.as_deref(),
Some("target")
);
assert_eq!(fetcher.calls(), 1);
}
#[tokio::test(start_paused = true)]
async fn redirects_are_followed() {
let fetcher = FakeFetcher::new(|uri| match uri.path() {
"/.well-known/matrix/server" => response(302, "", &[("location", "/elsewhere")]),
"/elsewhere" => response(301, "", &[("location", "https://other.example/final")]),
"/final" => {
assert_eq!(uri.host(), Some("other.example"));
response(200, r#"{"m.server": "target"}"#, &[])
}
other => panic!("unexpected {other}"),
});
let (resolver, _) = make_resolver(fetcher.clone());
assert_eq!(
resolver.get_well_known("example.com").await.as_deref(),
Some("target")
);
assert_eq!(fetcher.calls(), 3);
// A redirect loop gives up.
let fetcher = FakeFetcher::new(|_| response(302, "", &[("location", "/loop")]));
let (resolver, _) = make_resolver(fetcher.clone());
assert_eq!(resolver.get_well_known("example.com").await, None);
assert_eq!(fetcher.calls(), MAX_REDIRECTS + 1);
}
#[tokio::test(start_paused = true)]
async fn concurrent_lookups_share_a_fetch() {
let fetcher = FakeFetcher::new(delegate("target"));
let (resolver, _) = make_resolver(fetcher.clone());
let (a, b) = tokio::join!(
resolver.get_well_known("example.com"),
resolver.get_well_known("example.com")
);
assert_eq!(a.as_deref(), Some("target"));
assert_eq!(b.as_deref(), Some("target"));
assert_eq!(fetcher.calls(), 1);
assert!(resolver.in_flight.lock().unwrap().is_empty());
}
}
+3
View File
@@ -17,12 +17,15 @@
//!
//! This is being built up from the network end, one layer at a time.
//!
//! - [`agent`]: the parts of the agent that will send requests: server
//! name resolution, and the IP range and TLS policies.
//! - [`signing`]: the `X-Matrix` request signature.
//! - [`transport`]: the seam between the request layer and the network, with
//! a Python-backed implementation for tests.
use pyo3::prelude::*;
pub mod agent;
pub mod signing;
pub mod transport;