Pass the outbound federation config to Rust

Extend the Rust view of the homeserver config with what the federation
request layer and agent need:

- `config.federation`: timeouts, retry counts and delays, destination
  backoff, the domain whitelist and the metrics domains.
- `config.tls`: certificate verification, the per-host verification
  whitelist, the minimum TLS version, and the custom CA list. Python now
  keeps the PEM text of that list too, so Rust never reads the files.
- `config.server`: the federation IP range block and allow lists (taken
  from `netaddr.IPSet`s), the user agent suffix and the proxy config.

The verification whitelist holds Python regexes made by
`glob_to_regex`. They translate to Rust's syntax with two changes: `\Z`
becomes `\z` and `re.IGNORECASE` becomes `(?i)`.

The changelog entry needs renaming to the PR number.

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 489c09175d
commit 9d4d050a0b
6 files changed
+177 -3

No files matched your search

Generated
+1
View File
@@ -1834,6 +1834,7 @@ dependencies = [
"http",
"http-body-util",
"icu_segmenter",
"ipnet",
"itertools",
"lazy_static",
"libc",
+1
View File
@@ -0,0 +1 @@
Add foundations for sending federation requests from Rust.
+2
View File
@@ -105,6 +105,8 @@ lru = "0.16.4"
# Signing outbound federation requests. 2.x rather than 3.x, which needs a
# newer rustc than our MSRV.
ed25519-dalek = "2.2.0"
# The IP range lists in config.
ipnet = "2.11.0"
[dev-dependencies]
tokio = { version = "1.44.2", features = ["macros", "test-util"] }
+50 -1
View File
@@ -13,7 +13,7 @@
*
*/
use std::collections::BTreeSet;
use std::collections::{BTreeSet, HashMap, HashSet};
use pyo3::prelude::*;
@@ -35,6 +35,8 @@ pub struct SynapseHomeServerConfig {
pub server: ServerConfig,
pub experimental: ExperimentalConfig,
pub retention: RetentionConfig,
pub federation: FederationConfig,
pub tls: TlsConfig,
}
#[derive(FromPyObject, Clone)]
@@ -51,6 +53,53 @@ pub struct ServerConfig {
pub msc4140_enabled: bool,
pub public_baseurl: String,
pub include_profile_updates_in_sync: bool,
/// IP ranges outbound federation may not connect to.
pub federation_ip_range_blocklist: types::IpRangeSet,
/// Exceptions to `federation_ip_range_blocklist`.
pub federation_ip_range_allowlist: Option<types::IpRangeSet>,
pub user_agent_suffix: Option<String>,
pub proxy_config: types::ProxyConfig,
}
/// `config.federation`: outbound federation request settings.
#[derive(FromPyObject, Clone)]
pub struct FederationConfig {
/// If set, the only server names we federate with. Python keeps this as
/// a dict of name to `True`.
pub federation_domain_whitelist: Option<HashMap<String, bool>>,
/// The destinations to label per-destination metrics with.
pub federation_metrics_domains: HashSet<String>,
pub client_timeout_ms: u64,
pub max_long_retry_delay_ms: u64,
pub max_short_retry_delay_ms: u64,
pub max_long_retries: u32,
pub max_short_retries: u32,
pub destination_min_retry_interval_ms: u64,
pub destination_retry_multiplier: f64,
pub destination_max_retry_interval_ms: u64,
}
impl FederationConfig {
/// Whether the federation whitelist (if any) lets us talk to `domain`.
/// Mirrors `is_domain_allowed_according_to_federation_whitelist`.
pub fn is_domain_allowed(&self, domain: &str) -> bool {
self.federation_domain_whitelist
.as_ref()
.is_none_or(|whitelist| whitelist.contains_key(domain))
}
}
/// `config.tls`: the TLS policy for outbound federation.
#[derive(FromPyObject, Clone)]
pub struct TlsConfig {
pub federation_verify_certificates: bool,
/// One of `1`, `1.1`, `1.2` or `1.3`.
pub federation_client_minimum_tls_version: String,
/// Hosts whose certificates are not verified, as anchored regexes.
pub federation_certificate_verification_whitelist: Vec<types::PythonRegex>,
/// The PEM text of `federation_custom_ca_list`, which replaces the
/// platform trust roots when set.
pub federation_custom_ca_pems: Option<Vec<String>>,
}
/// `config.retention`: the message retention policy settings.
+118 -2
View File
@@ -13,9 +13,14 @@
*
*/
use std::str::FromStr;
use std::{net::IpAddr, str::FromStr};
use pyo3::{exceptions::PyAssertionError, prelude::*};
use ipnet::IpNet;
use pyo3::{
exceptions::{PyAssertionError, PyValueError},
intern,
prelude::*,
};
/// The presets available when creating a Matrix room according to the Matrix spec:
///
@@ -58,3 +63,114 @@ impl<'a, 'py> FromPyObject<'a, 'py> for RoomCreationPreset {
value.extract::<&str>()?.parse()
}
}
/// A set of IP ranges: a `netaddr.IPSet` in Python.
#[derive(Clone, Debug, Default)]
pub struct IpRangeSet(Vec<IpNet>);
impl IpRangeSet {
pub fn new(ranges: Vec<IpNet>) -> Self {
IpRangeSet(ranges)
}
pub fn contains(&self, ip: &IpAddr) -> bool {
self.0.iter().any(|net| net.contains(ip))
}
}
impl<'a, 'py> FromPyObject<'a, 'py> for IpRangeSet {
type Error = PyErr;
fn extract(value: Borrowed<'a, 'py, PyAny>) -> PyResult<Self> {
let py = value.py();
let mut ranges = Vec::new();
for cidr in value.call_method0(intern!(py, "iter_cidrs"))?.try_iter()? {
let cidr = cidr?.str()?;
let cidr = cidr.to_str()?;
ranges.push(
cidr.parse()
.map_err(|e| PyValueError::new_err(format!("bad IP range {cidr}: {e}")))?,
);
}
Ok(IpRangeSet(ranges))
}
}
/// A compiled Python regex (`re.Pattern`), translated to a Rust [`regex::Regex`].
///
/// Only handles what config produces with `glob_to_regex`: `\Z` becomes `\z`
/// and `re.IGNORECASE` becomes `(?i)`. Anything else that Rust's regex syntax
/// rejects is a config error.
#[derive(Clone, Debug)]
pub struct PythonRegex(regex::Regex);
impl PythonRegex {
pub fn from_python_pattern(pattern: &str, ignore_case: bool) -> Result<Self, regex::Error> {
let pattern = pattern.replace(r"\Z", r"\z");
let pattern = if ignore_case {
format!("(?i){pattern}")
} else {
pattern
};
Ok(PythonRegex(regex::Regex::new(&pattern)?))
}
/// Whether the pattern matches at the start of `s`, as `re.match` does.
pub fn is_match_at_start(&self, s: &str) -> bool {
self.0.find(s).is_some_and(|m| m.start() == 0)
}
}
impl<'a, 'py> FromPyObject<'a, 'py> for PythonRegex {
type Error = PyErr;
fn extract(value: Borrowed<'a, 'py, PyAny>) -> PyResult<Self> {
let py = value.py();
let pattern: String = value.getattr(intern!(py, "pattern"))?.extract()?;
let flags: u32 = value.getattr(intern!(py, "flags"))?.extract()?;
// `re.IGNORECASE`
let ignore_case = flags & 2 != 0;
PythonRegex::from_python_pattern(&pattern, ignore_case)
.map_err(|e| PyValueError::new_err(format!("cannot use regex {pattern:?}: {e}")))
}
}
/// `config.server.proxy_config`: the forward proxy for outbound HTTP.
#[derive(FromPyObject, Clone, Debug, Default)]
pub struct ProxyConfig {
pub http_proxy: Option<String>,
pub https_proxy: Option<String>,
pub no_proxy_hosts: Option<Vec<String>>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glob_regexes_translate() {
// What `glob_to_regex("*.example.com")` compiles to.
let regex = PythonRegex::from_python_pattern(r"\A(.{0,}\.example\.com)\Z", true).unwrap();
assert!(regex.is_match_at_start("foo.example.com"));
assert!(regex.is_match_at_start("FOO.Example.com"));
assert!(!regex.is_match_at_start("example.com"));
assert!(!regex.is_match_at_start("foo.example.com.evil"));
assert!(!regex.is_match_at_start("foo.example.com\n"));
// `re.escape` escapes `-`, which Rust's regex must accept too.
let regex = PythonRegex::from_python_pattern(r"\A(my\-host)\Z", true).unwrap();
assert!(regex.is_match_at_start("my-host"));
}
#[test]
fn ip_range_set() {
let set = IpRangeSet::new(vec![
"10.0.0.0/8".parse().unwrap(),
"::1/128".parse().unwrap(),
]);
assert!(set.contains(&"10.1.2.3".parse().unwrap()));
assert!(set.contains(&"::1".parse().unwrap()));
assert!(!set.contains(&"11.0.0.1".parse().unwrap()));
}
}
+5
View File
@@ -102,6 +102,8 @@ class TlsConfig(Config):
# Read in and parse custom CA certificates
self.federation_ca_trust_root = None
# The PEM text of the same certificates, for the Rust federation agent.
self.federation_custom_ca_pems: list[str] | None = None
if custom_ca_list is not None:
if len(custom_ca_list) == 0:
# A trustroot cannot be generated without any CA certificates.
@@ -112,6 +114,7 @@ class TlsConfig(Config):
)
certs = []
pems = []
for ca_file in custom_ca_list:
logger.debug("Reading custom CA certificate file: %s", ca_file)
content = self.read_file(ca_file, "federation_custom_ca_list")
@@ -120,12 +123,14 @@ class TlsConfig(Config):
try:
cert_base = Certificate.loadPEM(content.encode("ascii"))
certs.append(cert_base)
pems.append(content)
except Exception as e:
raise ConfigError(
"Error parsing custom CA certificate file %s: %s" % (ca_file, e)
)
self.federation_ca_trust_root = trustRootFromCertificates(certs)
self.federation_custom_ca_pems = pems
# This config option applies to non-federation HTTP clients
# (e.g. for talking to recaptcha, identity servers, and such)