diff --git a/Cargo.lock b/Cargo.lock index a4263339cb..caab8ace95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1834,6 +1834,7 @@ dependencies = [ "http", "http-body-util", "icu_segmenter", + "ipnet", "itertools", "lazy_static", "libc", diff --git a/changelog.d/rust-fed-prereqs.misc b/changelog.d/rust-fed-prereqs.misc new file mode 100644 index 0000000000..b90208fc65 --- /dev/null +++ b/changelog.d/rust-fed-prereqs.misc @@ -0,0 +1 @@ +Add foundations for sending federation requests from Rust. diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 93a1012b2c..ad55b4ddde 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -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"] } diff --git a/rust/src/config/mod.rs b/rust/src/config/mod.rs index dc87db15df..ba0bbae632 100644 --- a/rust/src/config/mod.rs +++ b/rust/src/config/mod.rs @@ -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, + pub user_agent_suffix: Option, + 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>, + /// The destinations to label per-destination metrics with. + pub federation_metrics_domains: HashSet, + 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, + /// The PEM text of `federation_custom_ca_list`, which replaces the + /// platform trust roots when set. + pub federation_custom_ca_pems: Option>, } /// `config.retention`: the message retention policy settings. diff --git a/rust/src/config/types.rs b/rust/src/config/types.rs index 8990b52f65..0ee399784e 100644 --- a/rust/src/config/types.rs +++ b/rust/src/config/types.rs @@ -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); + +impl IpRangeSet { + pub fn new(ranges: Vec) -> 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 { + 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 { + 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 { + 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, + pub https_proxy: Option, + pub no_proxy_hosts: Option>, +} + +#[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())); + } +} diff --git a/synapse/config/tls.py b/synapse/config/tls.py index 9be944a9ac..baf4ad581c 100644 --- a/synapse/config/tls.py +++ b/synapse/config/tls.py @@ -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)