Hardened listener reload + config persistence + SYN Limit startup safety

This commit is contained in:
Alexey
2026-08-22 13:45:57 +03:00
parent 96d467a400
commit 189e10800a
28 changed files with 2749 additions and 1900 deletions
+20 -96
View File
@@ -16,12 +16,10 @@
//! | `general` | `telemetry` / `me_*_policy` | Applied immediately |
//! | `network` | `dns_overrides` | Applied immediately |
//! | `access` | All user/quota fields | Effective immediately |
//! | `server.listeners` | `synlimit*` for existing endpoints | Netfilter rules reconciled immediately |
//!
//! Fields that require re-binding sockets (`server.listeners`, legacy
//! `server.port`, `censorship.*`, `network.*`, `use_middle_proxy`) are **not**
//! applied, except for SYN limiter fields on unchanged listener endpoints; a
//! warning is emitted.
//! applied; a warning is emitted. SYN limiter rules are process-owned and are
//! reconciled only during privileged startup.
//! Non-hot changes are never mixed into the runtime config snapshot.
use std::collections::BTreeSet;
@@ -36,9 +34,11 @@ use tracing::{error, info, warn};
use super::load::{LoadedConfig, ProxyConfig};
use crate::config::{
CidrRateLimitKey, ListenerConfig, LogLevel, MeBindStaleMode, MeFloorMode, MeSocksKdfPolicy,
MeTelemetryLevel, MeWriterPickMode, SynLimitMode,
CidrRateLimitKey, LogLevel, MeBindStaleMode, MeFloorMode, MeSocksKdfPolicy, MeTelemetryLevel,
MeWriterPickMode,
};
#[cfg(test)]
use crate::config::{ListenerConfig, SynLimitMode};
const HOT_RELOAD_DEBOUNCE: Duration = Duration::from_millis(50);
@@ -133,22 +133,6 @@ pub struct HotFields {
pub user_max_unique_ips_global_each: usize,
pub user_max_unique_ips_mode: crate::config::UserMaxUniqueIpsMode,
pub user_max_unique_ips_window_secs: u64,
pub listener_synlimit: Vec<ListenerSynLimitHotFields>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListenerSynLimitHotFields {
pub ip: IpAddr,
pub port: Option<u16>,
pub synlimit: SynLimitMode,
pub synlimit_seconds: u32,
pub synlimit_hitcount: u32,
pub synlimit_burst: u32,
pub synlimit_ios_seconds: u32,
pub synlimit_ios_hitcount: u32,
pub synlimit_ios_burst: u32,
pub synlimit_hashlimit_expire_ms: u32,
pub synlimit_hashlimit_size: u32,
}
impl HotFields {
@@ -278,30 +262,6 @@ impl HotFields {
user_max_unique_ips_global_each: cfg.access.user_max_unique_ips_global_each,
user_max_unique_ips_mode: cfg.access.user_max_unique_ips_mode,
user_max_unique_ips_window_secs: cfg.access.user_max_unique_ips_window_secs,
listener_synlimit: cfg
.server
.listeners
.iter()
.map(ListenerSynLimitHotFields::from_listener)
.collect(),
}
}
}
impl ListenerSynLimitHotFields {
fn from_listener(listener: &ListenerConfig) -> Self {
Self {
ip: listener.ip,
port: listener.port,
synlimit: listener.synlimit,
synlimit_seconds: listener.synlimit_seconds,
synlimit_hitcount: listener.synlimit_hitcount,
synlimit_burst: listener.synlimit_burst,
synlimit_ios_seconds: listener.synlimit_ios_seconds,
synlimit_ios_hitcount: listener.synlimit_ios_hitcount,
synlimit_ios_burst: listener.synlimit_ios_burst,
synlimit_hashlimit_expire_ms: listener.synlimit_hashlimit_expire_ms,
synlimit_hashlimit_size: listener.synlimit_hashlimit_size,
}
}
}
@@ -348,18 +308,7 @@ fn listeners_equal(
lhs: &[crate::config::ListenerConfig],
rhs: &[crate::config::ListenerConfig],
) -> bool {
if lhs.len() != rhs.len() {
return false;
}
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
&& a.reuse_allow == b.reuse_allow
})
serde_json::to_value(lhs).ok() == serde_json::to_value(rhs).ok()
}
fn resolve_default_link_port(cfg: &ProxyConfig) -> u16 {
@@ -608,8 +557,6 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
cfg.access.user_max_unique_ips_global_each = new.access.user_max_unique_ips_global_each;
cfg.access.user_max_unique_ips_mode = new.access.user_max_unique_ips_mode;
cfg.access.user_max_unique_ips_window_secs = new.access.user_max_unique_ips_window_secs;
overlay_listener_synlimit_fields(&mut cfg.server.listeners, &new.server.listeners);
if cfg.rebuild_runtime_user_auth().is_err() {
cfg.runtime_user_auth = None;
}
@@ -617,26 +564,6 @@ fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
cfg
}
fn overlay_listener_synlimit_fields(old: &mut [ListenerConfig], new: &[ListenerConfig]) {
if old.len() != new.len() {
return;
}
for (old_listener, new_listener) in old.iter_mut().zip(new.iter()) {
if old_listener.ip != new_listener.ip || old_listener.port != new_listener.port {
continue;
}
old_listener.synlimit = new_listener.synlimit;
old_listener.synlimit_seconds = new_listener.synlimit_seconds;
old_listener.synlimit_hitcount = new_listener.synlimit_hitcount;
old_listener.synlimit_burst = new_listener.synlimit_burst;
old_listener.synlimit_ios_seconds = new_listener.synlimit_ios_seconds;
old_listener.synlimit_ios_hitcount = new_listener.synlimit_ios_hitcount;
old_listener.synlimit_ios_burst = new_listener.synlimit_ios_burst;
old_listener.synlimit_hashlimit_expire_ms = new_listener.synlimit_hashlimit_expire_ms;
old_listener.synlimit_hashlimit_size = new_listener.synlimit_hashlimit_size;
}
}
/// Warn if any non-hot fields changed (require restart).
fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: bool) {
let mut warned = false;
@@ -913,13 +840,6 @@ fn log_changes(
);
}
if old_hot.listener_synlimit != new_hot.listener_synlimit {
info!(
"config reload: server.listeners SYN limiter updated ({} listeners)",
new_hot.listener_synlimit.len()
);
}
if old_hot.desync_all_full != new_hot.desync_all_full {
info!(
"config reload: desync_all_full: {} → {}",
@@ -1338,6 +1258,7 @@ fn reload_config(
let LoadedConfig {
config: new_cfg,
source_files,
source_contents: _,
rendered_hash,
} = loaded;
let next_manifest = WatchManifest::from_source_files(&source_files);
@@ -1731,7 +1652,7 @@ mod tests {
}
#[test]
fn listener_synlimit_extended_fields_are_hot() {
fn listener_synlimit_fields_are_process_owned() {
let mut old = sample_config();
old.server.listeners.push(ListenerConfig {
ip: "0.0.0.0".parse().unwrap(),
@@ -1765,14 +1686,17 @@ mod tests {
let applied = overlay_hot_fields(&old, &new);
let listener = &applied.server.listeners[0];
assert_eq!(applied.server.port, old.server.port);
assert_eq!(listener.synlimit_seconds, 120);
assert_eq!(listener.synlimit_hitcount, 96);
assert_eq!(listener.synlimit_burst, 2);
assert_eq!(listener.synlimit_ios_seconds, 2);
assert_eq!(listener.synlimit_ios_hitcount, 18);
assert_eq!(listener.synlimit_ios_burst, 36);
assert_eq!(listener.synlimit_hashlimit_expire_ms, 90_000);
assert_eq!(listener.synlimit_hashlimit_size, 65_536);
assert_eq!(listener.synlimit_seconds, old.server.listeners[0].synlimit_seconds);
assert_eq!(
listener.synlimit_hitcount,
old.server.listeners[0].synlimit_hitcount
);
assert_eq!(listener.synlimit_burst, old.server.listeners[0].synlimit_burst);
assert_eq!(
listener.synlimit_hashlimit_size,
old.server.listeners[0].synlimit_hashlimit_size
);
assert!(classify_config_changes(&old, &new).restart_required);
}
#[test]
+75 -7
View File
@@ -1,6 +1,6 @@
#![allow(deprecated)]
use std::collections::{BTreeSet, HashMap, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -34,8 +34,8 @@ use self::normalize::{
pub(crate) use self::runtime_auth::UserAuthSnapshot;
use self::strict_keys::handle_unknown_config_keys;
use self::validation::{
normalize_upstream_family_policy, validate_logging_config, validate_network_cfg,
validate_upstreams,
normalize_upstream_family_policy, validate_listener_runtime_profiles,
validate_logging_config, validate_network_cfg, validate_upstreams,
};
const MAX_ME_WRITER_CMD_CHANNEL_CAPACITY: usize = 16_384;
@@ -49,12 +49,27 @@ const MAX_MAX_CLIENT_FRAME_BYTES: usize = 16 * 1024 * 1024;
const MAX_API_REQUEST_BODY_LIMIT_BYTES: usize = 1024 * 1024;
#[derive(Debug, Clone)]
/// Validated config plus the exact recursive source snapshot used to build it.
pub(crate) struct LoadedConfig {
/// Validated and normalized effective configuration.
pub(crate) config: ProxyConfig,
/// Canonical paths participating in the recursive include graph.
pub(crate) source_files: Vec<PathBuf>,
/// Raw source bytes keyed by canonical source path.
pub(crate) source_contents: BTreeMap<PathBuf, String>,
/// Legacy hash of the include-expanded rendered snapshot.
pub(crate) rendered_hash: u64,
}
/// Raw recursive source graph captured before typed deserialization.
#[derive(Debug, Clone)]
pub(crate) struct ConfigSourceGraph {
/// Raw source bytes keyed by canonical source path.
pub(crate) source_contents: BTreeMap<PathBuf, String>,
/// Include-expanded TOML used for typed deserialization.
pub(crate) rendered: String,
}
/// Main runtime configuration loaded from TOML.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProxyConfig {
@@ -120,14 +135,64 @@ impl ProxyConfig {
Self::load_with_metadata(path).map(|loaded| loaded.config)
}
/// Loads typed configuration together with its source and rendered metadata.
pub(crate) fn load_with_metadata<P: AsRef<Path>>(path: P) -> Result<LoadedConfig> {
Self::load_with_source_overrides(path, &BTreeMap::new())
}
/// Loads a typed snapshot while replacing selected captured source documents.
pub(crate) fn load_with_source_overrides<P: AsRef<Path>>(
path: P,
source_overrides: &BTreeMap<PathBuf, String>,
) -> Result<LoadedConfig> {
let graph = Self::read_source_graph_with_overrides(path, source_overrides)?;
Self::load_source_graph(graph)
}
/// Captures the raw include graph without requiring typed config validity.
pub(crate) fn read_source_graph<P: AsRef<Path>>(path: P) -> Result<ConfigSourceGraph> {
Self::read_source_graph_with_overrides(path, &BTreeMap::new())
}
/// Captures a raw include graph with in-memory source replacements.
pub(crate) fn read_source_graph_with_overrides<P: AsRef<Path>>(
path: P,
source_overrides: &BTreeMap<PathBuf, String>,
) -> Result<ConfigSourceGraph> {
let path = path.as_ref();
let content =
std::fs::read_to_string(path).map_err(|e| ProxyError::Config(e.to_string()))?;
let normalized_path = normalize_config_path(path);
let content = source_overrides
.get(&normalized_path)
.cloned()
.map(Ok)
.unwrap_or_else(|| std::fs::read_to_string(path))
.map_err(|e| ProxyError::Config(e.to_string()))?;
let base_dir = path.parent().unwrap_or(Path::new("."));
let mut source_files = BTreeSet::new();
source_files.insert(normalize_config_path(path));
let processed = preprocess_includes(&content, base_dir, 0, &mut source_files)?;
source_files.insert(normalized_path.clone());
let mut source_contents = BTreeMap::new();
source_contents.insert(normalized_path, content.clone());
let processed = preprocess_includes(
&content,
base_dir,
0,
&mut source_files,
&mut source_contents,
source_overrides,
)?;
Ok(ConfigSourceGraph {
source_contents,
rendered: processed,
})
}
fn load_source_graph(graph: ConfigSourceGraph) -> Result<LoadedConfig> {
let ConfigSourceGraph {
source_contents,
rendered: processed,
} = graph;
let source_files: BTreeSet<PathBuf> = source_contents.keys().cloned().collect();
let parsed_toml: toml::Value =
toml::from_str(&processed).map_err(|e| ProxyError::Config(e.to_string()))?;
@@ -1349,6 +1414,7 @@ impl ProxyConfig {
listener.announce = Some(ip.to_string());
}
}
validate_listener_runtime_profiles(&config)?;
// Migration: show_link (top-level) → general.links.show.
if !config.show_link.is_empty() && config.general.links.show.is_empty() {
@@ -1387,6 +1453,7 @@ impl ProxyConfig {
Ok(LoadedConfig {
config,
source_files: source_files.into_iter().collect(),
source_contents,
rendered_hash: hash_rendered_snapshot(&processed),
})
}
@@ -1458,6 +1525,7 @@ impl ProxyConfig {
}
crate::network::dns_overrides::validate_entries(&self.network.dns_overrides)?;
validate_listener_runtime_profiles(self)?;
Ok(())
}
+13 -3
View File
@@ -1,4 +1,4 @@
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::{Path, PathBuf};
@@ -27,6 +27,8 @@ pub(super) fn preprocess_includes(
base_dir: &Path,
depth: u8,
source_files: &mut BTreeSet<PathBuf>,
source_contents: &mut BTreeMap<PathBuf, String>,
source_overrides: &BTreeMap<PathBuf, String>,
) -> Result<String> {
if depth > 10 {
return Err(ProxyError::Config("Include depth > 10".into()));
@@ -39,15 +41,23 @@ pub(super) fn preprocess_includes(
if let Some(rest) = rest.strip_prefix('=') {
let path_str = rest.trim().trim_matches('"');
let resolved = base_dir.join(path_str);
source_files.insert(normalize_config_path(&resolved));
let included = std::fs::read_to_string(&resolved)
let normalized = normalize_config_path(&resolved);
source_files.insert(normalized.clone());
let included = source_overrides
.get(&normalized)
.cloned()
.map(Ok)
.unwrap_or_else(|| std::fs::read_to_string(&resolved))
.map_err(|e| ProxyError::Config(e.to_string()))?;
source_contents.insert(normalized, included.clone());
let included_dir = resolved.parent().unwrap_or(base_dir);
output.push_str(&preprocess_includes(
&included,
included_dir,
depth + 1,
source_files,
source_contents,
source_overrides,
)?);
output.push('\n');
continue;
+74 -1
View File
@@ -3,7 +3,9 @@ use tracing::warn;
use crate::error::{ProxyError, Result};
use super::super::types::{LoggingConfig, LoggingDestination, NetworkConfig, UpstreamType};
use super::super::types::{
LoggingConfig, LoggingDestination, NetworkConfig, SynLimitMode, UpstreamType,
};
use super::ProxyConfig;
pub(super) fn validate_network_cfg(net: &mut NetworkConfig) -> Result<()> {
@@ -90,6 +92,77 @@ pub(super) fn validate_upstreams(config: &ProxyConfig) -> Result<()> {
Ok(())
}
pub(super) fn validate_listener_runtime_profiles(config: &ProxyConfig) -> Result<()> {
for (index, listener) in config.server.listeners.iter().enumerate() {
let supported = if cfg!(target_os = "linux") {
matches!(
listener.synlimit,
SynLimitMode::Off | SynLimitMode::Iptables | SynLimitMode::Nftables
)
} else if cfg!(target_os = "freebsd") {
matches!(listener.synlimit, SynLimitMode::Off | SynLimitMode::Pf)
} else {
listener.synlimit == SynLimitMode::Off
};
if !supported {
let backend = match listener.synlimit {
SynLimitMode::Off => "off",
SynLimitMode::Iptables => "iptables",
SynLimitMode::Nftables => "nftables",
SynLimitMode::Pf => "pf",
};
let supported = if cfg!(target_os = "linux") {
"off, iptables, nftables"
} else if cfg!(target_os = "freebsd") {
"off, pf"
} else {
"off"
};
return Err(ProxyError::Config(format!(
"server.listeners[{index}].synlimit backend {backend} is unsupported on this platform; supported values: {supported}"
)));
}
}
let Some(bulk_mss) = config
.server
.client_mss_bulk_value()
.map_err(|error| ProxyError::Config(format!("server.client_mss_bulk {error}")))?
else {
return Ok(());
};
if !cfg!(target_os = "linux") {
return Err(ProxyError::Config(
"server.client_mss_bulk is supported only on Linux".to_string(),
));
}
let mut participants = 0usize;
for (index, listener) in config.server.listeners.iter().enumerate() {
let handshake_mss = listener
.effective_client_mss(&config.server)
.map_err(|error| {
ProxyError::Config(format!("server.listeners[{index}].client_mss {error}"))
})?;
let Some(handshake_mss) = handshake_mss else {
continue;
};
participants = participants.saturating_add(1);
if bulk_mss <= handshake_mss {
return Err(ProxyError::Config(format!(
"server.client_mss_bulk ({bulk_mss}) must be greater than the effective handshake MSS ({handshake_mss}) for server.listeners[{index}]"
)));
}
}
if participants == 0 {
return Err(ProxyError::Config(
"server.client_mss_bulk requires an effective client_mss on at least one listener"
.to_string(),
));
}
Ok(())
}
pub(super) fn normalize_upstream_family_policy(config: &mut ProxyConfig) {
for (idx, upstream) in config.upstreams.iter_mut().enumerate() {
if matches!(upstream.ipv4, Some(false)) && upstream.prefer == Some(4) {
+1
View File
@@ -5,5 +5,6 @@ pub mod hot_reload;
mod load;
mod types;
pub(crate) use load::{ConfigSourceGraph, LoadedConfig};
pub use load::ProxyConfig;
pub use types::*;
+100 -2
View File
@@ -62,6 +62,7 @@ fn synlimit_synfix_defaults_are_loaded_for_listener() {
assert_eq!(listener.synlimit_hashlimit_size, 32_768);
}
#[cfg(target_os = "freebsd")]
#[test]
fn synlimit_pf_mode_is_loaded_for_listener() {
let cfg = load_config_from_temp_toml(
@@ -82,6 +83,30 @@ fn synlimit_pf_mode_is_loaded_for_listener() {
assert_eq!(cfg.server.listeners[0].synlimit, SynLimitMode::Pf);
}
#[cfg(not(target_os = "freebsd"))]
#[test]
fn synlimit_pf_mode_is_rejected_off_freebsd() {
let toml = r#"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
[[server.listeners]]
ip = "0.0.0.0"
port = 443
synlimit = "pf"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_synlimit_pf_unsupported_test.toml");
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains("backend pf is unsupported on this platform"));
let _ = std::fs::remove_file(path);
}
#[test]
fn synlimit_synfix_zero_values_are_rejected() {
for (field, expected) in [
@@ -1742,11 +1767,12 @@ fn client_mss_presets_and_listener_override_are_resolved() {
let _ = std::fs::remove_file(path);
}
#[cfg(target_os = "linux")]
#[test]
fn client_mss_custom_value_is_accepted() {
let toml = r#"
[server]
client_mss = "4096"
client_mss = "92"
client_mss_bulk = "1400"
[censorship]
@@ -1760,11 +1786,83 @@ fn client_mss_custom_value_is_accepted() {
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
assert_eq!(cfg.server.client_mss_value(), Ok(Some(4096)));
assert_eq!(cfg.server.client_mss_value(), Ok(Some(92)));
assert_eq!(cfg.server.client_mss_bulk_value(), Ok(Some(1400)));
let _ = std::fs::remove_file(path);
}
#[cfg(target_os = "linux")]
#[test]
fn client_mss_bulk_requires_a_larger_bulk_profile_and_handshake_participant() {
for (name, server, expected) in [
(
"without_handshake",
"client_mss_bulk = \"1400\"",
"requires an effective client_mss",
),
(
"equal",
"client_mss = \"1400\"\nclient_mss_bulk = \"1400\"",
"must be greater than the effective handshake MSS",
),
(
"inverted",
"client_mss = \"1500\"\nclient_mss_bulk = \"1400\"",
"must be greater than the effective handshake MSS",
),
] {
let toml = format!(
"[server]\n{server}\n\n[censorship]\ntls_domain = \"example.com\"\n\n[access.users]\nuser = \"00000000000000000000000000000000\"\n"
);
let dir = std::env::temp_dir();
let path = dir.join(format!("telemt_client_mss_bulk_{name}_test.toml"));
std::fs::write(&path, toml).unwrap();
let err = ProxyConfig::load(&path).unwrap_err().to_string();
assert!(err.contains(expected), "unexpected error: {err}");
let _ = std::fs::remove_file(path);
}
}
#[cfg(target_os = "linux")]
#[test]
fn client_mss_bulk_allows_explicit_listener_opt_out() {
let toml = r#"
[server]
client_mss = "92"
client_mss_bulk = "1400"
[[server.listeners]]
ip = "0.0.0.0"
port = 443
[[server.listeners]]
ip = "::"
port = 443
client_mss = ""
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
"#;
let dir = std::env::temp_dir();
let path = dir.join("telemt_client_mss_bulk_listener_opt_out_test.toml");
std::fs::write(&path, toml).unwrap();
let cfg = ProxyConfig::load(&path).unwrap();
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(None)
);
let _ = std::fs::remove_file(path);
}
#[test]
fn client_mss_out_of_range_is_rejected() {
for value in ["87", "4097"] {
+5 -5
View File
@@ -1623,11 +1623,11 @@ pub struct ServerConfig {
#[serde(default)]
pub client_mss: Option<String>,
/// Client-facing TCP MSS for bulk traffic when initial-response fragmentation
/// is enabled on Linux. The listener uses this MSS from connection setup;
/// `client_mss` becomes the fragment size for the authenticated FakeTLS
/// response. Empty/omitted keeps `client_mss` connection-wide. Uses the same
/// preset/integer grammar as `client_mss`.
/// Experimental Linux-only bulk MSS used with best-effort userspace
/// chunking of the authenticated FakeTLS response. TCP offloads, loss, and
/// retransmission may coalesce write boundaries. Empty or omitted keeps
/// `client_mss` connection-wide. Uses the same preset/integer grammar as
/// `client_mss`.
#[serde(default)]
pub client_mss_bulk: Option<String>,