diff --git a/Cargo.lock b/Cargo.lock index fb0f718..aad4bbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2790,7 +2790,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "telemt" -version = "3.4.14" +version = "3.4.15" dependencies = [ "aes", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 64094e1..ed158f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "telemt" -version = "3.4.14" +version = "3.4.15" edition = "2024" [features] diff --git a/src/config/hot_reload.rs b/src/config/hot_reload.rs index 4faef9b..c869e74 100644 --- a/src/config/hot_reload.rs +++ b/src/config/hot_reload.rs @@ -312,6 +312,7 @@ fn listeners_equal( lhs.iter().zip(rhs.iter()).all(|(a, b)| { a.ip == b.ip && a.port == b.port + && a.client_mss == b.client_mss && a.announce == b.announce && a.announce_ip == b.announce_ip && a.proxy_protocol == b.proxy_protocol @@ -608,6 +609,7 @@ fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: b || old.server.listen_addr_ipv4 != new.server.listen_addr_ipv4 || old.server.listen_addr_ipv6 != new.server.listen_addr_ipv6 || old.server.listen_tcp != new.server.listen_tcp + || old.server.client_mss != new.server.client_mss || old.server.listen_unix_sock != new.server.listen_unix_sock || old.server.listen_unix_sock_perm != new.server.listen_unix_sock_perm { diff --git a/src/config/load.rs b/src/config/load.rs index 231b164..e4e837e 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -299,6 +299,7 @@ const SERVER_CONFIG_KEYS: &[&str] = &[ "listen_unix_sock", "listen_unix_sock_perm", "listen_tcp", + "client_mss", "proxy_protocol", "proxy_protocol_header_timeout_ms", "proxy_protocol_trusted_cidrs", @@ -344,6 +345,7 @@ const CONNTRACK_CONTROL_CONFIG_KEYS: &[&str] = &[ const LISTENER_CONFIG_KEYS: &[&str] = &[ "ip", "port", + "client_mss", "announce", "announce_ip", "proxy_protocol", @@ -1933,6 +1935,18 @@ impl ProxyConfig { )); } + config + .server + .client_mss_value() + .map_err(|error| ProxyError::Config(format!("server.client_mss {error}")))?; + for (idx, listener) in config.server.listeners.iter().enumerate() { + if listener.client_mss.is_some() { + listener.effective_client_mss(&config.server).map_err(|error| { + ProxyError::Config(format!("server.listeners[{idx}].client_mss {error}")) + })?; + } + } + if config.server.accept_permit_timeout_ms > 60_000 { return Err(ProxyError::Config( "server.accept_permit_timeout_ms must be within [0, 60000]".to_string(), @@ -2173,6 +2187,7 @@ impl ProxyConfig { config.server.listeners.push(ListenerConfig { ip: ipv4, port: Some(config.server.port), + client_mss: None, announce: None, announce_ip: None, proxy_protocol: None, @@ -2185,6 +2200,7 @@ impl ProxyConfig { config.server.listeners.push(ListenerConfig { ip: ipv6, port: Some(config.server.port), + client_mss: None, announce: None, announce_ip: None, proxy_protocol: None, @@ -2460,6 +2476,7 @@ mod tests { assert_eq!(cfg.general.update_every, default_update_every()); assert_eq!(cfg.server.listen_addr_ipv4, default_listen_addr_ipv4()); assert_eq!(cfg.server.listen_addr_ipv6, default_listen_addr_ipv6_opt()); + assert_eq!(cfg.server.client_mss_value(), Ok(None)); assert_eq!( cfg.server.proxy_protocol_trusted_cidrs, default_proxy_protocol_trusted_cidrs() @@ -3787,6 +3804,153 @@ mod tests { let _ = std::fs::remove_file(path); } + #[test] + fn client_mss_presets_and_listener_override_are_resolved() { + let toml = r#" + [server] + client_mss = "tspu" + + [[server.listeners]] + ip = "127.0.0.1" + port = 1443 + + [[server.listeners]] + ip = "127.0.0.2" + port = 1444 + client_mss = "2in8" + + [[server.listeners]] + ip = "127.0.0.3" + port = 1445 + client_mss = "" + + [[server.listeners]] + ip = "127.0.0.4" + port = 1446 + client_mss = "extreme-low" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_client_mss_valid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + + assert_eq!(cfg.server.client_mss_value(), Ok(Some(92))); + assert_eq!( + cfg.server.listeners[0].effective_client_mss(&cfg.server), + Ok(Some(92)) + ); + assert_eq!( + cfg.server.listeners[1].effective_client_mss(&cfg.server), + Ok(Some(256)) + ); + assert_eq!( + cfg.server.listeners[2].effective_client_mss(&cfg.server), + Ok(None) + ); + assert_eq!( + cfg.server.listeners[3].effective_client_mss(&cfg.server), + Ok(Some(88)) + ); + let _ = std::fs::remove_file(path); + } + + #[test] + fn client_mss_custom_value_is_accepted() { + let toml = r#" + [server] + client_mss = "4096" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_client_mss_custom_valid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let cfg = ProxyConfig::load(&path).unwrap(); + + assert_eq!(cfg.server.client_mss_value(), Ok(Some(4096))); + let _ = std::fs::remove_file(path); + } + + #[test] + fn client_mss_out_of_range_is_rejected() { + for value in ["87", "4097"] { + let toml = format!( + r#" + [server] + client_mss = "{value}" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "# + ); + let dir = std::env::temp_dir(); + let path = dir.join(format!("telemt_client_mss_out_of_range_{value}_test.toml")); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + + assert!(err.contains("server.client_mss custom value must be within [88, 4096]")); + let _ = std::fs::remove_file(path); + } + } + + #[test] + fn client_mss_unquoted_number_is_rejected() { + let toml = r#" + [server] + client_mss = 256 + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_client_mss_unquoted_number_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + + assert!(err.contains("client_mss")); + let _ = std::fs::remove_file(path); + } + + #[test] + fn listener_client_mss_invalid_preset_is_rejected() { + let toml = r#" + [[server.listeners]] + ip = "127.0.0.1" + port = 1443 + client_mss = "tiny" + + [censorship] + tls_domain = "example.com" + + [access.users] + user = "00000000000000000000000000000000" + "#; + let dir = std::env::temp_dir(); + let path = dir.join("telemt_listener_client_mss_invalid_test.toml"); + std::fs::write(&path, toml).unwrap(); + let err = ProxyConfig::load(&path).unwrap_err().to_string(); + + assert!(err.contains("server.listeners[0].client_mss")); + assert!(err.contains("must be \"\", extreme-low, tspu, 2in8")); + let _ = std::fs::remove_file(path); + } + #[test] fn api_runtime_edge_cache_ttl_out_of_range_is_rejected() { let toml = r#" diff --git a/src/config/types.rs b/src/config/types.rs index 4f9d568..e810240 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1451,6 +1451,11 @@ pub struct ServerConfig { #[serde(default)] pub listen_tcp: Option, + /// Client-facing TCP MSS preset or custom value for all TCP listeners. + /// Empty string or omitted value keeps the kernel default. + #[serde(default)] + pub client_mss: Option, + /// Accept HAProxy PROXY protocol headers on incoming connections. /// When enabled, real client IPs are extracted from PROXY v1/v2 headers. #[serde(default)] @@ -1517,6 +1522,7 @@ impl Default for ServerConfig { listen_unix_sock: None, listen_unix_sock_perm: None, listen_tcp: None, + client_mss: None, proxy_protocol: false, proxy_protocol_header_timeout_ms: default_proxy_protocol_header_timeout_ms(), proxy_protocol_trusted_cidrs: default_proxy_protocol_trusted_cidrs(), @@ -2087,6 +2093,10 @@ pub struct ListenerConfig { /// Per-listener TCP port. If omitted, falls back to legacy `server.port`. #[serde(default)] pub port: Option, + /// Per-listener client-facing TCP MSS preset or custom value. + /// Empty string disables MSS shaping for this listener. + #[serde(default)] + pub client_mss: Option, /// IP address or hostname to announce in proxy links. /// Takes precedence over `announce_ip` if both are set. #[serde(default)] @@ -2104,6 +2114,64 @@ pub struct ListenerConfig { pub reuse_allow: bool, } +/// Client-facing TCP MSS preset for extreme-low fragmentation profiles. +pub const CLIENT_MSS_EXTREME_LOW: u16 = 88; +/// Client-facing TCP MSS preset matching TSPU-oriented deployments. +pub const CLIENT_MSS_TSPU: u16 = 92; +/// Client-facing TCP MSS preset for 2-in-8 segment shaping. +pub const CLIENT_MSS_2IN8: u16 = 256; +/// Minimum accepted custom client-facing TCP MSS value. +pub const CLIENT_MSS_MIN: u16 = CLIENT_MSS_EXTREME_LOW; +/// Maximum accepted custom client-facing TCP MSS value. +pub const CLIENT_MSS_MAX: u16 = 4096; + +impl ServerConfig { + /// Resolves the global client-facing TCP MSS setting. + pub fn client_mss_value(&self) -> std::result::Result, String> { + parse_client_mss(self.client_mss.as_deref()) + } +} + +impl ListenerConfig { + /// Resolves the listener MSS override, falling back to the global server value. + pub fn effective_client_mss( + &self, + server: &ServerConfig, + ) -> std::result::Result, String> { + match self.client_mss.as_deref() { + Some(value) => parse_client_mss(Some(value)), + None => server.client_mss_value(), + } + } +} + +fn parse_client_mss(raw: Option<&str>) -> std::result::Result, String> { + let Some(raw) = raw else { + return Ok(None); + }; + let value = raw.trim(); + if value.is_empty() { + return Ok(None); + } + + match value.to_ascii_lowercase().as_str() { + "extreme-low" => return Ok(Some(CLIENT_MSS_EXTREME_LOW)), + "tspu" => return Ok(Some(CLIENT_MSS_TSPU)), + "2in8" => return Ok(Some(CLIENT_MSS_2IN8)), + _ => {} + } + + let parsed = value + .parse::() + .map_err(|_| "must be \"\", extreme-low, tspu, 2in8, or a decimal value".to_string())?; + if !(CLIENT_MSS_MIN..=CLIENT_MSS_MAX).contains(&parsed) { + return Err(format!( + "custom value must be within [{CLIENT_MSS_MIN}, {CLIENT_MSS_MAX}]" + )); + } + Ok(Some(parsed)) +} + // ============= ShowLink ============= /// Controls which users' proxy links are displayed at startup. diff --git a/src/maestro/listeners.rs b/src/maestro/listeners.rs index d47e1a4..15cd31f 100644 --- a/src/maestro/listeners.rs +++ b/src/maestro/listeners.rs @@ -47,6 +47,10 @@ fn default_link_port(config: &ProxyConfig) -> u16 { .unwrap_or(config.server.port) } +fn mss_segment_multiplier(client_mss: u16) -> u16 { + 1460u16.div_ceil(client_mss) +} + #[allow(clippy::too_many_arguments)] pub(crate) async fn bind_listeners( config: &Arc, @@ -90,10 +94,22 @@ pub(crate) async fn bind_listeners( warn!(%addr, "Skipping IPv6 listener: IPv6 disabled by [network]"); continue; } + let client_mss = match listener_conf.effective_client_mss(&config.server) { + Ok(value) => value, + Err(error) => { + warn!( + %addr, + error = %error, + "Invalid listener client MSS after config validation; using kernel default" + ); + None + } + }; let options = ListenOptions { reuse_port: listener_conf.reuse_allow, ipv6_only: listener_conf.ip.is_ipv6(), backlog: config.server.listen_backlog, + client_mss, ..Default::default() }; @@ -101,6 +117,14 @@ pub(crate) async fn bind_listeners( Ok(socket) => { let listener = TcpListener::from_std(socket.into())?; info!("Listening on {}", addr); + if let Some(client_mss) = client_mss { + info!( + %addr, + client_mss, + segment_multiplier = mss_segment_multiplier(client_mss), + "Client-facing TCP MSS configured" + ); + } let listener_proxy_protocol = listener_conf .proxy_protocol .unwrap_or(config.server.proxy_protocol); diff --git a/src/transport/socket.rs b/src/transport/socket.rs index 58d3b97..edcd626 100644 --- a/src/transport/socket.rs +++ b/src/transport/socket.rs @@ -9,7 +9,7 @@ use std::io::Result; use std::net::{IpAddr, SocketAddr}; use std::time::Duration; use tokio::net::TcpStream; -use tracing::debug; +use tracing::{debug, warn}; const DEFAULT_SOCKET_BUFFER_BYTES: usize = 256 * 1024; @@ -283,6 +283,8 @@ pub struct ListenOptions { pub backlog: u32, /// IPv6 only (disable dual-stack) pub ipv6_only: bool, + /// Client-facing TCP MSS to announce on accepted TCP sessions. + pub client_mss: Option, } impl Default for ListenOptions { @@ -292,6 +294,7 @@ impl Default for ListenOptions { reuse_port: true, backlog: 1024, ipv6_only: false, + client_mss: None, } } } @@ -319,6 +322,19 @@ pub fn create_listener(addr: SocketAddr, options: &ListenOptions) -> Result socket, + Err(e) if e.kind() == ErrorKind::PermissionDenied => return, + Err(e) => panic!("create_listener failed: {e}"), + }; + let mss = match socket.tcp_mss() { + Ok(mss) => mss, + Err(e) if e.kind() == ErrorKind::PermissionDenied => return, + Err(e) => panic!("tcp_mss failed: {e}"), + }; + assert_eq!(mss, 256); } }