mirror of
https://github.com/telemt/telemt.git
synced 2026-11-01 13:21:58 +03:00
Redesign runtime w/ include-aware config + Module Split + Listener Lifecycle + Atomic Reload
This commit is contained in:
+11
-1135
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(in crate::api) async fn create_user(
|
||||||
|
body: CreateUserRequest,
|
||||||
|
expected_revision: Option<String>,
|
||||||
|
shared: &ApiShared,
|
||||||
|
) -> Result<(CreateUserResponse, String), ApiFailure> {
|
||||||
|
let touches_user_ad_tags = body.user_ad_tag.is_some();
|
||||||
|
let touches_user_max_tcp_conns = body.max_tcp_conns.is_some();
|
||||||
|
let touches_user_expirations = body.expiration_rfc3339.is_some();
|
||||||
|
let touches_user_data_quota = body.data_quota_bytes.is_some();
|
||||||
|
let touches_user_rate_limits =
|
||||||
|
body.rate_limit_up_bps.is_some() || body.rate_limit_down_bps.is_some();
|
||||||
|
let touches_user_max_unique_ips = body.max_unique_ips.is_some();
|
||||||
|
let touches_user_enabled = matches!(body.enabled, Some(false));
|
||||||
|
|
||||||
|
if !is_valid_username(&body.username) {
|
||||||
|
return Err(ApiFailure::bad_request(
|
||||||
|
"username must match [A-Za-z0-9_.-] and be 1..64 chars",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let secret = match body.secret {
|
||||||
|
Some(secret) => {
|
||||||
|
if !is_valid_user_secret(&secret) {
|
||||||
|
return Err(ApiFailure::bad_request(
|
||||||
|
"secret must be exactly 32 hex characters",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
secret
|
||||||
|
}
|
||||||
|
None => random_user_secret(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(ad_tag) = body.user_ad_tag.as_ref()
|
||||||
|
&& !is_valid_ad_tag(ad_tag)
|
||||||
|
{
|
||||||
|
return Err(ApiFailure::bad_request(
|
||||||
|
"user_ad_tag must be exactly 32 hex characters",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let expiration = parse_optional_expiration(body.expiration_rfc3339.as_deref())?;
|
||||||
|
let _guard = shared.mutation_lock.lock().await;
|
||||||
|
let mut cfg = load_config_from_disk(&shared.config_path).await?;
|
||||||
|
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
|
||||||
|
|
||||||
|
if cfg.access.users.contains_key(&body.username) {
|
||||||
|
return Err(ApiFailure::new(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"user_exists",
|
||||||
|
"User already exists",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.access
|
||||||
|
.users
|
||||||
|
.insert(body.username.clone(), secret.clone());
|
||||||
|
if let Some(ad_tag) = body.user_ad_tag {
|
||||||
|
cfg.access
|
||||||
|
.user_ad_tags
|
||||||
|
.insert(body.username.clone(), ad_tag);
|
||||||
|
}
|
||||||
|
if let Some(limit) = body.max_tcp_conns {
|
||||||
|
cfg.access
|
||||||
|
.user_max_tcp_conns
|
||||||
|
.insert(body.username.clone(), limit);
|
||||||
|
}
|
||||||
|
if let Some(expiration) = expiration {
|
||||||
|
cfg.access
|
||||||
|
.user_expirations
|
||||||
|
.insert(body.username.clone(), expiration);
|
||||||
|
}
|
||||||
|
if let Some(quota) = body.data_quota_bytes {
|
||||||
|
cfg.access
|
||||||
|
.user_data_quota
|
||||||
|
.insert(body.username.clone(), quota);
|
||||||
|
}
|
||||||
|
if touches_user_rate_limits {
|
||||||
|
cfg.access.user_rate_limits.insert(
|
||||||
|
body.username.clone(),
|
||||||
|
RateLimitBps {
|
||||||
|
up_bps: body.rate_limit_up_bps.unwrap_or(0),
|
||||||
|
down_bps: body.rate_limit_down_bps.unwrap_or(0),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated_limit = body.max_unique_ips;
|
||||||
|
if let Some(limit) = updated_limit {
|
||||||
|
cfg.access
|
||||||
|
.user_max_unique_ips
|
||||||
|
.insert(body.username.clone(), limit);
|
||||||
|
}
|
||||||
|
if matches!(body.enabled, Some(false)) {
|
||||||
|
cfg.access.user_enabled.insert(body.username.clone(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.validate()
|
||||||
|
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
||||||
|
|
||||||
|
let mut touched_sections = vec![AccessSection::Users];
|
||||||
|
if touches_user_ad_tags {
|
||||||
|
touched_sections.push(AccessSection::UserAdTags);
|
||||||
|
}
|
||||||
|
if touches_user_max_tcp_conns {
|
||||||
|
touched_sections.push(AccessSection::UserMaxTcpConns);
|
||||||
|
}
|
||||||
|
if touches_user_expirations {
|
||||||
|
touched_sections.push(AccessSection::UserExpirations);
|
||||||
|
}
|
||||||
|
if touches_user_data_quota {
|
||||||
|
touched_sections.push(AccessSection::UserDataQuota);
|
||||||
|
}
|
||||||
|
if touches_user_rate_limits {
|
||||||
|
touched_sections.push(AccessSection::UserRateLimits);
|
||||||
|
}
|
||||||
|
if touches_user_max_unique_ips {
|
||||||
|
touched_sections.push(AccessSection::UserMaxUniqueIps);
|
||||||
|
}
|
||||||
|
if touches_user_enabled {
|
||||||
|
touched_sections.push(AccessSection::UserEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
let revision =
|
||||||
|
save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?;
|
||||||
|
drop(_guard);
|
||||||
|
|
||||||
|
if let Some(limit) = updated_limit {
|
||||||
|
shared
|
||||||
|
.ip_tracker
|
||||||
|
.set_user_limit(&body.username, limit)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips();
|
||||||
|
|
||||||
|
let users = users_from_config(
|
||||||
|
&cfg,
|
||||||
|
&shared.stats,
|
||||||
|
&shared.ip_tracker,
|
||||||
|
detected_ip_v4,
|
||||||
|
detected_ip_v6,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let user = users
|
||||||
|
.into_iter()
|
||||||
|
.find(|entry| entry.username == body.username)
|
||||||
|
.unwrap_or(UserInfo {
|
||||||
|
username: body.username.clone(),
|
||||||
|
enabled: cfg.access.is_user_enabled(&body.username),
|
||||||
|
in_runtime: false,
|
||||||
|
user_ad_tag: None,
|
||||||
|
max_tcp_conns: cfg
|
||||||
|
.access
|
||||||
|
.user_max_tcp_conns
|
||||||
|
.get(&body.username)
|
||||||
|
.copied()
|
||||||
|
.filter(|limit| *limit > 0)
|
||||||
|
.or((cfg.access.user_max_tcp_conns_global_each > 0)
|
||||||
|
.then_some(cfg.access.user_max_tcp_conns_global_each)),
|
||||||
|
expiration_rfc3339: None,
|
||||||
|
data_quota_bytes: None,
|
||||||
|
rate_limit_up_bps: body.rate_limit_up_bps.filter(|limit| *limit > 0),
|
||||||
|
rate_limit_down_bps: body.rate_limit_down_bps.filter(|limit| *limit > 0),
|
||||||
|
max_unique_ips: updated_limit,
|
||||||
|
current_connections: 0,
|
||||||
|
active_unique_ips: 0,
|
||||||
|
active_unique_ips_list: Vec::new(),
|
||||||
|
recent_unique_ips: 0,
|
||||||
|
recent_unique_ips_list: Vec::new(),
|
||||||
|
total_octets: 0,
|
||||||
|
links: build_user_links(&cfg, &secret, detected_ip_v4, detected_ip_v6),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok((CreateUserResponse { user, secret }, revision))
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(in crate::api) async fn rotate_secret(
|
||||||
|
user: &str,
|
||||||
|
body: RotateSecretRequest,
|
||||||
|
expected_revision: Option<String>,
|
||||||
|
shared: &ApiShared,
|
||||||
|
) -> Result<(CreateUserResponse, String), ApiFailure> {
|
||||||
|
let secret = body.secret.unwrap_or_else(random_user_secret);
|
||||||
|
if !is_valid_user_secret(&secret) {
|
||||||
|
return Err(ApiFailure::bad_request(
|
||||||
|
"secret must be exactly 32 hex characters",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let _guard = shared.mutation_lock.lock().await;
|
||||||
|
let mut cfg = load_config_from_disk(&shared.config_path).await?;
|
||||||
|
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
|
||||||
|
|
||||||
|
if !cfg.access.users.contains_key(user) {
|
||||||
|
return Err(ApiFailure::new(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"not_found",
|
||||||
|
"User not found",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.access.users.insert(user.to_string(), secret.clone());
|
||||||
|
cfg.validate()
|
||||||
|
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
||||||
|
let revision =
|
||||||
|
save_access_sections_to_disk(&shared.config_path, &cfg, &[AccessSection::Users]).await?;
|
||||||
|
drop(_guard);
|
||||||
|
|
||||||
|
let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips();
|
||||||
|
let users = users_from_config(
|
||||||
|
&cfg,
|
||||||
|
&shared.stats,
|
||||||
|
&shared.ip_tracker,
|
||||||
|
detected_ip_v4,
|
||||||
|
detected_ip_v6,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let user_info = users
|
||||||
|
.into_iter()
|
||||||
|
.find(|entry| entry.username == user)
|
||||||
|
.ok_or_else(|| ApiFailure::internal("failed to build updated user view"))?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
CreateUserResponse {
|
||||||
|
user: user_info,
|
||||||
|
secret,
|
||||||
|
},
|
||||||
|
revision,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub(in crate::api) async fn delete_user(
|
||||||
|
user: &str,
|
||||||
|
expected_revision: Option<String>,
|
||||||
|
shared: &ApiShared,
|
||||||
|
) -> Result<(String, String), ApiFailure> {
|
||||||
|
let _guard = shared.mutation_lock.lock().await;
|
||||||
|
let mut cfg = load_config_from_disk(&shared.config_path).await?;
|
||||||
|
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
|
||||||
|
|
||||||
|
if !cfg.access.users.contains_key(user) {
|
||||||
|
return Err(ApiFailure::new(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"not_found",
|
||||||
|
"User not found",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if cfg.access.users.len() <= 1 {
|
||||||
|
return Err(ApiFailure::new(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"last_user_forbidden",
|
||||||
|
"Cannot delete the last configured user",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut touched_sections = vec![AccessSection::Users];
|
||||||
|
cfg.access.users.remove(user);
|
||||||
|
if cfg.access.user_enabled.remove(user).is_some() {
|
||||||
|
touched_sections.push(AccessSection::UserEnabled);
|
||||||
|
}
|
||||||
|
if cfg.access.user_ad_tags.remove(user).is_some() {
|
||||||
|
touched_sections.push(AccessSection::UserAdTags);
|
||||||
|
}
|
||||||
|
if cfg.access.user_max_tcp_conns.remove(user).is_some() {
|
||||||
|
touched_sections.push(AccessSection::UserMaxTcpConns);
|
||||||
|
}
|
||||||
|
if cfg.access.user_expirations.remove(user).is_some() {
|
||||||
|
touched_sections.push(AccessSection::UserExpirations);
|
||||||
|
}
|
||||||
|
if cfg.access.user_data_quota.remove(user).is_some() {
|
||||||
|
touched_sections.push(AccessSection::UserDataQuota);
|
||||||
|
}
|
||||||
|
if cfg.access.user_rate_limits.remove(user).is_some() {
|
||||||
|
touched_sections.push(AccessSection::UserRateLimits);
|
||||||
|
}
|
||||||
|
if cfg.access.user_max_unique_ips.remove(user).is_some() {
|
||||||
|
touched_sections.push(AccessSection::UserMaxUniqueIps);
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.validate()
|
||||||
|
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
||||||
|
let revision =
|
||||||
|
save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?;
|
||||||
|
drop(_guard);
|
||||||
|
shared.ip_tracker.remove_user_limit(user).await;
|
||||||
|
shared.ip_tracker.clear_user_ips(user).await;
|
||||||
|
|
||||||
|
Ok((user.to_string(), revision))
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(in crate::api::users) fn empty_user_links() -> UserLinks {
|
||||||
|
UserLinks {
|
||||||
|
classic: Vec::new(),
|
||||||
|
secure: Vec::new(),
|
||||||
|
tls: Vec::new(),
|
||||||
|
tls_domains: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::api::users) fn build_user_links(
|
||||||
|
cfg: &ProxyConfig,
|
||||||
|
secret: &str,
|
||||||
|
startup_detected_ip_v4: Option<IpAddr>,
|
||||||
|
startup_detected_ip_v6: Option<IpAddr>,
|
||||||
|
) -> UserLinks {
|
||||||
|
let hosts = resolve_link_hosts(cfg, startup_detected_ip_v4, startup_detected_ip_v6);
|
||||||
|
let port = cfg
|
||||||
|
.general
|
||||||
|
.links
|
||||||
|
.public_port
|
||||||
|
.unwrap_or(resolve_default_link_port(cfg));
|
||||||
|
let tls_domains = resolve_tls_domains(cfg);
|
||||||
|
let extra_tls_domains = resolve_extra_tls_domains(cfg);
|
||||||
|
|
||||||
|
let mut classic = Vec::new();
|
||||||
|
let mut secure = Vec::new();
|
||||||
|
let mut tls = Vec::new();
|
||||||
|
let mut tls_domain_links = Vec::new();
|
||||||
|
|
||||||
|
for host in &hosts {
|
||||||
|
if cfg.general.modes.classic {
|
||||||
|
classic.push(format!(
|
||||||
|
"tg://proxy?server={}&port={}&secret={}",
|
||||||
|
host, port, secret
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if cfg.general.modes.secure {
|
||||||
|
secure.push(format!(
|
||||||
|
"tg://proxy?server={}&port={}&secret=dd{}",
|
||||||
|
host, port, secret
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if cfg.general.modes.tls {
|
||||||
|
for domain in &tls_domains {
|
||||||
|
let domain_hex = hex::encode(domain);
|
||||||
|
tls.push(format!(
|
||||||
|
"tg://proxy?server={}&port={}&secret=ee{}{}",
|
||||||
|
host, port, secret, domain_hex
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for domain in &extra_tls_domains {
|
||||||
|
let domain_hex = hex::encode(domain);
|
||||||
|
let link = format!(
|
||||||
|
"tg://proxy?server={}&port={}&secret=ee{}{}",
|
||||||
|
host, port, secret, domain_hex
|
||||||
|
);
|
||||||
|
tls_domain_links.push(TlsDomainLink {
|
||||||
|
domain: (*domain).to_string(),
|
||||||
|
link,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
UserLinks {
|
||||||
|
classic,
|
||||||
|
secure,
|
||||||
|
tls,
|
||||||
|
tls_domains: tls_domain_links,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_default_link_port(cfg: &ProxyConfig) -> u16 {
|
||||||
|
cfg.server
|
||||||
|
.listeners
|
||||||
|
.first()
|
||||||
|
.and_then(|listener| listener.port)
|
||||||
|
.unwrap_or(cfg.server.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_link_hosts(
|
||||||
|
cfg: &ProxyConfig,
|
||||||
|
startup_detected_ip_v4: Option<IpAddr>,
|
||||||
|
startup_detected_ip_v6: Option<IpAddr>,
|
||||||
|
) -> Vec<String> {
|
||||||
|
if let Some(host) = cfg
|
||||||
|
.general
|
||||||
|
.links
|
||||||
|
.public_host
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
return vec![host.to_string()];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut hosts = Vec::new();
|
||||||
|
for listener in &cfg.server.listeners {
|
||||||
|
if let Some(host) = listener
|
||||||
|
.announce
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
push_unique_host(&mut hosts, host);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(ip) = listener.announce_ip
|
||||||
|
&& !ip.is_unspecified()
|
||||||
|
{
|
||||||
|
push_unique_host(&mut hosts, &ip.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if listener.ip.is_unspecified() {
|
||||||
|
let detected_ip = if listener.ip.is_ipv4() {
|
||||||
|
startup_detected_ip_v4
|
||||||
|
} else {
|
||||||
|
startup_detected_ip_v6
|
||||||
|
};
|
||||||
|
if let Some(ip) = detected_ip {
|
||||||
|
push_unique_host(&mut hosts, &ip.to_string());
|
||||||
|
} else {
|
||||||
|
push_unique_host(&mut hosts, &listener.ip.to_string());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
push_unique_host(&mut hosts, &listener.ip.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hosts.is_empty() {
|
||||||
|
return hosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ip) = startup_detected_ip_v4.or(startup_detected_ip_v6) {
|
||||||
|
return vec![ip.to_string()];
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(host) = cfg.server.listen_addr_ipv4.as_deref() {
|
||||||
|
push_host_from_legacy_listen(&mut hosts, host);
|
||||||
|
}
|
||||||
|
if let Some(host) = cfg.server.listen_addr_ipv6.as_deref() {
|
||||||
|
push_host_from_legacy_listen(&mut hosts, host);
|
||||||
|
}
|
||||||
|
if !hosts.is_empty() {
|
||||||
|
return hosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
vec!["UNKNOWN".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_host_from_legacy_listen(hosts: &mut Vec<String>, raw: &str) {
|
||||||
|
let candidate = raw.trim();
|
||||||
|
if candidate.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match candidate.parse::<IpAddr>() {
|
||||||
|
Ok(ip) if ip.is_unspecified() => {}
|
||||||
|
Ok(ip) => push_unique_host(hosts, &ip.to_string()),
|
||||||
|
Err(_) => push_unique_host(hosts, candidate),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_unique_host(hosts: &mut Vec<String>, candidate: &str) {
|
||||||
|
if !hosts.iter().any(|existing| existing == candidate) {
|
||||||
|
hosts.push(candidate.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_tls_domains(cfg: &ProxyConfig) -> Vec<&str> {
|
||||||
|
let mut domains = Vec::with_capacity(1 + cfg.censorship.tls_domains.len());
|
||||||
|
let primary = cfg.censorship.tls_domain.as_str();
|
||||||
|
if !primary.is_empty() {
|
||||||
|
domains.push(primary);
|
||||||
|
}
|
||||||
|
for domain in &cfg.censorship.tls_domains {
|
||||||
|
let value = domain.as_str();
|
||||||
|
if value.is_empty() || domains.contains(&value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
domains.push(value);
|
||||||
|
}
|
||||||
|
domains
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_extra_tls_domains(cfg: &ProxyConfig) -> Vec<&str> {
|
||||||
|
let mut domains = Vec::with_capacity(cfg.censorship.tls_domains.len());
|
||||||
|
let primary = cfg.censorship.tls_domain.as_str();
|
||||||
|
for domain in &cfg.censorship.tls_domains {
|
||||||
|
let value = domain.as_str();
|
||||||
|
if value.is_empty() || value == primary || domains.contains(&value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
domains.push(value);
|
||||||
|
}
|
||||||
|
domains
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::ip_tracker::UserIpTracker;
|
||||||
|
use crate::stats::Stats;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn users_from_config_reports_effective_tcp_limit_with_global_fallback() {
|
||||||
|
let mut cfg = ProxyConfig::default();
|
||||||
|
cfg.access.users.insert(
|
||||||
|
"alice".to_string(),
|
||||||
|
"0123456789abcdef0123456789abcdef".to_string(),
|
||||||
|
);
|
||||||
|
cfg.access.user_max_tcp_conns_global_each = 7;
|
||||||
|
|
||||||
|
let stats = Stats::new();
|
||||||
|
let tracker = UserIpTracker::new();
|
||||||
|
|
||||||
|
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
|
||||||
|
let alice = users
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.username == "alice")
|
||||||
|
.expect("alice must be present");
|
||||||
|
assert!(!alice.in_runtime);
|
||||||
|
assert_eq!(alice.max_tcp_conns, Some(7));
|
||||||
|
|
||||||
|
cfg.access.user_max_tcp_conns.insert("alice".to_string(), 5);
|
||||||
|
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
|
||||||
|
let alice = users
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.username == "alice")
|
||||||
|
.expect("alice must be present");
|
||||||
|
assert!(!alice.in_runtime);
|
||||||
|
assert_eq!(alice.max_tcp_conns, Some(5));
|
||||||
|
|
||||||
|
cfg.access.user_max_tcp_conns.insert("alice".to_string(), 0);
|
||||||
|
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
|
||||||
|
let alice = users
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.username == "alice")
|
||||||
|
.expect("alice must be present");
|
||||||
|
assert!(!alice.in_runtime);
|
||||||
|
assert_eq!(alice.max_tcp_conns, Some(7));
|
||||||
|
|
||||||
|
cfg.access.user_max_tcp_conns_global_each = 0;
|
||||||
|
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
|
||||||
|
let alice = users
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.username == "alice")
|
||||||
|
.expect("alice must be present");
|
||||||
|
assert!(!alice.in_runtime);
|
||||||
|
assert_eq!(alice.max_tcp_conns, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn users_from_config_reports_user_rate_limits() {
|
||||||
|
let mut cfg = ProxyConfig::default();
|
||||||
|
cfg.access.users.insert(
|
||||||
|
"alice".to_string(),
|
||||||
|
"0123456789abcdef0123456789abcdef".to_string(),
|
||||||
|
);
|
||||||
|
cfg.access.user_rate_limits.insert(
|
||||||
|
"alice".to_string(),
|
||||||
|
RateLimitBps {
|
||||||
|
up_bps: 1024,
|
||||||
|
down_bps: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let stats = Stats::new();
|
||||||
|
let tracker = UserIpTracker::new();
|
||||||
|
|
||||||
|
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
|
||||||
|
let alice = users
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.username == "alice")
|
||||||
|
.expect("alice must be present");
|
||||||
|
|
||||||
|
assert_eq!(alice.rate_limit_up_bps, Some(1024));
|
||||||
|
assert_eq!(alice.rate_limit_down_bps, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn users_from_config_reports_user_enabled_default_and_override() {
|
||||||
|
let mut cfg = ProxyConfig::default();
|
||||||
|
cfg.access.users.insert(
|
||||||
|
"alice".to_string(),
|
||||||
|
"0123456789abcdef0123456789abcdef".to_string(),
|
||||||
|
);
|
||||||
|
cfg.access.users.insert(
|
||||||
|
"bob".to_string(),
|
||||||
|
"fedcba9876543210fedcba9876543210".to_string(),
|
||||||
|
);
|
||||||
|
cfg.access.user_enabled.insert("bob".to_string(), false);
|
||||||
|
|
||||||
|
let stats = Stats::new();
|
||||||
|
let tracker = UserIpTracker::new();
|
||||||
|
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
|
||||||
|
let alice = users
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.username == "alice")
|
||||||
|
.expect("alice must be present");
|
||||||
|
let bob = users
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.username == "bob")
|
||||||
|
.expect("bob must be present");
|
||||||
|
|
||||||
|
assert!(alice.enabled);
|
||||||
|
assert!(!bob.enabled);
|
||||||
|
|
||||||
|
cfg.access.user_enabled.insert("bob".to_string(), true);
|
||||||
|
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
|
||||||
|
let bob = users
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.username == "bob")
|
||||||
|
.expect("bob must be present");
|
||||||
|
assert!(bob.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn users_from_config_marks_runtime_membership_when_snapshot_is_provided() {
|
||||||
|
let mut disk_cfg = ProxyConfig::default();
|
||||||
|
disk_cfg.access.users.insert(
|
||||||
|
"alice".to_string(),
|
||||||
|
"0123456789abcdef0123456789abcdef".to_string(),
|
||||||
|
);
|
||||||
|
disk_cfg.access.users.insert(
|
||||||
|
"bob".to_string(),
|
||||||
|
"fedcba9876543210fedcba9876543210".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut runtime_cfg = ProxyConfig::default();
|
||||||
|
runtime_cfg.access.users.insert(
|
||||||
|
"alice".to_string(),
|
||||||
|
"0123456789abcdef0123456789abcdef".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let stats = Stats::new();
|
||||||
|
let tracker = UserIpTracker::new();
|
||||||
|
let users =
|
||||||
|
users_from_config(&disk_cfg, &stats, &tracker, None, None, Some(&runtime_cfg)).await;
|
||||||
|
|
||||||
|
let alice = users
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.username == "alice")
|
||||||
|
.expect("alice must be present");
|
||||||
|
let bob = users
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.username == "bob")
|
||||||
|
.expect("bob must be present");
|
||||||
|
|
||||||
|
assert!(alice.in_runtime);
|
||||||
|
assert!(!bob.in_runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn users_from_config_returns_tls_link_for_each_tls_domain() {
|
||||||
|
let mut cfg = ProxyConfig::default();
|
||||||
|
cfg.access.users.insert(
|
||||||
|
"alice".to_string(),
|
||||||
|
"0123456789abcdef0123456789abcdef".to_string(),
|
||||||
|
);
|
||||||
|
cfg.general.modes.classic = false;
|
||||||
|
cfg.general.modes.secure = false;
|
||||||
|
cfg.general.modes.tls = true;
|
||||||
|
cfg.general.links.public_host = Some("proxy.example.net".to_string());
|
||||||
|
cfg.general.links.public_port = Some(443);
|
||||||
|
cfg.censorship.tls_domain = "front-a.example.com".to_string();
|
||||||
|
cfg.censorship.tls_domains = vec![
|
||||||
|
"front-b.example.com".to_string(),
|
||||||
|
"front-c.example.com".to_string(),
|
||||||
|
"front-b.example.com".to_string(),
|
||||||
|
"front-a.example.com".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let stats = Stats::new();
|
||||||
|
let tracker = UserIpTracker::new();
|
||||||
|
let users = users_from_config(&cfg, &stats, &tracker, None, None, None).await;
|
||||||
|
let alice = users
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.username == "alice")
|
||||||
|
.expect("alice must be present");
|
||||||
|
|
||||||
|
assert_eq!(alice.links.tls.len(), 3);
|
||||||
|
assert!(
|
||||||
|
alice
|
||||||
|
.links
|
||||||
|
.tls
|
||||||
|
.iter()
|
||||||
|
.any(|link| link.ends_with(&hex::encode("front-a.example.com")))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
alice
|
||||||
|
.links
|
||||||
|
.tls
|
||||||
|
.iter()
|
||||||
|
.any(|link| link.ends_with(&hex::encode("front-b.example.com")))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
alice
|
||||||
|
.links
|
||||||
|
.tls
|
||||||
|
.iter()
|
||||||
|
.any(|link| link.ends_with(&hex::encode("front-c.example.com")))
|
||||||
|
);
|
||||||
|
assert_eq!(alice.links.tls_domains.len(), 2);
|
||||||
|
assert!(
|
||||||
|
alice
|
||||||
|
.links
|
||||||
|
.tls_domains
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.domain == "front-b.example.com"
|
||||||
|
&& entry.link.ends_with(&hex::encode("front-b.example.com")))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
alice
|
||||||
|
.links
|
||||||
|
.tls_domains
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.domain == "front-c.example.com"
|
||||||
|
&& entry.link.ends_with(&hex::encode("front-c.example.com")))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!alice
|
||||||
|
.links
|
||||||
|
.tls_domains
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.domain == "front-a.example.com")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_user_quota_list_skips_users_without_positive_quota_and_sorts_by_username() {
|
||||||
|
let mut cfg = ProxyConfig::default();
|
||||||
|
cfg.access.users.insert(
|
||||||
|
"alice".to_string(),
|
||||||
|
"0123456789abcdef0123456789abcdef".to_string(),
|
||||||
|
);
|
||||||
|
cfg.access.users.insert(
|
||||||
|
"bob".to_string(),
|
||||||
|
"fedcba9876543210fedcba9876543210".to_string(),
|
||||||
|
);
|
||||||
|
cfg.access.users.insert(
|
||||||
|
"carol".to_string(),
|
||||||
|
"aaaabbbbccccddddeeeeffff00001111".to_string(),
|
||||||
|
);
|
||||||
|
// alice has a positive quota and should be listed.
|
||||||
|
cfg.access
|
||||||
|
.user_data_quota
|
||||||
|
.insert("alice".to_string(), 1 << 20);
|
||||||
|
// bob has no quota entry at all (None) — should be skipped.
|
||||||
|
// carol has an explicit zero quota — should be skipped.
|
||||||
|
cfg.access.user_data_quota.insert("carol".to_string(), 0);
|
||||||
|
|
||||||
|
let stats = Stats::new();
|
||||||
|
// Charge some traffic against alice; carol gets traffic too but should
|
||||||
|
// still be filtered out by the quota check.
|
||||||
|
let alice_stats = stats.get_or_create_user_stats_handle("alice");
|
||||||
|
stats.quota_charge_post_write(&alice_stats, 4096);
|
||||||
|
let carol_stats = stats.get_or_create_user_stats_handle("carol");
|
||||||
|
stats.quota_charge_post_write(&carol_stats, 99);
|
||||||
|
|
||||||
|
let data = build_user_quota_list(&cfg, &stats);
|
||||||
|
|
||||||
|
assert_eq!(data.users.len(), 1);
|
||||||
|
let entry = &data.users[0];
|
||||||
|
assert_eq!(entry.username, "alice");
|
||||||
|
assert_eq!(entry.data_quota_bytes, 1 << 20);
|
||||||
|
assert_eq!(entry.used_bytes, 4096);
|
||||||
|
assert_eq!(entry.last_reset_epoch_secs, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_user_quota_list_orders_multiple_users_by_username_ascending() {
|
||||||
|
let mut cfg = ProxyConfig::default();
|
||||||
|
for name in ["charlie", "alice", "bob"] {
|
||||||
|
cfg.access.users.insert(
|
||||||
|
name.to_string(),
|
||||||
|
"0123456789abcdef0123456789abcdef".to_string(),
|
||||||
|
);
|
||||||
|
cfg.access.user_data_quota.insert(name.to_string(), 1 << 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stats = Stats::new();
|
||||||
|
let data = build_user_quota_list(&cfg, &stats);
|
||||||
|
|
||||||
|
let names: Vec<&str> = data.users.iter().map(|e| e.username.as_str()).collect();
|
||||||
|
assert_eq!(names, vec!["alice", "bob", "charlie"]);
|
||||||
|
for entry in &data.users {
|
||||||
|
assert_eq!(entry.used_bytes, 0);
|
||||||
|
assert_eq!(entry.last_reset_epoch_secs, 0);
|
||||||
|
assert_eq!(entry.data_quota_bytes, 1 << 30);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(in crate::api) async fn patch_user(
|
||||||
|
user: &str,
|
||||||
|
body: PatchUserRequest,
|
||||||
|
expected_revision: Option<String>,
|
||||||
|
shared: &ApiShared,
|
||||||
|
) -> Result<(UserInfo, String), ApiFailure> {
|
||||||
|
let touches_users = body.secret.is_some();
|
||||||
|
let touches_user_ad_tags = !matches!(&body.user_ad_tag, Patch::Unchanged);
|
||||||
|
let touches_user_max_tcp_conns = !matches!(&body.max_tcp_conns, Patch::Unchanged);
|
||||||
|
let touches_user_expirations = !matches!(&body.expiration_rfc3339, Patch::Unchanged);
|
||||||
|
let touches_user_data_quota = !matches!(&body.data_quota_bytes, Patch::Unchanged);
|
||||||
|
let touches_user_rate_limits = !matches!(&body.rate_limit_up_bps, Patch::Unchanged)
|
||||||
|
|| !matches!(&body.rate_limit_down_bps, Patch::Unchanged);
|
||||||
|
let touches_user_max_unique_ips = !matches!(&body.max_unique_ips, Patch::Unchanged);
|
||||||
|
let touches_user_enabled = !matches!(&body.enabled, Patch::Unchanged);
|
||||||
|
|
||||||
|
if let Some(secret) = body.secret.as_ref()
|
||||||
|
&& !is_valid_user_secret(secret)
|
||||||
|
{
|
||||||
|
return Err(ApiFailure::bad_request(
|
||||||
|
"secret must be exactly 32 hex characters",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Patch::Set(ad_tag) = &body.user_ad_tag
|
||||||
|
&& !is_valid_ad_tag(ad_tag)
|
||||||
|
{
|
||||||
|
return Err(ApiFailure::bad_request(
|
||||||
|
"user_ad_tag must be exactly 32 hex characters",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let expiration = parse_patch_expiration(&body.expiration_rfc3339)?;
|
||||||
|
let _guard = shared.mutation_lock.lock().await;
|
||||||
|
let mut cfg = load_config_from_disk(&shared.config_path).await?;
|
||||||
|
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
|
||||||
|
|
||||||
|
if !cfg.access.users.contains_key(user) {
|
||||||
|
return Err(ApiFailure::new(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"not_found",
|
||||||
|
"User not found",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(secret) = body.secret {
|
||||||
|
cfg.access.users.insert(user.to_string(), secret);
|
||||||
|
}
|
||||||
|
match body.user_ad_tag {
|
||||||
|
Patch::Unchanged => {}
|
||||||
|
Patch::Remove => {
|
||||||
|
cfg.access.user_ad_tags.remove(user);
|
||||||
|
}
|
||||||
|
Patch::Set(ad_tag) => {
|
||||||
|
cfg.access.user_ad_tags.insert(user.to_string(), ad_tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match body.max_tcp_conns {
|
||||||
|
Patch::Unchanged => {}
|
||||||
|
Patch::Remove => {
|
||||||
|
cfg.access.user_max_tcp_conns.remove(user);
|
||||||
|
}
|
||||||
|
Patch::Set(limit) => {
|
||||||
|
cfg.access
|
||||||
|
.user_max_tcp_conns
|
||||||
|
.insert(user.to_string(), limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match expiration {
|
||||||
|
Patch::Unchanged => {}
|
||||||
|
Patch::Remove => {
|
||||||
|
cfg.access.user_expirations.remove(user);
|
||||||
|
}
|
||||||
|
Patch::Set(expiration) => {
|
||||||
|
cfg.access
|
||||||
|
.user_expirations
|
||||||
|
.insert(user.to_string(), expiration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match body.data_quota_bytes {
|
||||||
|
Patch::Unchanged => {}
|
||||||
|
Patch::Remove => {
|
||||||
|
cfg.access.user_data_quota.remove(user);
|
||||||
|
}
|
||||||
|
Patch::Set(quota) => {
|
||||||
|
cfg.access.user_data_quota.insert(user.to_string(), quota);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if touches_user_rate_limits {
|
||||||
|
let mut rate_limit = cfg
|
||||||
|
.access
|
||||||
|
.user_rate_limits
|
||||||
|
.get(user)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default();
|
||||||
|
match body.rate_limit_up_bps {
|
||||||
|
Patch::Unchanged => {}
|
||||||
|
Patch::Remove => rate_limit.up_bps = 0,
|
||||||
|
Patch::Set(limit) => rate_limit.up_bps = limit,
|
||||||
|
}
|
||||||
|
match body.rate_limit_down_bps {
|
||||||
|
Patch::Unchanged => {}
|
||||||
|
Patch::Remove => rate_limit.down_bps = 0,
|
||||||
|
Patch::Set(limit) => rate_limit.down_bps = limit,
|
||||||
|
}
|
||||||
|
if rate_limit.up_bps == 0 && rate_limit.down_bps == 0 {
|
||||||
|
cfg.access.user_rate_limits.remove(user);
|
||||||
|
} else {
|
||||||
|
cfg.access
|
||||||
|
.user_rate_limits
|
||||||
|
.insert(user.to_string(), rate_limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Capture how the per-user IP limit changed, so the in-memory ip_tracker
|
||||||
|
// can be synced (set or removed) after the config is persisted.
|
||||||
|
let max_unique_ips_change = match body.max_unique_ips {
|
||||||
|
Patch::Unchanged => None,
|
||||||
|
Patch::Remove => {
|
||||||
|
cfg.access.user_max_unique_ips.remove(user);
|
||||||
|
Some(None)
|
||||||
|
}
|
||||||
|
Patch::Set(limit) => {
|
||||||
|
cfg.access
|
||||||
|
.user_max_unique_ips
|
||||||
|
.insert(user.to_string(), limit);
|
||||||
|
Some(Some(limit))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match body.enabled {
|
||||||
|
Patch::Unchanged => {}
|
||||||
|
Patch::Remove | Patch::Set(true) => {
|
||||||
|
cfg.access.user_enabled.remove(user);
|
||||||
|
}
|
||||||
|
Patch::Set(false) => {
|
||||||
|
cfg.access.user_enabled.insert(user.to_string(), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.validate()
|
||||||
|
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
||||||
|
|
||||||
|
let mut touched_sections = Vec::new();
|
||||||
|
if touches_users {
|
||||||
|
touched_sections.push(AccessSection::Users);
|
||||||
|
}
|
||||||
|
if touches_user_ad_tags {
|
||||||
|
touched_sections.push(AccessSection::UserAdTags);
|
||||||
|
}
|
||||||
|
if touches_user_max_tcp_conns {
|
||||||
|
touched_sections.push(AccessSection::UserMaxTcpConns);
|
||||||
|
}
|
||||||
|
if touches_user_expirations {
|
||||||
|
touched_sections.push(AccessSection::UserExpirations);
|
||||||
|
}
|
||||||
|
if touches_user_data_quota {
|
||||||
|
touched_sections.push(AccessSection::UserDataQuota);
|
||||||
|
}
|
||||||
|
if touches_user_rate_limits {
|
||||||
|
touched_sections.push(AccessSection::UserRateLimits);
|
||||||
|
}
|
||||||
|
if touches_user_max_unique_ips {
|
||||||
|
touched_sections.push(AccessSection::UserMaxUniqueIps);
|
||||||
|
}
|
||||||
|
if touches_user_enabled {
|
||||||
|
touched_sections.push(AccessSection::UserEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
let revision = if touched_sections.is_empty() {
|
||||||
|
current_revision(&shared.config_path).await?
|
||||||
|
} else {
|
||||||
|
save_access_sections_to_disk(&shared.config_path, &cfg, &touched_sections).await?
|
||||||
|
};
|
||||||
|
drop(_guard);
|
||||||
|
match max_unique_ips_change {
|
||||||
|
Some(Some(limit)) => shared.ip_tracker.set_user_limit(user, limit).await,
|
||||||
|
Some(None) => shared.ip_tracker.remove_user_limit(user).await,
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips();
|
||||||
|
let users = users_from_config(
|
||||||
|
&cfg,
|
||||||
|
&shared.stats,
|
||||||
|
&shared.ip_tracker,
|
||||||
|
detected_ip_v4,
|
||||||
|
detected_ip_v6,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let user_info = users
|
||||||
|
.into_iter()
|
||||||
|
.find(|entry| entry.username == user)
|
||||||
|
.ok_or_else(|| ApiFailure::internal("failed to build updated user view"))?;
|
||||||
|
|
||||||
|
Ok((user_info, revision))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub(in crate::api) async fn set_user_enabled(
|
||||||
|
user: &str,
|
||||||
|
enabled: bool,
|
||||||
|
expected_revision: Option<String>,
|
||||||
|
shared: &ApiShared,
|
||||||
|
) -> Result<(UserInfo, String), ApiFailure> {
|
||||||
|
let _guard = shared.mutation_lock.lock().await;
|
||||||
|
let mut cfg = load_config_from_disk(&shared.config_path).await?;
|
||||||
|
ensure_expected_revision(&shared.config_path, expected_revision.as_deref()).await?;
|
||||||
|
|
||||||
|
if !cfg.access.users.contains_key(user) {
|
||||||
|
return Err(ApiFailure::new(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"not_found",
|
||||||
|
"User not found",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
cfg.access.user_enabled.remove(user);
|
||||||
|
} else {
|
||||||
|
cfg.access.user_enabled.insert(user.to_string(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.validate()
|
||||||
|
.map_err(|e| ApiFailure::bad_request(format!("config validation failed: {}", e)))?;
|
||||||
|
let revision =
|
||||||
|
save_access_sections_to_disk(&shared.config_path, &cfg, &[AccessSection::UserEnabled])
|
||||||
|
.await?;
|
||||||
|
drop(_guard);
|
||||||
|
|
||||||
|
let (detected_ip_v4, detected_ip_v6) = shared.detected_link_ips();
|
||||||
|
let users = users_from_config(
|
||||||
|
&cfg,
|
||||||
|
&shared.stats,
|
||||||
|
&shared.ip_tracker,
|
||||||
|
detected_ip_v4,
|
||||||
|
detected_ip_v6,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let user_info = users
|
||||||
|
.into_iter()
|
||||||
|
.find(|entry| entry.username == user)
|
||||||
|
.ok_or_else(|| ApiFailure::internal("failed to build updated user view"))?;
|
||||||
|
|
||||||
|
Ok((user_info, revision))
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(in crate::api) async fn users_from_config(
|
||||||
|
cfg: &ProxyConfig,
|
||||||
|
stats: &Stats,
|
||||||
|
ip_tracker: &UserIpTracker,
|
||||||
|
startup_detected_ip_v4: Option<IpAddr>,
|
||||||
|
startup_detected_ip_v6: Option<IpAddr>,
|
||||||
|
runtime_cfg: Option<&ProxyConfig>,
|
||||||
|
) -> Vec<UserInfo> {
|
||||||
|
let mut names = cfg.access.users.keys().cloned().collect::<Vec<_>>();
|
||||||
|
names.sort();
|
||||||
|
let active_ip_lists = ip_tracker.get_active_ips_for_users(&names).await;
|
||||||
|
let recent_ip_lists = ip_tracker.get_recent_ips_for_users(&names).await;
|
||||||
|
|
||||||
|
let mut users = Vec::with_capacity(names.len());
|
||||||
|
for username in names {
|
||||||
|
let active_ip_list = active_ip_lists
|
||||||
|
.get(&username)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(Vec::new);
|
||||||
|
let recent_ip_list = recent_ip_lists
|
||||||
|
.get(&username)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(Vec::new);
|
||||||
|
let links = cfg
|
||||||
|
.access
|
||||||
|
.users
|
||||||
|
.get(&username)
|
||||||
|
.map(|secret| {
|
||||||
|
build_user_links(cfg, secret, startup_detected_ip_v4, startup_detected_ip_v6)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(empty_user_links);
|
||||||
|
users.push(UserInfo {
|
||||||
|
enabled: cfg.access.is_user_enabled(&username),
|
||||||
|
in_runtime: runtime_cfg
|
||||||
|
.map(|runtime| runtime.access.users.contains_key(&username))
|
||||||
|
.unwrap_or(false),
|
||||||
|
user_ad_tag: cfg.access.user_ad_tags.get(&username).cloned(),
|
||||||
|
max_tcp_conns: cfg
|
||||||
|
.access
|
||||||
|
.user_max_tcp_conns
|
||||||
|
.get(&username)
|
||||||
|
.copied()
|
||||||
|
.filter(|limit| *limit > 0)
|
||||||
|
.or((cfg.access.user_max_tcp_conns_global_each > 0)
|
||||||
|
.then_some(cfg.access.user_max_tcp_conns_global_each)),
|
||||||
|
expiration_rfc3339: cfg
|
||||||
|
.access
|
||||||
|
.user_expirations
|
||||||
|
.get(&username)
|
||||||
|
.map(chrono::DateTime::<chrono::Utc>::to_rfc3339),
|
||||||
|
data_quota_bytes: cfg.access.user_data_quota.get(&username).copied(),
|
||||||
|
rate_limit_up_bps: cfg
|
||||||
|
.access
|
||||||
|
.user_rate_limits
|
||||||
|
.get(&username)
|
||||||
|
.map(|limit| limit.up_bps)
|
||||||
|
.filter(|limit| *limit > 0),
|
||||||
|
rate_limit_down_bps: cfg
|
||||||
|
.access
|
||||||
|
.user_rate_limits
|
||||||
|
.get(&username)
|
||||||
|
.map(|limit| limit.down_bps)
|
||||||
|
.filter(|limit| *limit > 0),
|
||||||
|
max_unique_ips: cfg
|
||||||
|
.access
|
||||||
|
.user_max_unique_ips
|
||||||
|
.get(&username)
|
||||||
|
.copied()
|
||||||
|
.filter(|limit| *limit > 0)
|
||||||
|
.or((cfg.access.user_max_unique_ips_global_each > 0)
|
||||||
|
.then_some(cfg.access.user_max_unique_ips_global_each)),
|
||||||
|
current_connections: stats.get_user_curr_connects(&username),
|
||||||
|
active_unique_ips: active_ip_list.len(),
|
||||||
|
active_unique_ips_list: active_ip_list,
|
||||||
|
recent_unique_ips: recent_ip_list.len(),
|
||||||
|
recent_unique_ips_list: recent_ip_list,
|
||||||
|
total_octets: stats.get_user_total_octets(&username),
|
||||||
|
links,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
users
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(in crate::api) fn build_user_quota_list(cfg: &ProxyConfig, stats: &Stats) -> UserQuotaListData {
|
||||||
|
let mut names = cfg.access.users.keys().cloned().collect::<Vec<_>>();
|
||||||
|
names.sort();
|
||||||
|
|
||||||
|
let snapshot = stats.user_quota_snapshot();
|
||||||
|
let mut users = Vec::with_capacity(names.len());
|
||||||
|
for username in names {
|
||||||
|
let Some(&data_quota_bytes) = cfg.access.user_data_quota.get(&username) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if data_quota_bytes == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (used_bytes, last_reset_epoch_secs) = snapshot
|
||||||
|
.get(&username)
|
||||||
|
.map(|entry| (entry.used_bytes, entry.last_reset_epoch_secs))
|
||||||
|
.unwrap_or((0, 0));
|
||||||
|
users.push(UserQuotaEntry {
|
||||||
|
username,
|
||||||
|
data_quota_bytes,
|
||||||
|
used_bytes,
|
||||||
|
last_reset_epoch_secs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
UserQuotaListData { users }
|
||||||
|
}
|
||||||
|
|
||||||
+17
-1772
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,273 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
|
||||||
|
fn canonicalize_json(value: &mut serde_json::Value) {
|
||||||
|
match value {
|
||||||
|
serde_json::Value::Object(map) => {
|
||||||
|
let mut pairs: Vec<(String, serde_json::Value)> =
|
||||||
|
std::mem::take(map).into_iter().collect();
|
||||||
|
pairs.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
for (_, item) in pairs.iter_mut() {
|
||||||
|
canonicalize_json(item);
|
||||||
|
}
|
||||||
|
for (key, item) in pairs {
|
||||||
|
map.insert(key, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::Value::Array(items) => {
|
||||||
|
for item in items {
|
||||||
|
canonicalize_json(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn config_equal(lhs: &ProxyConfig, rhs: &ProxyConfig) -> bool {
|
||||||
|
let mut left = match serde_json::to_value(lhs) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let mut right = match serde_json::to_value(rhs) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
canonicalize_json(&mut left);
|
||||||
|
canonicalize_json(&mut right);
|
||||||
|
left == right
|
||||||
|
}
|
||||||
|
|
||||||
|
fn listeners_equal(
|
||||||
|
lhs: &[crate::config::ListenerConfig],
|
||||||
|
rhs: &[crate::config::ListenerConfig],
|
||||||
|
) -> bool {
|
||||||
|
serde_json::to_value(lhs).ok() == serde_json::to_value(rhs).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Warns when the requested snapshot contains fields that require restart.
|
||||||
|
pub(super) fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: bool) {
|
||||||
|
let mut warned = false;
|
||||||
|
if old.server.port != new.server.port {
|
||||||
|
warned = true;
|
||||||
|
warn!(
|
||||||
|
"config reload: server.port changed ({} → {}); restart required",
|
||||||
|
old.server.port, new.server.port
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if old.server.api.enabled != new.server.api.enabled
|
||||||
|
|| old.server.api.listen != new.server.api.listen
|
||||||
|
|| old.server.api.whitelist != new.server.api.whitelist
|
||||||
|
|| old.server.api.gray_action != new.server.api.gray_action
|
||||||
|
|| old.server.api.auth_header != new.server.api.auth_header
|
||||||
|
|| old.server.api.request_body_limit_bytes != new.server.api.request_body_limit_bytes
|
||||||
|
|| old.server.api.minimal_runtime_enabled != new.server.api.minimal_runtime_enabled
|
||||||
|
|| old.server.api.minimal_runtime_cache_ttl_ms
|
||||||
|
!= new.server.api.minimal_runtime_cache_ttl_ms
|
||||||
|
|| old.server.api.runtime_edge_enabled != new.server.api.runtime_edge_enabled
|
||||||
|
|| old.server.api.runtime_edge_cache_ttl_ms != new.server.api.runtime_edge_cache_ttl_ms
|
||||||
|
|| old.server.api.runtime_edge_top_n != new.server.api.runtime_edge_top_n
|
||||||
|
|| old.server.api.runtime_edge_events_capacity
|
||||||
|
!= new.server.api.runtime_edge_events_capacity
|
||||||
|
|| old.server.api.read_only != new.server.api.read_only
|
||||||
|
{
|
||||||
|
warned = true;
|
||||||
|
warn!("config reload: server.api changed; restart required");
|
||||||
|
}
|
||||||
|
if old.server.proxy_protocol != new.server.proxy_protocol
|
||||||
|
|| !listeners_equal(&old.server.listeners, &new.server.listeners)
|
||||||
|
|| old.server.listen_backlog != new.server.listen_backlog
|
||||||
|
|| 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
|
||||||
|
{
|
||||||
|
warned = true;
|
||||||
|
warn!("config reload: server listener settings changed; restart required");
|
||||||
|
}
|
||||||
|
if old.censorship.tls_domain != new.censorship.tls_domain
|
||||||
|
|| old.censorship.tls_domains != new.censorship.tls_domains
|
||||||
|
|| old.censorship.tls_fetch_scope != new.censorship.tls_fetch_scope
|
||||||
|
|| old.censorship.mask != new.censorship.mask
|
||||||
|
|| old.censorship.mask_dynamic != new.censorship.mask_dynamic
|
||||||
|
|| old.censorship.mask_host != new.censorship.mask_host
|
||||||
|
|| old.censorship.mask_port != new.censorship.mask_port
|
||||||
|
|| old.censorship.exclusive_mask != new.censorship.exclusive_mask
|
||||||
|
|| old.censorship.mask_unix_sock != new.censorship.mask_unix_sock
|
||||||
|
|| old.censorship.fake_cert_len != new.censorship.fake_cert_len
|
||||||
|
|| old.censorship.tls_emulation != new.censorship.tls_emulation
|
||||||
|
|| old.censorship.tls_front_dir != new.censorship.tls_front_dir
|
||||||
|
|| old.censorship.server_hello_delay_min_ms != new.censorship.server_hello_delay_min_ms
|
||||||
|
|| old.censorship.server_hello_delay_max_ms != new.censorship.server_hello_delay_max_ms
|
||||||
|
|| old.censorship.tls_new_session_tickets != new.censorship.tls_new_session_tickets
|
||||||
|
|| old.censorship.serverhello_compact != new.censorship.serverhello_compact
|
||||||
|
|| old.censorship.tls_full_cert_ttl_secs != new.censorship.tls_full_cert_ttl_secs
|
||||||
|
|| old.censorship.alpn_enforce != new.censorship.alpn_enforce
|
||||||
|
|| old.censorship.mask_proxy_protocol != new.censorship.mask_proxy_protocol
|
||||||
|
|| old.censorship.mask_shape_hardening != new.censorship.mask_shape_hardening
|
||||||
|
|| old.censorship.mask_shape_bucket_floor_bytes
|
||||||
|
!= new.censorship.mask_shape_bucket_floor_bytes
|
||||||
|
|| old.censorship.mask_shape_bucket_cap_bytes != new.censorship.mask_shape_bucket_cap_bytes
|
||||||
|
|| old.censorship.mask_shape_above_cap_blur != new.censorship.mask_shape_above_cap_blur
|
||||||
|
|| old.censorship.mask_shape_above_cap_blur_max_bytes
|
||||||
|
!= new.censorship.mask_shape_above_cap_blur_max_bytes
|
||||||
|
|| old.censorship.mask_relay_max_bytes != new.censorship.mask_relay_max_bytes
|
||||||
|
|| old.censorship.mask_relay_timeout_ms != new.censorship.mask_relay_timeout_ms
|
||||||
|
|| old.censorship.mask_relay_idle_timeout_ms != new.censorship.mask_relay_idle_timeout_ms
|
||||||
|
|| old.censorship.mask_classifier_prefetch_timeout_ms
|
||||||
|
!= new.censorship.mask_classifier_prefetch_timeout_ms
|
||||||
|
|| old.censorship.mask_timing_normalization_enabled
|
||||||
|
!= new.censorship.mask_timing_normalization_enabled
|
||||||
|
|| old.censorship.mask_timing_normalization_floor_ms
|
||||||
|
!= new.censorship.mask_timing_normalization_floor_ms
|
||||||
|
|| old.censorship.mask_timing_normalization_ceiling_ms
|
||||||
|
!= new.censorship.mask_timing_normalization_ceiling_ms
|
||||||
|
{
|
||||||
|
warned = true;
|
||||||
|
warn!("config reload: censorship settings changed; restart required");
|
||||||
|
}
|
||||||
|
if old.censorship.tls_domain != new.censorship.tls_domain {
|
||||||
|
warned = true;
|
||||||
|
warn!(
|
||||||
|
"config reload: censorship.tls_domain changed ('{}' → '{}'); restart required",
|
||||||
|
old.censorship.tls_domain, new.censorship.tls_domain
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if old.network.ipv4 != new.network.ipv4 || old.network.ipv6 != new.network.ipv6 {
|
||||||
|
warned = true;
|
||||||
|
warn!("config reload: network.ipv4/ipv6 changed; restart required");
|
||||||
|
}
|
||||||
|
if old.network.prefer != new.network.prefer
|
||||||
|
|| old.network.multipath != new.network.multipath
|
||||||
|
|| old.network.stun_use != new.network.stun_use
|
||||||
|
|| old.network.stun_servers != new.network.stun_servers
|
||||||
|
|| old.network.stun_tcp_fallback != new.network.stun_tcp_fallback
|
||||||
|
|| old.network.http_ip_detect_urls != new.network.http_ip_detect_urls
|
||||||
|
|| old.network.cache_public_ip_path != new.network.cache_public_ip_path
|
||||||
|
{
|
||||||
|
warned = true;
|
||||||
|
warn!("config reload: non-hot network settings changed; restart required");
|
||||||
|
}
|
||||||
|
if old.general.use_middle_proxy != new.general.use_middle_proxy {
|
||||||
|
warned = true;
|
||||||
|
warn!("config reload: use_middle_proxy changed; restart required");
|
||||||
|
}
|
||||||
|
if old.general.stun_nat_probe_concurrency != new.general.stun_nat_probe_concurrency {
|
||||||
|
warned = true;
|
||||||
|
warn!("config reload: general.stun_nat_probe_concurrency changed; restart required");
|
||||||
|
}
|
||||||
|
if old.general.middle_proxy_pool_size != new.general.middle_proxy_pool_size {
|
||||||
|
warned = true;
|
||||||
|
warn!("config reload: general.middle_proxy_pool_size changed; restart required");
|
||||||
|
}
|
||||||
|
if old.general.me_route_no_writer_mode != new.general.me_route_no_writer_mode
|
||||||
|
|| old.general.me_route_no_writer_wait_ms != new.general.me_route_no_writer_wait_ms
|
||||||
|
|| old.general.me_route_hybrid_max_wait_ms != new.general.me_route_hybrid_max_wait_ms
|
||||||
|
|| old.general.me_route_blocking_send_timeout_ms
|
||||||
|
!= new.general.me_route_blocking_send_timeout_ms
|
||||||
|
|| old.general.me_route_inline_recovery_attempts
|
||||||
|
!= new.general.me_route_inline_recovery_attempts
|
||||||
|
|| old.general.me_route_inline_recovery_wait_ms
|
||||||
|
!= new.general.me_route_inline_recovery_wait_ms
|
||||||
|
{
|
||||||
|
warned = true;
|
||||||
|
warn!("config reload: general.me_route_no_writer_* changed; restart required");
|
||||||
|
}
|
||||||
|
if old.general.unknown_dc_log_path != new.general.unknown_dc_log_path
|
||||||
|
|| old.general.unknown_dc_file_log_enabled != new.general.unknown_dc_file_log_enabled
|
||||||
|
{
|
||||||
|
warned = true;
|
||||||
|
warn!("config reload: general.unknown_dc_* changed; restart required");
|
||||||
|
}
|
||||||
|
if old.general.me_init_retry_attempts != new.general.me_init_retry_attempts {
|
||||||
|
warned = true;
|
||||||
|
warn!("config reload: general.me_init_retry_attempts changed; restart required");
|
||||||
|
}
|
||||||
|
if old.general.me2dc_fallback != new.general.me2dc_fallback
|
||||||
|
|| old.general.me2dc_fast != new.general.me2dc_fast
|
||||||
|
{
|
||||||
|
warned = true;
|
||||||
|
warn!("config reload: general.me2dc_fallback/me2dc_fast changed; restart required");
|
||||||
|
}
|
||||||
|
if old.general.proxy_config_v4_cache_path != new.general.proxy_config_v4_cache_path
|
||||||
|
|| old.general.proxy_config_v6_cache_path != new.general.proxy_config_v6_cache_path
|
||||||
|
{
|
||||||
|
warned = true;
|
||||||
|
warn!("config reload: general.proxy_config_*_cache_path changed; restart required");
|
||||||
|
}
|
||||||
|
if old.general.me_keepalive_enabled != new.general.me_keepalive_enabled
|
||||||
|
|| old.general.me_keepalive_interval_secs != new.general.me_keepalive_interval_secs
|
||||||
|
|| old.general.me_keepalive_jitter_secs != new.general.me_keepalive_jitter_secs
|
||||||
|
|| old.general.me_keepalive_payload_random != new.general.me_keepalive_payload_random
|
||||||
|
{
|
||||||
|
warned = true;
|
||||||
|
warn!("config reload: general.me_keepalive_* changed; restart required");
|
||||||
|
}
|
||||||
|
if old.general.upstream_connect_retry_attempts != new.general.upstream_connect_retry_attempts
|
||||||
|
|| old.general.upstream_connect_retry_backoff_ms
|
||||||
|
!= new.general.upstream_connect_retry_backoff_ms
|
||||||
|
|| old.general.tg_connect != new.general.tg_connect
|
||||||
|
|| old.general.upstream_unhealthy_fail_threshold
|
||||||
|
!= new.general.upstream_unhealthy_fail_threshold
|
||||||
|
|| old.general.upstream_connect_failfast_hard_errors
|
||||||
|
!= new.general.upstream_connect_failfast_hard_errors
|
||||||
|
|| old.general.rpc_proxy_req_every != new.general.rpc_proxy_req_every
|
||||||
|
{
|
||||||
|
warned = true;
|
||||||
|
warn!("config reload: general.upstream_* changed; restart required");
|
||||||
|
}
|
||||||
|
if non_hot_changed && !warned {
|
||||||
|
warn!("config reload: one or more non-hot fields changed; restart required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the public host for link generation — mirrors the logic in main.rs.
|
||||||
|
///
|
||||||
|
/// Priority:
|
||||||
|
/// 1. `[general.links] public_host` — explicit override in config
|
||||||
|
/// 2. `detected_ip_v4` — from STUN/interface probe at startup
|
||||||
|
/// 3. `detected_ip_v6` — fallback
|
||||||
|
/// 4. `"UNKNOWN"` — warn the user to set `public_host`
|
||||||
|
|
||||||
|
|
||||||
|
/// Which top-level config sections changed and whether any require a restart.
|
||||||
|
#[derive(Debug, Default, Clone, serde::Serialize)]
|
||||||
|
pub struct ChangeClassification {
|
||||||
|
pub changed: Vec<String>,
|
||||||
|
pub restart_required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify old->new using Telemt's OWN reload rule: overlay the hot fields and
|
||||||
|
/// see if anything non-hot remains different. This guarantees `restart_required`
|
||||||
|
/// matches actual runtime behavior and never drifts as new fields are added.
|
||||||
|
pub fn classify_config_changes(old: &ProxyConfig, new: &ProxyConfig) -> ChangeClassification {
|
||||||
|
let applied = overlay_hot_fields(old, new);
|
||||||
|
let restart_required = !config_equal(&applied, new);
|
||||||
|
ChangeClassification {
|
||||||
|
changed: changed_sections(old, new),
|
||||||
|
restart_required,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top-level config sections whose canonical serialized form differs between
|
||||||
|
/// old and new. Uses the same serialize+canonicalize path as `config_equal`.
|
||||||
|
fn changed_sections(old: &ProxyConfig, new: &ProxyConfig) -> Vec<String> {
|
||||||
|
let mut lhs = serde_json::to_value(old).unwrap_or(serde_json::Value::Null);
|
||||||
|
let mut rhs = serde_json::to_value(new).unwrap_or(serde_json::Value::Null);
|
||||||
|
canonicalize_json(&mut lhs);
|
||||||
|
canonicalize_json(&mut rhs);
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if let (Some(lo), Some(ro)) = (lhs.as_object(), rhs.as_object()) {
|
||||||
|
let mut keys: std::collections::BTreeSet<&String> = lo.keys().collect();
|
||||||
|
keys.extend(ro.keys());
|
||||||
|
for key in keys {
|
||||||
|
if lo.get(key) != ro.get(key) {
|
||||||
|
out.push(key.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
|
||||||
|
/// Fields that are safe to swap without restarting listeners.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct HotFields {
|
||||||
|
pub log_level: LogLevel,
|
||||||
|
pub ad_tag: Option<String>,
|
||||||
|
pub dns_overrides: Vec<String>,
|
||||||
|
pub desync_all_full: bool,
|
||||||
|
pub update_every_secs: u64,
|
||||||
|
pub me_reinit_every_secs: u64,
|
||||||
|
pub me_reinit_singleflight: bool,
|
||||||
|
pub me_reinit_coalesce_window_ms: u64,
|
||||||
|
pub hardswap: bool,
|
||||||
|
pub me_pool_drain_ttl_secs: u64,
|
||||||
|
pub me_instadrain: bool,
|
||||||
|
pub me_pool_drain_threshold: u64,
|
||||||
|
pub me_pool_min_fresh_ratio: f32,
|
||||||
|
pub me_reinit_drain_timeout_secs: u64,
|
||||||
|
pub me_hardswap_warmup_delay_min_ms: u64,
|
||||||
|
pub me_hardswap_warmup_delay_max_ms: u64,
|
||||||
|
pub me_hardswap_warmup_extra_passes: u8,
|
||||||
|
pub me_hardswap_warmup_pass_backoff_base_ms: u64,
|
||||||
|
pub me_bind_stale_mode: MeBindStaleMode,
|
||||||
|
pub me_bind_stale_ttl_secs: u64,
|
||||||
|
pub me_secret_atomic_snapshot: bool,
|
||||||
|
pub me_deterministic_writer_sort: bool,
|
||||||
|
pub me_writer_pick_mode: MeWriterPickMode,
|
||||||
|
pub me_writer_pick_sample_size: u8,
|
||||||
|
pub me_single_endpoint_shadow_writers: u8,
|
||||||
|
pub me_single_endpoint_outage_mode_enabled: bool,
|
||||||
|
pub me_single_endpoint_outage_disable_quarantine: bool,
|
||||||
|
pub me_single_endpoint_outage_backoff_min_ms: u64,
|
||||||
|
pub me_single_endpoint_outage_backoff_max_ms: u64,
|
||||||
|
pub me_single_endpoint_shadow_rotate_every_secs: u64,
|
||||||
|
pub me_config_stable_snapshots: u8,
|
||||||
|
pub me_config_apply_cooldown_secs: u64,
|
||||||
|
pub me_snapshot_require_http_2xx: bool,
|
||||||
|
pub me_snapshot_reject_empty_map: bool,
|
||||||
|
pub me_snapshot_min_proxy_for_lines: u32,
|
||||||
|
pub proxy_secret_stable_snapshots: u8,
|
||||||
|
pub proxy_secret_rotate_runtime: bool,
|
||||||
|
pub proxy_secret_len_max: usize,
|
||||||
|
pub telemetry_core_enabled: bool,
|
||||||
|
pub telemetry_user_enabled: bool,
|
||||||
|
pub telemetry_me_level: MeTelemetryLevel,
|
||||||
|
pub me_socks_kdf_policy: MeSocksKdfPolicy,
|
||||||
|
pub me_route_backpressure_enabled: bool,
|
||||||
|
pub me_route_fairshare_enabled: bool,
|
||||||
|
pub me_floor_mode: MeFloorMode,
|
||||||
|
pub me_adaptive_floor_idle_secs: u64,
|
||||||
|
pub me_adaptive_floor_min_writers_single_endpoint: u8,
|
||||||
|
pub me_adaptive_floor_min_writers_multi_endpoint: u8,
|
||||||
|
pub me_adaptive_floor_recover_grace_secs: u64,
|
||||||
|
pub me_adaptive_floor_writers_per_core_total: u16,
|
||||||
|
pub me_adaptive_floor_cpu_cores_override: u16,
|
||||||
|
pub me_adaptive_floor_max_extra_writers_single_per_core: u16,
|
||||||
|
pub me_adaptive_floor_max_extra_writers_multi_per_core: u16,
|
||||||
|
pub me_adaptive_floor_max_active_writers_per_core: u16,
|
||||||
|
pub me_adaptive_floor_max_warm_writers_per_core: u16,
|
||||||
|
pub me_adaptive_floor_max_active_writers_global: u32,
|
||||||
|
pub me_adaptive_floor_max_warm_writers_global: u32,
|
||||||
|
pub me_route_backpressure_base_timeout_ms: u64,
|
||||||
|
pub me_route_backpressure_high_timeout_ms: u64,
|
||||||
|
pub me_route_backpressure_high_watermark_pct: u8,
|
||||||
|
pub me_reader_route_data_wait_ms: u64,
|
||||||
|
pub me_d2c_flush_batch_max_frames: usize,
|
||||||
|
pub me_d2c_flush_batch_max_bytes: usize,
|
||||||
|
pub me_d2c_flush_batch_max_delay_us: u64,
|
||||||
|
pub me_d2c_ack_flush_immediate: bool,
|
||||||
|
pub me_quota_soft_overshoot_bytes: u64,
|
||||||
|
pub me_d2c_frame_buf_shrink_threshold_bytes: usize,
|
||||||
|
pub direct_relay_copy_buf_c2s_bytes: usize,
|
||||||
|
pub direct_relay_copy_buf_s2c_bytes: usize,
|
||||||
|
pub me_health_interval_ms_unhealthy: u64,
|
||||||
|
pub me_health_interval_ms_healthy: u64,
|
||||||
|
pub me_admission_poll_ms: u64,
|
||||||
|
pub me_warn_rate_limit_ms: u64,
|
||||||
|
pub users: std::collections::HashMap<String, String>,
|
||||||
|
pub user_enabled: std::collections::HashMap<String, bool>,
|
||||||
|
pub user_ad_tags: std::collections::HashMap<String, String>,
|
||||||
|
pub user_max_tcp_conns: std::collections::HashMap<String, usize>,
|
||||||
|
pub user_max_tcp_conns_global_each: usize,
|
||||||
|
pub user_expirations: std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
|
||||||
|
pub user_data_quota: std::collections::HashMap<String, u64>,
|
||||||
|
pub user_rate_limits: std::collections::HashMap<String, crate::config::RateLimitBps>,
|
||||||
|
pub cidr_rate_limits: std::collections::HashMap<CidrRateLimitKey, crate::config::RateLimitBps>,
|
||||||
|
pub user_max_unique_ips: std::collections::HashMap<String, usize>,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HotFields {
|
||||||
|
pub fn from_config(cfg: &ProxyConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
log_level: cfg.general.log_level.clone(),
|
||||||
|
ad_tag: cfg.general.ad_tag.clone(),
|
||||||
|
dns_overrides: cfg.network.dns_overrides.clone(),
|
||||||
|
desync_all_full: cfg.general.desync_all_full,
|
||||||
|
update_every_secs: cfg.general.effective_update_every_secs(),
|
||||||
|
me_reinit_every_secs: cfg.general.me_reinit_every_secs,
|
||||||
|
me_reinit_singleflight: cfg.general.me_reinit_singleflight,
|
||||||
|
me_reinit_coalesce_window_ms: cfg.general.me_reinit_coalesce_window_ms,
|
||||||
|
hardswap: cfg.general.hardswap,
|
||||||
|
me_pool_drain_ttl_secs: cfg.general.me_pool_drain_ttl_secs,
|
||||||
|
me_instadrain: cfg.general.me_instadrain,
|
||||||
|
me_pool_drain_threshold: cfg.general.me_pool_drain_threshold,
|
||||||
|
me_pool_min_fresh_ratio: cfg.general.me_pool_min_fresh_ratio,
|
||||||
|
me_reinit_drain_timeout_secs: cfg.general.me_reinit_drain_timeout_secs,
|
||||||
|
me_hardswap_warmup_delay_min_ms: cfg.general.me_hardswap_warmup_delay_min_ms,
|
||||||
|
me_hardswap_warmup_delay_max_ms: cfg.general.me_hardswap_warmup_delay_max_ms,
|
||||||
|
me_hardswap_warmup_extra_passes: cfg.general.me_hardswap_warmup_extra_passes,
|
||||||
|
me_hardswap_warmup_pass_backoff_base_ms: cfg
|
||||||
|
.general
|
||||||
|
.me_hardswap_warmup_pass_backoff_base_ms,
|
||||||
|
me_bind_stale_mode: cfg.general.me_bind_stale_mode,
|
||||||
|
me_bind_stale_ttl_secs: cfg.general.me_bind_stale_ttl_secs,
|
||||||
|
me_secret_atomic_snapshot: cfg.general.me_secret_atomic_snapshot,
|
||||||
|
me_deterministic_writer_sort: cfg.general.me_deterministic_writer_sort,
|
||||||
|
me_writer_pick_mode: cfg.general.me_writer_pick_mode,
|
||||||
|
me_writer_pick_sample_size: cfg.general.me_writer_pick_sample_size,
|
||||||
|
me_single_endpoint_shadow_writers: cfg.general.me_single_endpoint_shadow_writers,
|
||||||
|
me_single_endpoint_outage_mode_enabled: cfg
|
||||||
|
.general
|
||||||
|
.me_single_endpoint_outage_mode_enabled,
|
||||||
|
me_single_endpoint_outage_disable_quarantine: cfg
|
||||||
|
.general
|
||||||
|
.me_single_endpoint_outage_disable_quarantine,
|
||||||
|
me_single_endpoint_outage_backoff_min_ms: cfg
|
||||||
|
.general
|
||||||
|
.me_single_endpoint_outage_backoff_min_ms,
|
||||||
|
me_single_endpoint_outage_backoff_max_ms: cfg
|
||||||
|
.general
|
||||||
|
.me_single_endpoint_outage_backoff_max_ms,
|
||||||
|
me_single_endpoint_shadow_rotate_every_secs: cfg
|
||||||
|
.general
|
||||||
|
.me_single_endpoint_shadow_rotate_every_secs,
|
||||||
|
me_config_stable_snapshots: cfg.general.me_config_stable_snapshots,
|
||||||
|
me_config_apply_cooldown_secs: cfg.general.me_config_apply_cooldown_secs,
|
||||||
|
me_snapshot_require_http_2xx: cfg.general.me_snapshot_require_http_2xx,
|
||||||
|
me_snapshot_reject_empty_map: cfg.general.me_snapshot_reject_empty_map,
|
||||||
|
me_snapshot_min_proxy_for_lines: cfg.general.me_snapshot_min_proxy_for_lines,
|
||||||
|
proxy_secret_stable_snapshots: cfg.general.proxy_secret_stable_snapshots,
|
||||||
|
proxy_secret_rotate_runtime: cfg.general.proxy_secret_rotate_runtime,
|
||||||
|
proxy_secret_len_max: cfg.general.proxy_secret_len_max,
|
||||||
|
telemetry_core_enabled: cfg.general.telemetry.core_enabled,
|
||||||
|
telemetry_user_enabled: cfg.general.telemetry.user_enabled,
|
||||||
|
telemetry_me_level: cfg.general.telemetry.me_level,
|
||||||
|
me_socks_kdf_policy: cfg.general.me_socks_kdf_policy,
|
||||||
|
me_route_backpressure_enabled: cfg.general.me_route_backpressure_enabled,
|
||||||
|
me_route_fairshare_enabled: cfg.general.me_route_fairshare_enabled,
|
||||||
|
me_floor_mode: cfg.general.me_floor_mode,
|
||||||
|
me_adaptive_floor_idle_secs: cfg.general.me_adaptive_floor_idle_secs,
|
||||||
|
me_adaptive_floor_min_writers_single_endpoint: cfg
|
||||||
|
.general
|
||||||
|
.me_adaptive_floor_min_writers_single_endpoint,
|
||||||
|
me_adaptive_floor_min_writers_multi_endpoint: cfg
|
||||||
|
.general
|
||||||
|
.me_adaptive_floor_min_writers_multi_endpoint,
|
||||||
|
me_adaptive_floor_recover_grace_secs: cfg.general.me_adaptive_floor_recover_grace_secs,
|
||||||
|
me_adaptive_floor_writers_per_core_total: cfg
|
||||||
|
.general
|
||||||
|
.me_adaptive_floor_writers_per_core_total,
|
||||||
|
me_adaptive_floor_cpu_cores_override: cfg.general.me_adaptive_floor_cpu_cores_override,
|
||||||
|
me_adaptive_floor_max_extra_writers_single_per_core: cfg
|
||||||
|
.general
|
||||||
|
.me_adaptive_floor_max_extra_writers_single_per_core,
|
||||||
|
me_adaptive_floor_max_extra_writers_multi_per_core: cfg
|
||||||
|
.general
|
||||||
|
.me_adaptive_floor_max_extra_writers_multi_per_core,
|
||||||
|
me_adaptive_floor_max_active_writers_per_core: cfg
|
||||||
|
.general
|
||||||
|
.me_adaptive_floor_max_active_writers_per_core,
|
||||||
|
me_adaptive_floor_max_warm_writers_per_core: cfg
|
||||||
|
.general
|
||||||
|
.me_adaptive_floor_max_warm_writers_per_core,
|
||||||
|
me_adaptive_floor_max_active_writers_global: cfg
|
||||||
|
.general
|
||||||
|
.me_adaptive_floor_max_active_writers_global,
|
||||||
|
me_adaptive_floor_max_warm_writers_global: cfg
|
||||||
|
.general
|
||||||
|
.me_adaptive_floor_max_warm_writers_global,
|
||||||
|
me_route_backpressure_base_timeout_ms: cfg
|
||||||
|
.general
|
||||||
|
.me_route_backpressure_base_timeout_ms,
|
||||||
|
me_route_backpressure_high_timeout_ms: cfg
|
||||||
|
.general
|
||||||
|
.me_route_backpressure_high_timeout_ms,
|
||||||
|
me_route_backpressure_high_watermark_pct: cfg
|
||||||
|
.general
|
||||||
|
.me_route_backpressure_high_watermark_pct,
|
||||||
|
me_reader_route_data_wait_ms: cfg.general.me_reader_route_data_wait_ms,
|
||||||
|
me_d2c_flush_batch_max_frames: cfg.general.me_d2c_flush_batch_max_frames,
|
||||||
|
me_d2c_flush_batch_max_bytes: cfg.general.me_d2c_flush_batch_max_bytes,
|
||||||
|
me_d2c_flush_batch_max_delay_us: cfg.general.me_d2c_flush_batch_max_delay_us,
|
||||||
|
me_d2c_ack_flush_immediate: cfg.general.me_d2c_ack_flush_immediate,
|
||||||
|
me_quota_soft_overshoot_bytes: cfg.general.me_quota_soft_overshoot_bytes,
|
||||||
|
me_d2c_frame_buf_shrink_threshold_bytes: cfg
|
||||||
|
.general
|
||||||
|
.me_d2c_frame_buf_shrink_threshold_bytes,
|
||||||
|
direct_relay_copy_buf_c2s_bytes: cfg.general.direct_relay_copy_buf_c2s_bytes,
|
||||||
|
direct_relay_copy_buf_s2c_bytes: cfg.general.direct_relay_copy_buf_s2c_bytes,
|
||||||
|
me_health_interval_ms_unhealthy: cfg.general.me_health_interval_ms_unhealthy,
|
||||||
|
me_health_interval_ms_healthy: cfg.general.me_health_interval_ms_healthy,
|
||||||
|
me_admission_poll_ms: cfg.general.me_admission_poll_ms,
|
||||||
|
me_warn_rate_limit_ms: cfg.general.me_warn_rate_limit_ms,
|
||||||
|
users: cfg.access.users.clone(),
|
||||||
|
user_enabled: cfg.access.user_enabled.clone(),
|
||||||
|
user_ad_tags: cfg.access.user_ad_tags.clone(),
|
||||||
|
user_max_tcp_conns: cfg.access.user_max_tcp_conns.clone(),
|
||||||
|
user_max_tcp_conns_global_each: cfg.access.user_max_tcp_conns_global_each,
|
||||||
|
user_expirations: cfg.access.user_expirations.clone(),
|
||||||
|
user_data_quota: cfg.access.user_data_quota.clone(),
|
||||||
|
user_rate_limits: cfg.access.user_rate_limits.clone(),
|
||||||
|
cidr_rate_limits: cfg.access.cidr_rate_limits.clone(),
|
||||||
|
user_max_unique_ips: cfg.access.user_max_unique_ips.clone(),
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub(super) fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyConfig {
|
||||||
|
let mut cfg = old.clone();
|
||||||
|
|
||||||
|
cfg.general.log_level = new.general.log_level.clone();
|
||||||
|
cfg.general.ad_tag = new.general.ad_tag.clone();
|
||||||
|
cfg.network.dns_overrides = new.network.dns_overrides.clone();
|
||||||
|
cfg.general.desync_all_full = new.general.desync_all_full;
|
||||||
|
cfg.general.update_every = new.general.update_every;
|
||||||
|
cfg.general.proxy_secret_auto_reload_secs = new.general.proxy_secret_auto_reload_secs;
|
||||||
|
cfg.general.proxy_config_auto_reload_secs = new.general.proxy_config_auto_reload_secs;
|
||||||
|
cfg.general.me_reinit_every_secs = new.general.me_reinit_every_secs;
|
||||||
|
cfg.general.me_reinit_singleflight = new.general.me_reinit_singleflight;
|
||||||
|
cfg.general.me_reinit_coalesce_window_ms = new.general.me_reinit_coalesce_window_ms;
|
||||||
|
cfg.general.hardswap = new.general.hardswap;
|
||||||
|
cfg.general.me_pool_drain_ttl_secs = new.general.me_pool_drain_ttl_secs;
|
||||||
|
cfg.general.me_instadrain = new.general.me_instadrain;
|
||||||
|
cfg.general.me_pool_drain_threshold = new.general.me_pool_drain_threshold;
|
||||||
|
cfg.general.me_pool_min_fresh_ratio = new.general.me_pool_min_fresh_ratio;
|
||||||
|
cfg.general.me_reinit_drain_timeout_secs = new.general.me_reinit_drain_timeout_secs;
|
||||||
|
cfg.general.me_hardswap_warmup_delay_min_ms = new.general.me_hardswap_warmup_delay_min_ms;
|
||||||
|
cfg.general.me_hardswap_warmup_delay_max_ms = new.general.me_hardswap_warmup_delay_max_ms;
|
||||||
|
cfg.general.me_hardswap_warmup_extra_passes = new.general.me_hardswap_warmup_extra_passes;
|
||||||
|
cfg.general.me_hardswap_warmup_pass_backoff_base_ms =
|
||||||
|
new.general.me_hardswap_warmup_pass_backoff_base_ms;
|
||||||
|
cfg.general.me_bind_stale_mode = new.general.me_bind_stale_mode;
|
||||||
|
cfg.general.me_bind_stale_ttl_secs = new.general.me_bind_stale_ttl_secs;
|
||||||
|
cfg.general.me_secret_atomic_snapshot = new.general.me_secret_atomic_snapshot;
|
||||||
|
cfg.general.me_deterministic_writer_sort = new.general.me_deterministic_writer_sort;
|
||||||
|
cfg.general.me_writer_pick_mode = new.general.me_writer_pick_mode;
|
||||||
|
cfg.general.me_writer_pick_sample_size = new.general.me_writer_pick_sample_size;
|
||||||
|
cfg.general.me_single_endpoint_shadow_writers = new.general.me_single_endpoint_shadow_writers;
|
||||||
|
cfg.general.me_single_endpoint_outage_mode_enabled =
|
||||||
|
new.general.me_single_endpoint_outage_mode_enabled;
|
||||||
|
cfg.general.me_single_endpoint_outage_disable_quarantine =
|
||||||
|
new.general.me_single_endpoint_outage_disable_quarantine;
|
||||||
|
cfg.general.me_single_endpoint_outage_backoff_min_ms =
|
||||||
|
new.general.me_single_endpoint_outage_backoff_min_ms;
|
||||||
|
cfg.general.me_single_endpoint_outage_backoff_max_ms =
|
||||||
|
new.general.me_single_endpoint_outage_backoff_max_ms;
|
||||||
|
cfg.general.me_single_endpoint_shadow_rotate_every_secs =
|
||||||
|
new.general.me_single_endpoint_shadow_rotate_every_secs;
|
||||||
|
cfg.general.me_config_stable_snapshots = new.general.me_config_stable_snapshots;
|
||||||
|
cfg.general.me_config_apply_cooldown_secs = new.general.me_config_apply_cooldown_secs;
|
||||||
|
cfg.general.me_snapshot_require_http_2xx = new.general.me_snapshot_require_http_2xx;
|
||||||
|
cfg.general.me_snapshot_reject_empty_map = new.general.me_snapshot_reject_empty_map;
|
||||||
|
cfg.general.me_snapshot_min_proxy_for_lines = new.general.me_snapshot_min_proxy_for_lines;
|
||||||
|
cfg.general.proxy_secret_stable_snapshots = new.general.proxy_secret_stable_snapshots;
|
||||||
|
cfg.general.proxy_secret_rotate_runtime = new.general.proxy_secret_rotate_runtime;
|
||||||
|
cfg.general.proxy_secret_len_max = new.general.proxy_secret_len_max;
|
||||||
|
cfg.general.telemetry = new.general.telemetry.clone();
|
||||||
|
cfg.general.me_socks_kdf_policy = new.general.me_socks_kdf_policy;
|
||||||
|
cfg.general.me_floor_mode = new.general.me_floor_mode;
|
||||||
|
cfg.general.me_adaptive_floor_idle_secs = new.general.me_adaptive_floor_idle_secs;
|
||||||
|
cfg.general.me_adaptive_floor_min_writers_single_endpoint =
|
||||||
|
new.general.me_adaptive_floor_min_writers_single_endpoint;
|
||||||
|
cfg.general.me_adaptive_floor_min_writers_multi_endpoint =
|
||||||
|
new.general.me_adaptive_floor_min_writers_multi_endpoint;
|
||||||
|
cfg.general.me_adaptive_floor_recover_grace_secs =
|
||||||
|
new.general.me_adaptive_floor_recover_grace_secs;
|
||||||
|
cfg.general.me_adaptive_floor_writers_per_core_total =
|
||||||
|
new.general.me_adaptive_floor_writers_per_core_total;
|
||||||
|
cfg.general.me_adaptive_floor_cpu_cores_override =
|
||||||
|
new.general.me_adaptive_floor_cpu_cores_override;
|
||||||
|
cfg.general
|
||||||
|
.me_adaptive_floor_max_extra_writers_single_per_core = new
|
||||||
|
.general
|
||||||
|
.me_adaptive_floor_max_extra_writers_single_per_core;
|
||||||
|
cfg.general
|
||||||
|
.me_adaptive_floor_max_extra_writers_multi_per_core = new
|
||||||
|
.general
|
||||||
|
.me_adaptive_floor_max_extra_writers_multi_per_core;
|
||||||
|
cfg.general.me_adaptive_floor_max_active_writers_per_core =
|
||||||
|
new.general.me_adaptive_floor_max_active_writers_per_core;
|
||||||
|
cfg.general.me_adaptive_floor_max_warm_writers_per_core =
|
||||||
|
new.general.me_adaptive_floor_max_warm_writers_per_core;
|
||||||
|
cfg.general.me_adaptive_floor_max_active_writers_global =
|
||||||
|
new.general.me_adaptive_floor_max_active_writers_global;
|
||||||
|
cfg.general.me_adaptive_floor_max_warm_writers_global =
|
||||||
|
new.general.me_adaptive_floor_max_warm_writers_global;
|
||||||
|
cfg.general.me_route_backpressure_base_timeout_ms =
|
||||||
|
new.general.me_route_backpressure_base_timeout_ms;
|
||||||
|
cfg.general.me_route_backpressure_high_timeout_ms =
|
||||||
|
new.general.me_route_backpressure_high_timeout_ms;
|
||||||
|
cfg.general.me_route_backpressure_high_watermark_pct =
|
||||||
|
new.general.me_route_backpressure_high_watermark_pct;
|
||||||
|
cfg.general.me_route_backpressure_enabled = new.general.me_route_backpressure_enabled;
|
||||||
|
cfg.general.me_route_fairshare_enabled = new.general.me_route_fairshare_enabled;
|
||||||
|
cfg.general.me_reader_route_data_wait_ms = new.general.me_reader_route_data_wait_ms;
|
||||||
|
cfg.general.me_d2c_flush_batch_max_frames = new.general.me_d2c_flush_batch_max_frames;
|
||||||
|
cfg.general.me_d2c_flush_batch_max_bytes = new.general.me_d2c_flush_batch_max_bytes;
|
||||||
|
cfg.general.me_d2c_flush_batch_max_delay_us = new.general.me_d2c_flush_batch_max_delay_us;
|
||||||
|
cfg.general.me_d2c_ack_flush_immediate = new.general.me_d2c_ack_flush_immediate;
|
||||||
|
cfg.general.me_quota_soft_overshoot_bytes = new.general.me_quota_soft_overshoot_bytes;
|
||||||
|
cfg.general.me_d2c_frame_buf_shrink_threshold_bytes =
|
||||||
|
new.general.me_d2c_frame_buf_shrink_threshold_bytes;
|
||||||
|
cfg.general.direct_relay_copy_buf_c2s_bytes = new.general.direct_relay_copy_buf_c2s_bytes;
|
||||||
|
cfg.general.direct_relay_copy_buf_s2c_bytes = new.general.direct_relay_copy_buf_s2c_bytes;
|
||||||
|
cfg.general.me_health_interval_ms_unhealthy = new.general.me_health_interval_ms_unhealthy;
|
||||||
|
cfg.general.me_health_interval_ms_healthy = new.general.me_health_interval_ms_healthy;
|
||||||
|
cfg.general.me_admission_poll_ms = new.general.me_admission_poll_ms;
|
||||||
|
cfg.general.me_warn_rate_limit_ms = new.general.me_warn_rate_limit_ms;
|
||||||
|
|
||||||
|
cfg.access.users = new.access.users.clone();
|
||||||
|
cfg.access.user_enabled = new.access.user_enabled.clone();
|
||||||
|
cfg.access.user_ad_tags = new.access.user_ad_tags.clone();
|
||||||
|
cfg.access.user_max_tcp_conns = new.access.user_max_tcp_conns.clone();
|
||||||
|
cfg.access.user_max_tcp_conns_global_each = new.access.user_max_tcp_conns_global_each;
|
||||||
|
cfg.access.user_expirations = new.access.user_expirations.clone();
|
||||||
|
cfg.access.user_data_quota = new.access.user_data_quota.clone();
|
||||||
|
cfg.access.user_rate_limits = new.access.user_rate_limits.clone();
|
||||||
|
cfg.access.cidr_rate_limits = new.access.cidr_rate_limits.clone();
|
||||||
|
cfg.access.user_max_unique_ips = new.access.user_max_unique_ips.clone();
|
||||||
|
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;
|
||||||
|
if cfg.rebuild_runtime_user_auth().is_err() {
|
||||||
|
cfg.runtime_user_auth = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg
|
||||||
|
}
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn resolve_default_link_port(cfg: &ProxyConfig) -> u16 {
|
||||||
|
cfg.server
|
||||||
|
.listeners
|
||||||
|
.first()
|
||||||
|
.and_then(|listener| listener.port)
|
||||||
|
.unwrap_or(cfg.server.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_link_host(
|
||||||
|
cfg: &ProxyConfig,
|
||||||
|
detected_ip_v4: Option<IpAddr>,
|
||||||
|
detected_ip_v6: Option<IpAddr>,
|
||||||
|
) -> String {
|
||||||
|
if let Some(ref h) = cfg.general.links.public_host {
|
||||||
|
return h.clone();
|
||||||
|
}
|
||||||
|
detected_ip_v4
|
||||||
|
.or(detected_ip_v6)
|
||||||
|
.map(|ip| ip.to_string())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
warn!(
|
||||||
|
"config reload: could not determine public IP for proxy links. \
|
||||||
|
Set [general.links] public_host in config."
|
||||||
|
);
|
||||||
|
"UNKNOWN".to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print TG proxy links for a single user — mirrors print_proxy_links() in main.rs.
|
||||||
|
fn print_user_links(user: &str, secret: &str, host: &str, port: u16, cfg: &ProxyConfig) {
|
||||||
|
info!(target: "telemt::links", "--- New user: {} ---", user);
|
||||||
|
if cfg.general.modes.classic {
|
||||||
|
info!(
|
||||||
|
target: "telemt::links",
|
||||||
|
" Classic: tg://proxy?server={}&port={}&secret={}",
|
||||||
|
host, port, secret
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if cfg.general.modes.secure {
|
||||||
|
info!(
|
||||||
|
target: "telemt::links",
|
||||||
|
" DD: tg://proxy?server={}&port={}&secret=dd{}",
|
||||||
|
host, port, secret
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if cfg.general.modes.tls {
|
||||||
|
let mut domains = vec![cfg.censorship.tls_domain.clone()];
|
||||||
|
for d in &cfg.censorship.tls_domains {
|
||||||
|
if !domains.contains(d) {
|
||||||
|
domains.push(d.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for domain in &domains {
|
||||||
|
let domain_hex = hex::encode(domain.as_bytes());
|
||||||
|
info!(
|
||||||
|
target: "telemt::links",
|
||||||
|
" EE-TLS: tg://proxy?server={}&port={}&secret=ee{}{}",
|
||||||
|
host, port, secret, domain_hex
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!(target: "telemt::links", "--------------------");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log all detected changes and emit TG links for new users.
|
||||||
|
pub(super) fn log_changes(
|
||||||
|
old_hot: &HotFields,
|
||||||
|
new_hot: &HotFields,
|
||||||
|
new_cfg: &ProxyConfig,
|
||||||
|
log_tx: &watch::Sender<LogLevel>,
|
||||||
|
detected_ip_v4: Option<IpAddr>,
|
||||||
|
detected_ip_v6: Option<IpAddr>,
|
||||||
|
) {
|
||||||
|
if old_hot.log_level != new_hot.log_level {
|
||||||
|
info!(
|
||||||
|
"config reload: log_level: '{}' → '{}'",
|
||||||
|
old_hot.log_level, new_hot.log_level
|
||||||
|
);
|
||||||
|
log_tx.send(new_hot.log_level.clone()).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
if old_hot.user_ad_tags != new_hot.user_ad_tags {
|
||||||
|
info!(
|
||||||
|
"config reload: user_ad_tags updated ({} entries)",
|
||||||
|
new_hot.user_ad_tags.len(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if old_hot.ad_tag != new_hot.ad_tag {
|
||||||
|
info!("config reload: general.ad_tag updated (applied on next connection)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if old_hot.dns_overrides != new_hot.dns_overrides {
|
||||||
|
info!(
|
||||||
|
"config reload: network.dns_overrides updated ({} entries)",
|
||||||
|
new_hot.dns_overrides.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if old_hot.desync_all_full != new_hot.desync_all_full {
|
||||||
|
info!(
|
||||||
|
"config reload: desync_all_full: {} → {}",
|
||||||
|
old_hot.desync_all_full, new_hot.desync_all_full,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if old_hot.update_every_secs != new_hot.update_every_secs {
|
||||||
|
info!(
|
||||||
|
"config reload: update_every(effective): {}s → {}s",
|
||||||
|
old_hot.update_every_secs, new_hot.update_every_secs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if old_hot.me_reinit_every_secs != new_hot.me_reinit_every_secs
|
||||||
|
|| old_hot.me_reinit_singleflight != new_hot.me_reinit_singleflight
|
||||||
|
|| old_hot.me_reinit_coalesce_window_ms != new_hot.me_reinit_coalesce_window_ms
|
||||||
|
{
|
||||||
|
info!(
|
||||||
|
"config reload: me_reinit: interval={}s singleflight={} coalesce={}ms",
|
||||||
|
new_hot.me_reinit_every_secs,
|
||||||
|
new_hot.me_reinit_singleflight,
|
||||||
|
new_hot.me_reinit_coalesce_window_ms
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if old_hot.hardswap != new_hot.hardswap {
|
||||||
|
info!(
|
||||||
|
"config reload: hardswap: {} → {}",
|
||||||
|
old_hot.hardswap, new_hot.hardswap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if old_hot.me_pool_drain_ttl_secs != new_hot.me_pool_drain_ttl_secs {
|
||||||
|
info!(
|
||||||
|
"config reload: me_pool_drain_ttl_secs: {}s → {}s",
|
||||||
|
old_hot.me_pool_drain_ttl_secs, new_hot.me_pool_drain_ttl_secs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if old_hot.me_instadrain != new_hot.me_instadrain {
|
||||||
|
info!(
|
||||||
|
"config reload: me_instadrain: {} → {}",
|
||||||
|
old_hot.me_instadrain, new_hot.me_instadrain,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if old_hot.me_pool_drain_threshold != new_hot.me_pool_drain_threshold {
|
||||||
|
info!(
|
||||||
|
"config reload: me_pool_drain_threshold: {} → {}",
|
||||||
|
old_hot.me_pool_drain_threshold, new_hot.me_pool_drain_threshold,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (old_hot.me_pool_min_fresh_ratio - new_hot.me_pool_min_fresh_ratio).abs() > f32::EPSILON {
|
||||||
|
info!(
|
||||||
|
"config reload: me_pool_min_fresh_ratio: {:.3} → {:.3}",
|
||||||
|
old_hot.me_pool_min_fresh_ratio, new_hot.me_pool_min_fresh_ratio,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if old_hot.me_reinit_drain_timeout_secs != new_hot.me_reinit_drain_timeout_secs {
|
||||||
|
info!(
|
||||||
|
"config reload: me_reinit_drain_timeout_secs: {}s → {}s",
|
||||||
|
old_hot.me_reinit_drain_timeout_secs, new_hot.me_reinit_drain_timeout_secs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if old_hot.me_hardswap_warmup_delay_min_ms != new_hot.me_hardswap_warmup_delay_min_ms
|
||||||
|
|| old_hot.me_hardswap_warmup_delay_max_ms != new_hot.me_hardswap_warmup_delay_max_ms
|
||||||
|
|| old_hot.me_hardswap_warmup_extra_passes != new_hot.me_hardswap_warmup_extra_passes
|
||||||
|
|| old_hot.me_hardswap_warmup_pass_backoff_base_ms
|
||||||
|
!= new_hot.me_hardswap_warmup_pass_backoff_base_ms
|
||||||
|
{
|
||||||
|
info!(
|
||||||
|
"config reload: me_hardswap_warmup: min={}ms max={}ms extra_passes={} pass_backoff={}ms",
|
||||||
|
new_hot.me_hardswap_warmup_delay_min_ms,
|
||||||
|
new_hot.me_hardswap_warmup_delay_max_ms,
|
||||||
|
new_hot.me_hardswap_warmup_extra_passes,
|
||||||
|
new_hot.me_hardswap_warmup_pass_backoff_base_ms
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if old_hot.me_bind_stale_mode != new_hot.me_bind_stale_mode
|
||||||
|
|| old_hot.me_bind_stale_ttl_secs != new_hot.me_bind_stale_ttl_secs
|
||||||
|
{
|
||||||
|
info!(
|
||||||
|
"config reload: me_bind_stale: mode={:?} ttl={}s",
|
||||||
|
new_hot.me_bind_stale_mode, new_hot.me_bind_stale_ttl_secs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if old_hot.me_secret_atomic_snapshot != new_hot.me_secret_atomic_snapshot
|
||||||
|
|| old_hot.me_deterministic_writer_sort != new_hot.me_deterministic_writer_sort
|
||||||
|
|| old_hot.me_writer_pick_mode != new_hot.me_writer_pick_mode
|
||||||
|
|| old_hot.me_writer_pick_sample_size != new_hot.me_writer_pick_sample_size
|
||||||
|
{
|
||||||
|
info!(
|
||||||
|
"config reload: me_runtime_flags: secret_atomic_snapshot={} deterministic_sort={} writer_pick_mode={:?} writer_pick_sample_size={}",
|
||||||
|
new_hot.me_secret_atomic_snapshot,
|
||||||
|
new_hot.me_deterministic_writer_sort,
|
||||||
|
new_hot.me_writer_pick_mode,
|
||||||
|
new_hot.me_writer_pick_sample_size,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if old_hot.me_single_endpoint_shadow_writers != new_hot.me_single_endpoint_shadow_writers
|
||||||
|
|| old_hot.me_single_endpoint_outage_mode_enabled
|
||||||
|
!= new_hot.me_single_endpoint_outage_mode_enabled
|
||||||
|
|| old_hot.me_single_endpoint_outage_disable_quarantine
|
||||||
|
!= new_hot.me_single_endpoint_outage_disable_quarantine
|
||||||
|
|| old_hot.me_single_endpoint_outage_backoff_min_ms
|
||||||
|
!= new_hot.me_single_endpoint_outage_backoff_min_ms
|
||||||
|
|| old_hot.me_single_endpoint_outage_backoff_max_ms
|
||||||
|
!= new_hot.me_single_endpoint_outage_backoff_max_ms
|
||||||
|
|| old_hot.me_single_endpoint_shadow_rotate_every_secs
|
||||||
|
!= new_hot.me_single_endpoint_shadow_rotate_every_secs
|
||||||
|
{
|
||||||
|
info!(
|
||||||
|
"config reload: me_single_endpoint: shadow={} outage_enabled={} disable_quarantine={} backoff=[{}..{}]ms rotate={}s",
|
||||||
|
new_hot.me_single_endpoint_shadow_writers,
|
||||||
|
new_hot.me_single_endpoint_outage_mode_enabled,
|
||||||
|
new_hot.me_single_endpoint_outage_disable_quarantine,
|
||||||
|
new_hot.me_single_endpoint_outage_backoff_min_ms,
|
||||||
|
new_hot.me_single_endpoint_outage_backoff_max_ms,
|
||||||
|
new_hot.me_single_endpoint_shadow_rotate_every_secs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if old_hot.me_config_stable_snapshots != new_hot.me_config_stable_snapshots
|
||||||
|
|| old_hot.me_config_apply_cooldown_secs != new_hot.me_config_apply_cooldown_secs
|
||||||
|
|| old_hot.me_snapshot_require_http_2xx != new_hot.me_snapshot_require_http_2xx
|
||||||
|
|| old_hot.me_snapshot_reject_empty_map != new_hot.me_snapshot_reject_empty_map
|
||||||
|
|| old_hot.me_snapshot_min_proxy_for_lines != new_hot.me_snapshot_min_proxy_for_lines
|
||||||
|
{
|
||||||
|
info!(
|
||||||
|
"config reload: me_snapshot_guard: stable={} cooldown={}s require_2xx={} reject_empty={} min_proxy_for={}",
|
||||||
|
new_hot.me_config_stable_snapshots,
|
||||||
|
new_hot.me_config_apply_cooldown_secs,
|
||||||
|
new_hot.me_snapshot_require_http_2xx,
|
||||||
|
new_hot.me_snapshot_reject_empty_map,
|
||||||
|
new_hot.me_snapshot_min_proxy_for_lines
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if old_hot.proxy_secret_stable_snapshots != new_hot.proxy_secret_stable_snapshots
|
||||||
|
|| old_hot.proxy_secret_rotate_runtime != new_hot.proxy_secret_rotate_runtime
|
||||||
|
|| old_hot.proxy_secret_len_max != new_hot.proxy_secret_len_max
|
||||||
|
{
|
||||||
|
info!(
|
||||||
|
"config reload: proxy_secret_runtime: stable={} rotate={} len_max={}",
|
||||||
|
new_hot.proxy_secret_stable_snapshots,
|
||||||
|
new_hot.proxy_secret_rotate_runtime,
|
||||||
|
new_hot.proxy_secret_len_max
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if old_hot.telemetry_core_enabled != new_hot.telemetry_core_enabled
|
||||||
|
|| old_hot.telemetry_user_enabled != new_hot.telemetry_user_enabled
|
||||||
|
|| old_hot.telemetry_me_level != new_hot.telemetry_me_level
|
||||||
|
{
|
||||||
|
info!(
|
||||||
|
"config reload: telemetry: core_enabled={} user_enabled={} me_level={}",
|
||||||
|
new_hot.telemetry_core_enabled,
|
||||||
|
new_hot.telemetry_user_enabled,
|
||||||
|
new_hot.telemetry_me_level,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if old_hot.me_socks_kdf_policy != new_hot.me_socks_kdf_policy {
|
||||||
|
info!(
|
||||||
|
"config reload: me_socks_kdf_policy: {:?} → {:?}",
|
||||||
|
old_hot.me_socks_kdf_policy, new_hot.me_socks_kdf_policy,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if old_hot.me_floor_mode != new_hot.me_floor_mode
|
||||||
|
|| old_hot.me_adaptive_floor_idle_secs != new_hot.me_adaptive_floor_idle_secs
|
||||||
|
|| old_hot.me_adaptive_floor_min_writers_single_endpoint
|
||||||
|
!= new_hot.me_adaptive_floor_min_writers_single_endpoint
|
||||||
|
|| old_hot.me_adaptive_floor_min_writers_multi_endpoint
|
||||||
|
!= new_hot.me_adaptive_floor_min_writers_multi_endpoint
|
||||||
|
|| old_hot.me_adaptive_floor_recover_grace_secs
|
||||||
|
!= new_hot.me_adaptive_floor_recover_grace_secs
|
||||||
|
|| old_hot.me_adaptive_floor_writers_per_core_total
|
||||||
|
!= new_hot.me_adaptive_floor_writers_per_core_total
|
||||||
|
|| old_hot.me_adaptive_floor_cpu_cores_override
|
||||||
|
!= new_hot.me_adaptive_floor_cpu_cores_override
|
||||||
|
|| old_hot.me_adaptive_floor_max_extra_writers_single_per_core
|
||||||
|
!= new_hot.me_adaptive_floor_max_extra_writers_single_per_core
|
||||||
|
|| old_hot.me_adaptive_floor_max_extra_writers_multi_per_core
|
||||||
|
!= new_hot.me_adaptive_floor_max_extra_writers_multi_per_core
|
||||||
|
|| old_hot.me_adaptive_floor_max_active_writers_per_core
|
||||||
|
!= new_hot.me_adaptive_floor_max_active_writers_per_core
|
||||||
|
|| old_hot.me_adaptive_floor_max_warm_writers_per_core
|
||||||
|
!= new_hot.me_adaptive_floor_max_warm_writers_per_core
|
||||||
|
|| old_hot.me_adaptive_floor_max_active_writers_global
|
||||||
|
!= new_hot.me_adaptive_floor_max_active_writers_global
|
||||||
|
|| old_hot.me_adaptive_floor_max_warm_writers_global
|
||||||
|
!= new_hot.me_adaptive_floor_max_warm_writers_global
|
||||||
|
{
|
||||||
|
info!(
|
||||||
|
"config reload: me_floor: mode={:?} idle={}s min_single={} min_multi={} recover_grace={}s per_core_total={} cores_override={} extra_single_per_core={} extra_multi_per_core={} max_active_per_core={} max_warm_per_core={} max_active_global={} max_warm_global={}",
|
||||||
|
new_hot.me_floor_mode,
|
||||||
|
new_hot.me_adaptive_floor_idle_secs,
|
||||||
|
new_hot.me_adaptive_floor_min_writers_single_endpoint,
|
||||||
|
new_hot.me_adaptive_floor_min_writers_multi_endpoint,
|
||||||
|
new_hot.me_adaptive_floor_recover_grace_secs,
|
||||||
|
new_hot.me_adaptive_floor_writers_per_core_total,
|
||||||
|
new_hot.me_adaptive_floor_cpu_cores_override,
|
||||||
|
new_hot.me_adaptive_floor_max_extra_writers_single_per_core,
|
||||||
|
new_hot.me_adaptive_floor_max_extra_writers_multi_per_core,
|
||||||
|
new_hot.me_adaptive_floor_max_active_writers_per_core,
|
||||||
|
new_hot.me_adaptive_floor_max_warm_writers_per_core,
|
||||||
|
new_hot.me_adaptive_floor_max_active_writers_global,
|
||||||
|
new_hot.me_adaptive_floor_max_warm_writers_global,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if old_hot.me_route_backpressure_base_timeout_ms
|
||||||
|
!= new_hot.me_route_backpressure_base_timeout_ms
|
||||||
|
|| old_hot.me_route_backpressure_high_timeout_ms
|
||||||
|
!= new_hot.me_route_backpressure_high_timeout_ms
|
||||||
|
|| old_hot.me_route_backpressure_high_watermark_pct
|
||||||
|
!= new_hot.me_route_backpressure_high_watermark_pct
|
||||||
|
|| old_hot.me_route_backpressure_enabled != new_hot.me_route_backpressure_enabled
|
||||||
|
|| old_hot.me_route_fairshare_enabled != new_hot.me_route_fairshare_enabled
|
||||||
|
|| old_hot.me_reader_route_data_wait_ms != new_hot.me_reader_route_data_wait_ms
|
||||||
|
|| old_hot.me_health_interval_ms_unhealthy != new_hot.me_health_interval_ms_unhealthy
|
||||||
|
|| old_hot.me_health_interval_ms_healthy != new_hot.me_health_interval_ms_healthy
|
||||||
|
|| old_hot.me_admission_poll_ms != new_hot.me_admission_poll_ms
|
||||||
|
|| old_hot.me_warn_rate_limit_ms != new_hot.me_warn_rate_limit_ms
|
||||||
|
{
|
||||||
|
info!(
|
||||||
|
"config reload: me_route_backpressure: enabled={} base={}ms high={}ms watermark={}%; me_route_fairshare_enabled={}; me_reader_route_data_wait_ms={}; me_health_interval: unhealthy={}ms healthy={}ms; me_admission_poll={}ms; me_warn_rate_limit={}ms",
|
||||||
|
new_hot.me_route_backpressure_enabled,
|
||||||
|
new_hot.me_route_backpressure_base_timeout_ms,
|
||||||
|
new_hot.me_route_backpressure_high_timeout_ms,
|
||||||
|
new_hot.me_route_backpressure_high_watermark_pct,
|
||||||
|
new_hot.me_route_fairshare_enabled,
|
||||||
|
new_hot.me_reader_route_data_wait_ms,
|
||||||
|
new_hot.me_health_interval_ms_unhealthy,
|
||||||
|
new_hot.me_health_interval_ms_healthy,
|
||||||
|
new_hot.me_admission_poll_ms,
|
||||||
|
new_hot.me_warn_rate_limit_ms,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if old_hot.me_d2c_flush_batch_max_frames != new_hot.me_d2c_flush_batch_max_frames
|
||||||
|
|| old_hot.me_d2c_flush_batch_max_bytes != new_hot.me_d2c_flush_batch_max_bytes
|
||||||
|
|| old_hot.me_d2c_flush_batch_max_delay_us != new_hot.me_d2c_flush_batch_max_delay_us
|
||||||
|
|| old_hot.me_d2c_ack_flush_immediate != new_hot.me_d2c_ack_flush_immediate
|
||||||
|
|| old_hot.me_quota_soft_overshoot_bytes != new_hot.me_quota_soft_overshoot_bytes
|
||||||
|
|| old_hot.me_d2c_frame_buf_shrink_threshold_bytes
|
||||||
|
!= new_hot.me_d2c_frame_buf_shrink_threshold_bytes
|
||||||
|
|| old_hot.direct_relay_copy_buf_c2s_bytes != new_hot.direct_relay_copy_buf_c2s_bytes
|
||||||
|
|| old_hot.direct_relay_copy_buf_s2c_bytes != new_hot.direct_relay_copy_buf_s2c_bytes
|
||||||
|
{
|
||||||
|
info!(
|
||||||
|
"config reload: relay_tuning: me_d2c_frames={} me_d2c_bytes={} me_d2c_delay_us={} me_ack_flush_immediate={} me_quota_soft_overshoot_bytes={} me_d2c_frame_buf_shrink_threshold_bytes={} direct_buf_c2s={} direct_buf_s2c={}",
|
||||||
|
new_hot.me_d2c_flush_batch_max_frames,
|
||||||
|
new_hot.me_d2c_flush_batch_max_bytes,
|
||||||
|
new_hot.me_d2c_flush_batch_max_delay_us,
|
||||||
|
new_hot.me_d2c_ack_flush_immediate,
|
||||||
|
new_hot.me_quota_soft_overshoot_bytes,
|
||||||
|
new_hot.me_d2c_frame_buf_shrink_threshold_bytes,
|
||||||
|
new_hot.direct_relay_copy_buf_c2s_bytes,
|
||||||
|
new_hot.direct_relay_copy_buf_s2c_bytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if old_hot.users != new_hot.users {
|
||||||
|
let mut added: Vec<&String> = new_hot
|
||||||
|
.users
|
||||||
|
.keys()
|
||||||
|
.filter(|u| !old_hot.users.contains_key(*u))
|
||||||
|
.collect();
|
||||||
|
added.sort();
|
||||||
|
|
||||||
|
let mut removed: Vec<&String> = old_hot
|
||||||
|
.users
|
||||||
|
.keys()
|
||||||
|
.filter(|u| !new_hot.users.contains_key(*u))
|
||||||
|
.collect();
|
||||||
|
removed.sort();
|
||||||
|
|
||||||
|
let mut changed: Vec<&String> = new_hot
|
||||||
|
.users
|
||||||
|
.keys()
|
||||||
|
.filter(|u| {
|
||||||
|
old_hot
|
||||||
|
.users
|
||||||
|
.get(*u)
|
||||||
|
.map(|s| s != &new_hot.users[*u])
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
changed.sort();
|
||||||
|
|
||||||
|
if !added.is_empty() {
|
||||||
|
info!(
|
||||||
|
"config reload: users added: [{}]",
|
||||||
|
added
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
);
|
||||||
|
let host = resolve_link_host(new_cfg, detected_ip_v4, detected_ip_v6);
|
||||||
|
let port = new_cfg
|
||||||
|
.general
|
||||||
|
.links
|
||||||
|
.public_port
|
||||||
|
.unwrap_or(resolve_default_link_port(new_cfg));
|
||||||
|
for user in &added {
|
||||||
|
if let Some(secret) = new_hot.users.get(*user) {
|
||||||
|
print_user_links(user, secret, &host, port, new_cfg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !removed.is_empty() {
|
||||||
|
info!(
|
||||||
|
"config reload: users removed: [{}]",
|
||||||
|
removed
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !changed.is_empty() {
|
||||||
|
info!(
|
||||||
|
"config reload: users secret changed: [{}]",
|
||||||
|
changed
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if old_hot.user_enabled != new_hot.user_enabled {
|
||||||
|
info!(
|
||||||
|
"config reload: user_enabled updated ({} disabled overrides)",
|
||||||
|
new_hot
|
||||||
|
.user_enabled
|
||||||
|
.values()
|
||||||
|
.filter(|enabled| !**enabled)
|
||||||
|
.count()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if old_hot.user_max_tcp_conns != new_hot.user_max_tcp_conns {
|
||||||
|
info!(
|
||||||
|
"config reload: user_max_tcp_conns updated ({} entries)",
|
||||||
|
new_hot.user_max_tcp_conns.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if old_hot.user_max_tcp_conns_global_each != new_hot.user_max_tcp_conns_global_each {
|
||||||
|
info!(
|
||||||
|
"config reload: user_max_tcp_conns policy global_each={}",
|
||||||
|
new_hot.user_max_tcp_conns_global_each
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if old_hot.user_expirations != new_hot.user_expirations {
|
||||||
|
info!(
|
||||||
|
"config reload: user_expirations updated ({} entries)",
|
||||||
|
new_hot.user_expirations.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if old_hot.user_data_quota != new_hot.user_data_quota {
|
||||||
|
info!(
|
||||||
|
"config reload: user_data_quota updated ({} entries)",
|
||||||
|
new_hot.user_data_quota.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if old_hot.user_rate_limits != new_hot.user_rate_limits {
|
||||||
|
info!(
|
||||||
|
"config reload: user_rate_limits updated ({} entries)",
|
||||||
|
new_hot.user_rate_limits.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if old_hot.cidr_rate_limits != new_hot.cidr_rate_limits {
|
||||||
|
info!(
|
||||||
|
"config reload: cidr_rate_limits updated ({} entries)",
|
||||||
|
new_hot.cidr_rate_limits.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if old_hot.user_max_unique_ips != new_hot.user_max_unique_ips {
|
||||||
|
info!(
|
||||||
|
"config reload: user_max_unique_ips updated ({} entries)",
|
||||||
|
new_hot.user_max_unique_ips.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if old_hot.user_max_unique_ips_global_each != new_hot.user_max_unique_ips_global_each
|
||||||
|
|| old_hot.user_max_unique_ips_mode != new_hot.user_max_unique_ips_mode
|
||||||
|
|| old_hot.user_max_unique_ips_window_secs != new_hot.user_max_unique_ips_window_secs
|
||||||
|
{
|
||||||
|
info!(
|
||||||
|
"config reload: user_max_unique_ips policy global_each={} mode={:?} window={}s",
|
||||||
|
new_hot.user_max_unique_ips_global_each,
|
||||||
|
new_hot.user_max_unique_ips_mode,
|
||||||
|
new_hot.user_max_unique_ips_window_secs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample_config() -> ProxyConfig {
|
||||||
|
ProxyConfig::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_reload_config(path: &Path, ad_tag: Option<&str>, server_port: Option<u16>) {
|
||||||
|
let mut config = String::from(
|
||||||
|
r#"
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ad_tag.is_some() {
|
||||||
|
config.push_str("\n[general]\n");
|
||||||
|
if let Some(tag) = ad_tag {
|
||||||
|
config.push_str(&format!("ad_tag = \"{tag}\"\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(port) = server_port {
|
||||||
|
config.push_str("\n[server]\n");
|
||||||
|
config.push_str(&format!("port = {port}\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::write(path, config).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp_config_path(prefix: &str) -> PathBuf {
|
||||||
|
let nonce = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
std::env::temp_dir().join(format!("{prefix}_{nonce}.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overlay_applies_hot_and_preserves_non_hot() {
|
||||||
|
let old = sample_config();
|
||||||
|
let mut new = old.clone();
|
||||||
|
new.general.hardswap = !old.general.hardswap;
|
||||||
|
new.server.port = old.server.port.saturating_add(1);
|
||||||
|
|
||||||
|
let applied = overlay_hot_fields(&old, &new);
|
||||||
|
assert_eq!(applied.general.hardswap, new.general.hardswap);
|
||||||
|
assert_eq!(applied.server.port, old.server.port);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_hot_only_change_does_not_change_hot_snapshot() {
|
||||||
|
let old = sample_config();
|
||||||
|
let mut new = old.clone();
|
||||||
|
new.server.port = old.server.port.saturating_add(1);
|
||||||
|
|
||||||
|
let applied = overlay_hot_fields(&old, &new);
|
||||||
|
assert_eq!(
|
||||||
|
HotFields::from_config(&old),
|
||||||
|
HotFields::from_config(&applied)
|
||||||
|
);
|
||||||
|
assert_eq!(applied.server.port, old.server.port);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bind_stale_mode_is_hot() {
|
||||||
|
let old = sample_config();
|
||||||
|
let mut new = old.clone();
|
||||||
|
new.general.me_bind_stale_mode = match old.general.me_bind_stale_mode {
|
||||||
|
MeBindStaleMode::Never => MeBindStaleMode::Ttl,
|
||||||
|
MeBindStaleMode::Ttl => MeBindStaleMode::Always,
|
||||||
|
MeBindStaleMode::Always => MeBindStaleMode::Never,
|
||||||
|
};
|
||||||
|
|
||||||
|
let applied = overlay_hot_fields(&old, &new);
|
||||||
|
assert_eq!(
|
||||||
|
applied.general.me_bind_stale_mode,
|
||||||
|
new.general.me_bind_stale_mode
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
HotFields::from_config(&old),
|
||||||
|
HotFields::from_config(&applied)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keepalive_is_not_hot() {
|
||||||
|
let old = sample_config();
|
||||||
|
let mut new = old.clone();
|
||||||
|
new.general.me_keepalive_interval_secs = old.general.me_keepalive_interval_secs + 5;
|
||||||
|
|
||||||
|
let applied = overlay_hot_fields(&old, &new);
|
||||||
|
assert_eq!(
|
||||||
|
applied.general.me_keepalive_interval_secs,
|
||||||
|
old.general.me_keepalive_interval_secs
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
HotFields::from_config(&old),
|
||||||
|
HotFields::from_config(&applied)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mixed_hot_and_non_hot_change_applies_only_hot_subset() {
|
||||||
|
let old = sample_config();
|
||||||
|
let mut new = old.clone();
|
||||||
|
new.general.hardswap = !old.general.hardswap;
|
||||||
|
new.general.use_middle_proxy = !old.general.use_middle_proxy;
|
||||||
|
|
||||||
|
let applied = overlay_hot_fields(&old, &new);
|
||||||
|
assert_eq!(applied.general.hardswap, new.general.hardswap);
|
||||||
|
assert_eq!(
|
||||||
|
applied.general.use_middle_proxy,
|
||||||
|
old.general.use_middle_proxy
|
||||||
|
);
|
||||||
|
assert!(!config_equal(&applied, &new));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn listener_synlimit_fields_are_process_owned() {
|
||||||
|
let mut old = sample_config();
|
||||||
|
old.server.listeners.push(ListenerConfig {
|
||||||
|
ip: "0.0.0.0".parse().unwrap(),
|
||||||
|
port: Some(443),
|
||||||
|
client_mss: None,
|
||||||
|
synlimit: SynLimitMode::Iptables,
|
||||||
|
synlimit_seconds: 60,
|
||||||
|
synlimit_hitcount: 48,
|
||||||
|
synlimit_burst: 1,
|
||||||
|
synlimit_ios_seconds: 1,
|
||||||
|
synlimit_ios_hitcount: 12,
|
||||||
|
synlimit_ios_burst: 24,
|
||||||
|
synlimit_hashlimit_expire_ms: 60_000,
|
||||||
|
synlimit_hashlimit_size: 32_768,
|
||||||
|
announce: None,
|
||||||
|
announce_ip: None,
|
||||||
|
proxy_protocol: None,
|
||||||
|
reuse_allow: false,
|
||||||
|
});
|
||||||
|
let mut new = old.clone();
|
||||||
|
new.server.port = 8443;
|
||||||
|
new.server.listeners[0].synlimit_seconds = 120;
|
||||||
|
new.server.listeners[0].synlimit_hitcount = 96;
|
||||||
|
new.server.listeners[0].synlimit_burst = 2;
|
||||||
|
new.server.listeners[0].synlimit_ios_seconds = 2;
|
||||||
|
new.server.listeners[0].synlimit_ios_hitcount = 18;
|
||||||
|
new.server.listeners[0].synlimit_ios_burst = 36;
|
||||||
|
new.server.listeners[0].synlimit_hashlimit_expire_ms = 90_000;
|
||||||
|
new.server.listeners[0].synlimit_hashlimit_size = 65_536;
|
||||||
|
|
||||||
|
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, 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]
|
||||||
|
fn reload_applies_hot_change_on_first_observed_snapshot() {
|
||||||
|
let initial_tag = "11111111111111111111111111111111";
|
||||||
|
let final_tag = "22222222222222222222222222222222";
|
||||||
|
let path = temp_config_path("telemt_hot_reload_stable");
|
||||||
|
|
||||||
|
write_reload_config(&path, Some(initial_tag), None);
|
||||||
|
let initial_cfg = Arc::new(ProxyConfig::load(&path).unwrap());
|
||||||
|
let initial_hash = ProxyConfig::load_with_metadata(&path)
|
||||||
|
.unwrap()
|
||||||
|
.rendered_hash;
|
||||||
|
let (config_tx, _config_rx) = watch::channel(initial_cfg.clone());
|
||||||
|
let (log_tx, _log_rx) = watch::channel(initial_cfg.general.log_level.clone());
|
||||||
|
let mut reload_state = ReloadState::new(Some(initial_hash));
|
||||||
|
|
||||||
|
write_reload_config(&path, Some(final_tag), None);
|
||||||
|
reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
config_tx.borrow().general.ad_tag.as_deref(),
|
||||||
|
Some(final_tag)
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reload_keeps_hot_apply_when_non_hot_fields_change() {
|
||||||
|
let initial_tag = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||||
|
let final_tag = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||||
|
let path = temp_config_path("telemt_hot_reload_mixed");
|
||||||
|
|
||||||
|
write_reload_config(&path, Some(initial_tag), None);
|
||||||
|
let initial_cfg = Arc::new(ProxyConfig::load(&path).unwrap());
|
||||||
|
let initial_hash = ProxyConfig::load_with_metadata(&path)
|
||||||
|
.unwrap()
|
||||||
|
.rendered_hash;
|
||||||
|
let (config_tx, _config_rx) = watch::channel(initial_cfg.clone());
|
||||||
|
let (log_tx, _log_rx) = watch::channel(initial_cfg.general.log_level.clone());
|
||||||
|
let mut reload_state = ReloadState::new(Some(initial_hash));
|
||||||
|
|
||||||
|
write_reload_config(&path, Some(final_tag), Some(initial_cfg.server.port + 1));
|
||||||
|
reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).unwrap();
|
||||||
|
|
||||||
|
let applied = config_tx.borrow().clone();
|
||||||
|
assert_eq!(applied.general.ad_tag.as_deref(), Some(final_tag));
|
||||||
|
assert_eq!(applied.server.port, initial_cfg.server.port);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_sni_change_requires_restart() {
|
||||||
|
// censorship.* is not in overlay_hot_fields -> restart.
|
||||||
|
let old = ProxyConfig::default();
|
||||||
|
let mut new = ProxyConfig::default();
|
||||||
|
new.censorship.tls_domain = "front.example".to_string();
|
||||||
|
|
||||||
|
let class = classify_config_changes(&old, &new);
|
||||||
|
assert!(class.restart_required);
|
||||||
|
assert!(class.changed.iter().any(|c| c == "censorship"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_dns_overrides_change_is_hot() {
|
||||||
|
// network.dns_overrides IS in overlay_hot_fields -> no restart.
|
||||||
|
let old = ProxyConfig::default();
|
||||||
|
let mut new = ProxyConfig::default();
|
||||||
|
new.network.dns_overrides.push("1.1.1.1".to_string());
|
||||||
|
|
||||||
|
let class = classify_config_changes(&old, &new);
|
||||||
|
assert!(!class.restart_required);
|
||||||
|
assert!(class.changed.iter().any(|c| c == "network"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_timeouts_change_requires_restart() {
|
||||||
|
// timeouts.* is NOT in overlay_hot_fields -> restart.
|
||||||
|
let old = ProxyConfig::default();
|
||||||
|
let mut new = ProxyConfig::default();
|
||||||
|
new.timeouts.client_handshake = old.timeouts.client_handshake + 1;
|
||||||
|
|
||||||
|
let class = classify_config_changes(&old, &new);
|
||||||
|
assert!(class.restart_required);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reload_recovers_after_parse_error_on_next_attempt() {
|
||||||
|
let initial_tag = "cccccccccccccccccccccccccccccccc";
|
||||||
|
let final_tag = "dddddddddddddddddddddddddddddddd";
|
||||||
|
let path = temp_config_path("telemt_hot_reload_parse_recovery");
|
||||||
|
|
||||||
|
write_reload_config(&path, Some(initial_tag), None);
|
||||||
|
let initial_cfg = Arc::new(ProxyConfig::load(&path).unwrap());
|
||||||
|
let initial_hash = ProxyConfig::load_with_metadata(&path)
|
||||||
|
.unwrap()
|
||||||
|
.rendered_hash;
|
||||||
|
let (config_tx, _config_rx) = watch::channel(initial_cfg.clone());
|
||||||
|
let (log_tx, _log_rx) = watch::channel(initial_cfg.general.log_level.clone());
|
||||||
|
let mut reload_state = ReloadState::new(Some(initial_hash));
|
||||||
|
|
||||||
|
std::fs::write(&path, "[access.users\nuser = \"broken\"\n").unwrap();
|
||||||
|
assert!(reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
config_tx.borrow().general.ad_tag.as_deref(),
|
||||||
|
Some(initial_tag)
|
||||||
|
);
|
||||||
|
|
||||||
|
write_reload_config(&path, Some(final_tag), None);
|
||||||
|
reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
config_tx.borrow().general.ad_tag.as_deref(),
|
||||||
|
Some(final_tag)
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub(super) struct WatchManifest {
|
||||||
|
files: BTreeSet<PathBuf>,
|
||||||
|
dirs: BTreeSet<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WatchManifest {
|
||||||
|
fn from_source_files(source_files: &[PathBuf]) -> Self {
|
||||||
|
let mut files = BTreeSet::new();
|
||||||
|
let mut dirs = BTreeSet::new();
|
||||||
|
|
||||||
|
for path in source_files {
|
||||||
|
let normalized = normalize_watch_path(path);
|
||||||
|
files.insert(normalized.clone());
|
||||||
|
if let Some(parent) = normalized.parent() {
|
||||||
|
dirs.insert(parent.to_path_buf());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self { files, dirs }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches_event_paths(&self, event_paths: &[PathBuf]) -> bool {
|
||||||
|
event_paths
|
||||||
|
.iter()
|
||||||
|
.map(|path| normalize_watch_path(path))
|
||||||
|
.any(|path| self.files.contains(&path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub(super) struct ReloadState {
|
||||||
|
applied_snapshot_hash: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReloadState {
|
||||||
|
pub(super) fn new(applied_snapshot_hash: Option<u64>) -> Self {
|
||||||
|
Self {
|
||||||
|
applied_snapshot_hash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_applied(&self, hash: u64) -> bool {
|
||||||
|
self.applied_snapshot_hash == Some(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_applied(&mut self, hash: u64) {
|
||||||
|
self.applied_snapshot_hash = Some(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_watch_path(path: &Path) -> PathBuf {
|
||||||
|
path.canonicalize().unwrap_or_else(|_| {
|
||||||
|
if path.is_absolute() {
|
||||||
|
path.to_path_buf()
|
||||||
|
} else {
|
||||||
|
std::env::current_dir()
|
||||||
|
.map(|cwd| cwd.join(path))
|
||||||
|
.unwrap_or_else(|_| path.to_path_buf())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_watch_paths<W: Watcher>(
|
||||||
|
watcher: &mut W,
|
||||||
|
current: &BTreeSet<PathBuf>,
|
||||||
|
next: &BTreeSet<PathBuf>,
|
||||||
|
recursive_mode: RecursiveMode,
|
||||||
|
kind: &str,
|
||||||
|
) {
|
||||||
|
for path in current.difference(next) {
|
||||||
|
if let Err(e) = watcher.unwatch(path) {
|
||||||
|
warn!(path = %path.display(), error = %e, "config watcher: failed to unwatch {kind}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for path in next.difference(current) {
|
||||||
|
if let Err(e) = watcher.watch(path, recursive_mode) {
|
||||||
|
warn!(path = %path.display(), error = %e, "config watcher: failed to watch {kind}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_watch_manifest<W1: Watcher, W2: Watcher>(
|
||||||
|
notify_watcher: Option<&mut W1>,
|
||||||
|
poll_watcher: Option<&mut W2>,
|
||||||
|
manifest_state: &Arc<StdRwLock<WatchManifest>>,
|
||||||
|
next_manifest: WatchManifest,
|
||||||
|
) {
|
||||||
|
let current_manifest = manifest_state
|
||||||
|
.read()
|
||||||
|
.map(|manifest| manifest.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if current_manifest == next_manifest {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(watcher) = notify_watcher {
|
||||||
|
sync_watch_paths(
|
||||||
|
watcher,
|
||||||
|
¤t_manifest.dirs,
|
||||||
|
&next_manifest.dirs,
|
||||||
|
RecursiveMode::NonRecursive,
|
||||||
|
"config directory",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(watcher) = poll_watcher {
|
||||||
|
sync_watch_paths(
|
||||||
|
watcher,
|
||||||
|
¤t_manifest.files,
|
||||||
|
&next_manifest.files,
|
||||||
|
RecursiveMode::NonRecursive,
|
||||||
|
"config file",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(mut manifest) = manifest_state.write() {
|
||||||
|
*manifest = next_manifest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Load config, validate, diff against current, and broadcast if changed.
|
||||||
|
pub(super) fn reload_config(
|
||||||
|
config_path: &PathBuf,
|
||||||
|
config_tx: &watch::Sender<Arc<ProxyConfig>>,
|
||||||
|
log_tx: &watch::Sender<LogLevel>,
|
||||||
|
detected_ip_v4: Option<IpAddr>,
|
||||||
|
detected_ip_v6: Option<IpAddr>,
|
||||||
|
reload_state: &mut ReloadState,
|
||||||
|
) -> Option<WatchManifest> {
|
||||||
|
let loaded = match ProxyConfig::load_with_metadata(config_path) {
|
||||||
|
Ok(loaded) => loaded,
|
||||||
|
Err(e) => {
|
||||||
|
error!("config reload: failed to parse {:?}: {}", config_path, e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let LoadedConfig {
|
||||||
|
config: new_cfg,
|
||||||
|
source_files,
|
||||||
|
source_contents: _,
|
||||||
|
rendered_hash,
|
||||||
|
} = loaded;
|
||||||
|
let next_manifest = WatchManifest::from_source_files(&source_files);
|
||||||
|
|
||||||
|
if let Err(e) = new_cfg.validate() {
|
||||||
|
error!(
|
||||||
|
"config reload: validation failed: {}; keeping old config",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return Some(next_manifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if reload_state.is_applied(rendered_hash) {
|
||||||
|
return Some(next_manifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
let old_cfg = config_tx.borrow().clone();
|
||||||
|
let applied_cfg = overlay_hot_fields(&old_cfg, &new_cfg);
|
||||||
|
let old_hot = HotFields::from_config(&old_cfg);
|
||||||
|
let applied_hot = HotFields::from_config(&applied_cfg);
|
||||||
|
let non_hot_changed = !config_equal(&applied_cfg, &new_cfg);
|
||||||
|
let hot_changed = old_hot != applied_hot;
|
||||||
|
|
||||||
|
if non_hot_changed {
|
||||||
|
warn_non_hot_changes(&old_cfg, &new_cfg, non_hot_changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hot_changed {
|
||||||
|
reload_state.mark_applied(rendered_hash);
|
||||||
|
return Some(next_manifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if old_hot.dns_overrides != applied_hot.dns_overrides
|
||||||
|
&& let Err(e) = crate::network::dns_overrides::install_entries(&applied_hot.dns_overrides)
|
||||||
|
{
|
||||||
|
error!(
|
||||||
|
"config reload: invalid network.dns_overrides: {}; keeping old config",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return Some(next_manifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
log_changes(
|
||||||
|
&old_hot,
|
||||||
|
&applied_hot,
|
||||||
|
&applied_cfg,
|
||||||
|
log_tx,
|
||||||
|
detected_ip_v4,
|
||||||
|
detected_ip_v6,
|
||||||
|
);
|
||||||
|
config_tx.send(Arc::new(applied_cfg)).ok();
|
||||||
|
reload_state.mark_applied(rendered_hash);
|
||||||
|
Some(next_manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Spawn the hot-reload watcher task.
|
||||||
|
///
|
||||||
|
/// Uses `notify` (inotify on Linux) to detect file changes instantly.
|
||||||
|
/// SIGHUP is also handled on Unix as an additional manual trigger.
|
||||||
|
///
|
||||||
|
/// `detected_ip_v4` / `detected_ip_v6` are the IPs discovered during the
|
||||||
|
/// startup probe — used when generating proxy links for newly added users,
|
||||||
|
/// matching the same logic as the startup output.
|
||||||
|
/// The watcher releases its notify and signal resources when `cancellation` fires.
|
||||||
|
pub fn spawn_config_watcher(
|
||||||
|
config_path: PathBuf,
|
||||||
|
initial: Arc<ProxyConfig>,
|
||||||
|
detected_ip_v4: Option<IpAddr>,
|
||||||
|
detected_ip_v6: Option<IpAddr>,
|
||||||
|
cancellation: tokio_util::sync::CancellationToken,
|
||||||
|
) -> (watch::Receiver<Arc<ProxyConfig>>, watch::Receiver<LogLevel>) {
|
||||||
|
let initial_level = initial.general.log_level.clone();
|
||||||
|
let (config_tx, config_rx) = watch::channel(initial);
|
||||||
|
let (log_tx, log_rx) = watch::channel(initial_level);
|
||||||
|
|
||||||
|
let config_path = normalize_watch_path(&config_path);
|
||||||
|
let initial_loaded = ProxyConfig::load_with_metadata(&config_path).ok();
|
||||||
|
let initial_manifest = initial_loaded
|
||||||
|
.as_ref()
|
||||||
|
.map(|loaded| WatchManifest::from_source_files(&loaded.source_files))
|
||||||
|
.unwrap_or_else(|| WatchManifest::from_source_files(std::slice::from_ref(&config_path)));
|
||||||
|
let initial_snapshot_hash = initial_loaded.as_ref().map(|loaded| loaded.rendered_hash);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (notify_tx, mut notify_rx) = mpsc::channel::<()>(4);
|
||||||
|
let manifest_state = Arc::new(StdRwLock::new(WatchManifest::default()));
|
||||||
|
let mut reload_state = ReloadState::new(initial_snapshot_hash);
|
||||||
|
|
||||||
|
let tx_inotify = notify_tx.clone();
|
||||||
|
let manifest_for_inotify = manifest_state.clone();
|
||||||
|
let mut inotify_watcher =
|
||||||
|
match recommended_watcher(move |res: notify::Result<notify::Event>| {
|
||||||
|
let Ok(event) = res else { return };
|
||||||
|
if !matches!(
|
||||||
|
event.kind,
|
||||||
|
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let is_our_file = manifest_for_inotify
|
||||||
|
.read()
|
||||||
|
.map(|manifest| manifest.matches_event_paths(&event.paths))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if is_our_file {
|
||||||
|
let _ = tx_inotify.try_send(());
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Ok(watcher) => Some(watcher),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("config watcher: inotify unavailable: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
apply_watch_manifest(
|
||||||
|
inotify_watcher.as_mut(),
|
||||||
|
Option::<&mut notify::poll::PollWatcher>::None,
|
||||||
|
&manifest_state,
|
||||||
|
initial_manifest.clone(),
|
||||||
|
);
|
||||||
|
if inotify_watcher.is_some() {
|
||||||
|
info!("config watcher: inotify active on {:?}", config_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tx_poll = notify_tx.clone();
|
||||||
|
let manifest_for_poll = manifest_state.clone();
|
||||||
|
let mut poll_watcher = match notify::poll::PollWatcher::new(
|
||||||
|
move |res: notify::Result<notify::Event>| {
|
||||||
|
let Ok(event) = res else { return };
|
||||||
|
if !matches!(
|
||||||
|
event.kind,
|
||||||
|
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let is_our_file = manifest_for_poll
|
||||||
|
.read()
|
||||||
|
.map(|manifest| manifest.matches_event_paths(&event.paths))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if is_our_file {
|
||||||
|
let _ = tx_poll.try_send(());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
notify::Config::default()
|
||||||
|
.with_poll_interval(Duration::from_secs(3))
|
||||||
|
.with_compare_contents(true),
|
||||||
|
) {
|
||||||
|
Ok(watcher) => Some(watcher),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("config watcher: poll watcher unavailable: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
apply_watch_manifest(
|
||||||
|
Option::<&mut notify::RecommendedWatcher>::None,
|
||||||
|
poll_watcher.as_mut(),
|
||||||
|
&manifest_state,
|
||||||
|
initial_manifest.clone(),
|
||||||
|
);
|
||||||
|
if poll_watcher.is_some() {
|
||||||
|
info!("config watcher: poll watcher active (Docker/NFS safe)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
let mut sighup = {
|
||||||
|
use tokio::signal::unix::{SignalKind, signal};
|
||||||
|
signal(SignalKind::hangup()).expect("Failed to register SIGHUP handler")
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
#[cfg(unix)]
|
||||||
|
tokio::select! {
|
||||||
|
msg = notify_rx.recv() => {
|
||||||
|
if msg.is_none() { break; }
|
||||||
|
}
|
||||||
|
_ = sighup.recv() => {
|
||||||
|
info!("SIGHUP received — reloading {:?}", config_path);
|
||||||
|
}
|
||||||
|
_ = cancellation.cancelled() => break,
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
tokio::select! {
|
||||||
|
msg = notify_rx.recv() => {
|
||||||
|
if msg.is_none() { break; }
|
||||||
|
}
|
||||||
|
_ = cancellation.cancelled() => break,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce: drain extra events that arrive within a short quiet window.
|
||||||
|
tokio::time::sleep(HOT_RELOAD_DEBOUNCE).await;
|
||||||
|
while notify_rx.try_recv().is_ok() {}
|
||||||
|
|
||||||
|
let mut next_manifest = reload_config(
|
||||||
|
&config_path,
|
||||||
|
&config_tx,
|
||||||
|
&log_tx,
|
||||||
|
detected_ip_v4,
|
||||||
|
detected_ip_v6,
|
||||||
|
&mut reload_state,
|
||||||
|
);
|
||||||
|
if next_manifest.is_none() {
|
||||||
|
tokio::time::sleep(HOT_RELOAD_DEBOUNCE).await;
|
||||||
|
while notify_rx.try_recv().is_ok() {}
|
||||||
|
next_manifest = reload_config(
|
||||||
|
&config_path,
|
||||||
|
&config_tx,
|
||||||
|
&log_tx,
|
||||||
|
detected_ip_v4,
|
||||||
|
detected_ip_v6,
|
||||||
|
&mut reload_state,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(next_manifest) = next_manifest {
|
||||||
|
apply_watch_manifest(
|
||||||
|
inotify_watcher.as_mut(),
|
||||||
|
poll_watcher.as_mut(),
|
||||||
|
&manifest_state,
|
||||||
|
next_manifest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
(config_rx, log_rx)
|
||||||
|
}
|
||||||
+8
-1268
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,165 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) type DecodedSourceGraph = (
|
||||||
|
ProxyConfig,
|
||||||
|
BTreeSet<PathBuf>,
|
||||||
|
BTreeMap<PathBuf, String>,
|
||||||
|
String,
|
||||||
|
);
|
||||||
|
|
||||||
|
pub(super) fn decode_source_graph(graph: ConfigSourceGraph) -> Result<DecodedSourceGraph> {
|
||||||
|
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()))?;
|
||||||
|
handle_unknown_config_keys(&parsed_toml)?;
|
||||||
|
let general_table = parsed_toml
|
||||||
|
.get("general")
|
||||||
|
.and_then(|value| value.as_table());
|
||||||
|
let network_table = parsed_toml
|
||||||
|
.get("network")
|
||||||
|
.and_then(|value| value.as_table());
|
||||||
|
let server_table = parsed_toml.get("server").and_then(|value| value.as_table());
|
||||||
|
let conntrack_control_table = server_table
|
||||||
|
.and_then(|table| table.get("conntrack_control"))
|
||||||
|
.and_then(|value| value.as_table());
|
||||||
|
let update_every_is_explicit = general_table
|
||||||
|
.map(|table| table.contains_key("update_every"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let beobachten_is_explicit = general_table
|
||||||
|
.map(|table| table.contains_key("beobachten"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let beobachten_minutes_is_explicit = general_table
|
||||||
|
.map(|table| table.contains_key("beobachten_minutes"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let beobachten_flush_secs_is_explicit = general_table
|
||||||
|
.map(|table| table.contains_key("beobachten_flush_secs"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let beobachten_file_is_explicit = general_table
|
||||||
|
.map(|table| table.contains_key("beobachten_file"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let legacy_secret_is_explicit = general_table
|
||||||
|
.map(|table| table.contains_key("proxy_secret_auto_reload_secs"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let legacy_config_is_explicit = general_table
|
||||||
|
.map(|table| table.contains_key("proxy_config_auto_reload_secs"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let legacy_top_level_beobachten = parsed_toml.get("beobachten").cloned();
|
||||||
|
let legacy_top_level_beobachten_minutes = parsed_toml.get("beobachten_minutes").cloned();
|
||||||
|
let legacy_top_level_beobachten_flush_secs =
|
||||||
|
parsed_toml.get("beobachten_flush_secs").cloned();
|
||||||
|
let legacy_top_level_beobachten_file = parsed_toml.get("beobachten_file").cloned();
|
||||||
|
let stun_servers_is_explicit = network_table
|
||||||
|
.map(|table| table.contains_key("stun_servers"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let inline_conntrack_control_is_explicit = conntrack_control_table
|
||||||
|
.map(|table| table.contains_key("inline_conntrack_control"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let mut config: ProxyConfig = parsed_toml
|
||||||
|
.try_into()
|
||||||
|
.map_err(|e| ProxyError::Config(e.to_string()))?;
|
||||||
|
config
|
||||||
|
.server
|
||||||
|
.conntrack_control
|
||||||
|
.inline_conntrack_control_explicit = inline_conntrack_control_is_explicit;
|
||||||
|
|
||||||
|
if !update_every_is_explicit && (legacy_secret_is_explicit || legacy_config_is_explicit) {
|
||||||
|
config.general.update_every = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward compatibility: legacy top-level beobachten* keys.
|
||||||
|
// Prefer `[general].*` when both are present.
|
||||||
|
let mut legacy_beobachten_applied = false;
|
||||||
|
if !beobachten_is_explicit && let Some(value) = legacy_top_level_beobachten.as_ref() {
|
||||||
|
let parsed = value.as_bool().ok_or_else(|| {
|
||||||
|
ProxyError::Config("beobachten (top-level) must be a boolean".to_string())
|
||||||
|
})?;
|
||||||
|
config.general.beobachten = parsed;
|
||||||
|
legacy_beobachten_applied = true;
|
||||||
|
}
|
||||||
|
if !beobachten_minutes_is_explicit
|
||||||
|
&& let Some(value) = legacy_top_level_beobachten_minutes.as_ref()
|
||||||
|
{
|
||||||
|
let raw = value.as_integer().ok_or_else(|| {
|
||||||
|
ProxyError::Config("beobachten_minutes (top-level) must be an integer".to_string())
|
||||||
|
})?;
|
||||||
|
let parsed = u64::try_from(raw).map_err(|_| {
|
||||||
|
ProxyError::Config(
|
||||||
|
"beobachten_minutes (top-level) must be within u64 range".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
config.general.beobachten_minutes = parsed;
|
||||||
|
legacy_beobachten_applied = true;
|
||||||
|
}
|
||||||
|
if !beobachten_flush_secs_is_explicit
|
||||||
|
&& let Some(value) = legacy_top_level_beobachten_flush_secs.as_ref()
|
||||||
|
{
|
||||||
|
let raw = value.as_integer().ok_or_else(|| {
|
||||||
|
ProxyError::Config(
|
||||||
|
"beobachten_flush_secs (top-level) must be an integer".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let parsed = u64::try_from(raw).map_err(|_| {
|
||||||
|
ProxyError::Config(
|
||||||
|
"beobachten_flush_secs (top-level) must be within u64 range".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
config.general.beobachten_flush_secs = parsed;
|
||||||
|
legacy_beobachten_applied = true;
|
||||||
|
}
|
||||||
|
if !beobachten_file_is_explicit
|
||||||
|
&& let Some(value) = legacy_top_level_beobachten_file.as_ref()
|
||||||
|
{
|
||||||
|
let parsed = value.as_str().ok_or_else(|| {
|
||||||
|
ProxyError::Config("beobachten_file (top-level) must be a string".to_string())
|
||||||
|
})?;
|
||||||
|
config.general.beobachten_file = parsed.to_string();
|
||||||
|
legacy_beobachten_applied = true;
|
||||||
|
}
|
||||||
|
if legacy_beobachten_applied {
|
||||||
|
warn!("top-level beobachten* keys are deprecated; use general.beobachten* instead");
|
||||||
|
}
|
||||||
|
|
||||||
|
let legacy_nat_stun = config.general.middle_proxy_nat_stun.take();
|
||||||
|
let legacy_nat_stun_servers =
|
||||||
|
std::mem::take(&mut config.general.middle_proxy_nat_stun_servers);
|
||||||
|
let legacy_nat_stun_used = legacy_nat_stun.is_some() || !legacy_nat_stun_servers.is_empty();
|
||||||
|
if stun_servers_is_explicit {
|
||||||
|
let mut explicit_stun_servers = Vec::new();
|
||||||
|
for stun in std::mem::take(&mut config.network.stun_servers) {
|
||||||
|
push_unique_nonempty(&mut explicit_stun_servers, stun);
|
||||||
|
}
|
||||||
|
config.network.stun_servers = explicit_stun_servers;
|
||||||
|
|
||||||
|
if legacy_nat_stun_used {
|
||||||
|
warn!(
|
||||||
|
"general.middle_proxy_nat_stun and general.middle_proxy_nat_stun_servers are ignored because network.stun_servers is explicitly set"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Keep the default STUN pool unless network.stun_servers is explicitly overridden.
|
||||||
|
let mut unified_stun_servers = default_stun_servers();
|
||||||
|
if let Some(stun) = legacy_nat_stun {
|
||||||
|
push_unique_nonempty(&mut unified_stun_servers, stun);
|
||||||
|
}
|
||||||
|
for stun in legacy_nat_stun_servers {
|
||||||
|
push_unique_nonempty(&mut unified_stun_servers, stun);
|
||||||
|
}
|
||||||
|
|
||||||
|
config.network.stun_servers = unified_stun_servers;
|
||||||
|
|
||||||
|
if legacy_nat_stun_used {
|
||||||
|
warn!(
|
||||||
|
"general.middle_proxy_nat_stun and general.middle_proxy_nat_stun_servers are deprecated; use network.stun_servers"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitize_ad_tag(&mut config.general.ad_tag);
|
||||||
|
Ok((config, source_files, source_contents, processed))
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
|
||||||
|
// Normalize optional TLS fetch scope: whitespace-only values disable scoped routing.
|
||||||
|
config.censorship.tls_fetch_scope = config.censorship.tls_fetch_scope.trim().to_string();
|
||||||
|
|
||||||
|
if config.censorship.tls_fetch.profiles.is_empty() {
|
||||||
|
config.censorship.tls_fetch.profiles = TlsFetchConfig::default().profiles;
|
||||||
|
} else {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
config
|
||||||
|
.censorship
|
||||||
|
.tls_fetch
|
||||||
|
.profiles
|
||||||
|
.retain(|profile| seen.insert(*profile));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.censorship.tls_fetch.attempt_timeout_ms == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"censorship.tls_fetch.attempt_timeout_ms must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if config.censorship.tls_fetch.total_budget_ms == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"censorship.tls_fetch.total_budget_ms must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge primary + extra TLS domains, deduplicate (primary always first).
|
||||||
|
if !config.censorship.tls_domains.is_empty() {
|
||||||
|
let mut all = Vec::with_capacity(1 + config.censorship.tls_domains.len());
|
||||||
|
all.push(config.censorship.tls_domain.clone());
|
||||||
|
for d in std::mem::take(&mut config.censorship.tls_domains) {
|
||||||
|
if !d.is_empty() {
|
||||||
|
let domain = normalize_domain_to_ascii(&d, "censorship.tls_domains entry")?;
|
||||||
|
if !all.contains(&domain) {
|
||||||
|
all.push(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// keep primary as tls_domain; store remaining back to tls_domains
|
||||||
|
if all.len() > 1 {
|
||||||
|
config.censorship.tls_domains = all[1..].to_vec();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut exclusive_mask = HashMap::with_capacity(config.censorship.exclusive_mask.len());
|
||||||
|
let mut exclusive_mask_targets =
|
||||||
|
HashMap::with_capacity(config.censorship.exclusive_mask.len());
|
||||||
|
for (domain, target) in std::mem::take(&mut config.censorship.exclusive_mask) {
|
||||||
|
let domain = normalize_domain_to_ascii(&domain, "censorship.exclusive_mask domain")?;
|
||||||
|
let target =
|
||||||
|
normalize_exclusive_mask_target(&target, "censorship.exclusive_mask target")?;
|
||||||
|
let Some((host, port)) = parse_exclusive_mask_target(&target) else {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"Invalid censorship.exclusive_mask target for '{}': '{}'. Expected host:port with port > 0",
|
||||||
|
domain, target
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
exclusive_mask_targets.insert(
|
||||||
|
domain.clone(),
|
||||||
|
ExclusiveMaskTarget {
|
||||||
|
host: host.to_string(),
|
||||||
|
port,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
exclusive_mask.insert(domain, target);
|
||||||
|
}
|
||||||
|
config.censorship.exclusive_mask = exclusive_mask;
|
||||||
|
config.censorship.exclusive_mask_targets = exclusive_mask_targets;
|
||||||
|
|
||||||
|
// Migration: prefer_ipv6 -> network.prefer.
|
||||||
|
if config.general.prefer_ipv6 {
|
||||||
|
if config.network.prefer == 4 {
|
||||||
|
config.network.prefer = 6;
|
||||||
|
}
|
||||||
|
warn!("prefer_ipv6 is deprecated, use [network].prefer = 6");
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.use_middle_proxy && !config.general.me_secret_atomic_snapshot {
|
||||||
|
config.general.me_secret_atomic_snapshot = true;
|
||||||
|
warn!(
|
||||||
|
"Auto-enabled me_secret_atomic_snapshot for middle proxy mode to keep KDF key_selector/secret coherent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_network_cfg(&mut config.network)?;
|
||||||
|
crate::network::dns_overrides::validate_entries(&config.network.dns_overrides)?;
|
||||||
|
|
||||||
|
if config.general.use_middle_proxy && config.network.ipv6 == Some(true) {
|
||||||
|
warn!(
|
||||||
|
"IPv6 with Middle Proxy is experimental and may cause KDF address mismatch; consider disabling IPv6 or ME"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random fake_cert_len only when default is in use.
|
||||||
|
if !config.censorship.tls_emulation
|
||||||
|
&& config.censorship.fake_cert_len == default_fake_cert_len()
|
||||||
|
{
|
||||||
|
config.censorship.fake_cert_len = rand::rng().random_range(1024..4096);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve listen_tcp: explicit value wins, otherwise auto-detect.
|
||||||
|
// If unix socket is set → TCP only when listen_addr_ipv4 or listeners are explicitly provided.
|
||||||
|
// If no unix socket → TCP always (backward compat).
|
||||||
|
let listen_tcp = config.server.listen_tcp.unwrap_or_else(|| {
|
||||||
|
if config.server.listen_unix_sock.is_some() {
|
||||||
|
// Unix socket present: TCP only if user explicitly set addresses or listeners.
|
||||||
|
config.server.listen_addr_ipv4.is_some() || !config.server.listeners.is_empty()
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Migration: Populate listeners if empty (skip when listen_tcp = false).
|
||||||
|
if config.server.listeners.is_empty() && listen_tcp {
|
||||||
|
let ipv4_str = config
|
||||||
|
.server
|
||||||
|
.listen_addr_ipv4
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("0.0.0.0");
|
||||||
|
if let Ok(ipv4) = ipv4_str.parse::<IpAddr>() {
|
||||||
|
config.server.listeners.push(ListenerConfig {
|
||||||
|
ip: ipv4,
|
||||||
|
port: Some(config.server.port),
|
||||||
|
client_mss: None,
|
||||||
|
synlimit: SynLimitMode::default(),
|
||||||
|
synlimit_seconds: default_synlimit_seconds(),
|
||||||
|
synlimit_hitcount: default_synlimit_hitcount(),
|
||||||
|
synlimit_burst: default_synlimit_burst(),
|
||||||
|
synlimit_ios_seconds: default_synlimit_ios_seconds(),
|
||||||
|
synlimit_ios_hitcount: default_synlimit_ios_hitcount(),
|
||||||
|
synlimit_ios_burst: default_synlimit_ios_burst(),
|
||||||
|
synlimit_hashlimit_expire_ms: default_synlimit_hashlimit_expire_ms(),
|
||||||
|
synlimit_hashlimit_size: default_synlimit_hashlimit_size(),
|
||||||
|
announce: None,
|
||||||
|
announce_ip: None,
|
||||||
|
proxy_protocol: None,
|
||||||
|
reuse_allow: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(ipv6_str) = &config.server.listen_addr_ipv6
|
||||||
|
&& let Ok(ipv6) = ipv6_str.parse::<IpAddr>()
|
||||||
|
{
|
||||||
|
config.server.listeners.push(ListenerConfig {
|
||||||
|
ip: ipv6,
|
||||||
|
port: Some(config.server.port),
|
||||||
|
client_mss: None,
|
||||||
|
synlimit: SynLimitMode::default(),
|
||||||
|
synlimit_seconds: default_synlimit_seconds(),
|
||||||
|
synlimit_hitcount: default_synlimit_hitcount(),
|
||||||
|
synlimit_burst: default_synlimit_burst(),
|
||||||
|
synlimit_ios_seconds: default_synlimit_ios_seconds(),
|
||||||
|
synlimit_ios_hitcount: default_synlimit_ios_hitcount(),
|
||||||
|
synlimit_ios_burst: default_synlimit_ios_burst(),
|
||||||
|
synlimit_hashlimit_expire_ms: default_synlimit_hashlimit_expire_ms(),
|
||||||
|
synlimit_hashlimit_size: default_synlimit_hashlimit_size(),
|
||||||
|
announce: None,
|
||||||
|
announce_ip: None,
|
||||||
|
proxy_protocol: None,
|
||||||
|
reuse_allow: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: listeners[].port fallback to legacy server.port.
|
||||||
|
for listener in &mut config.server.listeners {
|
||||||
|
if listener.port.is_none() {
|
||||||
|
listener.port = Some(config.server.port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: announce_ip → announce for each listener.
|
||||||
|
for listener in &mut config.server.listeners {
|
||||||
|
if listener.announce.is_none()
|
||||||
|
&& let Some(ip) = listener.announce_ip.take()
|
||||||
|
{
|
||||||
|
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() {
|
||||||
|
config.general.links.show = config.show_link.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: Populate upstreams if empty (Default Direct).
|
||||||
|
if config.upstreams.is_empty() {
|
||||||
|
config.upstreams.push(UpstreamConfig {
|
||||||
|
upstream_type: UpstreamType::Direct {
|
||||||
|
interface: None,
|
||||||
|
bind_addresses: None,
|
||||||
|
bindtodevice: None,
|
||||||
|
},
|
||||||
|
weight: 1,
|
||||||
|
enabled: true,
|
||||||
|
scopes: String::new(),
|
||||||
|
selected_scope: String::new(),
|
||||||
|
ipv4: None,
|
||||||
|
ipv6: None,
|
||||||
|
prefer: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
normalize_upstream_family_policy(config);
|
||||||
|
|
||||||
|
// Ensure default DC203 override is present.
|
||||||
|
config
|
||||||
|
.dc_overrides
|
||||||
|
.entry("203".to_string())
|
||||||
|
.or_insert_with(|| vec!["91.105.192.100:443".to_string()]);
|
||||||
|
|
||||||
|
validate_logging_config(&config.logging)?;
|
||||||
|
validate_upstreams(config)?;
|
||||||
|
config.rebuild_runtime_user_auth()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) fn load_source_graph(graph: ConfigSourceGraph) -> Result<LoadedConfig> {
|
||||||
|
let (mut config, source_files, source_contents, processed) =
|
||||||
|
decode::decode_source_graph(graph)?;
|
||||||
|
validate_core::validate(&mut config)?;
|
||||||
|
validate_runtime::validate(&mut config)?;
|
||||||
|
validate_me::validate(&mut config)?;
|
||||||
|
validate_server::validate(&mut config)?;
|
||||||
|
effective::apply(&mut config)?;
|
||||||
|
Ok(LoadedConfig {
|
||||||
|
config,
|
||||||
|
source_files: source_files.into_iter().collect(),
|
||||||
|
source_contents,
|
||||||
|
rendered_hash: hash_rendered_snapshot(&processed),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
|
||||||
|
if let Some(path) = &config.general.proxy_config_v4_cache_path
|
||||||
|
&& path.trim().is_empty()
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.proxy_config_v4_cache_path cannot be empty when provided".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(path) = &config.general.proxy_config_v6_cache_path
|
||||||
|
&& path.trim().is_empty()
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.proxy_config_v6_cache_path cannot be empty when provided".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(update_every) = config.general.update_every {
|
||||||
|
if update_every == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.update_every must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let legacy_secret = config.general.proxy_secret_auto_reload_secs;
|
||||||
|
let legacy_config = config.general.proxy_config_auto_reload_secs;
|
||||||
|
let effective = legacy_secret.min(legacy_config);
|
||||||
|
if effective == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"legacy proxy_*_auto_reload_secs values must be > 0 when general.update_every is not set".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if legacy_secret != default_proxy_secret_reload_secs()
|
||||||
|
|| legacy_config != default_proxy_config_reload_secs()
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
proxy_secret_auto_reload_secs = legacy_secret,
|
||||||
|
proxy_config_auto_reload_secs = legacy_config,
|
||||||
|
effective_update_every_secs = effective,
|
||||||
|
"proxy_*_auto_reload_secs are deprecated; set general.update_every"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.stun_nat_probe_concurrency == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.stun_nat_probe_concurrency must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_init_retry_attempts > 1_000_000 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_init_retry_attempts must be within [0, 1000000]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.upstream_connect_retry_attempts == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.upstream_connect_retry_attempts must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.upstream_connect_budget_ms == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.upstream_connect_budget_ms must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.tg_connect == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.tg_connect must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.upstream_unhealthy_fail_threshold == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.upstream_unhealthy_fail_threshold must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.rpc_proxy_req_every != 0
|
||||||
|
&& !(10..=300).contains(&config.general.rpc_proxy_req_every)
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.rpc_proxy_req_every must be 0 or within [10, 300]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.timeouts.client_handshake == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"timeouts.client_handshake must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let handshake_timeout_ms = config
|
||||||
|
.timeouts
|
||||||
|
.client_handshake
|
||||||
|
.checked_mul(1000)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ProxyError::Config(
|
||||||
|
"timeouts.client_handshake is too large to validate milliseconds budget"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if config.censorship.server_hello_delay_max_ms >= handshake_timeout_ms {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"censorship.server_hello_delay_max_ms must be < timeouts.client_handshake * 1000"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.censorship.mask_shape_bucket_floor_bytes == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"censorship.mask_shape_bucket_floor_bytes must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.censorship.mask_shape_bucket_cap_bytes
|
||||||
|
< config.censorship.mask_shape_bucket_floor_bytes
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"censorship.mask_shape_bucket_cap_bytes must be >= censorship.mask_shape_bucket_floor_bytes"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.censorship.mask_shape_above_cap_blur && !config.censorship.mask_shape_hardening {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"censorship.mask_shape_above_cap_blur requires censorship.mask_shape_hardening = true"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.censorship.mask_shape_hardening_aggressive_mode
|
||||||
|
&& !config.censorship.mask_shape_hardening
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"censorship.mask_shape_hardening_aggressive_mode requires censorship.mask_shape_hardening = true"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.censorship.mask_shape_above_cap_blur
|
||||||
|
&& config.censorship.mask_shape_above_cap_blur_max_bytes == 0
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"censorship.mask_shape_above_cap_blur_max_bytes must be > 0 when censorship.mask_shape_above_cap_blur is enabled"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.censorship.mask_shape_above_cap_blur_max_bytes > 1_048_576 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"censorship.mask_shape_above_cap_blur_max_bytes must be <= 1048576".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.censorship.mask_relay_max_bytes > 67_108_864 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"censorship.mask_relay_max_bytes must be <= 67108864".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(5..=50).contains(&config.censorship.mask_classifier_prefetch_timeout_ms) {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"censorship.mask_classifier_prefetch_timeout_ms must be within [5, 50]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.censorship.mask_timing_normalization_ceiling_ms
|
||||||
|
< config.censorship.mask_timing_normalization_floor_ms
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"censorship.mask_timing_normalization_ceiling_ms must be >= censorship.mask_timing_normalization_floor_ms"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.censorship.mask_timing_normalization_enabled
|
||||||
|
&& config.censorship.mask_timing_normalization_floor_ms == 0
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"censorship.mask_timing_normalization_floor_ms must be > 0 when censorship.mask_timing_normalization_enabled is true"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.censorship.mask_timing_normalization_ceiling_ms > 60_000 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"censorship.mask_timing_normalization_ceiling_ms must be <= 60000".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.timeouts.relay_client_idle_soft_secs == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"timeouts.relay_client_idle_soft_secs must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.timeouts.relay_client_idle_hard_secs == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"timeouts.relay_client_idle_hard_secs must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.timeouts.relay_client_idle_hard_secs < config.timeouts.relay_client_idle_soft_secs
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"timeouts.relay_client_idle_hard_secs must be >= timeouts.relay_client_idle_soft_secs"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config
|
||||||
|
.timeouts
|
||||||
|
.relay_idle_grace_after_downstream_activity_secs
|
||||||
|
> config.timeouts.relay_client_idle_hard_secs
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"timeouts.relay_idle_grace_after_downstream_activity_secs must be <= timeouts.relay_client_idle_hard_secs"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
|
||||||
|
if config.general.me_reinit_every_secs == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_reinit_every_secs must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_single_endpoint_shadow_writers > 32 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_single_endpoint_shadow_writers must be within [0, 32]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_adaptive_floor_min_writers_single_endpoint == 0
|
||||||
|
|| config.general.me_adaptive_floor_min_writers_single_endpoint > 32
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_adaptive_floor_min_writers_single_endpoint must be within [1, 32]"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_adaptive_floor_min_writers_multi_endpoint == 0
|
||||||
|
|| config.general.me_adaptive_floor_min_writers_multi_endpoint > 32
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_adaptive_floor_min_writers_multi_endpoint must be within [1, 32]"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_adaptive_floor_writers_per_core_total == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_adaptive_floor_writers_per_core_total must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_adaptive_floor_max_active_writers_per_core == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_adaptive_floor_max_active_writers_per_core must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_adaptive_floor_max_warm_writers_per_core == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_adaptive_floor_max_warm_writers_per_core must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_adaptive_floor_max_active_writers_global == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_adaptive_floor_max_active_writers_global must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_adaptive_floor_max_warm_writers_global == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_adaptive_floor_max_warm_writers_global must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_single_endpoint_outage_backoff_min_ms == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_single_endpoint_outage_backoff_min_ms must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_single_endpoint_outage_backoff_max_ms == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_single_endpoint_outage_backoff_max_ms must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_single_endpoint_outage_backoff_min_ms
|
||||||
|
> config.general.me_single_endpoint_outage_backoff_max_ms
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_single_endpoint_outage_backoff_min_ms must be <= general.me_single_endpoint_outage_backoff_max_ms".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.beobachten_minutes == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.beobachten_minutes must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.beobachten_flush_secs == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.beobachten_flush_secs must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.beobachten_file.trim().is_empty() {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.beobachten_file cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_hardswap_warmup_delay_max_ms == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_hardswap_warmup_delay_max_ms must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_hardswap_warmup_delay_min_ms
|
||||||
|
> config.general.me_hardswap_warmup_delay_max_ms
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_hardswap_warmup_delay_min_ms must be <= general.me_hardswap_warmup_delay_max_ms".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_hardswap_warmup_extra_passes > 10 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_hardswap_warmup_extra_passes must be within [0, 10]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_hardswap_warmup_pass_backoff_base_ms == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_hardswap_warmup_pass_backoff_base_ms must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_config_stable_snapshots == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_config_stable_snapshots must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_snapshot_min_proxy_for_lines == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_snapshot_min_proxy_for_lines must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.proxy_secret_stable_snapshots == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.proxy_secret_stable_snapshots must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_reinit_trigger_channel == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_reinit_trigger_channel must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(32..=4096).contains(&config.general.proxy_secret_len_max) {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.proxy_secret_len_max must be within [32, 4096]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(0.0..=1.0).contains(&config.general.me_pool_min_fresh_ratio) {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_pool_min_fresh_ratio must be within [0.0, 1.0]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_route_backpressure_base_timeout_ms == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_route_backpressure_base_timeout_ms must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if config.general.me_route_backpressure_base_timeout_ms > 5000 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_route_backpressure_base_timeout_ms must be within [1, 5000]"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_route_backpressure_high_timeout_ms
|
||||||
|
< config.general.me_route_backpressure_base_timeout_ms
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_route_backpressure_high_timeout_ms must be >= general.me_route_backpressure_base_timeout_ms".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if config.general.me_route_backpressure_high_timeout_ms > 5000 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_route_backpressure_high_timeout_ms must be within [1, 5000]"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(1..=100).contains(&config.general.me_route_backpressure_high_watermark_pct) {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_route_backpressure_high_watermark_pct must be within [1, 100]"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(10..=5000).contains(&config.general.me_route_no_writer_wait_ms) {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_route_no_writer_wait_ms must be within [10, 5000]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(50..=60_000).contains(&config.general.me_route_hybrid_max_wait_ms) {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_route_hybrid_max_wait_ms must be within [50, 60000]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(1..=5000).contains(&config.general.me_route_blocking_send_timeout_ms) {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_route_blocking_send_timeout_ms must be within [1, 5000]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(2..=4).contains(&config.general.me_writer_pick_sample_size) {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_writer_pick_sample_size must be within [2, 4]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_route_inline_recovery_attempts == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_route_inline_recovery_attempts must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(10..=30000).contains(&config.general.me_route_inline_recovery_wait_ms) {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_route_inline_recovery_wait_ms must be within [10, 30000]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
|
||||||
|
if config.general.me_writer_cmd_channel_capacity == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_writer_cmd_channel_capacity must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if config.general.me_writer_cmd_channel_capacity > MAX_ME_WRITER_CMD_CHANNEL_CAPACITY {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"general.me_writer_cmd_channel_capacity must be within [1, {MAX_ME_WRITER_CMD_CHANNEL_CAPACITY}]"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_route_channel_capacity == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_route_channel_capacity must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if config.general.me_route_channel_capacity > MAX_ME_ROUTE_CHANNEL_CAPACITY {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"general.me_route_channel_capacity must be within [1, {MAX_ME_ROUTE_CHANNEL_CAPACITY}]"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_c2me_channel_capacity == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_c2me_channel_capacity must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if config.general.me_c2me_channel_capacity > MAX_ME_C2ME_CHANNEL_CAPACITY {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"general.me_c2me_channel_capacity must be within [1, {MAX_ME_C2ME_CHANNEL_CAPACITY}]"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(MIN_MAX_CLIENT_FRAME_BYTES..=MAX_MAX_CLIENT_FRAME_BYTES)
|
||||||
|
.contains(&config.general.max_client_frame)
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"general.max_client_frame must be within [{MIN_MAX_CLIENT_FRAME_BYTES}, {MAX_MAX_CLIENT_FRAME_BYTES}]"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let min_writer_byte_budget =
|
||||||
|
minimum_me_writer_byte_budget_bytes(config.general.max_client_frame);
|
||||||
|
if config.general.me_writer_byte_budget_bytes % ME_WRITER_BYTE_PERMIT_UNIT_BYTES != 0 {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"general.me_writer_byte_budget_bytes must be a multiple of {ME_WRITER_BYTE_PERMIT_UNIT_BYTES}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !(min_writer_byte_budget..=MAX_ME_WRITER_BYTE_BUDGET_BYTES)
|
||||||
|
.contains(&config.general.me_writer_byte_budget_bytes)
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"general.me_writer_byte_budget_bytes must be within [{min_writer_byte_budget}, {MAX_ME_WRITER_BYTE_BUDGET_BYTES}] for general.max_client_frame={}",
|
||||||
|
config.general.max_client_frame
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_c2me_send_timeout_ms > 60_000 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_c2me_send_timeout_ms must be within [0, 60000]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_reader_route_data_wait_ms > 20 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_reader_route_data_wait_ms must be within [0, 20]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(1..=512).contains(&config.general.me_d2c_flush_batch_max_frames) {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_d2c_flush_batch_max_frames must be within [1, 512]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(4096..=2 * 1024 * 1024).contains(&config.general.me_d2c_flush_batch_max_bytes) {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_d2c_flush_batch_max_bytes must be within [4096, 2097152]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_d2c_flush_batch_max_delay_us > 5000 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_d2c_flush_batch_max_delay_us must be within [0, 5000]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_quota_soft_overshoot_bytes > 16 * 1024 * 1024 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_quota_soft_overshoot_bytes must be within [0, 16777216]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(4096..=16 * 1024 * 1024)
|
||||||
|
.contains(&config.general.me_d2c_frame_buf_shrink_threshold_bytes)
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_d2c_frame_buf_shrink_threshold_bytes must be within [4096, 16777216]"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(4096..=1024 * 1024).contains(&config.general.direct_relay_copy_buf_c2s_bytes) {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.direct_relay_copy_buf_c2s_bytes must be within [4096, 1048576]"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(8192..=2 * 1024 * 1024).contains(&config.general.direct_relay_copy_buf_s2c_bytes) {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.direct_relay_copy_buf_s2c_bytes must be within [8192, 2097152]"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.direct_relay_buffer_budget_max_bytes != 0 {
|
||||||
|
if config.general.direct_relay_buffer_budget_max_bytes
|
||||||
|
% DIRECT_RELAY_BUFFER_BUDGET_UNIT_BYTES
|
||||||
|
!= 0
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"general.direct_relay_buffer_budget_max_bytes must be 0 or a multiple of {DIRECT_RELAY_BUFFER_BUDGET_UNIT_BYTES}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !(MIN_DIRECT_RELAY_BUFFER_BUDGET_BYTES..=MAX_DIRECT_RELAY_BUFFER_BUDGET_BYTES)
|
||||||
|
.contains(&config.general.direct_relay_buffer_budget_max_bytes)
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"general.direct_relay_buffer_budget_max_bytes must be 0 or within [{MIN_DIRECT_RELAY_BUFFER_BUDGET_BYTES}, {MAX_DIRECT_RELAY_BUFFER_BUDGET_BYTES}]"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_health_interval_ms_unhealthy == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_health_interval_ms_unhealthy must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_health_interval_ms_healthy == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_health_interval_ms_healthy must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_admission_poll_ms == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_admission_poll_ms must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_warn_rate_limit_ms == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_warn_rate_limit_ms must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_pool_drain_soft_evict_grace_secs > 3600 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_pool_drain_soft_evict_grace_secs must be within [0, 3600]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_pool_drain_soft_evict_per_writer == 0
|
||||||
|
|| config.general.me_pool_drain_soft_evict_per_writer > 16
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_pool_drain_soft_evict_per_writer must be within [1, 16]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_pool_drain_soft_evict_budget_per_core == 0
|
||||||
|
|| config.general.me_pool_drain_soft_evict_budget_per_core > 64
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_pool_drain_soft_evict_budget_per_core must be within [1, 64]"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.me_pool_drain_soft_evict_cooldown_ms == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"general.me_pool_drain_soft_evict_cooldown_ms must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.access.user_max_unique_ips_window_secs == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"access.user_max_unique_ips_window_secs must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (user, limit) in &config.access.user_rate_limits {
|
||||||
|
if limit.up_bps == 0 && limit.down_bps == 0 {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"access.user_rate_limits.{user} must set at least one non-zero direction"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (cidr, limit) in &config.access.cidr_rate_limits {
|
||||||
|
if limit.up_bps == 0 && limit.down_bps == 0 {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"access.cidr_rate_limits.{cidr} must set at least one non-zero direction"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut cidr_auto_templates = HashSet::new();
|
||||||
|
for cidr in config.access.cidr_rate_limits.keys() {
|
||||||
|
for template in cidr.auto_templates().into_iter().flatten() {
|
||||||
|
if !cidr_auto_templates.insert(template) {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"access.cidr_rate_limits.{cidr} duplicates normalized auto-template {template}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
|
||||||
|
if !(1..=MAX_API_REQUEST_BODY_LIMIT_BYTES)
|
||||||
|
.contains(&config.server.api.request_body_limit_bytes)
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"server.api.request_body_limit_bytes must be within [1, 1048576]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.server.api.minimal_runtime_cache_ttl_ms > 60_000 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"server.api.minimal_runtime_cache_ttl_ms must be within [0, 60000]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.server.api.runtime_edge_cache_ttl_ms > 60_000 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"server.api.runtime_edge_cache_ttl_ms must be within [0, 60000]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(1..=1000).contains(&config.server.api.runtime_edge_top_n) {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"server.api.runtime_edge_top_n must be within [1, 1000]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(16..=4096).contains(&config.server.api.runtime_edge_events_capacity) {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"server.api.runtime_edge_events_capacity must be within [16, 4096]".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.server.api.listen.parse::<SocketAddr>().is_err() {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"server.api.listen must be in IP:PORT format".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.server.proxy_protocol_header_timeout_ms == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"server.proxy_protocol_header_timeout_ms must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.server.listen_backlog == 0 || config.server.listen_backlog > i32::MAX as u32 {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"server.listen_backlog must be within [1, {}]",
|
||||||
|
i32::MAX
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
config
|
||||||
|
.server
|
||||||
|
.client_mss_value()
|
||||||
|
.map_err(|error| ProxyError::Config(format!("server.client_mss {error}")))?;
|
||||||
|
config
|
||||||
|
.server
|
||||||
|
.client_mss_bulk_value()
|
||||||
|
.map_err(|error| ProxyError::Config(format!("server.client_mss_bulk {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 listener.synlimit_seconds == 0 {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"server.listeners[{idx}].synlimit_seconds must be > 0"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if listener.synlimit_hitcount == 0 {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"server.listeners[{idx}].synlimit_hitcount must be > 0"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if listener.synlimit_burst == 0 {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"server.listeners[{idx}].synlimit_burst must be > 0"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if listener.synlimit_ios_seconds == 0 {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"server.listeners[{idx}].synlimit_ios_seconds must be > 0"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if listener.synlimit_ios_hitcount == 0 {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"server.listeners[{idx}].synlimit_ios_hitcount must be > 0"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if listener.synlimit_ios_burst == 0 {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"server.listeners[{idx}].synlimit_ios_burst must be > 0"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if listener.synlimit_hashlimit_expire_ms == 0 {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"server.listeners[{idx}].synlimit_hashlimit_expire_ms must be > 0"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if listener.synlimit_hashlimit_size == 0 {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"server.listeners[{idx}].synlimit_hashlimit_size must be > 0"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.server.conntrack_control.pressure_high_watermark_pct == 0
|
||||||
|
|| config.server.conntrack_control.pressure_high_watermark_pct > 100
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"server.conntrack_control.pressure_high_watermark_pct must be within [1, 100]"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.server.conntrack_control.pressure_low_watermark_pct
|
||||||
|
>= config.server.conntrack_control.pressure_high_watermark_pct
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"server.conntrack_control.pressure_low_watermark_pct must be < pressure_high_watermark_pct"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.server.conntrack_control.delete_budget_per_sec == 0 {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"server.conntrack_control.delete_budget_per_sec must be > 0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(config.server.conntrack_control.mode, ConntrackMode::Hybrid)
|
||||||
|
&& config
|
||||||
|
.server
|
||||||
|
.conntrack_control
|
||||||
|
.hybrid_listener_ips
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"server.conntrack_control.hybrid_listener_ips must be non-empty in mode=hybrid"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.effective_me_pool_force_close_secs() > 0
|
||||||
|
&& config.general.effective_me_pool_force_close_secs()
|
||||||
|
< config.general.me_pool_drain_ttl_secs
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
me_pool_drain_ttl_secs = config.general.me_pool_drain_ttl_secs,
|
||||||
|
me_reinit_drain_timeout_secs = config.general.effective_me_pool_force_close_secs(),
|
||||||
|
"force-close timeout is lower than drain TTL; bumping force-close timeout to TTL"
|
||||||
|
);
|
||||||
|
config.general.me_reinit_drain_timeout_secs = config.general.me_pool_drain_ttl_secs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate secrets.
|
||||||
|
for (user, secret) in &config.access.users {
|
||||||
|
if !secret.chars().all(|c| c.is_ascii_hexdigit()) || secret.len() != 32 {
|
||||||
|
return Err(ProxyError::InvalidSecret {
|
||||||
|
user: user.clone(),
|
||||||
|
reason: "Must be 32 hex characters".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.censorship.tls_domain =
|
||||||
|
normalize_domain_to_ascii(&config.censorship.tls_domain, "censorship.tls_domain")?;
|
||||||
|
|
||||||
|
// Validate mask_unix_sock.
|
||||||
|
if let Some(ref sock_path) = config.censorship.mask_unix_sock {
|
||||||
|
if sock_path.is_empty() {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"mask_unix_sock cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
if sock_path.len() > 107 {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"mask_unix_sock path too long: {} bytes (max 107)",
|
||||||
|
sock_path.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"mask_unix_sock is only supported on Unix platforms".to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
|
if config.censorship.mask_host.is_some() {
|
||||||
|
return Err(ProxyError::Config(
|
||||||
|
"mask_unix_sock and mask_host are mutually exclusive".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(mask_host) = config.censorship.mask_host.as_mut() {
|
||||||
|
*mask_host = normalize_mask_host_to_ascii(mask_host, "censorship.mask_host")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (domain, target) in &config.censorship.exclusive_mask {
|
||||||
|
if !is_valid_tls_domain_name(domain) {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"Invalid censorship.exclusive_mask domain: '{}'. Must be a valid domain name",
|
||||||
|
domain
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if parse_exclusive_mask_target(target).is_none() {
|
||||||
|
return Err(ProxyError::Config(format!(
|
||||||
|
"Invalid censorship.exclusive_mask target for '{}': '{}'. Expected host:port with port > 0",
|
||||||
|
domain, target
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_minimal_runtime_cache_ttl_out_of_range_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[server.api]
|
||||||
|
enabled = true
|
||||||
|
listen = "127.0.0.1:9091"
|
||||||
|
minimal_runtime_cache_ttl_ms = 70000
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_api_minimal_runtime_cache_ttl_invalid_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("server.api.minimal_runtime_cache_ttl_ms must be within [0, 60000]"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_runtime_edge_cache_ttl_out_of_range_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[server.api]
|
||||||
|
enabled = true
|
||||||
|
listen = "127.0.0.1:9091"
|
||||||
|
runtime_edge_cache_ttl_ms = 70000
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_api_runtime_edge_cache_ttl_invalid_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("server.api.runtime_edge_cache_ttl_ms must be within [0, 60000]"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_runtime_edge_top_n_out_of_range_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[server.api]
|
||||||
|
enabled = true
|
||||||
|
listen = "127.0.0.1:9091"
|
||||||
|
runtime_edge_top_n = 0
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_api_runtime_edge_top_n_invalid_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("server.api.runtime_edge_top_n must be within [1, 1000]"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_runtime_edge_events_capacity_out_of_range_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[server.api]
|
||||||
|
enabled = true
|
||||||
|
listen = "127.0.0.1:9091"
|
||||||
|
runtime_edge_events_capacity = 8
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_api_runtime_edge_events_capacity_invalid_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("server.api.runtime_edge_events_capacity must be within [16, 4096]"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conntrack_pressure_high_watermark_out_of_range_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[server.conntrack_control]
|
||||||
|
pressure_high_watermark_pct = 0
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_conntrack_high_watermark_invalid_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err.contains(
|
||||||
|
"server.conntrack_control.pressure_high_watermark_pct must be within [1, 100]"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conntrack_pressure_low_watermark_must_be_below_high() {
|
||||||
|
let toml = r#"
|
||||||
|
[server.conntrack_control]
|
||||||
|
pressure_high_watermark_pct = 50
|
||||||
|
pressure_low_watermark_pct = 50
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_conntrack_low_watermark_invalid_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains(
|
||||||
|
"server.conntrack_control.pressure_low_watermark_pct must be < pressure_high_watermark_pct"
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conntrack_delete_budget_zero_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[server.conntrack_control]
|
||||||
|
delete_budget_per_sec = 0
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_conntrack_delete_budget_invalid_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("server.conntrack_control.delete_budget_per_sec must be > 0"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conntrack_hybrid_mode_requires_listener_allow_list() {
|
||||||
|
let toml = r#"
|
||||||
|
[server.conntrack_control]
|
||||||
|
mode = "hybrid"
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_conntrack_hybrid_requires_ips_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err.contains(
|
||||||
|
"server.conntrack_control.hybrid_listener_ips must be non-empty in mode=hybrid"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conntrack_profile_is_loaded_from_config() {
|
||||||
|
let toml = r#"
|
||||||
|
[server.conntrack_control]
|
||||||
|
profile = "aggressive"
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_conntrack_profile_parse_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.server.conntrack_control.profile,
|
||||||
|
ConntrackPressureProfile::Aggressive
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn force_close_default_matches_drain_ttl() {
|
||||||
|
let toml = r#"
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_force_close_default_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
assert_eq!(cfg.general.me_reinit_drain_timeout_secs, 90);
|
||||||
|
assert_eq!(cfg.general.effective_me_pool_force_close_secs(), 90);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn force_close_zero_uses_runtime_safety_fallback() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
me_reinit_drain_timeout_secs = 0
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_force_close_zero_fallback_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
assert_eq!(cfg.general.me_reinit_drain_timeout_secs, 0);
|
||||||
|
assert_eq!(cfg.general.effective_me_pool_force_close_secs(), 300);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn force_close_bumped_when_below_drain_ttl() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
me_pool_drain_ttl_secs = 90
|
||||||
|
me_reinit_drain_timeout_secs = 30
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_force_close_bump_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
assert_eq!(cfg.general.me_reinit_drain_timeout_secs, 90);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_defaults_remain_unchanged_for_present_sections() {
|
||||||
|
let toml = r#"
|
||||||
|
[network]
|
||||||
|
[general]
|
||||||
|
[server]
|
||||||
|
[access]
|
||||||
|
"#;
|
||||||
|
let cfg: ProxyConfig = toml::from_str(toml).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(cfg.logging, LoggingConfig::default());
|
||||||
|
assert_eq!(cfg.network.ipv6, default_network_ipv6());
|
||||||
|
assert_eq!(cfg.network.stun_use, default_true());
|
||||||
|
assert_eq!(cfg.network.stun_tcp_fallback, default_stun_tcp_fallback());
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.middle_proxy_warm_standby,
|
||||||
|
default_middle_proxy_warm_standby()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.me_reconnect_max_concurrent_per_dc,
|
||||||
|
default_me_reconnect_max_concurrent_per_dc()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.me_reconnect_fast_retry_count,
|
||||||
|
default_me_reconnect_fast_retry_count()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.me_init_retry_attempts,
|
||||||
|
default_me_init_retry_attempts()
|
||||||
|
);
|
||||||
|
assert_eq!(cfg.general.me2dc_fallback, default_me2dc_fallback());
|
||||||
|
assert_eq!(cfg.general.me2dc_fast, default_me2dc_fast());
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.proxy_config_v4_cache_path,
|
||||||
|
default_proxy_config_v4_cache_path()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.proxy_config_v6_cache_path,
|
||||||
|
default_proxy_config_v6_cache_path()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.me_single_endpoint_shadow_writers,
|
||||||
|
default_me_single_endpoint_shadow_writers()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.me_single_endpoint_outage_mode_enabled,
|
||||||
|
default_me_single_endpoint_outage_mode_enabled()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.me_single_endpoint_outage_disable_quarantine,
|
||||||
|
default_me_single_endpoint_outage_disable_quarantine()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.me_single_endpoint_outage_backoff_min_ms,
|
||||||
|
default_me_single_endpoint_outage_backoff_min_ms()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.me_single_endpoint_outage_backoff_max_ms,
|
||||||
|
default_me_single_endpoint_outage_backoff_max_ms()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.me_single_endpoint_shadow_rotate_every_secs,
|
||||||
|
default_me_single_endpoint_shadow_rotate_every_secs()
|
||||||
|
);
|
||||||
|
assert_eq!(cfg.general.me_floor_mode, MeFloorMode::default());
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.me_adaptive_floor_idle_secs,
|
||||||
|
default_me_adaptive_floor_idle_secs()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.me_adaptive_floor_min_writers_single_endpoint,
|
||||||
|
default_me_adaptive_floor_min_writers_single_endpoint()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.me_adaptive_floor_recover_grace_secs,
|
||||||
|
default_me_adaptive_floor_recover_grace_secs()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.upstream_connect_retry_attempts,
|
||||||
|
default_upstream_connect_retry_attempts()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.upstream_connect_retry_backoff_ms,
|
||||||
|
default_upstream_connect_retry_backoff_ms()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.upstream_unhealthy_fail_threshold,
|
||||||
|
default_upstream_unhealthy_fail_threshold()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.upstream_connect_failfast_hard_errors,
|
||||||
|
default_upstream_connect_failfast_hard_errors()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.rpc_proxy_req_every,
|
||||||
|
default_rpc_proxy_req_every()
|
||||||
|
);
|
||||||
|
assert_eq!(cfg.general.beobachten_file, default_beobachten_file());
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
assert_eq!(cfg.censorship.unknown_sni_action, UnknownSniAction::Drop);
|
||||||
|
assert_eq!(cfg.server.api.listen, default_api_listen());
|
||||||
|
assert_eq!(cfg.server.api.whitelist, default_api_whitelist());
|
||||||
|
assert_eq!(cfg.server.api.gray_action, ApiGrayAction::Drop);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.server.api.request_body_limit_bytes,
|
||||||
|
default_api_request_body_limit_bytes()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.server.api.minimal_runtime_enabled,
|
||||||
|
default_api_minimal_runtime_enabled()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.server.api.minimal_runtime_cache_ttl_ms,
|
||||||
|
default_api_minimal_runtime_cache_ttl_ms()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.server.api.runtime_edge_enabled,
|
||||||
|
default_api_runtime_edge_enabled()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.server.api.runtime_edge_cache_ttl_ms,
|
||||||
|
default_api_runtime_edge_cache_ttl_ms()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.server.api.runtime_edge_top_n,
|
||||||
|
default_api_runtime_edge_top_n()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.server.api.runtime_edge_events_capacity,
|
||||||
|
default_api_runtime_edge_events_capacity()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.server.conntrack_control.inline_conntrack_control,
|
||||||
|
default_conntrack_control_enabled()
|
||||||
|
);
|
||||||
|
assert_eq!(cfg.server.conntrack_control.mode, ConntrackMode::default());
|
||||||
|
assert_eq!(
|
||||||
|
cfg.server.conntrack_control.backend,
|
||||||
|
ConntrackBackend::default()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.server.conntrack_control.profile,
|
||||||
|
ConntrackPressureProfile::default()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.server.conntrack_control.pressure_high_watermark_pct,
|
||||||
|
default_conntrack_pressure_high_watermark_pct()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.server.conntrack_control.pressure_low_watermark_pct,
|
||||||
|
default_conntrack_pressure_low_watermark_pct()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.server.conntrack_control.delete_budget_per_sec,
|
||||||
|
default_conntrack_delete_budget_per_sec()
|
||||||
|
);
|
||||||
|
assert_eq!(cfg.access.users, default_access_users());
|
||||||
|
assert_eq!(
|
||||||
|
cfg.access.user_max_tcp_conns_global_each,
|
||||||
|
default_user_max_tcp_conns_global_each()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.access.user_max_unique_ips_mode,
|
||||||
|
UserMaxUniqueIpsMode::default()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.access.user_max_unique_ips_window_secs,
|
||||||
|
default_user_max_unique_ips_window_secs()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn logging_config_is_loaded_from_strict_config() {
|
||||||
|
let cfg = load_config_from_temp_toml(
|
||||||
|
r#"
|
||||||
|
[general]
|
||||||
|
config_strict = true
|
||||||
|
|
||||||
|
[general.modes]
|
||||||
|
classic = false
|
||||||
|
secure = false
|
||||||
|
tls = true
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
destination = "file"
|
||||||
|
path = "/tmp/telemt.log"
|
||||||
|
rotation = "daily"
|
||||||
|
max_size_bytes = 1024
|
||||||
|
max_files = 3
|
||||||
|
max_age_secs = 60
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(cfg.logging.destination, LoggingDestination::File);
|
||||||
|
assert_eq!(cfg.logging.path.as_deref(), Some("/tmp/telemt.log"));
|
||||||
|
assert_eq!(cfg.logging.rotation, LogRotation::Daily);
|
||||||
|
assert_eq!(cfg.logging.max_size_bytes, 1024);
|
||||||
|
assert_eq!(cfg.logging.max_files, 3);
|
||||||
|
assert_eq!(cfg.logging.max_age_secs, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cidr_rate_limits_accept_auto_templates_in_strict_config() {
|
||||||
|
let cfg = load_config_from_temp_toml(
|
||||||
|
r#"
|
||||||
|
[general]
|
||||||
|
config_strict = true
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
|
||||||
|
[access.cidr_rate_limits]
|
||||||
|
"*/24" = { up_bps = 1024, down_bps = 0 }
|
||||||
|
"*4/30" = { up_bps = 0, down_bps = 2048 }
|
||||||
|
"*6/64" = { up_bps = 4096, down_bps = 0 }
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
cfg.access
|
||||||
|
.cidr_rate_limits
|
||||||
|
.contains_key(&CidrRateLimitKey::AutoDual(24))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
cfg.access
|
||||||
|
.cidr_rate_limits
|
||||||
|
.contains_key(&CidrRateLimitKey::AutoV4(30))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
cfg.access
|
||||||
|
.cidr_rate_limits
|
||||||
|
.contains_key(&CidrRateLimitKey::AutoV6(64))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cidr_rate_limits_reject_invalid_auto_template_prefix() {
|
||||||
|
let error = load_config_error_from_temp_toml(
|
||||||
|
r#"
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
|
||||||
|
[access.cidr_rate_limits]
|
||||||
|
"*4/33" = { up_bps = 1024, down_bps = 0 }
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(error.contains("prefix must be within 0..=32"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cidr_rate_limits_reject_duplicate_normalized_auto_templates() {
|
||||||
|
let error = load_config_error_from_temp_toml(
|
||||||
|
r#"
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
|
||||||
|
[access.cidr_rate_limits]
|
||||||
|
"*/32" = { up_bps = 1024, down_bps = 0 }
|
||||||
|
"*6/128" = { up_bps = 2048, down_bps = 0 }
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(error.contains("duplicates normalized auto-template *6/128"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_logging_requires_path() {
|
||||||
|
let error = load_config_error_from_temp_toml(
|
||||||
|
r#"
|
||||||
|
[general.modes]
|
||||||
|
classic = false
|
||||||
|
secure = false
|
||||||
|
tls = true
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
destination = "file"
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(error.contains("logging.path must be set"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn impl_defaults_are_sourced_from_default_helpers() {
|
||||||
|
let network = NetworkConfig::default();
|
||||||
|
assert_eq!(network.ipv6, default_network_ipv6());
|
||||||
|
assert_eq!(network.stun_use, default_true());
|
||||||
|
assert_eq!(network.stun_tcp_fallback, default_stun_tcp_fallback());
|
||||||
|
|
||||||
|
let general = GeneralConfig::default();
|
||||||
|
assert_eq!(
|
||||||
|
general.middle_proxy_warm_standby,
|
||||||
|
default_middle_proxy_warm_standby()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
general.me_reconnect_max_concurrent_per_dc,
|
||||||
|
default_me_reconnect_max_concurrent_per_dc()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
general.me_reconnect_fast_retry_count,
|
||||||
|
default_me_reconnect_fast_retry_count()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
general.me_init_retry_attempts,
|
||||||
|
default_me_init_retry_attempts()
|
||||||
|
);
|
||||||
|
assert_eq!(general.me2dc_fallback, default_me2dc_fallback());
|
||||||
|
assert_eq!(general.me2dc_fast, default_me2dc_fast());
|
||||||
|
assert_eq!(
|
||||||
|
general.proxy_config_v4_cache_path,
|
||||||
|
default_proxy_config_v4_cache_path()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
general.proxy_config_v6_cache_path,
|
||||||
|
default_proxy_config_v6_cache_path()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
general.me_single_endpoint_shadow_writers,
|
||||||
|
default_me_single_endpoint_shadow_writers()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
general.me_single_endpoint_outage_mode_enabled,
|
||||||
|
default_me_single_endpoint_outage_mode_enabled()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
general.me_single_endpoint_outage_disable_quarantine,
|
||||||
|
default_me_single_endpoint_outage_disable_quarantine()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
general.me_single_endpoint_outage_backoff_min_ms,
|
||||||
|
default_me_single_endpoint_outage_backoff_min_ms()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
general.me_single_endpoint_outage_backoff_max_ms,
|
||||||
|
default_me_single_endpoint_outage_backoff_max_ms()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
general.me_single_endpoint_shadow_rotate_every_secs,
|
||||||
|
default_me_single_endpoint_shadow_rotate_every_secs()
|
||||||
|
);
|
||||||
|
assert_eq!(general.me_floor_mode, MeFloorMode::default());
|
||||||
|
assert_eq!(
|
||||||
|
general.me_adaptive_floor_idle_secs,
|
||||||
|
default_me_adaptive_floor_idle_secs()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
general.me_adaptive_floor_min_writers_single_endpoint,
|
||||||
|
default_me_adaptive_floor_min_writers_single_endpoint()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
general.me_adaptive_floor_recover_grace_secs,
|
||||||
|
default_me_adaptive_floor_recover_grace_secs()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
general.upstream_connect_retry_attempts,
|
||||||
|
default_upstream_connect_retry_attempts()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
general.upstream_connect_retry_backoff_ms,
|
||||||
|
default_upstream_connect_retry_backoff_ms()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
general.upstream_unhealthy_fail_threshold,
|
||||||
|
default_upstream_unhealthy_fail_threshold()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
general.upstream_connect_failfast_hard_errors,
|
||||||
|
default_upstream_connect_failfast_hard_errors()
|
||||||
|
);
|
||||||
|
assert_eq!(general.rpc_proxy_req_every, default_rpc_proxy_req_every());
|
||||||
|
assert_eq!(general.beobachten_file, default_beobachten_file());
|
||||||
|
assert_eq!(general.update_every, default_update_every());
|
||||||
|
|
||||||
|
let server = ServerConfig::default();
|
||||||
|
assert_eq!(server.listen_addr_ipv6, Some(default_listen_addr_ipv6()));
|
||||||
|
assert_eq!(
|
||||||
|
server.proxy_protocol_trusted_cidrs,
|
||||||
|
default_proxy_protocol_trusted_cidrs()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
AntiCensorshipConfig::default().unknown_sni_action,
|
||||||
|
UnknownSniAction::Drop
|
||||||
|
);
|
||||||
|
assert_eq!(server.api.listen, default_api_listen());
|
||||||
|
assert_eq!(server.api.whitelist, default_api_whitelist());
|
||||||
|
assert_eq!(server.api.gray_action, ApiGrayAction::Drop);
|
||||||
|
assert_eq!(
|
||||||
|
server.api.request_body_limit_bytes,
|
||||||
|
default_api_request_body_limit_bytes()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
server.api.minimal_runtime_enabled,
|
||||||
|
default_api_minimal_runtime_enabled()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
server.api.minimal_runtime_cache_ttl_ms,
|
||||||
|
default_api_minimal_runtime_cache_ttl_ms()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
server.api.runtime_edge_enabled,
|
||||||
|
default_api_runtime_edge_enabled()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
server.api.runtime_edge_cache_ttl_ms,
|
||||||
|
default_api_runtime_edge_cache_ttl_ms()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
server.api.runtime_edge_top_n,
|
||||||
|
default_api_runtime_edge_top_n()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
server.api.runtime_edge_events_capacity,
|
||||||
|
default_api_runtime_edge_events_capacity()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
server.conntrack_control.inline_conntrack_control,
|
||||||
|
default_conntrack_control_enabled()
|
||||||
|
);
|
||||||
|
assert_eq!(server.conntrack_control.mode, ConntrackMode::default());
|
||||||
|
assert_eq!(
|
||||||
|
server.conntrack_control.backend,
|
||||||
|
ConntrackBackend::default()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
server.conntrack_control.profile,
|
||||||
|
ConntrackPressureProfile::default()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
server.conntrack_control.pressure_high_watermark_pct,
|
||||||
|
default_conntrack_pressure_high_watermark_pct()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
server.conntrack_control.pressure_low_watermark_pct,
|
||||||
|
default_conntrack_pressure_low_watermark_pct()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
server.conntrack_control.delete_budget_per_sec,
|
||||||
|
default_conntrack_delete_budget_per_sec()
|
||||||
|
);
|
||||||
|
|
||||||
|
let access = AccessConfig::default();
|
||||||
|
assert_eq!(access.users, default_access_users());
|
||||||
|
assert_eq!(
|
||||||
|
access.user_max_tcp_conns_global_each,
|
||||||
|
default_user_max_tcp_conns_global_each()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proxy_protocol_trusted_cidrs_missing_uses_trust_all_but_explicit_empty_stays_empty() {
|
||||||
|
let cfg_missing: ProxyConfig = toml::from_str(
|
||||||
|
r#"
|
||||||
|
[server]
|
||||||
|
[general]
|
||||||
|
[network]
|
||||||
|
[access]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfg_missing.server.proxy_protocol_trusted_cidrs,
|
||||||
|
default_proxy_protocol_trusted_cidrs()
|
||||||
|
);
|
||||||
|
|
||||||
|
let cfg_explicit_empty: ProxyConfig = toml::from_str(
|
||||||
|
r#"
|
||||||
|
[server]
|
||||||
|
proxy_protocol_trusted_cidrs = []
|
||||||
|
|
||||||
|
[general]
|
||||||
|
[network]
|
||||||
|
[access]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
cfg_explicit_empty
|
||||||
|
.server
|
||||||
|
.proxy_protocol_trusted_cidrs
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conntrack_inline_explicit_flag_is_false_when_omitted() {
|
||||||
|
let cfg = load_config_from_temp_toml(
|
||||||
|
r#"
|
||||||
|
[general]
|
||||||
|
[network]
|
||||||
|
[server]
|
||||||
|
[server.conntrack_control]
|
||||||
|
[access]
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!cfg.server
|
||||||
|
.conntrack_control
|
||||||
|
.inline_conntrack_control_explicit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conntrack_inline_explicit_flag_is_true_when_present() {
|
||||||
|
let cfg = load_config_from_temp_toml(
|
||||||
|
r#"
|
||||||
|
[general]
|
||||||
|
[network]
|
||||||
|
[server]
|
||||||
|
[server.conntrack_control]
|
||||||
|
inline_conntrack_control = true
|
||||||
|
[access]
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
cfg.server
|
||||||
|
.conntrack_control
|
||||||
|
.inline_conntrack_control_explicit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_sni_action_parses_and_defaults_to_drop() {
|
||||||
|
let cfg_default: ProxyConfig = toml::from_str(
|
||||||
|
r#"
|
||||||
|
[server]
|
||||||
|
[general]
|
||||||
|
[network]
|
||||||
|
[access]
|
||||||
|
[censorship]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfg_default.censorship.unknown_sni_action,
|
||||||
|
UnknownSniAction::Drop
|
||||||
|
);
|
||||||
|
|
||||||
|
let cfg_mask: ProxyConfig = toml::from_str(
|
||||||
|
r#"
|
||||||
|
[server]
|
||||||
|
[general]
|
||||||
|
[network]
|
||||||
|
[access]
|
||||||
|
[censorship]
|
||||||
|
unknown_sni_action = "mask"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfg_mask.censorship.unknown_sni_action,
|
||||||
|
UnknownSniAction::Mask
|
||||||
|
);
|
||||||
|
|
||||||
|
let cfg_accept: ProxyConfig = toml::from_str(
|
||||||
|
r#"
|
||||||
|
[server]
|
||||||
|
[general]
|
||||||
|
[network]
|
||||||
|
[access]
|
||||||
|
[censorship]
|
||||||
|
unknown_sni_action = "accept"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfg_accept.censorship.unknown_sni_action,
|
||||||
|
UnknownSniAction::Accept
|
||||||
|
);
|
||||||
|
|
||||||
|
let cfg_reject: ProxyConfig = toml::from_str(
|
||||||
|
r#"
|
||||||
|
[server]
|
||||||
|
[general]
|
||||||
|
[network]
|
||||||
|
[access]
|
||||||
|
[censorship]
|
||||||
|
unknown_sni_action = "reject_handshake"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfg_reject.censorship.unknown_sni_action,
|
||||||
|
UnknownSniAction::RejectHandshake
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exclusive_mask_parses_domain_target_map() {
|
||||||
|
let cfg = load_config_from_temp_toml(
|
||||||
|
r#"
|
||||||
|
[general]
|
||||||
|
[network]
|
||||||
|
[server]
|
||||||
|
[access]
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "weißbiergärten.de"
|
||||||
|
tls_domains = ["bürgeramt.de"]
|
||||||
|
[censorship.exclusive_mask]
|
||||||
|
"bürgeramt.de" = "rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz.de:443"
|
||||||
|
"ipv6.example" = "[::1]:443"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(cfg.censorship.tls_domain.is_ascii());
|
||||||
|
assert!(cfg.censorship.tls_domain.contains("xn--"));
|
||||||
|
assert_eq!(cfg.censorship.tls_domains.len(), 1);
|
||||||
|
let normalized_extra = &cfg.censorship.tls_domains[0];
|
||||||
|
assert!(normalized_extra.is_ascii());
|
||||||
|
assert!(normalized_extra.contains("xn--"));
|
||||||
|
|
||||||
|
let normalized_target = cfg
|
||||||
|
.censorship
|
||||||
|
.exclusive_mask
|
||||||
|
.get(normalized_extra)
|
||||||
|
.expect("exclusive_mask key must match normalized tls_domains entry");
|
||||||
|
assert!(normalized_target.is_ascii());
|
||||||
|
assert!(normalized_target.contains("xn--"));
|
||||||
|
assert!(normalized_target.ends_with(":443"));
|
||||||
|
assert_eq!(
|
||||||
|
cfg.censorship.exclusive_mask.get("ipv6.example"),
|
||||||
|
Some(&"[::1]:443".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_gray_action_parses_and_defaults_to_drop() {
|
||||||
|
let cfg_default: ProxyConfig = toml::from_str(
|
||||||
|
r#"
|
||||||
|
[server]
|
||||||
|
[general]
|
||||||
|
[network]
|
||||||
|
[access]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cfg_default.server.api.gray_action, ApiGrayAction::Drop);
|
||||||
|
|
||||||
|
let cfg_api: ProxyConfig = toml::from_str(
|
||||||
|
r#"
|
||||||
|
[server]
|
||||||
|
[general]
|
||||||
|
[network]
|
||||||
|
[access]
|
||||||
|
[server.api]
|
||||||
|
gray_action = "api"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cfg_api.server.api.gray_action, ApiGrayAction::Api);
|
||||||
|
|
||||||
|
let cfg_200: ProxyConfig = toml::from_str(
|
||||||
|
r#"
|
||||||
|
[server]
|
||||||
|
[general]
|
||||||
|
[network]
|
||||||
|
[access]
|
||||||
|
[server.api]
|
||||||
|
gray_action = "200"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cfg_200.server.api.gray_action, ApiGrayAction::Ok200);
|
||||||
|
|
||||||
|
let cfg_drop: ProxyConfig = toml::from_str(
|
||||||
|
r#"
|
||||||
|
[server]
|
||||||
|
[general]
|
||||||
|
[network]
|
||||||
|
[access]
|
||||||
|
[server.api]
|
||||||
|
gray_action = "drop"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cfg_drop.server.api.gray_action, ApiGrayAction::Drop);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn top_level_beobachten_keys_migrate_to_general_when_general_not_explicit() {
|
||||||
|
let cfg = load_config_from_temp_toml(
|
||||||
|
r#"
|
||||||
|
beobachten = false
|
||||||
|
beobachten_minutes = 7
|
||||||
|
beobachten_flush_secs = 3
|
||||||
|
beobachten_file = "tmp/legacy-beob.txt"
|
||||||
|
|
||||||
|
[server]
|
||||||
|
[general]
|
||||||
|
[network]
|
||||||
|
[access]
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!cfg.general.beobachten);
|
||||||
|
assert_eq!(cfg.general.beobachten_minutes, 7);
|
||||||
|
assert_eq!(cfg.general.beobachten_flush_secs, 3);
|
||||||
|
assert_eq!(cfg.general.beobachten_file, "tmp/legacy-beob.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn general_beobachten_keys_have_priority_over_legacy_top_level() {
|
||||||
|
let cfg = load_config_from_temp_toml(
|
||||||
|
r#"
|
||||||
|
beobachten = true
|
||||||
|
beobachten_minutes = 30
|
||||||
|
beobachten_flush_secs = 30
|
||||||
|
beobachten_file = "tmp/legacy-beob.txt"
|
||||||
|
|
||||||
|
[server]
|
||||||
|
[general]
|
||||||
|
beobachten = false
|
||||||
|
beobachten_minutes = 5
|
||||||
|
beobachten_flush_secs = 2
|
||||||
|
beobachten_file = "tmp/general-beob.txt"
|
||||||
|
[network]
|
||||||
|
[access]
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!cfg.general.beobachten);
|
||||||
|
assert_eq!(cfg.general.beobachten_minutes, 5);
|
||||||
|
assert_eq!(cfg.general.beobachten_flush_secs, 2);
|
||||||
|
assert_eq!(cfg.general.beobachten_file, "tmp/general-beob.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dc_overrides_allow_string_and_array() {
|
||||||
|
let toml = r#"
|
||||||
|
[dc_overrides]
|
||||||
|
"201" = "149.154.175.50:443"
|
||||||
|
"202" = ["149.154.167.51:443", "149.154.175.100:443"]
|
||||||
|
"#;
|
||||||
|
let cfg: ProxyConfig = toml::from_str(toml).unwrap();
|
||||||
|
assert_eq!(cfg.dc_overrides["201"], vec!["149.154.175.50:443"]);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.dc_overrides["202"],
|
||||||
|
vec!["149.154.167.51:443", "149.154.175.100:443"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_with_metadata_collects_include_files() {
|
||||||
|
let nonce = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let dir = std::env::temp_dir().join(format!("telemt_load_metadata_{nonce}"));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let main_path = dir.join("config.toml");
|
||||||
|
let include_path = dir.join("included.toml");
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
&include_path,
|
||||||
|
r#"
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
&main_path,
|
||||||
|
r#"
|
||||||
|
include = "included.toml"
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let loaded = ProxyConfig::load_with_metadata(&main_path).unwrap();
|
||||||
|
let main_normalized = normalize_config_path(&main_path);
|
||||||
|
let include_normalized = normalize_config_path(&include_path);
|
||||||
|
|
||||||
|
assert!(loaded.source_files.contains(&main_normalized));
|
||||||
|
assert!(loaded.source_files.contains(&include_normalized));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(main_path);
|
||||||
|
let _ = std::fs::remove_file(include_path);
|
||||||
|
let _ = std::fs::remove_dir(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dc_overrides_inject_dc203_default() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
use_middle_proxy = false
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_dc_override_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
assert!(
|
||||||
|
cfg.dc_overrides
|
||||||
|
.get("203")
|
||||||
|
.map(|v| v.contains(&"91.105.192.100:443".to_string()))
|
||||||
|
.unwrap_or(false)
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_every_overrides_legacy_fields() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
update_every = 123
|
||||||
|
proxy_secret_auto_reload_secs = 700
|
||||||
|
proxy_config_auto_reload_secs = 800
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_update_every_override_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
assert_eq!(cfg.general.effective_update_every_secs(), 123);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_every_fallback_to_legacy_min() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
proxy_secret_auto_reload_secs = 600
|
||||||
|
proxy_config_auto_reload_secs = 120
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_update_every_legacy_min_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
assert_eq!(cfg.general.update_every, None);
|
||||||
|
assert_eq!(cfg.general.effective_update_every_secs(), 120);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_every_zero_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
update_every = 0
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_update_every_zero_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.update_every must be > 0"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn me_route_backpressure_base_timeout_ms_out_of_range_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
me_route_backpressure_base_timeout_ms = 5001
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_me_route_backpressure_base_timeout_ms_out_of_range_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.me_route_backpressure_base_timeout_ms must be within [1, 5000]"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn me_route_backpressure_high_timeout_ms_out_of_range_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
me_route_backpressure_base_timeout_ms = 100
|
||||||
|
me_route_backpressure_high_timeout_ms = 5001
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_me_route_backpressure_high_timeout_ms_out_of_range_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.me_route_backpressure_high_timeout_ms must be within [1, 5000]"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn me_route_no_writer_wait_ms_out_of_range_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
me_route_no_writer_wait_ms = 5
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_me_route_no_writer_wait_ms_out_of_range_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.me_route_no_writer_wait_ms must be within [10, 5000]"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn me_route_blocking_send_timeout_ms_zero_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
me_route_blocking_send_timeout_ms = 0
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_me_route_blocking_send_timeout_zero_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.me_route_blocking_send_timeout_ms must be within [1, 5000]"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn me_route_no_writer_mode_is_parsed() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
me_route_no_writer_mode = "inline_recovery_legacy"
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_me_route_no_writer_mode_parse_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.me_route_no_writer_mode,
|
||||||
|
crate::config::MeRouteNoWriterMode::InlineRecoveryLegacy
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proxy_config_cache_paths_empty_are_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
proxy_config_v4_cache_path = " "
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_proxy_config_v4_cache_path_empty_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.proxy_config_v4_cache_path cannot be empty"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
|
||||||
|
let toml_v6 = r#"
|
||||||
|
[general]
|
||||||
|
proxy_config_v6_cache_path = ""
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let path_v6 = dir.join("telemt_proxy_config_v6_cache_path_empty_test.toml");
|
||||||
|
std::fs::write(&path_v6, toml_v6).unwrap();
|
||||||
|
let err_v6 = ProxyConfig::load(&path_v6).unwrap_err().to_string();
|
||||||
|
assert!(err_v6.contains("general.proxy_config_v6_cache_path cannot be empty"));
|
||||||
|
let _ = std::fs::remove_file(path_v6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn me_hardswap_warmup_defaults_are_set() {
|
||||||
|
let toml = r#"
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_me_hardswap_warmup_defaults_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.me_hardswap_warmup_delay_min_ms,
|
||||||
|
default_me_hardswap_warmup_delay_min_ms()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.me_hardswap_warmup_delay_max_ms,
|
||||||
|
default_me_hardswap_warmup_delay_max_ms()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.me_hardswap_warmup_extra_passes,
|
||||||
|
default_me_hardswap_warmup_extra_passes()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.me_hardswap_warmup_pass_backoff_base_ms,
|
||||||
|
default_me_hardswap_warmup_pass_backoff_base_ms()
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn me_hardswap_warmup_delay_range_is_validated() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
me_hardswap_warmup_delay_min_ms = 2001
|
||||||
|
me_hardswap_warmup_delay_max_ms = 2000
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_me_hardswap_warmup_delay_range_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains(
|
||||||
|
"general.me_hardswap_warmup_delay_min_ms must be <= general.me_hardswap_warmup_delay_max_ms"
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn me_hardswap_warmup_delay_max_zero_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
me_hardswap_warmup_delay_max_ms = 0
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_me_hardswap_warmup_delay_max_zero_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.me_hardswap_warmup_delay_max_ms must be > 0"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn me_hardswap_warmup_extra_passes_out_of_range_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
me_hardswap_warmup_extra_passes = 11
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_me_hardswap_warmup_extra_passes_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.me_hardswap_warmup_extra_passes must be within [0, 10]"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn me_hardswap_warmup_pass_backoff_zero_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
me_hardswap_warmup_pass_backoff_base_ms = 0
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_me_hardswap_warmup_backoff_zero_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.me_hardswap_warmup_pass_backoff_base_ms must be > 0"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn me_config_stable_snapshots_zero_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
me_config_stable_snapshots = 0
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_me_config_stable_snapshots_zero_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.me_config_stable_snapshots must be > 0"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proxy_secret_stable_snapshots_zero_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
proxy_secret_stable_snapshots = 0
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_proxy_secret_stable_snapshots_zero_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.proxy_secret_stable_snapshots must be > 0"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proxy_secret_len_max_out_of_range_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
proxy_secret_len_max = 16
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_proxy_secret_len_max_out_of_range_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.proxy_secret_len_max must be within [32, 4096]"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn me_pool_min_fresh_ratio_out_of_range_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
me_pool_min_fresh_ratio = 1.5
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_me_pool_min_ratio_invalid_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.me_pool_min_fresh_ratio must be within [0.0, 1.0]"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stun_nat_probe_concurrency_zero_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
stun_nat_probe_concurrency = 0
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_stun_nat_probe_concurrency_zero_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.stun_nat_probe_concurrency must be > 0"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn me_reinit_every_default_is_set() {
|
||||||
|
let toml = r#"
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_me_reinit_every_default_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.me_reinit_every_secs,
|
||||||
|
default_me_reinit_every_secs()
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn me_reinit_every_zero_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
me_reinit_every_secs = 0
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_me_reinit_every_zero_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.me_reinit_every_secs must be > 0"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn me_single_endpoint_outage_backoff_range_is_validated() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
me_single_endpoint_outage_backoff_min_ms = 4000
|
||||||
|
me_single_endpoint_outage_backoff_max_ms = 3000
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_me_single_endpoint_outage_backoff_range_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains(
|
||||||
|
"general.me_single_endpoint_outage_backoff_min_ms must be <= general.me_single_endpoint_outage_backoff_max_ms"
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn me_single_endpoint_shadow_writers_too_large_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
me_single_endpoint_shadow_writers = 33
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_me_single_endpoint_shadow_writers_limit_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.me_single_endpoint_shadow_writers must be within [0, 32]"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn me_adaptive_floor_min_writers_out_of_range_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
me_adaptive_floor_min_writers_single_endpoint = 0
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_me_adaptive_floor_min_writers_out_of_range_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err.contains(
|
||||||
|
"general.me_adaptive_floor_min_writers_single_endpoint must be within [1, 32]"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn me_floor_mode_adaptive_is_parsed() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
me_floor_mode = "adaptive"
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_me_floor_mode_adaptive_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
assert_eq!(cfg.general.me_floor_mode, MeFloorMode::Adaptive);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn me_adaptive_floor_max_active_writers_per_core_zero_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
me_adaptive_floor_max_active_writers_per_core = 0
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_me_adaptive_floor_max_active_per_core_zero_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.me_adaptive_floor_max_active_writers_per_core must be > 0"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn me_adaptive_floor_max_warm_writers_global_zero_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
me_adaptive_floor_max_warm_writers_global = 0
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_me_adaptive_floor_max_warm_global_zero_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.me_adaptive_floor_max_warm_writers_global must be > 0"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upstream_connect_retry_attempts_zero_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
upstream_connect_retry_attempts = 0
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_upstream_connect_retry_attempts_zero_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.upstream_connect_retry_attempts must be > 0"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upstream_unhealthy_fail_threshold_zero_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
upstream_unhealthy_fail_threshold = 0
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_upstream_unhealthy_fail_threshold_zero_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.upstream_unhealthy_fail_threshold must be > 0"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tg_connect_zero_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
tg_connect = 0
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_tg_connect_zero_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.tg_connect must be > 0"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rpc_proxy_req_every_out_of_range_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
rpc_proxy_req_every = 9
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_rpc_proxy_req_every_out_of_range_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("general.rpc_proxy_req_every must be 0 or within [10, 300]"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rpc_proxy_req_every_zero_and_valid_range_are_accepted() {
|
||||||
|
let toml_zero = r#"
|
||||||
|
[general]
|
||||||
|
rpc_proxy_req_every = 0
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path_zero = dir.join("telemt_rpc_proxy_req_every_zero_ok_test.toml");
|
||||||
|
std::fs::write(&path_zero, toml_zero).unwrap();
|
||||||
|
let cfg_zero = ProxyConfig::load(&path_zero).unwrap();
|
||||||
|
assert_eq!(cfg_zero.general.rpc_proxy_req_every, 0);
|
||||||
|
let _ = std::fs::remove_file(path_zero);
|
||||||
|
|
||||||
|
let toml_valid = r#"
|
||||||
|
[general]
|
||||||
|
rpc_proxy_req_every = 40
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let path_valid = dir.join("telemt_rpc_proxy_req_every_valid_ok_test.toml");
|
||||||
|
std::fs::write(&path_valid, toml_valid).unwrap();
|
||||||
|
let cfg_valid = ProxyConfig::load(&path_valid).unwrap();
|
||||||
|
assert_eq!(cfg_valid.general.rpc_proxy_req_every, 40);
|
||||||
|
let _ = std::fs::remove_file(path_valid);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn synlimit_synfix_defaults_are_loaded_for_listener() {
|
||||||
|
let cfg = load_config_from_temp_toml(
|
||||||
|
r#"
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
|
||||||
|
[[server.listeners]]
|
||||||
|
ip = "0.0.0.0"
|
||||||
|
port = 443
|
||||||
|
synlimit = "iptables"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let listener = &cfg.server.listeners[0];
|
||||||
|
assert_eq!(listener.synlimit_seconds, 60);
|
||||||
|
assert_eq!(listener.synlimit_hitcount, 48);
|
||||||
|
assert_eq!(listener.synlimit_burst, 24);
|
||||||
|
assert_eq!(listener.synlimit_ios_seconds, 1);
|
||||||
|
assert_eq!(listener.synlimit_ios_hitcount, 12);
|
||||||
|
assert_eq!(listener.synlimit_ios_burst, 24);
|
||||||
|
assert_eq!(listener.synlimit_hashlimit_expire_ms, 60_000);
|
||||||
|
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(
|
||||||
|
r#"
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
|
||||||
|
[[server.listeners]]
|
||||||
|
ip = "0.0.0.0"
|
||||||
|
port = 443
|
||||||
|
synlimit = "pf"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 [
|
||||||
|
(
|
||||||
|
"synlimit_ios_seconds",
|
||||||
|
"server.listeners[0].synlimit_ios_seconds must be > 0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"synlimit_ios_hitcount",
|
||||||
|
"server.listeners[0].synlimit_ios_hitcount must be > 0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"synlimit_ios_burst",
|
||||||
|
"server.listeners[0].synlimit_ios_burst must be > 0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"synlimit_hashlimit_expire_ms",
|
||||||
|
"server.listeners[0].synlimit_hashlimit_expire_ms must be > 0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"synlimit_hashlimit_size",
|
||||||
|
"server.listeners[0].synlimit_hashlimit_size must be > 0",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let toml = format!(
|
||||||
|
r#"
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
|
||||||
|
[[server.listeners]]
|
||||||
|
ip = "0.0.0.0"
|
||||||
|
port = 443
|
||||||
|
synlimit = "iptables"
|
||||||
|
{field} = 0
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
let error = load_config_error_from_temp_toml(&toml);
|
||||||
|
assert!(error.contains(expected), "{field}: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn client_mss_custom_value_is_accepted() {
|
||||||
|
let toml = r#"
|
||||||
|
[server]
|
||||||
|
client_mss = "92"
|
||||||
|
client_mss_bulk = "1400"
|
||||||
|
|
||||||
|
[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(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"] {
|
||||||
|
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_bulk_out_of_range_is_rejected() {
|
||||||
|
for value in ["87", "4097"] {
|
||||||
|
let toml = format!(
|
||||||
|
r#"
|
||||||
|
[server]
|
||||||
|
client_mss_bulk = "{value}"
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join(format!(
|
||||||
|
"telemt_client_mss_bulk_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_bulk 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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tls_fetch_scope_default_is_empty() {
|
||||||
|
let toml = r#"
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_tls_fetch_scope_default_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
assert!(cfg.censorship.tls_fetch_scope.is_empty());
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tls_fetch_scope_is_trimmed_during_load() {
|
||||||
|
let toml = r#"
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
tls_fetch_scope = " me "
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_tls_fetch_scope_trim_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
assert_eq!(cfg.censorship.tls_fetch_scope, "me");
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tls_fetch_scope_whitespace_becomes_empty() {
|
||||||
|
let toml = r#"
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
tls_fetch_scope = " "
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_tls_fetch_scope_blank_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
assert!(cfg.censorship.tls_fetch_scope.is_empty());
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tls_fetch_defaults_are_applied() {
|
||||||
|
let toml = r#"
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_tls_fetch_defaults_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.censorship.tls_fetch.profiles,
|
||||||
|
TlsFetchConfig::default().profiles
|
||||||
|
);
|
||||||
|
assert!(cfg.censorship.tls_fetch.strict_route);
|
||||||
|
assert_eq!(cfg.censorship.tls_fetch.attempt_timeout_ms, 5_000);
|
||||||
|
assert_eq!(cfg.censorship.tls_fetch.total_budget_ms, 15_000);
|
||||||
|
assert_eq!(cfg.censorship.tls_fetch.profile_cache_ttl_secs, 600);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tls_fetch_profiles_are_deduplicated_preserving_order() {
|
||||||
|
let toml = r#"
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
[censorship.tls_fetch]
|
||||||
|
profiles = ["compat_tls12", "modern_chrome_like", "compat_tls12", "legacy_minimal"]
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_tls_fetch_profiles_dedup_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.censorship.tls_fetch.profiles,
|
||||||
|
vec![
|
||||||
|
TlsFetchProfile::CompatTls12,
|
||||||
|
TlsFetchProfile::ModernChromeLike,
|
||||||
|
TlsFetchProfile::LegacyMinimal
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tls_fetch_attempt_timeout_zero_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
[censorship.tls_fetch]
|
||||||
|
attempt_timeout_ms = 0
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_tls_fetch_attempt_timeout_zero_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("censorship.tls_fetch.attempt_timeout_ms must be > 0"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tls_fetch_total_budget_zero_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
[censorship.tls_fetch]
|
||||||
|
total_budget_ms = 0
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_tls_fetch_total_budget_zero_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("censorship.tls_fetch.total_budget_ms must be > 0"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_ad_tag_is_disabled_during_load() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
ad_tag = "not_hex"
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_invalid_ad_tag_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
assert!(cfg.general.ad_tag.is_none());
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_ad_tag_is_preserved_during_load() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
ad_tag = "00112233445566778899aabbccddeeff"
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_valid_ad_tag_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.general.ad_tag.as_deref(),
|
||||||
|
Some("00112233445566778899aabbccddeeff")
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shadowsocks_upstream_url_loads_successfully() {
|
||||||
|
let toml = format!(
|
||||||
|
r#"
|
||||||
|
[general]
|
||||||
|
use_middle_proxy = false
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
|
||||||
|
[[upstreams]]
|
||||||
|
type = "shadowsocks"
|
||||||
|
url = "{url}"
|
||||||
|
interface = "127.0.0.2"
|
||||||
|
"#,
|
||||||
|
url = TEST_SHADOWSOCKS_URL,
|
||||||
|
);
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_shadowsocks_valid_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
&cfg.upstreams[0].upstream_type,
|
||||||
|
UpstreamType::Shadowsocks { url, interface }
|
||||||
|
if url == TEST_SHADOWSOCKS_URL && interface.as_deref() == Some("127.0.0.2")
|
||||||
|
));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shadowsocks_requires_direct_mode() {
|
||||||
|
let toml = format!(
|
||||||
|
r#"
|
||||||
|
[general]
|
||||||
|
use_middle_proxy = true
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
|
||||||
|
[[upstreams]]
|
||||||
|
type = "shadowsocks"
|
||||||
|
url = "{url}"
|
||||||
|
"#,
|
||||||
|
url = TEST_SHADOWSOCKS_URL,
|
||||||
|
);
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_shadowsocks_me_reject_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
|
||||||
|
assert!(err.contains("shadowsocks upstreams require general.use_middle_proxy = false"));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_shadowsocks_url_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[general]
|
||||||
|
use_middle_proxy = false
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
|
||||||
|
[[upstreams]]
|
||||||
|
type = "shadowsocks"
|
||||||
|
url = "not-a-valid-ss-url"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_shadowsocks_invalid_url_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
|
||||||
|
assert!(err.contains("invalid shadowsocks url"));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shadowsocks_plugins_are_rejected() {
|
||||||
|
let toml = format!(
|
||||||
|
r#"
|
||||||
|
[general]
|
||||||
|
use_middle_proxy = false
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
|
||||||
|
[[upstreams]]
|
||||||
|
type = "shadowsocks"
|
||||||
|
url = "{url}?plugin=obfs-local%3Bobfs%3Dhttp"
|
||||||
|
"#,
|
||||||
|
url = TEST_SHADOWSOCKS_URL,
|
||||||
|
);
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_shadowsocks_plugin_reject_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
|
||||||
|
assert!(err.contains("shadowsocks plugins are not supported"));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_user_ad_tag_reports_access_user_ad_tags_key() {
|
||||||
|
let toml = r#"
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
alice = "00000000000000000000000000000000"
|
||||||
|
|
||||||
|
[access.user_ad_tags]
|
||||||
|
alice = "not_hex"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_invalid_user_ad_tag_message_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
let err = cfg.validate().unwrap_err().to_string();
|
||||||
|
assert!(err.contains("access.user_ad_tags['alice'] must be exactly 32 hex characters"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_dns_override_is_rejected() {
|
||||||
|
let toml = r#"
|
||||||
|
[network]
|
||||||
|
dns_overrides = ["example.com:443:2001:db8::10"]
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_invalid_dns_override_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let err = ProxyConfig::load(&path).unwrap_err().to_string();
|
||||||
|
assert!(err.contains("must be bracketed"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_dns_override_is_accepted() {
|
||||||
|
let toml = r#"
|
||||||
|
[network]
|
||||||
|
dns_overrides = ["example.com:443:127.0.0.1", "example.net:443:[2001:db8::10]"]
|
||||||
|
|
||||||
|
[censorship]
|
||||||
|
tls_domain = "example.com"
|
||||||
|
|
||||||
|
[access.users]
|
||||||
|
user = "00000000000000000000000000000000"
|
||||||
|
"#;
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let path = dir.join("telemt_valid_dns_override_test.toml");
|
||||||
|
std::fs::write(&path, toml).unwrap();
|
||||||
|
let cfg = ProxyConfig::load(&path).unwrap();
|
||||||
|
assert_eq!(cfg.network.dns_overrides.len(), 2);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
+27
-2618
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,269 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct AccessConfig {
|
||||||
|
#[serde(default = "default_access_users")]
|
||||||
|
pub users: HashMap<String, String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub user_enabled: HashMap<String, bool>,
|
||||||
|
|
||||||
|
/// Per-user ad_tag (32 hex chars from @MTProxybot).
|
||||||
|
#[serde(default)]
|
||||||
|
pub user_ad_tags: HashMap<String, String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub user_max_tcp_conns: HashMap<String, usize>,
|
||||||
|
|
||||||
|
/// Global per-user TCP connection limit applied when a user has no
|
||||||
|
/// positive individual override.
|
||||||
|
/// `0` disables the inherited limit.
|
||||||
|
#[serde(default = "default_user_max_tcp_conns_global_each")]
|
||||||
|
pub user_max_tcp_conns_global_each: usize,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub user_expirations: HashMap<String, DateTime<Utc>>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub user_data_quota: HashMap<String, u64>,
|
||||||
|
|
||||||
|
/// Per-user transport rate limits in bits-per-second.
|
||||||
|
///
|
||||||
|
/// Each entry supports independent upload (`up_bps`) and download
|
||||||
|
/// (`down_bps`) ceilings. A value of `0` in one direction means
|
||||||
|
/// "unlimited" for that direction. Limits are amortized: a relay quantum
|
||||||
|
/// may pass as a bounded burst, and the limiter applies the resulting wait
|
||||||
|
/// before later traffic in the same direction proceeds.
|
||||||
|
#[serde(default)]
|
||||||
|
pub user_rate_limits: HashMap<String, RateLimitBps>,
|
||||||
|
|
||||||
|
/// Per-CIDR aggregate transport rate limits in bits-per-second.
|
||||||
|
///
|
||||||
|
/// Explicit CIDR keys use longest-prefix-wins semantics. Auto-template
|
||||||
|
/// keys (`*4/N`, `*6/N`, `*/N`) lazily create per-source-subnet buckets
|
||||||
|
/// after explicit CIDR matching misses. A value of `0` in one direction
|
||||||
|
/// means "unlimited" for that direction. Limits are amortized with the
|
||||||
|
/// same bounded-burst contract as per-user rate limits.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cidr_rate_limits: HashMap<CidrRateLimitKey, RateLimitBps>,
|
||||||
|
|
||||||
|
/// Per-username client source IP/CIDR deny list. Checked after successful
|
||||||
|
/// authentication; matching IPs get the same rejection path as invalid auth
|
||||||
|
/// (handshake fails closed for that connection).
|
||||||
|
#[serde(default)]
|
||||||
|
pub user_source_deny: HashMap<String, Vec<IpNetwork>>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub user_max_unique_ips: HashMap<String, usize>,
|
||||||
|
|
||||||
|
/// Global per-user unique IP limit applied when a user has no individual override.
|
||||||
|
/// `0` disables the inherited limit.
|
||||||
|
#[serde(default = "default_user_max_unique_ips_global_each")]
|
||||||
|
pub user_max_unique_ips_global_each: usize,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub user_max_unique_ips_mode: UserMaxUniqueIpsMode,
|
||||||
|
|
||||||
|
#[serde(default = "default_user_max_unique_ips_window_secs")]
|
||||||
|
pub user_max_unique_ips_window_secs: u64,
|
||||||
|
|
||||||
|
#[serde(default = "default_replay_check_len")]
|
||||||
|
pub replay_check_len: usize,
|
||||||
|
|
||||||
|
#[serde(default = "default_replay_window_secs")]
|
||||||
|
pub replay_window_secs: u64,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub ignore_time_skew: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AccessConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
users: default_access_users(),
|
||||||
|
user_enabled: HashMap::new(),
|
||||||
|
user_ad_tags: HashMap::new(),
|
||||||
|
user_max_tcp_conns: HashMap::new(),
|
||||||
|
user_max_tcp_conns_global_each: default_user_max_tcp_conns_global_each(),
|
||||||
|
user_expirations: HashMap::new(),
|
||||||
|
user_data_quota: HashMap::new(),
|
||||||
|
user_rate_limits: HashMap::new(),
|
||||||
|
cidr_rate_limits: HashMap::new(),
|
||||||
|
user_source_deny: HashMap::new(),
|
||||||
|
user_max_unique_ips: HashMap::new(),
|
||||||
|
user_max_unique_ips_global_each: default_user_max_unique_ips_global_each(),
|
||||||
|
user_max_unique_ips_mode: UserMaxUniqueIpsMode::default(),
|
||||||
|
user_max_unique_ips_window_secs: default_user_max_unique_ips_window_secs(),
|
||||||
|
replay_check_len: default_replay_check_len(),
|
||||||
|
replay_window_secs: default_replay_window_secs(),
|
||||||
|
ignore_time_skew: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccessConfig {
|
||||||
|
pub fn is_user_enabled(&self, username: &str) -> bool {
|
||||||
|
self.user_enabled.get(username).copied().unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if `ip` is contained in any CIDR listed for `username` under `user_source_deny`.
|
||||||
|
pub fn is_user_source_ip_denied(&self, username: &str, ip: IpAddr) -> bool {
|
||||||
|
self.user_source_deny
|
||||||
|
.get(username)
|
||||||
|
.is_some_and(|nets| nets.iter().any(|n| n.contains(ip)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Key used by `access.cidr_rate_limits`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub enum CidrRateLimitKey {
|
||||||
|
/// Explicit source CIDR rule.
|
||||||
|
Network(IpNetwork),
|
||||||
|
/// IPv4 auto-template that creates one bucket for each matching `/N`.
|
||||||
|
AutoV4(u8),
|
||||||
|
/// IPv6 auto-template that creates one bucket for each matching `/N`.
|
||||||
|
AutoV6(u8),
|
||||||
|
/// Dual-stack auto-template; IPv4 uses `/N`, IPv6 uses `/(N * 4)`.
|
||||||
|
AutoDual(u8),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub(crate) enum CidrAutoTemplateFamily {
|
||||||
|
V4,
|
||||||
|
V6,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CidrAutoTemplateFamily {
|
||||||
|
pub(crate) fn marker(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::V4 => "*4",
|
||||||
|
Self::V6 => "*6",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub(crate) struct CidrAutoTemplate {
|
||||||
|
pub(crate) family: CidrAutoTemplateFamily,
|
||||||
|
pub(crate) prefix_len: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for CidrAutoTemplate {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(formatter, "{}/{}", self.family.marker(), self.prefix_len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CidrRateLimitKey {
|
||||||
|
pub(crate) fn auto_templates(&self) -> [Option<CidrAutoTemplate>; 2] {
|
||||||
|
match *self {
|
||||||
|
Self::Network(_) => [None, None],
|
||||||
|
Self::AutoV4(prefix_len) => [
|
||||||
|
Some(CidrAutoTemplate {
|
||||||
|
family: CidrAutoTemplateFamily::V4,
|
||||||
|
prefix_len,
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
],
|
||||||
|
Self::AutoV6(prefix_len) => [
|
||||||
|
Some(CidrAutoTemplate {
|
||||||
|
family: CidrAutoTemplateFamily::V6,
|
||||||
|
prefix_len,
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
],
|
||||||
|
Self::AutoDual(prefix_len) => [
|
||||||
|
Some(CidrAutoTemplate {
|
||||||
|
family: CidrAutoTemplateFamily::V4,
|
||||||
|
prefix_len,
|
||||||
|
}),
|
||||||
|
Some(CidrAutoTemplate {
|
||||||
|
family: CidrAutoTemplateFamily::V6,
|
||||||
|
prefix_len: prefix_len.saturating_mul(4),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for CidrRateLimitKey {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Network(cidr) => write!(formatter, "{cidr}"),
|
||||||
|
Self::AutoV4(prefix_len) => write!(formatter, "*4/{prefix_len}"),
|
||||||
|
Self::AutoV6(prefix_len) => write!(formatter, "*6/{prefix_len}"),
|
||||||
|
Self::AutoDual(prefix_len) => write!(formatter, "*/{prefix_len}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for CidrRateLimitKey {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
serializer.collect_str(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for CidrRateLimitKey {
|
||||||
|
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let value = String::deserialize(deserializer)?;
|
||||||
|
parse_cidr_rate_limit_key(&value).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_cidr_rate_limit_key(value: &str) -> std::result::Result<CidrRateLimitKey, String> {
|
||||||
|
if let Some(prefix) = value.strip_prefix("*4/") {
|
||||||
|
return parse_cidr_auto_prefix(value, prefix, 32).map(CidrRateLimitKey::AutoV4);
|
||||||
|
}
|
||||||
|
if let Some(prefix) = value.strip_prefix("*6/") {
|
||||||
|
return parse_cidr_auto_prefix(value, prefix, 128).map(CidrRateLimitKey::AutoV6);
|
||||||
|
}
|
||||||
|
if let Some(prefix) = value.strip_prefix("*/") {
|
||||||
|
return parse_cidr_auto_prefix(value, prefix, 32).map(CidrRateLimitKey::AutoDual);
|
||||||
|
}
|
||||||
|
if value.starts_with('*') {
|
||||||
|
return Err(format!(
|
||||||
|
"invalid CIDR rate limit key {value:?}; expected CIDR, *4/N, *6/N, or */N"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
value
|
||||||
|
.parse::<IpNetwork>()
|
||||||
|
.map(CidrRateLimitKey::Network)
|
||||||
|
.map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"invalid CIDR rate limit key {value:?}: {error}; expected CIDR, *4/N, *6/N, or */N"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_cidr_auto_prefix(
|
||||||
|
key: &str,
|
||||||
|
prefix: &str,
|
||||||
|
max_prefix: u8,
|
||||||
|
) -> std::result::Result<u8, String> {
|
||||||
|
let prefix = prefix.parse::<u8>().map_err(|_| {
|
||||||
|
format!("invalid CIDR auto-template key {key:?}; prefix must be within 0..={max_prefix}")
|
||||||
|
})?;
|
||||||
|
if prefix > max_prefix {
|
||||||
|
return Err(format!(
|
||||||
|
"invalid CIDR auto-template key {key:?}; prefix must be within 0..={max_prefix}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transport rate limit in bits-per-second.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RateLimitBps {
|
||||||
|
/// Upload direction limit in bits-per-second; `0` means unlimited.
|
||||||
|
#[serde(default)]
|
||||||
|
pub up_bps: u64,
|
||||||
|
/// Download direction limit in bits-per-second; `0` means unlimited.
|
||||||
|
#[serde(default)]
|
||||||
|
pub down_bps: u64,
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Control-plane API settings.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ApiConfig {
|
||||||
|
/// Enable or disable REST API.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
|
||||||
|
/// Listen address for API in `IP:PORT` format.
|
||||||
|
#[serde(default = "default_api_listen")]
|
||||||
|
pub listen: String,
|
||||||
|
|
||||||
|
/// CIDR whitelist allowed to access API.
|
||||||
|
#[serde(default = "default_api_whitelist")]
|
||||||
|
pub whitelist: Vec<IpNetwork>,
|
||||||
|
|
||||||
|
/// Behavior for requests from source IPs outside `whitelist`.
|
||||||
|
/// - `api`: return structured API forbidden response.
|
||||||
|
/// - `200`: return `200 OK` with an empty body.
|
||||||
|
/// - `drop`: close the connection without HTTP response.
|
||||||
|
#[serde(default)]
|
||||||
|
pub gray_action: ApiGrayAction,
|
||||||
|
|
||||||
|
/// Optional static value for `Authorization` header validation.
|
||||||
|
/// Empty string disables header auth.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auth_header: String,
|
||||||
|
|
||||||
|
/// Maximum accepted HTTP request body size in bytes.
|
||||||
|
#[serde(default = "default_api_request_body_limit_bytes")]
|
||||||
|
pub request_body_limit_bytes: usize,
|
||||||
|
|
||||||
|
/// Enable runtime snapshots that require read-lock aggregation on API request path.
|
||||||
|
#[serde(default = "default_api_minimal_runtime_enabled")]
|
||||||
|
pub minimal_runtime_enabled: bool,
|
||||||
|
|
||||||
|
/// Cache TTL for minimal runtime snapshots in milliseconds (0 disables caching).
|
||||||
|
#[serde(default = "default_api_minimal_runtime_cache_ttl_ms")]
|
||||||
|
pub minimal_runtime_cache_ttl_ms: u64,
|
||||||
|
|
||||||
|
/// Enables runtime edge endpoints with optional cached aggregation.
|
||||||
|
#[serde(default = "default_api_runtime_edge_enabled")]
|
||||||
|
pub runtime_edge_enabled: bool,
|
||||||
|
|
||||||
|
/// Cache TTL for runtime edge aggregation payloads in milliseconds.
|
||||||
|
#[serde(default = "default_api_runtime_edge_cache_ttl_ms")]
|
||||||
|
pub runtime_edge_cache_ttl_ms: u64,
|
||||||
|
|
||||||
|
/// Top-N limit for edge connection leaderboard payloads.
|
||||||
|
#[serde(default = "default_api_runtime_edge_top_n")]
|
||||||
|
pub runtime_edge_top_n: usize,
|
||||||
|
|
||||||
|
/// Ring-buffer capacity for runtime edge control-plane events.
|
||||||
|
#[serde(default = "default_api_runtime_edge_events_capacity")]
|
||||||
|
pub runtime_edge_events_capacity: usize,
|
||||||
|
|
||||||
|
/// Read-only mode: mutating endpoints are rejected.
|
||||||
|
#[serde(default)]
|
||||||
|
pub read_only: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ApiConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: default_true(),
|
||||||
|
listen: default_api_listen(),
|
||||||
|
whitelist: default_api_whitelist(),
|
||||||
|
gray_action: ApiGrayAction::default(),
|
||||||
|
auth_header: String::new(),
|
||||||
|
request_body_limit_bytes: default_api_request_body_limit_bytes(),
|
||||||
|
minimal_runtime_enabled: default_api_minimal_runtime_enabled(),
|
||||||
|
minimal_runtime_cache_ttl_ms: default_api_minimal_runtime_cache_ttl_ms(),
|
||||||
|
runtime_edge_enabled: default_api_runtime_edge_enabled(),
|
||||||
|
runtime_edge_cache_ttl_ms: default_api_runtime_edge_cache_ttl_ms(),
|
||||||
|
runtime_edge_top_n: default_api_runtime_edge_top_n(),
|
||||||
|
runtime_edge_events_capacity: default_api_runtime_edge_events_capacity(),
|
||||||
|
read_only: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ApiGrayAction {
|
||||||
|
/// Preserve current API behavior for denied source IPs.
|
||||||
|
Api,
|
||||||
|
/// Mimic a plain web endpoint by returning `200 OK` with an empty body.
|
||||||
|
#[serde(rename = "200")]
|
||||||
|
Ok200,
|
||||||
|
/// Drop connection without HTTP response for denied source IPs.
|
||||||
|
#[default]
|
||||||
|
Drop,
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum UnknownSniAction {
|
||||||
|
#[default]
|
||||||
|
Drop,
|
||||||
|
Mask,
|
||||||
|
Accept,
|
||||||
|
/// Reject the TLS handshake by sending a fatal `unrecognized_name` alert
|
||||||
|
/// (RFC 6066, AlertDescription = 112) before closing the connection.
|
||||||
|
/// Mimics nginx `ssl_reject_handshake on;` behavior on the default vhost —
|
||||||
|
/// the wire response indistinguishable from a stock modern web server
|
||||||
|
/// that simply does not host the requested name.
|
||||||
|
#[serde(rename = "reject_handshake")]
|
||||||
|
RejectHandshake,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TlsFetchProfile {
|
||||||
|
ModernChromeLike,
|
||||||
|
ModernFirefoxLike,
|
||||||
|
CompatTls12,
|
||||||
|
LegacyMinimal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TlsFetchProfile {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TlsFetchProfile::ModernChromeLike => "modern_chrome_like",
|
||||||
|
TlsFetchProfile::ModernFirefoxLike => "modern_firefox_like",
|
||||||
|
TlsFetchProfile::CompatTls12 => "compat_tls12",
|
||||||
|
TlsFetchProfile::LegacyMinimal => "legacy_minimal",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_tls_fetch_profiles() -> Vec<TlsFetchProfile> {
|
||||||
|
vec![
|
||||||
|
TlsFetchProfile::ModernChromeLike,
|
||||||
|
TlsFetchProfile::ModernFirefoxLike,
|
||||||
|
TlsFetchProfile::CompatTls12,
|
||||||
|
TlsFetchProfile::LegacyMinimal,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TlsFetchConfig {
|
||||||
|
/// Ordered list of ClientHello profiles used for adaptive fallback.
|
||||||
|
#[serde(default = "default_tls_fetch_profiles")]
|
||||||
|
pub profiles: Vec<TlsFetchProfile>,
|
||||||
|
|
||||||
|
/// When true and upstream route is configured, TLS fetch fails closed on
|
||||||
|
/// upstream connect errors and does not fallback to direct TCP.
|
||||||
|
#[serde(default = "default_tls_fetch_strict_route")]
|
||||||
|
pub strict_route: bool,
|
||||||
|
|
||||||
|
/// Timeout per one profile attempt in milliseconds.
|
||||||
|
#[serde(default = "default_tls_fetch_attempt_timeout_ms")]
|
||||||
|
pub attempt_timeout_ms: u64,
|
||||||
|
|
||||||
|
/// Total wall-clock budget in milliseconds across all profile attempts.
|
||||||
|
#[serde(default = "default_tls_fetch_total_budget_ms")]
|
||||||
|
pub total_budget_ms: u64,
|
||||||
|
|
||||||
|
/// Adds GREASE-style values into selected ClientHello extensions.
|
||||||
|
#[serde(default)]
|
||||||
|
pub grease_enabled: bool,
|
||||||
|
|
||||||
|
/// Produces deterministic ClientHello randomness for debugging/tests.
|
||||||
|
#[serde(default)]
|
||||||
|
pub deterministic: bool,
|
||||||
|
|
||||||
|
/// TTL for winner-profile cache entries in seconds.
|
||||||
|
/// Set to 0 to disable profile cache.
|
||||||
|
#[serde(default = "default_tls_fetch_profile_cache_ttl_secs")]
|
||||||
|
pub profile_cache_ttl_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TlsFetchConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
profiles: default_tls_fetch_profiles(),
|
||||||
|
strict_route: default_tls_fetch_strict_route(),
|
||||||
|
attempt_timeout_ms: default_tls_fetch_attempt_timeout_ms(),
|
||||||
|
total_budget_ms: default_tls_fetch_total_budget_ms(),
|
||||||
|
grease_enabled: false,
|
||||||
|
deterministic: false,
|
||||||
|
profile_cache_ttl_secs: default_tls_fetch_profile_cache_ttl_secs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ExclusiveMaskTarget {
|
||||||
|
/// Target host after IDNA/IP normalization.
|
||||||
|
pub host: String,
|
||||||
|
/// TCP port for the selected target.
|
||||||
|
pub port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AntiCensorshipConfig {
|
||||||
|
#[serde(default = "default_tls_domain")]
|
||||||
|
pub tls_domain: String,
|
||||||
|
|
||||||
|
/// Additional TLS domains for generating multiple proxy links.
|
||||||
|
#[serde(default)]
|
||||||
|
pub tls_domains: Vec<String>,
|
||||||
|
|
||||||
|
/// Policy for TLS ClientHello with unknown (non-configured) SNI.
|
||||||
|
#[serde(default)]
|
||||||
|
pub unknown_sni_action: UnknownSniAction,
|
||||||
|
|
||||||
|
/// Upstream scope used for TLS front metadata fetches.
|
||||||
|
/// Empty value keeps default upstream routing behavior.
|
||||||
|
#[serde(default = "default_tls_fetch_scope")]
|
||||||
|
pub tls_fetch_scope: String,
|
||||||
|
|
||||||
|
/// Fetch strategy for TLS front metadata bootstrap and periodic refresh.
|
||||||
|
#[serde(default)]
|
||||||
|
pub tls_fetch: TlsFetchConfig,
|
||||||
|
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub mask: bool,
|
||||||
|
|
||||||
|
/// Use the ClientHello SNI as the mask TCP target for configured TLS domains.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub mask_dynamic: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub mask_host: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default = "default_mask_port")]
|
||||||
|
pub mask_port: u16,
|
||||||
|
|
||||||
|
/// Per-SNI TCP mask targets. Keys are SNI domains, values are `host:port`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub exclusive_mask: HashMap<String, String>,
|
||||||
|
|
||||||
|
/// Parsed runtime cache for per-SNI TCP mask targets.
|
||||||
|
#[serde(skip)]
|
||||||
|
pub exclusive_mask_targets: HashMap<String, ExclusiveMaskTarget>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub mask_unix_sock: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default = "default_fake_cert_len")]
|
||||||
|
pub fake_cert_len: usize,
|
||||||
|
|
||||||
|
/// Enable TLS certificate emulation using cached real certificates.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub tls_emulation: bool,
|
||||||
|
|
||||||
|
/// Directory to store TLS front cache (on disk).
|
||||||
|
#[serde(default = "default_tls_front_dir")]
|
||||||
|
pub tls_front_dir: String,
|
||||||
|
|
||||||
|
/// Minimum server_hello delay in milliseconds (anti-fingerprint).
|
||||||
|
#[serde(default = "default_server_hello_delay_min_ms")]
|
||||||
|
pub server_hello_delay_min_ms: u64,
|
||||||
|
|
||||||
|
/// Maximum server_hello delay in milliseconds.
|
||||||
|
#[serde(default = "default_server_hello_delay_max_ms")]
|
||||||
|
pub server_hello_delay_max_ms: u64,
|
||||||
|
|
||||||
|
/// Number of NewSessionTicket messages to emit post-handshake.
|
||||||
|
#[serde(default = "default_tls_new_session_tickets")]
|
||||||
|
pub tls_new_session_tickets: u8,
|
||||||
|
|
||||||
|
/// Enable compact ServerHello payload mode.
|
||||||
|
/// When false, FakeTLS always uses full ServerHello payload behavior.
|
||||||
|
/// When true, compact certificate payload mode can be used by TTL policy.
|
||||||
|
#[serde(default = "default_serverhello_compact")]
|
||||||
|
pub serverhello_compact: bool,
|
||||||
|
|
||||||
|
/// TTL in seconds for sending full certificate payload per client IP.
|
||||||
|
/// First client connection per (SNI domain, client IP) gets full cert payload.
|
||||||
|
/// Subsequent handshakes within TTL use compact cert metadata payload.
|
||||||
|
/// Applied only when `serverhello_compact` is enabled.
|
||||||
|
#[serde(default = "default_tls_full_cert_ttl_secs")]
|
||||||
|
pub tls_full_cert_ttl_secs: u64,
|
||||||
|
|
||||||
|
/// Enforce ALPN echo of client preference.
|
||||||
|
#[serde(default = "default_alpn_enforce")]
|
||||||
|
pub alpn_enforce: bool,
|
||||||
|
|
||||||
|
/// Send PROXY protocol header when connecting to mask_host.
|
||||||
|
/// 0 = disabled, 1 = v1 (text), 2 = v2 (binary).
|
||||||
|
/// Allows the backend to see the real client IP.
|
||||||
|
#[serde(default)]
|
||||||
|
pub mask_proxy_protocol: u8,
|
||||||
|
|
||||||
|
/// Enable shape-channel hardening on mask backend path by padding
|
||||||
|
/// client->mask stream tail to configured buckets on stream end.
|
||||||
|
#[serde(default = "default_mask_shape_hardening")]
|
||||||
|
pub mask_shape_hardening: bool,
|
||||||
|
|
||||||
|
/// Opt-in aggressive shape hardening mode.
|
||||||
|
/// When enabled, masking may shape some backend-silent timeout paths and
|
||||||
|
/// enforces strictly positive above-cap blur when blur is enabled.
|
||||||
|
#[serde(default = "default_mask_shape_hardening_aggressive_mode")]
|
||||||
|
pub mask_shape_hardening_aggressive_mode: bool,
|
||||||
|
|
||||||
|
/// Minimum bucket size for mask shape hardening padding.
|
||||||
|
#[serde(default = "default_mask_shape_bucket_floor_bytes")]
|
||||||
|
pub mask_shape_bucket_floor_bytes: usize,
|
||||||
|
|
||||||
|
/// Maximum bucket size for mask shape hardening padding.
|
||||||
|
#[serde(default = "default_mask_shape_bucket_cap_bytes")]
|
||||||
|
pub mask_shape_bucket_cap_bytes: usize,
|
||||||
|
|
||||||
|
/// Add bounded random tail bytes even when total bytes already exceed
|
||||||
|
/// mask_shape_bucket_cap_bytes.
|
||||||
|
#[serde(default = "default_mask_shape_above_cap_blur")]
|
||||||
|
pub mask_shape_above_cap_blur: bool,
|
||||||
|
|
||||||
|
/// Maximum random bytes appended above cap when above-cap blur is enabled.
|
||||||
|
#[serde(default = "default_mask_shape_above_cap_blur_max_bytes")]
|
||||||
|
pub mask_shape_above_cap_blur_max_bytes: usize,
|
||||||
|
|
||||||
|
/// Maximum bytes relayed per direction on unauthenticated masking fallback paths.
|
||||||
|
/// Set to 0 to disable byte cap (unlimited within relay/idle timeouts).
|
||||||
|
#[serde(default = "default_mask_relay_max_bytes")]
|
||||||
|
pub mask_relay_max_bytes: usize,
|
||||||
|
|
||||||
|
/// Wall-clock cap for the full masking relay on non-MTProto fallback paths.
|
||||||
|
/// Raise when the mask target is a long-lived service (e.g. WebSocket).
|
||||||
|
/// Default: 60 000 ms (60 s).
|
||||||
|
#[serde(default = "default_mask_relay_timeout_ms")]
|
||||||
|
pub mask_relay_timeout_ms: u64,
|
||||||
|
|
||||||
|
/// Per-read idle timeout on masking relay and drain paths.
|
||||||
|
/// Limits resource consumption by slow-loris attacks and port scanners.
|
||||||
|
/// A read call stalling beyond this is treated as an abandoned connection.
|
||||||
|
/// Default: 5 000 ms (5 s).
|
||||||
|
#[serde(default = "default_mask_relay_idle_timeout_ms")]
|
||||||
|
pub mask_relay_idle_timeout_ms: u64,
|
||||||
|
|
||||||
|
/// Prefetch timeout (ms) for extending fragmented masking classifier window.
|
||||||
|
#[serde(default = "default_mask_classifier_prefetch_timeout_ms")]
|
||||||
|
pub mask_classifier_prefetch_timeout_ms: u64,
|
||||||
|
|
||||||
|
/// Enable outcome-time normalization envelope for masking fallback.
|
||||||
|
#[serde(default = "default_mask_timing_normalization_enabled")]
|
||||||
|
pub mask_timing_normalization_enabled: bool,
|
||||||
|
|
||||||
|
/// Lower bound (ms) for masking outcome timing envelope.
|
||||||
|
#[serde(default = "default_mask_timing_normalization_floor_ms")]
|
||||||
|
pub mask_timing_normalization_floor_ms: u64,
|
||||||
|
|
||||||
|
/// Upper bound (ms) for masking outcome timing envelope.
|
||||||
|
#[serde(default = "default_mask_timing_normalization_ceiling_ms")]
|
||||||
|
pub mask_timing_normalization_ceiling_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AntiCensorshipConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
tls_domain: default_tls_domain(),
|
||||||
|
tls_domains: Vec::new(),
|
||||||
|
unknown_sni_action: UnknownSniAction::Drop,
|
||||||
|
tls_fetch_scope: default_tls_fetch_scope(),
|
||||||
|
tls_fetch: TlsFetchConfig::default(),
|
||||||
|
mask: default_true(),
|
||||||
|
mask_dynamic: default_true(),
|
||||||
|
mask_host: None,
|
||||||
|
mask_port: default_mask_port(),
|
||||||
|
exclusive_mask: HashMap::new(),
|
||||||
|
exclusive_mask_targets: HashMap::new(),
|
||||||
|
mask_unix_sock: None,
|
||||||
|
fake_cert_len: default_fake_cert_len(),
|
||||||
|
tls_emulation: true,
|
||||||
|
tls_front_dir: default_tls_front_dir(),
|
||||||
|
server_hello_delay_min_ms: default_server_hello_delay_min_ms(),
|
||||||
|
server_hello_delay_max_ms: default_server_hello_delay_max_ms(),
|
||||||
|
tls_new_session_tickets: default_tls_new_session_tickets(),
|
||||||
|
serverhello_compact: default_serverhello_compact(),
|
||||||
|
tls_full_cert_ttl_secs: default_tls_full_cert_ttl_secs(),
|
||||||
|
alpn_enforce: default_alpn_enforce(),
|
||||||
|
mask_proxy_protocol: 0,
|
||||||
|
mask_shape_hardening: default_mask_shape_hardening(),
|
||||||
|
mask_shape_hardening_aggressive_mode: default_mask_shape_hardening_aggressive_mode(),
|
||||||
|
mask_shape_bucket_floor_bytes: default_mask_shape_bucket_floor_bytes(),
|
||||||
|
mask_shape_bucket_cap_bytes: default_mask_shape_bucket_cap_bytes(),
|
||||||
|
mask_shape_above_cap_blur: default_mask_shape_above_cap_blur(),
|
||||||
|
mask_shape_above_cap_blur_max_bytes: default_mask_shape_above_cap_blur_max_bytes(),
|
||||||
|
mask_relay_max_bytes: default_mask_relay_max_bytes(),
|
||||||
|
mask_relay_timeout_ms: default_mask_relay_timeout_ms(),
|
||||||
|
mask_relay_idle_timeout_ms: default_mask_relay_idle_timeout_ms(),
|
||||||
|
mask_classifier_prefetch_timeout_ms: default_mask_classifier_prefetch_timeout_ms(),
|
||||||
|
mask_timing_normalization_enabled: default_mask_timing_normalization_enabled(),
|
||||||
|
mask_timing_normalization_floor_ms: default_mask_timing_normalization_floor_ms(),
|
||||||
|
mask_timing_normalization_ceiling_ms: default_mask_timing_normalization_ceiling_ms(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
use super::*;
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GeneralConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub data_path: Option<PathBuf>,
|
||||||
|
/// JSON state file for runtime per-user quota consumption.
|
||||||
|
#[serde(default = "default_quota_state_path")]
|
||||||
|
pub quota_state_path: PathBuf,
|
||||||
|
/// Reject unknown TOML config keys during load.
|
||||||
|
/// Startup fails fast; hot-reload rejects the new snapshot and keeps the current config.
|
||||||
|
#[serde(default)]
|
||||||
|
pub config_strict: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub modes: ProxyModes,
|
||||||
|
#[serde(default)]
|
||||||
|
pub prefer_ipv6: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub fast_mode: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub use_middle_proxy: bool,
|
||||||
|
/// Path to proxy-secret binary file (auto-downloaded if absent).
|
||||||
|
/// Infrastructure secret from https://core.telegram.org/getProxySecret.
|
||||||
|
#[serde(default = "default_proxy_secret_path")]
|
||||||
|
pub proxy_secret_path: Option<String>,
|
||||||
|
/// Optional custom URL for infrastructure secret (https://core.telegram.org/getProxySecret if absent).
|
||||||
|
#[serde(default)]
|
||||||
|
pub proxy_secret_url: Option<String>,
|
||||||
|
/// Optional path to cache raw getProxyConfig (IPv4) snapshot for startup fallback.
|
||||||
|
#[serde(default = "default_proxy_config_v4_cache_path")]
|
||||||
|
pub proxy_config_v4_cache_path: Option<String>,
|
||||||
|
/// Optional custom URL for getProxyConfig (https://core.telegram.org/getProxyConfig if absent).
|
||||||
|
#[serde(default)]
|
||||||
|
pub proxy_config_v4_url: Option<String>,
|
||||||
|
/// Optional path to cache raw getProxyConfigV6 snapshot for startup fallback.
|
||||||
|
#[serde(default = "default_proxy_config_v6_cache_path")]
|
||||||
|
pub proxy_config_v6_cache_path: Option<String>,
|
||||||
|
/// Optional custom URL for getProxyConfigV6 (https://core.telegram.org/getProxyConfigV6 if absent).
|
||||||
|
#[serde(default)]
|
||||||
|
pub proxy_config_v6_url: Option<String>,
|
||||||
|
/// Global ad_tag (32 hex chars from @MTProxybot). Fallback when user has no per-user tag in access.user_ad_tags.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ad_tag: Option<String>,
|
||||||
|
/// Public IP override for middle-proxy NAT environments.
|
||||||
|
/// When set, this IP is used in ME key derivation and local address translation.
|
||||||
|
#[serde(default)]
|
||||||
|
pub middle_proxy_nat_ip: Option<IpAddr>,
|
||||||
|
/// Enable STUN-based NAT probing to discover public IP:port for ME KDF.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub middle_proxy_nat_probe: bool,
|
||||||
|
/// Deprecated legacy single STUN server for NAT probing.
|
||||||
|
/// Use `network.stun_servers` instead.
|
||||||
|
#[serde(default = "default_middle_proxy_nat_stun")]
|
||||||
|
pub middle_proxy_nat_stun: Option<String>,
|
||||||
|
/// Deprecated legacy STUN list for NAT probing fallback.
|
||||||
|
/// Use `network.stun_servers` instead.
|
||||||
|
#[serde(default = "default_middle_proxy_nat_stun_servers")]
|
||||||
|
pub middle_proxy_nat_stun_servers: Vec<String>,
|
||||||
|
/// Maximum number of concurrent STUN probes during NAT detection.
|
||||||
|
#[serde(default = "default_stun_nat_probe_concurrency")]
|
||||||
|
pub stun_nat_probe_concurrency: usize,
|
||||||
|
/// Desired size of active Middle-Proxy writer pool.
|
||||||
|
#[serde(default = "default_pool_size")]
|
||||||
|
pub middle_proxy_pool_size: usize,
|
||||||
|
/// Number of warm standby ME connections kept pre-initialized.
|
||||||
|
#[serde(default = "default_middle_proxy_warm_standby")]
|
||||||
|
pub middle_proxy_warm_standby: usize,
|
||||||
|
/// Startup retries for Middle-End pool initialization before ME→Direct fallback.
|
||||||
|
/// 0 means unlimited retries.
|
||||||
|
#[serde(default = "default_me_init_retry_attempts")]
|
||||||
|
pub me_init_retry_attempts: u32,
|
||||||
|
/// Allow fallback from Middle-End mode to direct DC when ME startup cannot be initialized.
|
||||||
|
#[serde(default = "default_me2dc_fallback")]
|
||||||
|
pub me2dc_fallback: bool,
|
||||||
|
/// Fast ME->Direct fallback mode for new sessions.
|
||||||
|
/// Active only when both `use_middle_proxy=true` and `me2dc_fallback=true`.
|
||||||
|
#[serde(default = "default_me2dc_fast")]
|
||||||
|
pub me2dc_fast: bool,
|
||||||
|
/// Enable ME keepalive padding frames.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub me_keepalive_enabled: bool,
|
||||||
|
/// Keepalive interval in seconds.
|
||||||
|
#[serde(default = "default_keepalive_interval")]
|
||||||
|
pub me_keepalive_interval_secs: u64,
|
||||||
|
/// Keepalive jitter in seconds.
|
||||||
|
#[serde(default = "default_keepalive_jitter")]
|
||||||
|
pub me_keepalive_jitter_secs: u64,
|
||||||
|
/// Keepalive payload randomized (4 bytes); otherwise zeros.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub me_keepalive_payload_random: bool,
|
||||||
|
/// Interval in seconds for service RPC_PROXY_REQ activity signals to ME.
|
||||||
|
/// 0 disables service activity signals.
|
||||||
|
#[serde(default = "default_rpc_proxy_req_every")]
|
||||||
|
pub rpc_proxy_req_every: u64,
|
||||||
|
/// Capacity of per-ME writer command channel.
|
||||||
|
#[serde(default = "default_me_writer_cmd_channel_capacity")]
|
||||||
|
pub me_writer_cmd_channel_capacity: usize,
|
||||||
|
/// Resident-memory budget in bytes for each ME writer data queue.
|
||||||
|
#[serde(default = "default_me_writer_byte_budget_bytes")]
|
||||||
|
pub me_writer_byte_budget_bytes: usize,
|
||||||
|
/// Capacity of per-connection ME response route channel.
|
||||||
|
#[serde(default = "default_me_route_channel_capacity")]
|
||||||
|
pub me_route_channel_capacity: usize,
|
||||||
|
/// Capacity of per-client command queue from client reader to ME sender task.
|
||||||
|
#[serde(default = "default_me_c2me_channel_capacity")]
|
||||||
|
pub me_c2me_channel_capacity: usize,
|
||||||
|
/// Maximum wait in milliseconds for enqueueing C2ME commands when the queue is full.
|
||||||
|
/// `0` keeps legacy unbounded wait behavior.
|
||||||
|
#[serde(default = "default_me_c2me_send_timeout_ms")]
|
||||||
|
pub me_c2me_send_timeout_ms: u64,
|
||||||
|
/// Bounded wait in milliseconds for routing ME DATA to per-connection queue.
|
||||||
|
/// `0` keeps non-blocking routing; values >0 enable bounded wait for compatibility.
|
||||||
|
#[serde(default = "default_me_reader_route_data_wait_ms")]
|
||||||
|
pub me_reader_route_data_wait_ms: u64,
|
||||||
|
/// Maximum number of ME->Client responses coalesced before flush.
|
||||||
|
#[serde(default = "default_me_d2c_flush_batch_max_frames")]
|
||||||
|
pub me_d2c_flush_batch_max_frames: usize,
|
||||||
|
/// Maximum total payload bytes coalesced before flush.
|
||||||
|
#[serde(default = "default_me_d2c_flush_batch_max_bytes")]
|
||||||
|
pub me_d2c_flush_batch_max_bytes: usize,
|
||||||
|
/// Maximum wait in microseconds to coalesce additional ME->Client responses.
|
||||||
|
/// `0` disables timed coalescing.
|
||||||
|
#[serde(default = "default_me_d2c_flush_batch_max_delay_us")]
|
||||||
|
pub me_d2c_flush_batch_max_delay_us: u64,
|
||||||
|
/// Flush client writer immediately after quick-ack write.
|
||||||
|
#[serde(default = "default_me_d2c_ack_flush_immediate")]
|
||||||
|
pub me_d2c_ack_flush_immediate: bool,
|
||||||
|
/// Additional bytes above strict per-user quota allowed in hot-path soft mode.
|
||||||
|
#[serde(default = "default_me_quota_soft_overshoot_bytes")]
|
||||||
|
pub me_quota_soft_overshoot_bytes: u64,
|
||||||
|
/// Shrink threshold for reusable ME->Client frame assembly buffer.
|
||||||
|
#[serde(default = "default_me_d2c_frame_buf_shrink_threshold_bytes")]
|
||||||
|
pub me_d2c_frame_buf_shrink_threshold_bytes: usize,
|
||||||
|
/// Copy buffer ceiling for client->DC direction in direct relay.
|
||||||
|
///
|
||||||
|
/// This is also the upper bound for one amortized upload rate-limit burst:
|
||||||
|
/// upload debt is settled before the next relay read instead of blocking
|
||||||
|
/// inside the completed read path.
|
||||||
|
#[serde(default = "default_direct_relay_copy_buf_c2s_bytes")]
|
||||||
|
pub direct_relay_copy_buf_c2s_bytes: usize,
|
||||||
|
/// Copy buffer ceiling for DC->client direction in direct relay.
|
||||||
|
///
|
||||||
|
/// This bounds one direct download rate-limit grant because writes are
|
||||||
|
/// clipped to the currently available shaper budget.
|
||||||
|
#[serde(default = "default_direct_relay_copy_buf_s2c_bytes")]
|
||||||
|
pub direct_relay_copy_buf_s2c_bytes: usize,
|
||||||
|
/// Process-wide hard ceiling for Direct relay copy buffers.
|
||||||
|
/// `0` derives the ceiling from host and cgroup memory limits.
|
||||||
|
#[serde(default = "default_direct_relay_buffer_budget_max_bytes")]
|
||||||
|
pub direct_relay_buffer_budget_max_bytes: usize,
|
||||||
|
/// Max pending ciphertext buffer per client writer (bytes).
|
||||||
|
/// Controls FakeTLS backpressure vs throughput.
|
||||||
|
#[serde(default = "default_crypto_pending_buffer")]
|
||||||
|
pub crypto_pending_buffer: usize,
|
||||||
|
/// Maximum allowed client MTProto frame size (bytes).
|
||||||
|
#[serde(default = "default_max_client_frame")]
|
||||||
|
pub max_client_frame: usize,
|
||||||
|
/// Emit full crypto-desync forensic logs for every event.
|
||||||
|
/// When false, full forensic details are emitted once per key window.
|
||||||
|
#[serde(default = "default_desync_all_full")]
|
||||||
|
pub desync_all_full: bool,
|
||||||
|
/// Enable per-IP forensic observation buckets for scanners and handshake failures.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub beobachten: bool,
|
||||||
|
/// Observation retention window in minutes for per-IP forensic buckets.
|
||||||
|
#[serde(default = "default_beobachten_minutes")]
|
||||||
|
pub beobachten_minutes: u64,
|
||||||
|
/// Snapshot flush interval in seconds for beob output file.
|
||||||
|
#[serde(default = "default_beobachten_flush_secs")]
|
||||||
|
pub beobachten_flush_secs: u64,
|
||||||
|
/// Snapshot file path for beob output.
|
||||||
|
#[serde(default = "default_beobachten_file")]
|
||||||
|
pub beobachten_file: String,
|
||||||
|
/// Enable C-like hard-swap for ME pool generations.
|
||||||
|
/// When true, Telemt prewarms a new generation and switches once full coverage is reached.
|
||||||
|
#[serde(default = "default_hardswap")]
|
||||||
|
pub hardswap: bool,
|
||||||
|
/// Enable staggered warmup of extra ME writers.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub me_warmup_stagger_enabled: bool,
|
||||||
|
/// Base delay between warmup connections in ms.
|
||||||
|
#[serde(default = "default_warmup_step_delay_ms")]
|
||||||
|
pub me_warmup_step_delay_ms: u64,
|
||||||
|
/// Jitter for warmup delay in ms.
|
||||||
|
#[serde(default = "default_warmup_step_jitter_ms")]
|
||||||
|
pub me_warmup_step_jitter_ms: u64,
|
||||||
|
/// Max concurrent reconnect attempts per DC.
|
||||||
|
#[serde(default = "default_me_reconnect_max_concurrent_per_dc")]
|
||||||
|
pub me_reconnect_max_concurrent_per_dc: u32,
|
||||||
|
/// Base backoff in ms for reconnect.
|
||||||
|
#[serde(default = "default_reconnect_backoff_base_ms")]
|
||||||
|
pub me_reconnect_backoff_base_ms: u64,
|
||||||
|
/// Cap backoff in ms for reconnect.
|
||||||
|
#[serde(default = "default_reconnect_backoff_cap_ms")]
|
||||||
|
pub me_reconnect_backoff_cap_ms: u64,
|
||||||
|
/// Fast retry attempts before backoff.
|
||||||
|
#[serde(default = "default_me_reconnect_fast_retry_count")]
|
||||||
|
pub me_reconnect_fast_retry_count: u32,
|
||||||
|
/// Number of additional reserve writers for DC groups with exactly one endpoint.
|
||||||
|
#[serde(default = "default_me_single_endpoint_shadow_writers")]
|
||||||
|
pub me_single_endpoint_shadow_writers: u8,
|
||||||
|
/// Enable aggressive outage recovery mode for single-endpoint DC groups.
|
||||||
|
#[serde(default = "default_me_single_endpoint_outage_mode_enabled")]
|
||||||
|
pub me_single_endpoint_outage_mode_enabled: bool,
|
||||||
|
/// Ignore endpoint quarantine while in single-endpoint outage mode.
|
||||||
|
#[serde(default = "default_me_single_endpoint_outage_disable_quarantine")]
|
||||||
|
pub me_single_endpoint_outage_disable_quarantine: bool,
|
||||||
|
/// Minimum reconnect backoff in ms for single-endpoint outage mode.
|
||||||
|
#[serde(default = "default_me_single_endpoint_outage_backoff_min_ms")]
|
||||||
|
pub me_single_endpoint_outage_backoff_min_ms: u64,
|
||||||
|
/// Maximum reconnect backoff in ms for single-endpoint outage mode.
|
||||||
|
#[serde(default = "default_me_single_endpoint_outage_backoff_max_ms")]
|
||||||
|
pub me_single_endpoint_outage_backoff_max_ms: u64,
|
||||||
|
/// Periodic shadow writer rotation interval in seconds for single-endpoint DC groups.
|
||||||
|
/// Set to 0 to disable periodic shadow rotation.
|
||||||
|
#[serde(default = "default_me_single_endpoint_shadow_rotate_every_secs")]
|
||||||
|
pub me_single_endpoint_shadow_rotate_every_secs: u64,
|
||||||
|
/// Floor policy mode for ME writer targets.
|
||||||
|
#[serde(default)]
|
||||||
|
pub me_floor_mode: MeFloorMode,
|
||||||
|
/// Idle time in seconds before adaptive floor can reduce single-endpoint writer target.
|
||||||
|
#[serde(default = "default_me_adaptive_floor_idle_secs")]
|
||||||
|
pub me_adaptive_floor_idle_secs: u64,
|
||||||
|
/// Minimum writer target for single-endpoint DC groups in adaptive floor mode.
|
||||||
|
#[serde(default = "default_me_adaptive_floor_min_writers_single_endpoint")]
|
||||||
|
pub me_adaptive_floor_min_writers_single_endpoint: u8,
|
||||||
|
/// Minimum writer target for multi-endpoint DC groups in adaptive floor mode.
|
||||||
|
#[serde(default = "default_me_adaptive_floor_min_writers_multi_endpoint")]
|
||||||
|
pub me_adaptive_floor_min_writers_multi_endpoint: u8,
|
||||||
|
/// Grace period in seconds to hold static floor after activity in adaptive mode.
|
||||||
|
#[serde(default = "default_me_adaptive_floor_recover_grace_secs")]
|
||||||
|
pub me_adaptive_floor_recover_grace_secs: u64,
|
||||||
|
/// Global ME writer budget per logical CPU core in adaptive mode.
|
||||||
|
#[serde(default = "default_me_adaptive_floor_writers_per_core_total")]
|
||||||
|
pub me_adaptive_floor_writers_per_core_total: u16,
|
||||||
|
/// Override logical CPU core count for adaptive floor calculations.
|
||||||
|
/// Set to 0 to use runtime auto-detection.
|
||||||
|
#[serde(default = "default_me_adaptive_floor_cpu_cores_override")]
|
||||||
|
pub me_adaptive_floor_cpu_cores_override: u16,
|
||||||
|
/// Per-core max extra writers above base required floor for single-endpoint DC groups.
|
||||||
|
#[serde(default = "default_me_adaptive_floor_max_extra_writers_single_per_core")]
|
||||||
|
pub me_adaptive_floor_max_extra_writers_single_per_core: u16,
|
||||||
|
/// Per-core max extra writers above base required floor for multi-endpoint DC groups.
|
||||||
|
#[serde(default = "default_me_adaptive_floor_max_extra_writers_multi_per_core")]
|
||||||
|
pub me_adaptive_floor_max_extra_writers_multi_per_core: u16,
|
||||||
|
/// Hard cap for active ME writers per logical CPU core.
|
||||||
|
#[serde(default = "default_me_adaptive_floor_max_active_writers_per_core")]
|
||||||
|
pub me_adaptive_floor_max_active_writers_per_core: u16,
|
||||||
|
/// Hard cap for warm ME writers per logical CPU core.
|
||||||
|
#[serde(default = "default_me_adaptive_floor_max_warm_writers_per_core")]
|
||||||
|
pub me_adaptive_floor_max_warm_writers_per_core: u16,
|
||||||
|
/// Hard global cap for active ME writers.
|
||||||
|
#[serde(default = "default_me_adaptive_floor_max_active_writers_global")]
|
||||||
|
pub me_adaptive_floor_max_active_writers_global: u32,
|
||||||
|
/// Hard global cap for warm ME writers.
|
||||||
|
#[serde(default = "default_me_adaptive_floor_max_warm_writers_global")]
|
||||||
|
pub me_adaptive_floor_max_warm_writers_global: u32,
|
||||||
|
/// Connect attempts for the selected upstream before returning error/fallback.
|
||||||
|
#[serde(default = "default_upstream_connect_retry_attempts")]
|
||||||
|
pub upstream_connect_retry_attempts: u32,
|
||||||
|
/// Delay in milliseconds between upstream connect attempts.
|
||||||
|
#[serde(default = "default_upstream_connect_retry_backoff_ms")]
|
||||||
|
pub upstream_connect_retry_backoff_ms: u64,
|
||||||
|
/// Total wall-clock budget in milliseconds for one upstream connect request across retries.
|
||||||
|
#[serde(default = "default_upstream_connect_budget_ms")]
|
||||||
|
pub upstream_connect_budget_ms: u64,
|
||||||
|
/// Per-attempt TCP connect timeout to Telegram DC (seconds).
|
||||||
|
#[serde(default = "default_connect_timeout")]
|
||||||
|
pub tg_connect: u64,
|
||||||
|
/// Consecutive failed requests before upstream is marked unhealthy.
|
||||||
|
#[serde(default = "default_upstream_unhealthy_fail_threshold")]
|
||||||
|
pub upstream_unhealthy_fail_threshold: u32,
|
||||||
|
/// Skip additional retries for hard non-transient upstream connect errors.
|
||||||
|
#[serde(default = "default_upstream_connect_failfast_hard_errors")]
|
||||||
|
pub upstream_connect_failfast_hard_errors: bool,
|
||||||
|
/// Ignore STUN/interface IP mismatch (keep using Middle Proxy even if NAT detected).
|
||||||
|
#[serde(default)]
|
||||||
|
pub stun_iface_mismatch_ignore: bool,
|
||||||
|
/// Log unknown (non-standard) DC requests to a file (default: unknown-dc.txt). Set to null to disable.
|
||||||
|
#[serde(default = "default_unknown_dc_log_path")]
|
||||||
|
pub unknown_dc_log_path: Option<String>,
|
||||||
|
/// Enable unknown-DC file logging.
|
||||||
|
#[serde(default = "default_unknown_dc_file_log_enabled")]
|
||||||
|
pub unknown_dc_file_log_enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub log_level: LogLevel,
|
||||||
|
/// Disable colored output in logs (useful for files/systemd).
|
||||||
|
#[serde(default)]
|
||||||
|
pub disable_colors: bool,
|
||||||
|
/// Runtime telemetry controls for counters/metrics in hot paths.
|
||||||
|
#[serde(default)]
|
||||||
|
pub telemetry: TelemetryConfig,
|
||||||
|
/// SOCKS-bound KDF policy for Middle-End handshake.
|
||||||
|
#[serde(default)]
|
||||||
|
pub me_socks_kdf_policy: MeSocksKdfPolicy,
|
||||||
|
/// Enable route-level ME backpressure controls in reader fairness path.
|
||||||
|
#[serde(default = "default_me_route_backpressure_enabled")]
|
||||||
|
pub me_route_backpressure_enabled: bool,
|
||||||
|
/// Enable worker-local fairshare scheduler for ME reader routing.
|
||||||
|
#[serde(default = "default_me_route_fairshare_enabled")]
|
||||||
|
pub me_route_fairshare_enabled: bool,
|
||||||
|
/// Base backpressure timeout in milliseconds for ME route channel send.
|
||||||
|
#[serde(default = "default_me_route_backpressure_base_timeout_ms")]
|
||||||
|
pub me_route_backpressure_base_timeout_ms: u64,
|
||||||
|
/// High backpressure timeout in milliseconds when queue occupancy is above watermark.
|
||||||
|
#[serde(default = "default_me_route_backpressure_high_timeout_ms")]
|
||||||
|
pub me_route_backpressure_high_timeout_ms: u64,
|
||||||
|
/// Queue occupancy percent threshold for high backpressure timeout.
|
||||||
|
#[serde(default = "default_me_route_backpressure_high_watermark_pct")]
|
||||||
|
pub me_route_backpressure_high_watermark_pct: u8,
|
||||||
|
/// Health monitor interval in milliseconds while writer coverage is degraded.
|
||||||
|
#[serde(default = "default_me_health_interval_ms_unhealthy")]
|
||||||
|
pub me_health_interval_ms_unhealthy: u64,
|
||||||
|
/// Health monitor interval in milliseconds while writer coverage is stable.
|
||||||
|
#[serde(default = "default_me_health_interval_ms_healthy")]
|
||||||
|
pub me_health_interval_ms_healthy: u64,
|
||||||
|
/// Poll interval in milliseconds for conditional-admission state checks.
|
||||||
|
#[serde(default = "default_me_admission_poll_ms")]
|
||||||
|
pub me_admission_poll_ms: u64,
|
||||||
|
/// Cooldown for repetitive ME warning logs in milliseconds.
|
||||||
|
#[serde(default = "default_me_warn_rate_limit_ms")]
|
||||||
|
pub me_warn_rate_limit_ms: u64,
|
||||||
|
/// ME route behavior when no writer is immediately available.
|
||||||
|
#[serde(default)]
|
||||||
|
pub me_route_no_writer_mode: MeRouteNoWriterMode,
|
||||||
|
/// Maximum wait time in milliseconds for async-recovery failfast mode.
|
||||||
|
#[serde(default = "default_me_route_no_writer_wait_ms")]
|
||||||
|
pub me_route_no_writer_wait_ms: u64,
|
||||||
|
/// Maximum cumulative wait in milliseconds for hybrid no-writer mode before failfast.
|
||||||
|
#[serde(default = "default_me_route_hybrid_max_wait_ms")]
|
||||||
|
pub me_route_hybrid_max_wait_ms: u64,
|
||||||
|
/// Maximum wait in milliseconds for blocking ME writer channel send fallback.
|
||||||
|
/// Must be within [1, 5000].
|
||||||
|
#[serde(default = "default_me_route_blocking_send_timeout_ms")]
|
||||||
|
pub me_route_blocking_send_timeout_ms: u64,
|
||||||
|
/// Number of inline recovery attempts in legacy mode.
|
||||||
|
#[serde(default = "default_me_route_inline_recovery_attempts")]
|
||||||
|
pub me_route_inline_recovery_attempts: u32,
|
||||||
|
/// Maximum wait time in milliseconds for inline recovery in legacy mode.
|
||||||
|
#[serde(default = "default_me_route_inline_recovery_wait_ms")]
|
||||||
|
pub me_route_inline_recovery_wait_ms: u64,
|
||||||
|
/// [general.links] — proxy link generation overrides.
|
||||||
|
#[serde(default)]
|
||||||
|
pub links: LinksConfig,
|
||||||
|
/// Minimum TLS record size when fast_mode coalescing is enabled (0 = disabled).
|
||||||
|
#[serde(default = "default_fast_mode_min_tls_record")]
|
||||||
|
pub fast_mode_min_tls_record: usize,
|
||||||
|
/// Unified ME updater interval in seconds for getProxyConfig/getProxyConfigV6/getProxySecret.
|
||||||
|
/// When omitted, effective value falls back to legacy proxy_*_auto_reload_secs fields.
|
||||||
|
#[serde(default = "default_update_every")]
|
||||||
|
pub update_every: Option<u64>,
|
||||||
|
/// Periodic ME pool reinitialization interval in seconds.
|
||||||
|
#[serde(default = "default_me_reinit_every_secs")]
|
||||||
|
pub me_reinit_every_secs: u64,
|
||||||
|
/// Minimum delay in ms between hardswap warmup connect attempts.
|
||||||
|
#[serde(default = "default_me_hardswap_warmup_delay_min_ms")]
|
||||||
|
pub me_hardswap_warmup_delay_min_ms: u64,
|
||||||
|
/// Maximum delay in ms between hardswap warmup connect attempts.
|
||||||
|
#[serde(default = "default_me_hardswap_warmup_delay_max_ms")]
|
||||||
|
pub me_hardswap_warmup_delay_max_ms: u64,
|
||||||
|
/// Additional warmup passes in the same hardswap cycle after the base pass.
|
||||||
|
#[serde(default = "default_me_hardswap_warmup_extra_passes")]
|
||||||
|
pub me_hardswap_warmup_extra_passes: u8,
|
||||||
|
/// Base backoff in ms between hardswap warmup passes when floor is still incomplete.
|
||||||
|
#[serde(default = "default_me_hardswap_warmup_pass_backoff_base_ms")]
|
||||||
|
pub me_hardswap_warmup_pass_backoff_base_ms: u64,
|
||||||
|
/// Number of identical getProxyConfig snapshots required before applying ME map updates.
|
||||||
|
#[serde(default = "default_me_config_stable_snapshots")]
|
||||||
|
pub me_config_stable_snapshots: u8,
|
||||||
|
/// Cooldown in seconds between applied ME map updates.
|
||||||
|
#[serde(default = "default_me_config_apply_cooldown_secs")]
|
||||||
|
pub me_config_apply_cooldown_secs: u64,
|
||||||
|
/// Ensure getProxyConfig snapshots are applied only for 2xx HTTP responses.
|
||||||
|
#[serde(default = "default_me_snapshot_require_http_2xx")]
|
||||||
|
pub me_snapshot_require_http_2xx: bool,
|
||||||
|
/// Reject empty getProxyConfig snapshots instead of marking them applied.
|
||||||
|
#[serde(default = "default_me_snapshot_reject_empty_map")]
|
||||||
|
pub me_snapshot_reject_empty_map: bool,
|
||||||
|
/// Minimum parsed `proxy_for` rows required to accept a snapshot.
|
||||||
|
#[serde(default = "default_me_snapshot_min_proxy_for_lines")]
|
||||||
|
pub me_snapshot_min_proxy_for_lines: u32,
|
||||||
|
/// Number of identical getProxySecret snapshots required before runtime secret rotation.
|
||||||
|
#[serde(default = "default_proxy_secret_stable_snapshots")]
|
||||||
|
pub proxy_secret_stable_snapshots: u8,
|
||||||
|
/// Enable runtime proxy-secret rotation from getProxySecret.
|
||||||
|
#[serde(default = "default_proxy_secret_rotate_runtime")]
|
||||||
|
pub proxy_secret_rotate_runtime: bool,
|
||||||
|
/// Keep key-selector and secret bytes from one snapshot during ME handshake.
|
||||||
|
#[serde(default = "default_me_secret_atomic_snapshot")]
|
||||||
|
pub me_secret_atomic_snapshot: bool,
|
||||||
|
/// Maximum allowed proxy-secret length in bytes for startup and runtime refresh.
|
||||||
|
#[serde(default = "default_proxy_secret_len_max")]
|
||||||
|
pub proxy_secret_len_max: usize,
|
||||||
|
/// Drain-TTL in seconds for stale ME writers after endpoint map changes.
|
||||||
|
/// During TTL, stale writers may be used only as fallback for new bindings.
|
||||||
|
#[serde(default = "default_me_pool_drain_ttl_secs")]
|
||||||
|
pub me_pool_drain_ttl_secs: u64,
|
||||||
|
/// Force-remove any draining writer on the next cleanup tick, regardless of age/deadline.
|
||||||
|
#[serde(default = "default_me_instadrain")]
|
||||||
|
pub me_instadrain: bool,
|
||||||
|
/// Maximum allowed number of draining ME writers before oldest ones are force-closed in batches.
|
||||||
|
/// Set to 0 to disable threshold-based draining cleanup and keep timeout-only behavior.
|
||||||
|
#[serde(default = "default_me_pool_drain_threshold")]
|
||||||
|
pub me_pool_drain_threshold: u64,
|
||||||
|
/// Enable staged client eviction for draining ME writers that remain non-empty past TTL.
|
||||||
|
#[serde(default = "default_me_pool_drain_soft_evict_enabled")]
|
||||||
|
pub me_pool_drain_soft_evict_enabled: bool,
|
||||||
|
/// Extra grace in seconds after drain TTL before soft-eviction stage starts.
|
||||||
|
#[serde(default = "default_me_pool_drain_soft_evict_grace_secs")]
|
||||||
|
pub me_pool_drain_soft_evict_grace_secs: u64,
|
||||||
|
/// Maximum number of client sessions to evict from one draining writer per health tick.
|
||||||
|
#[serde(default = "default_me_pool_drain_soft_evict_per_writer")]
|
||||||
|
pub me_pool_drain_soft_evict_per_writer: u8,
|
||||||
|
/// Soft-eviction budget per CPU core for one health tick.
|
||||||
|
#[serde(default = "default_me_pool_drain_soft_evict_budget_per_core")]
|
||||||
|
pub me_pool_drain_soft_evict_budget_per_core: u16,
|
||||||
|
/// Cooldown for repetitive soft-eviction on the same writer in milliseconds.
|
||||||
|
#[serde(default = "default_me_pool_drain_soft_evict_cooldown_ms")]
|
||||||
|
pub me_pool_drain_soft_evict_cooldown_ms: u64,
|
||||||
|
/// Policy for new binds on stale draining writers.
|
||||||
|
#[serde(default)]
|
||||||
|
pub me_bind_stale_mode: MeBindStaleMode,
|
||||||
|
/// TTL for stale bind allowance when `me_bind_stale_mode = \"ttl\"`.
|
||||||
|
#[serde(default = "default_me_bind_stale_ttl_secs")]
|
||||||
|
pub me_bind_stale_ttl_secs: u64,
|
||||||
|
/// Minimum desired-DC coverage ratio required before draining stale writers.
|
||||||
|
/// Range: 0.0..=1.0.
|
||||||
|
#[serde(default = "default_me_pool_min_fresh_ratio")]
|
||||||
|
pub me_pool_min_fresh_ratio: f32,
|
||||||
|
/// Drain timeout in seconds for stale ME writers after endpoint map changes.
|
||||||
|
/// Set to 0 to use the runtime safety fallback timeout.
|
||||||
|
#[serde(default = "default_me_reinit_drain_timeout_secs")]
|
||||||
|
pub me_reinit_drain_timeout_secs: u64,
|
||||||
|
/// Deprecated legacy setting; kept for backward compatibility fallback.
|
||||||
|
/// Use `update_every` instead.
|
||||||
|
#[serde(default = "default_proxy_secret_reload_secs")]
|
||||||
|
pub proxy_secret_auto_reload_secs: u64,
|
||||||
|
/// Deprecated legacy setting; kept for backward compatibility fallback.
|
||||||
|
/// Use `update_every` instead.
|
||||||
|
#[serde(default = "default_proxy_config_reload_secs")]
|
||||||
|
pub proxy_config_auto_reload_secs: u64,
|
||||||
|
/// Serialize ME reinit cycles across all trigger sources.
|
||||||
|
#[serde(default = "default_me_reinit_singleflight")]
|
||||||
|
pub me_reinit_singleflight: bool,
|
||||||
|
/// Trigger queue capacity for reinit scheduler.
|
||||||
|
#[serde(default = "default_me_reinit_trigger_channel")]
|
||||||
|
pub me_reinit_trigger_channel: usize,
|
||||||
|
/// Trigger coalescing window before starting a reinit cycle.
|
||||||
|
#[serde(default = "default_me_reinit_coalesce_window_ms")]
|
||||||
|
pub me_reinit_coalesce_window_ms: u64,
|
||||||
|
/// Deterministic candidate sort for ME writer binding path.
|
||||||
|
#[serde(default = "default_me_deterministic_writer_sort")]
|
||||||
|
pub me_deterministic_writer_sort: bool,
|
||||||
|
/// Writer selection mode for ME route bind path.
|
||||||
|
#[serde(default)]
|
||||||
|
pub me_writer_pick_mode: MeWriterPickMode,
|
||||||
|
/// Number of candidates sampled by writer picker in `p2c` mode.
|
||||||
|
#[serde(default = "default_me_writer_pick_sample_size")]
|
||||||
|
pub me_writer_pick_sample_size: u8,
|
||||||
|
/// Enable NTP drift check at startup.
|
||||||
|
#[serde(default = "default_ntp_check")]
|
||||||
|
pub ntp_check: bool,
|
||||||
|
/// NTP servers for drift check.
|
||||||
|
#[serde(default = "default_ntp_servers")]
|
||||||
|
pub ntp_servers: Vec<String>,
|
||||||
|
/// Enable auto-degradation from ME to Direct-DC.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub auto_degradation_enabled: bool,
|
||||||
|
/// Minimum unavailable ME DC groups before degrading.
|
||||||
|
#[serde(default = "default_degradation_min_unavailable_dc_groups")]
|
||||||
|
pub degradation_min_unavailable_dc_groups: u8,
|
||||||
|
/// RST-on-close mode for accepted client sockets.
|
||||||
|
/// `off` — normal FIN on all closes (default).
|
||||||
|
/// `errors` — SO_LINGER(0) on accept, cleared after successful auth;
|
||||||
|
/// pre-handshake failures send RST, relayed sessions close gracefully.
|
||||||
|
/// `always` — SO_LINGER(0) on accept, never cleared; all closes send RST.
|
||||||
|
#[serde(default)]
|
||||||
|
pub rst_on_close: RstOnCloseMode,
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl Default for GeneralConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
data_path: None,
|
||||||
|
quota_state_path: default_quota_state_path(),
|
||||||
|
config_strict: false,
|
||||||
|
modes: ProxyModes::default(),
|
||||||
|
prefer_ipv6: false,
|
||||||
|
fast_mode: default_true(),
|
||||||
|
use_middle_proxy: default_true(),
|
||||||
|
ad_tag: None,
|
||||||
|
proxy_secret_path: default_proxy_secret_path(),
|
||||||
|
proxy_secret_url: None,
|
||||||
|
proxy_config_v4_cache_path: default_proxy_config_v4_cache_path(),
|
||||||
|
proxy_config_v4_url: None,
|
||||||
|
proxy_config_v6_cache_path: default_proxy_config_v6_cache_path(),
|
||||||
|
proxy_config_v6_url: None,
|
||||||
|
middle_proxy_nat_ip: None,
|
||||||
|
middle_proxy_nat_probe: default_true(),
|
||||||
|
middle_proxy_nat_stun: default_middle_proxy_nat_stun(),
|
||||||
|
middle_proxy_nat_stun_servers: default_middle_proxy_nat_stun_servers(),
|
||||||
|
stun_nat_probe_concurrency: default_stun_nat_probe_concurrency(),
|
||||||
|
middle_proxy_pool_size: default_pool_size(),
|
||||||
|
middle_proxy_warm_standby: default_middle_proxy_warm_standby(),
|
||||||
|
me_init_retry_attempts: default_me_init_retry_attempts(),
|
||||||
|
me2dc_fallback: default_me2dc_fallback(),
|
||||||
|
me2dc_fast: default_me2dc_fast(),
|
||||||
|
me_keepalive_enabled: default_true(),
|
||||||
|
me_keepalive_interval_secs: default_keepalive_interval(),
|
||||||
|
me_keepalive_jitter_secs: default_keepalive_jitter(),
|
||||||
|
me_keepalive_payload_random: default_true(),
|
||||||
|
rpc_proxy_req_every: default_rpc_proxy_req_every(),
|
||||||
|
me_writer_cmd_channel_capacity: default_me_writer_cmd_channel_capacity(),
|
||||||
|
me_writer_byte_budget_bytes: default_me_writer_byte_budget_bytes(),
|
||||||
|
me_route_channel_capacity: default_me_route_channel_capacity(),
|
||||||
|
me_c2me_channel_capacity: default_me_c2me_channel_capacity(),
|
||||||
|
me_c2me_send_timeout_ms: default_me_c2me_send_timeout_ms(),
|
||||||
|
me_reader_route_data_wait_ms: default_me_reader_route_data_wait_ms(),
|
||||||
|
me_d2c_flush_batch_max_frames: default_me_d2c_flush_batch_max_frames(),
|
||||||
|
me_d2c_flush_batch_max_bytes: default_me_d2c_flush_batch_max_bytes(),
|
||||||
|
me_d2c_flush_batch_max_delay_us: default_me_d2c_flush_batch_max_delay_us(),
|
||||||
|
me_d2c_ack_flush_immediate: default_me_d2c_ack_flush_immediate(),
|
||||||
|
me_quota_soft_overshoot_bytes: default_me_quota_soft_overshoot_bytes(),
|
||||||
|
me_d2c_frame_buf_shrink_threshold_bytes:
|
||||||
|
default_me_d2c_frame_buf_shrink_threshold_bytes(),
|
||||||
|
direct_relay_copy_buf_c2s_bytes: default_direct_relay_copy_buf_c2s_bytes(),
|
||||||
|
direct_relay_copy_buf_s2c_bytes: default_direct_relay_copy_buf_s2c_bytes(),
|
||||||
|
direct_relay_buffer_budget_max_bytes: default_direct_relay_buffer_budget_max_bytes(),
|
||||||
|
me_warmup_stagger_enabled: default_true(),
|
||||||
|
me_warmup_step_delay_ms: default_warmup_step_delay_ms(),
|
||||||
|
me_warmup_step_jitter_ms: default_warmup_step_jitter_ms(),
|
||||||
|
me_reconnect_max_concurrent_per_dc: default_me_reconnect_max_concurrent_per_dc(),
|
||||||
|
me_reconnect_backoff_base_ms: default_reconnect_backoff_base_ms(),
|
||||||
|
me_reconnect_backoff_cap_ms: default_reconnect_backoff_cap_ms(),
|
||||||
|
me_reconnect_fast_retry_count: default_me_reconnect_fast_retry_count(),
|
||||||
|
me_single_endpoint_shadow_writers: default_me_single_endpoint_shadow_writers(),
|
||||||
|
me_single_endpoint_outage_mode_enabled: default_me_single_endpoint_outage_mode_enabled(
|
||||||
|
),
|
||||||
|
me_single_endpoint_outage_disable_quarantine:
|
||||||
|
default_me_single_endpoint_outage_disable_quarantine(),
|
||||||
|
me_single_endpoint_outage_backoff_min_ms:
|
||||||
|
default_me_single_endpoint_outage_backoff_min_ms(),
|
||||||
|
me_single_endpoint_outage_backoff_max_ms:
|
||||||
|
default_me_single_endpoint_outage_backoff_max_ms(),
|
||||||
|
me_single_endpoint_shadow_rotate_every_secs:
|
||||||
|
default_me_single_endpoint_shadow_rotate_every_secs(),
|
||||||
|
me_floor_mode: MeFloorMode::default(),
|
||||||
|
me_adaptive_floor_idle_secs: default_me_adaptive_floor_idle_secs(),
|
||||||
|
me_adaptive_floor_min_writers_single_endpoint:
|
||||||
|
default_me_adaptive_floor_min_writers_single_endpoint(),
|
||||||
|
me_adaptive_floor_min_writers_multi_endpoint:
|
||||||
|
default_me_adaptive_floor_min_writers_multi_endpoint(),
|
||||||
|
me_adaptive_floor_recover_grace_secs: default_me_adaptive_floor_recover_grace_secs(),
|
||||||
|
me_adaptive_floor_writers_per_core_total:
|
||||||
|
default_me_adaptive_floor_writers_per_core_total(),
|
||||||
|
me_adaptive_floor_cpu_cores_override: default_me_adaptive_floor_cpu_cores_override(),
|
||||||
|
me_adaptive_floor_max_extra_writers_single_per_core:
|
||||||
|
default_me_adaptive_floor_max_extra_writers_single_per_core(),
|
||||||
|
me_adaptive_floor_max_extra_writers_multi_per_core:
|
||||||
|
default_me_adaptive_floor_max_extra_writers_multi_per_core(),
|
||||||
|
me_adaptive_floor_max_active_writers_per_core:
|
||||||
|
default_me_adaptive_floor_max_active_writers_per_core(),
|
||||||
|
me_adaptive_floor_max_warm_writers_per_core:
|
||||||
|
default_me_adaptive_floor_max_warm_writers_per_core(),
|
||||||
|
me_adaptive_floor_max_active_writers_global:
|
||||||
|
default_me_adaptive_floor_max_active_writers_global(),
|
||||||
|
me_adaptive_floor_max_warm_writers_global:
|
||||||
|
default_me_adaptive_floor_max_warm_writers_global(),
|
||||||
|
upstream_connect_retry_attempts: default_upstream_connect_retry_attempts(),
|
||||||
|
upstream_connect_retry_backoff_ms: default_upstream_connect_retry_backoff_ms(),
|
||||||
|
upstream_connect_budget_ms: default_upstream_connect_budget_ms(),
|
||||||
|
tg_connect: default_connect_timeout(),
|
||||||
|
upstream_unhealthy_fail_threshold: default_upstream_unhealthy_fail_threshold(),
|
||||||
|
upstream_connect_failfast_hard_errors: default_upstream_connect_failfast_hard_errors(),
|
||||||
|
stun_iface_mismatch_ignore: false,
|
||||||
|
unknown_dc_log_path: default_unknown_dc_log_path(),
|
||||||
|
unknown_dc_file_log_enabled: default_unknown_dc_file_log_enabled(),
|
||||||
|
log_level: LogLevel::Normal,
|
||||||
|
disable_colors: false,
|
||||||
|
telemetry: TelemetryConfig::default(),
|
||||||
|
me_socks_kdf_policy: MeSocksKdfPolicy::Strict,
|
||||||
|
me_route_backpressure_enabled: default_me_route_backpressure_enabled(),
|
||||||
|
me_route_fairshare_enabled: default_me_route_fairshare_enabled(),
|
||||||
|
me_route_backpressure_base_timeout_ms: default_me_route_backpressure_base_timeout_ms(),
|
||||||
|
me_route_backpressure_high_timeout_ms: default_me_route_backpressure_high_timeout_ms(),
|
||||||
|
me_route_backpressure_high_watermark_pct:
|
||||||
|
default_me_route_backpressure_high_watermark_pct(),
|
||||||
|
me_health_interval_ms_unhealthy: default_me_health_interval_ms_unhealthy(),
|
||||||
|
me_health_interval_ms_healthy: default_me_health_interval_ms_healthy(),
|
||||||
|
me_admission_poll_ms: default_me_admission_poll_ms(),
|
||||||
|
me_warn_rate_limit_ms: default_me_warn_rate_limit_ms(),
|
||||||
|
me_route_no_writer_mode: MeRouteNoWriterMode::default(),
|
||||||
|
me_route_no_writer_wait_ms: default_me_route_no_writer_wait_ms(),
|
||||||
|
me_route_hybrid_max_wait_ms: default_me_route_hybrid_max_wait_ms(),
|
||||||
|
me_route_blocking_send_timeout_ms: default_me_route_blocking_send_timeout_ms(),
|
||||||
|
me_route_inline_recovery_attempts: default_me_route_inline_recovery_attempts(),
|
||||||
|
me_route_inline_recovery_wait_ms: default_me_route_inline_recovery_wait_ms(),
|
||||||
|
links: LinksConfig::default(),
|
||||||
|
crypto_pending_buffer: default_crypto_pending_buffer(),
|
||||||
|
max_client_frame: default_max_client_frame(),
|
||||||
|
desync_all_full: default_desync_all_full(),
|
||||||
|
beobachten: default_true(),
|
||||||
|
beobachten_minutes: default_beobachten_minutes(),
|
||||||
|
beobachten_flush_secs: default_beobachten_flush_secs(),
|
||||||
|
beobachten_file: default_beobachten_file(),
|
||||||
|
hardswap: default_hardswap(),
|
||||||
|
fast_mode_min_tls_record: default_fast_mode_min_tls_record(),
|
||||||
|
update_every: default_update_every(),
|
||||||
|
me_reinit_every_secs: default_me_reinit_every_secs(),
|
||||||
|
me_hardswap_warmup_delay_min_ms: default_me_hardswap_warmup_delay_min_ms(),
|
||||||
|
me_hardswap_warmup_delay_max_ms: default_me_hardswap_warmup_delay_max_ms(),
|
||||||
|
me_hardswap_warmup_extra_passes: default_me_hardswap_warmup_extra_passes(),
|
||||||
|
me_hardswap_warmup_pass_backoff_base_ms:
|
||||||
|
default_me_hardswap_warmup_pass_backoff_base_ms(),
|
||||||
|
me_config_stable_snapshots: default_me_config_stable_snapshots(),
|
||||||
|
me_config_apply_cooldown_secs: default_me_config_apply_cooldown_secs(),
|
||||||
|
me_snapshot_require_http_2xx: default_me_snapshot_require_http_2xx(),
|
||||||
|
me_snapshot_reject_empty_map: default_me_snapshot_reject_empty_map(),
|
||||||
|
me_snapshot_min_proxy_for_lines: default_me_snapshot_min_proxy_for_lines(),
|
||||||
|
proxy_secret_stable_snapshots: default_proxy_secret_stable_snapshots(),
|
||||||
|
proxy_secret_rotate_runtime: default_proxy_secret_rotate_runtime(),
|
||||||
|
me_secret_atomic_snapshot: default_me_secret_atomic_snapshot(),
|
||||||
|
proxy_secret_len_max: default_proxy_secret_len_max(),
|
||||||
|
me_pool_drain_ttl_secs: default_me_pool_drain_ttl_secs(),
|
||||||
|
me_instadrain: default_me_instadrain(),
|
||||||
|
me_pool_drain_threshold: default_me_pool_drain_threshold(),
|
||||||
|
me_pool_drain_soft_evict_enabled: default_me_pool_drain_soft_evict_enabled(),
|
||||||
|
me_pool_drain_soft_evict_grace_secs: default_me_pool_drain_soft_evict_grace_secs(),
|
||||||
|
me_pool_drain_soft_evict_per_writer: default_me_pool_drain_soft_evict_per_writer(),
|
||||||
|
me_pool_drain_soft_evict_budget_per_core:
|
||||||
|
default_me_pool_drain_soft_evict_budget_per_core(),
|
||||||
|
me_pool_drain_soft_evict_cooldown_ms: default_me_pool_drain_soft_evict_cooldown_ms(),
|
||||||
|
me_bind_stale_mode: MeBindStaleMode::default(),
|
||||||
|
me_bind_stale_ttl_secs: default_me_bind_stale_ttl_secs(),
|
||||||
|
me_pool_min_fresh_ratio: default_me_pool_min_fresh_ratio(),
|
||||||
|
me_reinit_drain_timeout_secs: default_me_reinit_drain_timeout_secs(),
|
||||||
|
proxy_secret_auto_reload_secs: default_proxy_secret_reload_secs(),
|
||||||
|
proxy_config_auto_reload_secs: default_proxy_config_reload_secs(),
|
||||||
|
me_reinit_singleflight: default_me_reinit_singleflight(),
|
||||||
|
me_reinit_trigger_channel: default_me_reinit_trigger_channel(),
|
||||||
|
me_reinit_coalesce_window_ms: default_me_reinit_coalesce_window_ms(),
|
||||||
|
me_deterministic_writer_sort: default_me_deterministic_writer_sort(),
|
||||||
|
me_writer_pick_mode: MeWriterPickMode::default(),
|
||||||
|
me_writer_pick_sample_size: default_me_writer_pick_sample_size(),
|
||||||
|
ntp_check: default_ntp_check(),
|
||||||
|
ntp_servers: default_ntp_servers(),
|
||||||
|
auto_degradation_enabled: default_true(),
|
||||||
|
degradation_min_unavailable_dc_groups: default_degradation_min_unavailable_dc_groups(),
|
||||||
|
rst_on_close: RstOnCloseMode::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GeneralConfig {
|
||||||
|
/// Resolve the active updater interval for ME infrastructure refresh tasks.
|
||||||
|
/// `update_every` has priority, otherwise legacy proxy_*_auto_reload_secs are used.
|
||||||
|
pub fn effective_update_every_secs(&self) -> u64 {
|
||||||
|
self.update_every.unwrap_or_else(|| {
|
||||||
|
self.proxy_secret_auto_reload_secs
|
||||||
|
.min(self.proxy_config_auto_reload_secs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve periodic zero-downtime reinit interval for ME writers.
|
||||||
|
pub fn effective_me_reinit_every_secs(&self) -> u64 {
|
||||||
|
self.me_reinit_every_secs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve force-close timeout for stale writers.
|
||||||
|
/// `me_reinit_drain_timeout_secs` remains backward-compatible alias.
|
||||||
|
/// A configured `0` uses the runtime safety fallback (300s).
|
||||||
|
pub fn effective_me_pool_force_close_secs(&self) -> u64 {
|
||||||
|
if self.me_reinit_drain_timeout_secs == 0 {
|
||||||
|
300
|
||||||
|
} else {
|
||||||
|
self.me_reinit_drain_timeout_secs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Proxy link generation settings.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LinksConfig {
|
||||||
|
/// List of usernames whose tg:// links to display at startup.
|
||||||
|
/// `"*"` = all users, `["alice", "bob"]` = specific users.
|
||||||
|
#[serde(default = "default_links_show")]
|
||||||
|
pub show: ShowLink,
|
||||||
|
|
||||||
|
/// Public hostname/IP for tg:// link generation (overrides detected IP).
|
||||||
|
#[serde(default)]
|
||||||
|
pub public_host: Option<String>,
|
||||||
|
|
||||||
|
/// Public port for tg:// link generation.
|
||||||
|
/// Overrides listener ports and legacy `server.port`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub public_port: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LinksConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
show: default_links_show(),
|
||||||
|
public_host: None,
|
||||||
|
public_port: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// In TOML, this can be:
|
||||||
|
/// - `show_link = "*"` — show links for all users
|
||||||
|
/// - `show_link = ["a", "b"]` — show links for specific users
|
||||||
|
/// - omitted — default depends on the owning config field
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub enum ShowLink {
|
||||||
|
/// Don't show any links (default when omitted).
|
||||||
|
#[default]
|
||||||
|
None,
|
||||||
|
/// Show links for all configured users.
|
||||||
|
All,
|
||||||
|
/// Show links for specific users.
|
||||||
|
Specific(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_links_show() -> ShowLink {
|
||||||
|
ShowLink::All
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShowLink {
|
||||||
|
/// Returns true if no links should be shown.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
matches!(self, ShowLink::None) || matches!(self, ShowLink::Specific(v) if v.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the list of user names to display, given all configured users.
|
||||||
|
pub fn resolve_users<'a>(&'a self, all_users: &'a HashMap<String, String>) -> Vec<&'a String> {
|
||||||
|
match self {
|
||||||
|
ShowLink::None => vec![],
|
||||||
|
ShowLink::All => {
|
||||||
|
let mut names: Vec<&String> = all_users.keys().collect();
|
||||||
|
names.sort();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
ShowLink::Specific(names) => names.iter().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for ShowLink {
|
||||||
|
fn serialize<S: serde::Serializer>(
|
||||||
|
&self,
|
||||||
|
serializer: S,
|
||||||
|
) -> std::result::Result<S::Ok, S::Error> {
|
||||||
|
match self {
|
||||||
|
ShowLink::None => Vec::<String>::new().serialize(serializer),
|
||||||
|
ShowLink::All => serializer.serialize_str("*"),
|
||||||
|
ShowLink::Specific(v) => v.serialize(serializer),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for ShowLink {
|
||||||
|
fn deserialize<D: serde::Deserializer<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> std::result::Result<Self, D::Error> {
|
||||||
|
use serde::de;
|
||||||
|
|
||||||
|
struct ShowLinkVisitor;
|
||||||
|
|
||||||
|
impl<'de> de::Visitor<'de> for ShowLinkVisitor {
|
||||||
|
type Value = ShowLink;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
formatter.write_str(r#""*" or an array of user names"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<ShowLink, E> {
|
||||||
|
if v == "*" {
|
||||||
|
Ok(ShowLink::All)
|
||||||
|
} else {
|
||||||
|
Err(de::Error::invalid_value(de::Unexpected::Str(v), &r#""*""#))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_seq<A: de::SeqAccess<'de>>(
|
||||||
|
self,
|
||||||
|
mut seq: A,
|
||||||
|
) -> std::result::Result<ShowLink, A::Error> {
|
||||||
|
let mut names = Vec::new();
|
||||||
|
while let Some(name) = seq.next_element::<String>()? {
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
if names.is_empty() {
|
||||||
|
Ok(ShowLink::None)
|
||||||
|
} else {
|
||||||
|
Ok(ShowLink::Specific(names))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_any(ShowLinkVisitor)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Logging verbosity level.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum LogLevel {
|
||||||
|
/// All messages including trace (trace + debug + info + warn + error).
|
||||||
|
Debug,
|
||||||
|
/// Detailed operational logs (debug + info + warn + error).
|
||||||
|
Verbose,
|
||||||
|
/// Standard operational logs (info + warn + error).
|
||||||
|
#[default]
|
||||||
|
Normal,
|
||||||
|
/// Minimal output: only warnings and errors (warn + error).
|
||||||
|
/// Proxy links may still be emitted through their dedicated target.
|
||||||
|
Silent,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogLevel {
|
||||||
|
/// Convert to tracing EnvFilter directive string.
|
||||||
|
pub fn to_filter_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
LogLevel::Debug => "trace",
|
||||||
|
LogLevel::Verbose => "debug",
|
||||||
|
LogLevel::Normal => "info",
|
||||||
|
LogLevel::Silent => "warn",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse from a loose string (CLI argument).
|
||||||
|
pub fn from_str_loose(s: &str) -> Self {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"debug" | "trace" => LogLevel::Debug,
|
||||||
|
"verbose" => LogLevel::Verbose,
|
||||||
|
"normal" | "info" => LogLevel::Normal,
|
||||||
|
"silent" | "quiet" | "error" | "warn" => LogLevel::Silent,
|
||||||
|
_ => LogLevel::Normal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for LogLevel {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
LogLevel::Debug => write!(f, "debug"),
|
||||||
|
LogLevel::Verbose => write!(f, "verbose"),
|
||||||
|
LogLevel::Normal => write!(f, "normal"),
|
||||||
|
LogLevel::Silent => write!(f, "silent"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logging output destination.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum LoggingDestination {
|
||||||
|
/// Write logs to stderr.
|
||||||
|
#[default]
|
||||||
|
Stderr,
|
||||||
|
/// Write logs to syslog on Unix platforms.
|
||||||
|
Syslog,
|
||||||
|
/// Write logs to a file.
|
||||||
|
File,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Time-based log rotation interval for file logging.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum LogRotation {
|
||||||
|
/// Do not rotate logs by time.
|
||||||
|
#[default]
|
||||||
|
Never,
|
||||||
|
/// Rotate once per minute.
|
||||||
|
Minutely,
|
||||||
|
/// Rotate once per hour.
|
||||||
|
Hourly,
|
||||||
|
/// Rotate once per day.
|
||||||
|
Daily,
|
||||||
|
/// Rotate once per week.
|
||||||
|
Weekly,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogRotation {
|
||||||
|
/// Parse a CLI rotation value.
|
||||||
|
pub fn from_cli_arg(value: &str) -> Option<Self> {
|
||||||
|
match value.to_ascii_lowercase().as_str() {
|
||||||
|
"never" | "none" | "off" => Some(Self::Never),
|
||||||
|
"minutely" | "minute" => Some(Self::Minutely),
|
||||||
|
"hourly" | "hour" => Some(Self::Hourly),
|
||||||
|
"daily" | "day" => Some(Self::Daily),
|
||||||
|
"weekly" | "week" => Some(Self::Weekly),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File logging and retention settings.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct LoggingConfig {
|
||||||
|
/// Effective logging destination.
|
||||||
|
#[serde(default)]
|
||||||
|
pub destination: LoggingDestination,
|
||||||
|
/// File path used when `destination = "file"`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub path: Option<String>,
|
||||||
|
/// Time rotation interval for file logs.
|
||||||
|
#[serde(default)]
|
||||||
|
pub rotation: LogRotation,
|
||||||
|
/// Maximum active log file size before rotating. `0` disables size rotation.
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_size_bytes: u64,
|
||||||
|
/// Maximum number of matching log files to keep. `0` disables count retention.
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_files: usize,
|
||||||
|
/// Maximum age for rotated log files in seconds. `0` disables age retention.
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_age_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LoggingConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
destination: LoggingDestination::Stderr,
|
||||||
|
path: None,
|
||||||
|
rotation: LogRotation::Never,
|
||||||
|
max_size_bytes: 0,
|
||||||
|
max_files: 0,
|
||||||
|
max_age_secs: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProxyModes {
|
||||||
|
#[serde(default)]
|
||||||
|
pub classic: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub secure: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub tls: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProxyModes {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
classic: false,
|
||||||
|
secure: false,
|
||||||
|
tls: default_true(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NetworkConfig {
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub ipv4: bool,
|
||||||
|
|
||||||
|
/// None = auto-detect IPv6 availability.
|
||||||
|
#[serde(default = "default_network_ipv6")]
|
||||||
|
pub ipv6: Option<bool>,
|
||||||
|
|
||||||
|
/// 4 or 6.
|
||||||
|
#[serde(default = "default_prefer_4")]
|
||||||
|
pub prefer: u8,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub multipath: bool,
|
||||||
|
|
||||||
|
/// Global switch for STUN probing.
|
||||||
|
/// When false, STUN is fully disabled and only non-STUN detection remains.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub stun_use: bool,
|
||||||
|
|
||||||
|
/// STUN servers list for public IP discovery.
|
||||||
|
#[serde(default = "default_stun_servers")]
|
||||||
|
pub stun_servers: Vec<String>,
|
||||||
|
|
||||||
|
/// Enable TCP STUN fallback when UDP is blocked.
|
||||||
|
#[serde(default = "default_stun_tcp_fallback")]
|
||||||
|
pub stun_tcp_fallback: bool,
|
||||||
|
|
||||||
|
/// HTTP-based public IP detection endpoints (fallback after STUN).
|
||||||
|
#[serde(default = "default_http_ip_detect_urls")]
|
||||||
|
pub http_ip_detect_urls: Vec<String>,
|
||||||
|
|
||||||
|
/// Cache file path for detected public IP.
|
||||||
|
#[serde(default = "default_cache_public_ip_path")]
|
||||||
|
pub cache_public_ip_path: String,
|
||||||
|
|
||||||
|
/// Runtime DNS overrides in `host:port:ip` format.
|
||||||
|
/// IPv6 IP values must be bracketed: `[2001:db8::1]`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub dns_overrides: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for NetworkConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
ipv4: default_true(),
|
||||||
|
ipv6: default_network_ipv6(),
|
||||||
|
prefer: default_prefer_4(),
|
||||||
|
multipath: false,
|
||||||
|
stun_use: default_true(),
|
||||||
|
stun_servers: default_stun_servers(),
|
||||||
|
stun_tcp_fallback: default_stun_tcp_fallback(),
|
||||||
|
http_ip_detect_urls: default_http_ip_detect_urls(),
|
||||||
|
cache_public_ip_path: default_cache_public_ip_path(),
|
||||||
|
dns_overrides: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(tag = "type", rename_all = "lowercase")]
|
||||||
|
pub enum UpstreamType {
|
||||||
|
Direct {
|
||||||
|
#[serde(default)]
|
||||||
|
interface: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
bind_addresses: Option<Vec<String>>,
|
||||||
|
/// Linux-only hard interface pinning via `SO_BINDTODEVICE`.
|
||||||
|
/// Optional alias: `force_bind`.
|
||||||
|
#[serde(default, alias = "force_bind")]
|
||||||
|
bindtodevice: Option<String>,
|
||||||
|
},
|
||||||
|
Socks4 {
|
||||||
|
address: String,
|
||||||
|
#[serde(default)]
|
||||||
|
interface: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
user_id: Option<String>,
|
||||||
|
},
|
||||||
|
Socks5 {
|
||||||
|
address: String,
|
||||||
|
#[serde(default)]
|
||||||
|
interface: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
username: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
password: Option<String>,
|
||||||
|
},
|
||||||
|
Shadowsocks {
|
||||||
|
url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
interface: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpstreamConfig {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub upstream_type: UpstreamType,
|
||||||
|
#[serde(default = "default_weight")]
|
||||||
|
pub weight: u16,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub scopes: String,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub selected_scope: String,
|
||||||
|
/// Allow IPv4 DC targets for this upstream.
|
||||||
|
/// `None` means auto-detect from runtime connectivity state.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ipv4: Option<bool>,
|
||||||
|
/// Allow IPv6 DC targets for this upstream.
|
||||||
|
/// `None` means auto-detect from runtime connectivity state.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ipv6: Option<bool>,
|
||||||
|
/// Per-upstream IP family preference for Telegram DC targets.
|
||||||
|
/// `None` inherits the effective global `[network].prefer` decision.
|
||||||
|
#[serde(default)]
|
||||||
|
pub prefer: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpstreamConfig {
|
||||||
|
pub fn prefer_ipv6(&self, default_prefer_ipv6: bool) -> bool {
|
||||||
|
match self.prefer {
|
||||||
|
Some(6) => true,
|
||||||
|
Some(4) => false,
|
||||||
|
_ => default_prefer_ipv6,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Middle-End telemetry verbosity level.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum MeTelemetryLevel {
|
||||||
|
#[default]
|
||||||
|
Normal,
|
||||||
|
Silent,
|
||||||
|
Debug,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MeTelemetryLevel {
|
||||||
|
pub fn as_u8(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
MeTelemetryLevel::Silent => 0,
|
||||||
|
MeTelemetryLevel::Normal => 1,
|
||||||
|
MeTelemetryLevel::Debug => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_u8(raw: u8) -> Self {
|
||||||
|
match raw {
|
||||||
|
0 => MeTelemetryLevel::Silent,
|
||||||
|
2 => MeTelemetryLevel::Debug,
|
||||||
|
_ => MeTelemetryLevel::Normal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn allows_normal(self) -> bool {
|
||||||
|
!matches!(self, MeTelemetryLevel::Silent)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn allows_debug(self) -> bool {
|
||||||
|
matches!(self, MeTelemetryLevel::Debug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for MeTelemetryLevel {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
MeTelemetryLevel::Silent => write!(f, "silent"),
|
||||||
|
MeTelemetryLevel::Normal => write!(f, "normal"),
|
||||||
|
MeTelemetryLevel::Debug => write!(f, "debug"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Middle-End SOCKS KDF fallback policy.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum MeSocksKdfPolicy {
|
||||||
|
#[default]
|
||||||
|
Strict,
|
||||||
|
Compat,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MeSocksKdfPolicy {
|
||||||
|
pub fn as_u8(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
MeSocksKdfPolicy::Strict => 0,
|
||||||
|
MeSocksKdfPolicy::Compat => 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_u8(raw: u8) -> Self {
|
||||||
|
match raw {
|
||||||
|
1 => MeSocksKdfPolicy::Compat,
|
||||||
|
_ => MeSocksKdfPolicy::Strict,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stale ME writer bind policy during drain window.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum MeBindStaleMode {
|
||||||
|
#[default]
|
||||||
|
Never,
|
||||||
|
Ttl,
|
||||||
|
Always,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MeBindStaleMode {
|
||||||
|
pub fn as_u8(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
MeBindStaleMode::Never => 0,
|
||||||
|
MeBindStaleMode::Ttl => 1,
|
||||||
|
MeBindStaleMode::Always => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_u8(raw: u8) -> Self {
|
||||||
|
match raw {
|
||||||
|
0 => MeBindStaleMode::Never,
|
||||||
|
2 => MeBindStaleMode::Always,
|
||||||
|
_ => MeBindStaleMode::Ttl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RST-on-close mode for accepted client sockets.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum RstOnCloseMode {
|
||||||
|
/// Normal FIN on all closes (default, no behaviour change).
|
||||||
|
#[default]
|
||||||
|
Off,
|
||||||
|
/// SO_LINGER(0) on accept; cleared after successful auth.
|
||||||
|
/// Pre-handshake failures (scanners, DPI, timeouts) send RST;
|
||||||
|
/// authenticated relay sessions close gracefully with FIN.
|
||||||
|
Errors,
|
||||||
|
/// SO_LINGER(0) on accept, never cleared — all closes send RST.
|
||||||
|
Always,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Middle-End writer floor policy mode.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum MeFloorMode {
|
||||||
|
Static,
|
||||||
|
#[default]
|
||||||
|
Adaptive,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MeFloorMode {
|
||||||
|
pub fn as_u8(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
MeFloorMode::Static => 0,
|
||||||
|
MeFloorMode::Adaptive => 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_u8(raw: u8) -> Self {
|
||||||
|
match raw {
|
||||||
|
1 => MeFloorMode::Adaptive,
|
||||||
|
_ => MeFloorMode::Static,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Middle-End route behavior when no writer is immediately available.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MeRouteNoWriterMode {
|
||||||
|
AsyncRecoveryFailfast,
|
||||||
|
InlineRecoveryLegacy,
|
||||||
|
#[default]
|
||||||
|
HybridAsyncPersistent,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MeRouteNoWriterMode {
|
||||||
|
pub fn as_u8(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
MeRouteNoWriterMode::AsyncRecoveryFailfast => 0,
|
||||||
|
MeRouteNoWriterMode::InlineRecoveryLegacy => 1,
|
||||||
|
MeRouteNoWriterMode::HybridAsyncPersistent => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_u8(raw: u8) -> Self {
|
||||||
|
match raw {
|
||||||
|
0 => MeRouteNoWriterMode::AsyncRecoveryFailfast,
|
||||||
|
1 => MeRouteNoWriterMode::InlineRecoveryLegacy,
|
||||||
|
2 => MeRouteNoWriterMode::HybridAsyncPersistent,
|
||||||
|
_ => MeRouteNoWriterMode::HybridAsyncPersistent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Middle-End writer selection mode for new client bindings.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MeWriterPickMode {
|
||||||
|
SortedRr,
|
||||||
|
#[default]
|
||||||
|
P2c,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MeWriterPickMode {
|
||||||
|
pub fn as_u8(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
MeWriterPickMode::SortedRr => 0,
|
||||||
|
MeWriterPickMode::P2c => 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_u8(raw: u8) -> Self {
|
||||||
|
match raw {
|
||||||
|
0 => MeWriterPickMode::SortedRr,
|
||||||
|
1 => MeWriterPickMode::P2c,
|
||||||
|
_ => MeWriterPickMode::P2c,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-user unique source IP limit mode.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum UserMaxUniqueIpsMode {
|
||||||
|
/// Count only currently active source IPs.
|
||||||
|
#[default]
|
||||||
|
ActiveWindow,
|
||||||
|
/// Count source IPs seen within the recent time window.
|
||||||
|
TimeWindow,
|
||||||
|
/// Enforce both active and recent-window limits at the same time.
|
||||||
|
Combined,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Telemetry controls for hot-path counters and ME diagnostics.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TelemetryConfig {
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub core_enabled: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub user_enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub me_level: MeTelemetryLevel,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TelemetryConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
core_enabled: default_true(),
|
||||||
|
user_enabled: default_true(),
|
||||||
|
me_level: MeTelemetryLevel::Normal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ConntrackMode {
|
||||||
|
#[default]
|
||||||
|
Tracked,
|
||||||
|
Notrack,
|
||||||
|
Hybrid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ConntrackBackend {
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
Nftables,
|
||||||
|
Iptables,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ConntrackPressureProfile {
|
||||||
|
Conservative,
|
||||||
|
#[default]
|
||||||
|
Balanced,
|
||||||
|
Aggressive,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConntrackPressureProfile {
|
||||||
|
pub fn client_first_byte_idle_cap_secs(self) -> u64 {
|
||||||
|
match self {
|
||||||
|
Self::Conservative => 30,
|
||||||
|
Self::Balanced => 20,
|
||||||
|
Self::Aggressive => 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn direct_activity_timeout_secs(self) -> u64 {
|
||||||
|
match self {
|
||||||
|
Self::Conservative => 180,
|
||||||
|
Self::Balanced => 120,
|
||||||
|
Self::Aggressive => 60,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn middle_soft_idle_cap_secs(self) -> u64 {
|
||||||
|
match self {
|
||||||
|
Self::Conservative => 60,
|
||||||
|
Self::Balanced => 30,
|
||||||
|
Self::Aggressive => 20,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn middle_hard_idle_cap_secs(self) -> u64 {
|
||||||
|
match self {
|
||||||
|
Self::Conservative => 180,
|
||||||
|
Self::Balanced => 90,
|
||||||
|
Self::Aggressive => 60,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-listener SYN limiter mode.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum SynLimitMode {
|
||||||
|
/// Disable SYN limiting for this listener.
|
||||||
|
#[default]
|
||||||
|
Off,
|
||||||
|
/// Use iptables/ip6tables two-tier SYN-fix rules with the hashlimit match.
|
||||||
|
Iptables,
|
||||||
|
/// Use nftables two-tier SYN-fix rules with per-source token-bucket meters.
|
||||||
|
Nftables,
|
||||||
|
/// Use FreeBSD PF source tracking with connection-rate state limits.
|
||||||
|
Pf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for SynLimitMode {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
match self {
|
||||||
|
Self::Off => serializer.serialize_bool(false),
|
||||||
|
Self::Iptables => serializer.serialize_str("iptables"),
|
||||||
|
Self::Nftables => serializer.serialize_str("nftables"),
|
||||||
|
Self::Pf => serializer.serialize_str("pf"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for SynLimitMode {
|
||||||
|
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
struct SynLimitModeVisitor;
|
||||||
|
|
||||||
|
impl<'de> serde::de::Visitor<'de> for SynLimitModeVisitor {
|
||||||
|
type Value = SynLimitMode;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
formatter.write_str("false, iptables, nftables, or pf")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_bool<E>(self, value: bool) -> std::result::Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: serde::de::Error,
|
||||||
|
{
|
||||||
|
if value {
|
||||||
|
Err(E::custom(
|
||||||
|
"synlimit=true is ambiguous; use \"iptables\", \"nftables\", or \"pf\"",
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(SynLimitMode::Off)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: serde::de::Error,
|
||||||
|
{
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"false" | "off" | "disabled" | "none" => Ok(SynLimitMode::Off),
|
||||||
|
"iptables" => Ok(SynLimitMode::Iptables),
|
||||||
|
"nftables" => Ok(SynLimitMode::Nftables),
|
||||||
|
"pf" => Ok(SynLimitMode::Pf),
|
||||||
|
_ => Err(E::custom(
|
||||||
|
"synlimit must be false, \"iptables\", \"nftables\", or \"pf\"",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_any(SynLimitModeVisitor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConntrackControlConfig {
|
||||||
|
/// Enables runtime conntrack-control worker for pressure mitigation.
|
||||||
|
#[serde(default = "default_conntrack_control_enabled")]
|
||||||
|
pub inline_conntrack_control: bool,
|
||||||
|
|
||||||
|
/// Tracks whether inline_conntrack_control was explicitly set in config.
|
||||||
|
#[serde(skip)]
|
||||||
|
pub inline_conntrack_control_explicit: bool,
|
||||||
|
|
||||||
|
/// Conntrack mode for listener ingress traffic.
|
||||||
|
#[serde(default)]
|
||||||
|
pub mode: ConntrackMode,
|
||||||
|
|
||||||
|
/// Netfilter backend used to reconcile notrack rules.
|
||||||
|
#[serde(default)]
|
||||||
|
pub backend: ConntrackBackend,
|
||||||
|
|
||||||
|
/// Pressure profile for timeout caps under resource saturation.
|
||||||
|
#[serde(default)]
|
||||||
|
pub profile: ConntrackPressureProfile,
|
||||||
|
|
||||||
|
/// Listener IP allow-list for hybrid mode.
|
||||||
|
/// Ignored in tracked/notrack mode.
|
||||||
|
#[serde(default)]
|
||||||
|
pub hybrid_listener_ips: Vec<IpAddr>,
|
||||||
|
|
||||||
|
/// Pressure high watermark as percentage.
|
||||||
|
#[serde(default = "default_conntrack_pressure_high_watermark_pct")]
|
||||||
|
pub pressure_high_watermark_pct: u8,
|
||||||
|
|
||||||
|
/// Pressure low watermark as percentage.
|
||||||
|
#[serde(default = "default_conntrack_pressure_low_watermark_pct")]
|
||||||
|
pub pressure_low_watermark_pct: u8,
|
||||||
|
|
||||||
|
/// Maximum conntrack delete operations per second.
|
||||||
|
#[serde(default = "default_conntrack_delete_budget_per_sec")]
|
||||||
|
pub delete_budget_per_sec: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ConntrackControlConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
inline_conntrack_control: default_conntrack_control_enabled(),
|
||||||
|
inline_conntrack_control_explicit: false,
|
||||||
|
mode: ConntrackMode::default(),
|
||||||
|
backend: ConntrackBackend::default(),
|
||||||
|
profile: ConntrackPressureProfile::default(),
|
||||||
|
hybrid_listener_ips: Vec::new(),
|
||||||
|
pressure_high_watermark_pct: default_conntrack_pressure_high_watermark_pct(),
|
||||||
|
pressure_low_watermark_pct: default_conntrack_pressure_low_watermark_pct(),
|
||||||
|
delete_budget_per_sec: default_conntrack_delete_budget_per_sec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ServerConfig {
|
||||||
|
/// Legacy listener port used for backward compatibility.
|
||||||
|
/// For new configs prefer `[[server.listeners]].port`.
|
||||||
|
#[serde(default = "default_port")]
|
||||||
|
pub port: u16,
|
||||||
|
|
||||||
|
#[serde(default = "default_listen_addr_ipv4")]
|
||||||
|
pub listen_addr_ipv4: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default = "default_listen_addr_ipv6_opt")]
|
||||||
|
pub listen_addr_ipv6: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub listen_unix_sock: Option<String>,
|
||||||
|
|
||||||
|
/// Unix socket file permissions (octal, e.g. "0666" or "0777").
|
||||||
|
/// Applied via chmod after bind. Default: no change (inherits umask).
|
||||||
|
#[serde(default)]
|
||||||
|
pub listen_unix_sock_perm: Option<String>,
|
||||||
|
|
||||||
|
/// Enable TCP listening. Default: true when no unix socket, false when
|
||||||
|
/// listen_unix_sock is set. Set explicitly to override auto-detection.
|
||||||
|
#[serde(default)]
|
||||||
|
pub listen_tcp: Option<bool>,
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
|
||||||
|
/// 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>,
|
||||||
|
|
||||||
|
/// Accept HAProxy PROXY protocol headers on incoming connections.
|
||||||
|
/// When enabled, real client IPs are extracted from PROXY v1/v2 headers.
|
||||||
|
#[serde(default)]
|
||||||
|
pub proxy_protocol: bool,
|
||||||
|
|
||||||
|
/// Timeout in milliseconds for reading and parsing PROXY protocol headers.
|
||||||
|
#[serde(default = "default_proxy_protocol_header_timeout_ms")]
|
||||||
|
pub proxy_protocol_header_timeout_ms: u64,
|
||||||
|
|
||||||
|
/// Trusted source CIDRs allowed to send incoming PROXY protocol headers.
|
||||||
|
///
|
||||||
|
/// If this field is omitted in config, it defaults to trust-all CIDRs
|
||||||
|
/// (`0.0.0.0/0` and `::/0`). If it is explicitly set to an empty list,
|
||||||
|
/// all PROXY protocol headers are rejected.
|
||||||
|
#[serde(default = "default_proxy_protocol_trusted_cidrs")]
|
||||||
|
pub proxy_protocol_trusted_cidrs: Vec<IpNetwork>,
|
||||||
|
|
||||||
|
/// Port for the Prometheus-compatible metrics endpoint.
|
||||||
|
/// Enables metrics when set; binds on all interfaces (dual-stack) by default.
|
||||||
|
#[serde(default)]
|
||||||
|
pub metrics_port: Option<u16>,
|
||||||
|
|
||||||
|
/// Listen address for metrics in `IP:PORT` format (e.g. `"127.0.0.1:9090"`).
|
||||||
|
/// When set, takes precedence over `metrics_port` and binds on the specified address only.
|
||||||
|
#[serde(default)]
|
||||||
|
pub metrics_listen: Option<String>,
|
||||||
|
|
||||||
|
/// CIDR whitelist for the metrics endpoint.
|
||||||
|
#[serde(default = "default_metrics_whitelist")]
|
||||||
|
pub metrics_whitelist: Vec<IpNetwork>,
|
||||||
|
|
||||||
|
#[serde(default, alias = "admin_api")]
|
||||||
|
pub api: ApiConfig,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub listeners: Vec<ListenerConfig>,
|
||||||
|
|
||||||
|
/// TCP `listen(2)` backlog for client-facing sockets (also used for the metrics HTTP listener).
|
||||||
|
/// The effective queue is capped by the kernel (for example `somaxconn` on Linux).
|
||||||
|
#[serde(default = "default_listen_backlog")]
|
||||||
|
pub listen_backlog: u32,
|
||||||
|
|
||||||
|
/// Maximum number of concurrent client connections.
|
||||||
|
/// 0 means unlimited.
|
||||||
|
#[serde(default = "default_server_max_connections")]
|
||||||
|
pub max_connections: u32,
|
||||||
|
|
||||||
|
/// Maximum wait in milliseconds while acquiring a connection slot permit.
|
||||||
|
/// `0` keeps legacy unbounded wait behavior.
|
||||||
|
#[serde(default = "default_accept_permit_timeout_ms")]
|
||||||
|
pub accept_permit_timeout_ms: u64,
|
||||||
|
|
||||||
|
/// Runtime conntrack control and pressure policy.
|
||||||
|
#[serde(default)]
|
||||||
|
pub conntrack_control: ConntrackControlConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ServerConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
port: default_port(),
|
||||||
|
listen_addr_ipv4: default_listen_addr_ipv4(),
|
||||||
|
listen_addr_ipv6: default_listen_addr_ipv6_opt(),
|
||||||
|
listen_unix_sock: None,
|
||||||
|
listen_unix_sock_perm: None,
|
||||||
|
listen_tcp: None,
|
||||||
|
client_mss: None,
|
||||||
|
client_mss_bulk: None,
|
||||||
|
proxy_protocol: false,
|
||||||
|
proxy_protocol_header_timeout_ms: default_proxy_protocol_header_timeout_ms(),
|
||||||
|
proxy_protocol_trusted_cidrs: default_proxy_protocol_trusted_cidrs(),
|
||||||
|
metrics_port: None,
|
||||||
|
metrics_listen: None,
|
||||||
|
metrics_whitelist: default_metrics_whitelist(),
|
||||||
|
api: ApiConfig::default(),
|
||||||
|
listeners: Vec::new(),
|
||||||
|
listen_backlog: default_listen_backlog(),
|
||||||
|
max_connections: default_server_max_connections(),
|
||||||
|
accept_permit_timeout_ms: default_accept_permit_timeout_ms(),
|
||||||
|
conntrack_control: ConntrackControlConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TimeoutsConfig {
|
||||||
|
/// Maximum idle wait in seconds for the first client byte before handshake parsing starts.
|
||||||
|
/// `0` disables the separate idle phase and keeps legacy timeout behavior.
|
||||||
|
#[serde(default = "default_client_first_byte_idle_secs")]
|
||||||
|
pub client_first_byte_idle_secs: u64,
|
||||||
|
|
||||||
|
/// Maximum active handshake duration in seconds after the first client byte is received.
|
||||||
|
#[serde(default = "default_handshake_timeout")]
|
||||||
|
pub client_handshake: u64,
|
||||||
|
|
||||||
|
/// Enables soft/hard relay client idle policy for middle-relay sessions.
|
||||||
|
#[serde(default = "default_relay_idle_policy_v2_enabled")]
|
||||||
|
pub relay_idle_policy_v2_enabled: bool,
|
||||||
|
|
||||||
|
/// Soft idle threshold for middle-relay client uplink activity in seconds.
|
||||||
|
/// Hitting this threshold marks the session as idle-candidate, but does not close it.
|
||||||
|
#[serde(default = "default_relay_client_idle_soft_secs")]
|
||||||
|
pub relay_client_idle_soft_secs: u64,
|
||||||
|
|
||||||
|
/// Hard idle threshold for middle-relay client uplink activity in seconds.
|
||||||
|
/// Hitting this threshold closes the session.
|
||||||
|
#[serde(default = "default_relay_client_idle_hard_secs")]
|
||||||
|
pub relay_client_idle_hard_secs: u64,
|
||||||
|
|
||||||
|
/// Additional grace in seconds added to hard idle window after recent downstream activity.
|
||||||
|
#[serde(default = "default_relay_idle_grace_after_downstream_activity_secs")]
|
||||||
|
pub relay_idle_grace_after_downstream_activity_secs: u64,
|
||||||
|
|
||||||
|
#[serde(default = "default_keepalive")]
|
||||||
|
pub client_keepalive: u64,
|
||||||
|
|
||||||
|
#[serde(default = "default_ack_timeout")]
|
||||||
|
pub client_ack: u64,
|
||||||
|
|
||||||
|
/// Number of quick ME reconnect attempts for single-address DC.
|
||||||
|
#[serde(default = "default_me_one_retry")]
|
||||||
|
pub me_one_retry: u8,
|
||||||
|
|
||||||
|
/// Timeout per quick attempt in milliseconds for single-address DC.
|
||||||
|
#[serde(default = "default_me_one_timeout")]
|
||||||
|
pub me_one_timeout_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TimeoutsConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
client_first_byte_idle_secs: default_client_first_byte_idle_secs(),
|
||||||
|
client_handshake: default_handshake_timeout(),
|
||||||
|
relay_idle_policy_v2_enabled: default_relay_idle_policy_v2_enabled(),
|
||||||
|
relay_client_idle_soft_secs: default_relay_client_idle_soft_secs(),
|
||||||
|
relay_client_idle_hard_secs: default_relay_client_idle_hard_secs(),
|
||||||
|
relay_idle_grace_after_downstream_activity_secs:
|
||||||
|
default_relay_idle_grace_after_downstream_activity_secs(),
|
||||||
|
client_keepalive: default_keepalive(),
|
||||||
|
client_ack: default_ack_timeout(),
|
||||||
|
me_one_retry: default_me_one_retry(),
|
||||||
|
me_one_timeout_ms: default_me_one_timeout(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ListenerConfig {
|
||||||
|
pub ip: IpAddr,
|
||||||
|
/// Per-listener TCP port. If omitted, falls back to legacy `server.port`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub port: Option<u16>,
|
||||||
|
/// Per-listener client-facing TCP MSS preset or custom value.
|
||||||
|
/// Empty string disables MSS shaping for this listener.
|
||||||
|
#[serde(default)]
|
||||||
|
pub client_mss: Option<String>,
|
||||||
|
/// Per-listener SYN limiter mode.
|
||||||
|
#[serde(default)]
|
||||||
|
pub synlimit: SynLimitMode,
|
||||||
|
/// Generic SYN-fix token-bucket rate interval.
|
||||||
|
#[serde(default = "default_synlimit_seconds")]
|
||||||
|
pub synlimit_seconds: u32,
|
||||||
|
/// Generic SYN-fix token-bucket rate amount.
|
||||||
|
#[serde(default = "default_synlimit_hitcount")]
|
||||||
|
pub synlimit_hitcount: u32,
|
||||||
|
/// Generic SYN-fix token-bucket burst size.
|
||||||
|
#[serde(default = "default_synlimit_burst")]
|
||||||
|
pub synlimit_burst: u32,
|
||||||
|
/// iOS-like SYN-fix token-bucket rate interval.
|
||||||
|
#[serde(default = "default_synlimit_ios_seconds")]
|
||||||
|
pub synlimit_ios_seconds: u32,
|
||||||
|
/// iOS-like SYN-fix token-bucket rate amount.
|
||||||
|
#[serde(default = "default_synlimit_ios_hitcount")]
|
||||||
|
pub synlimit_ios_hitcount: u32,
|
||||||
|
/// iOS-like SYN-fix token-bucket burst size.
|
||||||
|
#[serde(default = "default_synlimit_ios_burst")]
|
||||||
|
pub synlimit_ios_burst: u32,
|
||||||
|
/// Hashlimit entry expiration in milliseconds for iptables/ip6tables rules.
|
||||||
|
#[serde(default = "default_synlimit_hashlimit_expire_ms")]
|
||||||
|
pub synlimit_hashlimit_expire_ms: u32,
|
||||||
|
/// Hashlimit table size for iptables/ip6tables rules.
|
||||||
|
#[serde(default = "default_synlimit_hashlimit_size")]
|
||||||
|
pub synlimit_hashlimit_size: u32,
|
||||||
|
/// IP address or hostname to announce in proxy links.
|
||||||
|
/// Takes precedence over `announce_ip` if both are set.
|
||||||
|
#[serde(default)]
|
||||||
|
pub announce: Option<String>,
|
||||||
|
/// Deprecated: Use `announce` instead. IP address to announce in proxy links.
|
||||||
|
/// Migrated to `announce` automatically if `announce` is not set.
|
||||||
|
#[serde(default)]
|
||||||
|
pub announce_ip: Option<IpAddr>,
|
||||||
|
/// Per-listener PROXY protocol override. When set, overrides global server.proxy_protocol.
|
||||||
|
#[serde(default)]
|
||||||
|
pub proxy_protocol: Option<bool>,
|
||||||
|
/// Allow multiple telemt instances to listen on the same IP:port (SO_REUSEPORT).
|
||||||
|
/// Default is false for safety.
|
||||||
|
#[serde(default)]
|
||||||
|
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<Option<u16>, String> {
|
||||||
|
parse_client_mss(self.client_mss.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the bulk-transfer client MSS, if configured.
|
||||||
|
pub fn client_mss_bulk_value(&self) -> std::result::Result<Option<u16>, String> {
|
||||||
|
parse_client_mss(self.client_mss_bulk.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<Option<u16>, 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<Option<u16>, 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::<u16>()
|
||||||
|
.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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use tracing_subscriber::{EnvFilter, fmt, prelude::*, reload as tracing_reload};
|
||||||
|
|
||||||
|
use crate::config::{LogLevel, ProxyConfig};
|
||||||
|
use crate::startup::{
|
||||||
|
COMPONENT_CONFIG_LOAD, COMPONENT_TRACING_INIT, StartupTracker,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::helpers::{
|
||||||
|
parse_cli, print_maestro_line, resolve_runtime_base_dir, resolve_runtime_config_path,
|
||||||
|
set_maestro_colors_enabled,
|
||||||
|
};
|
||||||
|
use super::runtime_tasks;
|
||||||
|
use super::validate_synlimit_privilege_drop;
|
||||||
|
|
||||||
|
pub(super) struct BootstrapState {
|
||||||
|
pub(super) process_started_at: Instant,
|
||||||
|
pub(super) process_started_at_epoch_secs: u64,
|
||||||
|
pub(super) startup_tracker: Arc<StartupTracker>,
|
||||||
|
pub(super) config: ProxyConfig,
|
||||||
|
pub(super) config_path: PathBuf,
|
||||||
|
pub(super) has_rust_log: bool,
|
||||||
|
pub(super) effective_log_level: LogLevel,
|
||||||
|
pub(super) runtime_log_filter: runtime_tasks::RuntimeLogFilter,
|
||||||
|
pub(super) logging_guard: Option<crate::logging::LoggingGuard>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn bootstrap(
|
||||||
|
privilege_drop_requested: bool,
|
||||||
|
) -> std::result::Result<BootstrapState, Box<dyn std::error::Error>> {
|
||||||
|
let process_started_at = Instant::now();
|
||||||
|
let process_started_at_epoch_secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let startup_tracker = Arc::new(StartupTracker::new(process_started_at_epoch_secs));
|
||||||
|
startup_tracker
|
||||||
|
.start_component(
|
||||||
|
COMPONENT_CONFIG_LOAD,
|
||||||
|
Some("load and validate config".to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let cli_args = parse_cli();
|
||||||
|
let config_path_cli = cli_args.config_path;
|
||||||
|
let config_path_explicit = cli_args.config_path_explicit;
|
||||||
|
let data_path = cli_args.data_path;
|
||||||
|
let cli_silent = cli_args.silent;
|
||||||
|
let cli_log_level = cli_args.log_level;
|
||||||
|
let log_cli_options = cli_args.log_cli_options;
|
||||||
|
let startup_cwd = match std::env::current_dir() {
|
||||||
|
Ok(cwd) => cwd,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[telemt] Can't read current_dir: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(ref data_path) = data_path
|
||||||
|
&& !data_path.is_absolute()
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] data_path must be absolute: {}",
|
||||||
|
data_path.display()
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
let mut config_path =
|
||||||
|
resolve_runtime_config_path(&config_path_cli, &startup_cwd, config_path_explicit);
|
||||||
|
let runtime_base_dir = resolve_runtime_base_dir(
|
||||||
|
&config_path,
|
||||||
|
&startup_cwd,
|
||||||
|
config_path_explicit,
|
||||||
|
data_path.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if !runtime_base_dir.exists()
|
||||||
|
&& let Err(e) = std::fs::create_dir_all(&runtime_base_dir)
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] Can't create runtime directory {}: {}",
|
||||||
|
runtime_base_dir.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !runtime_base_dir.is_dir() {
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] Runtime path exists but is not a directory: {}",
|
||||||
|
runtime_base_dir.display()
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = std::env::set_current_dir(&runtime_base_dir) {
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] Can't use runtime directory {}: {}",
|
||||||
|
runtime_base_dir.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut config = match ProxyConfig::load(&config_path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
if config_path.exists() {
|
||||||
|
eprintln!("[telemt] Error: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
} else {
|
||||||
|
let default = ProxyConfig::default();
|
||||||
|
|
||||||
|
let serialized =
|
||||||
|
match toml::to_string_pretty(&default).or_else(|_| toml::to_string(&default)) {
|
||||||
|
Ok(value) => Some(value),
|
||||||
|
Err(serialize_error) => {
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] Warning: failed to serialize default config: {}",
|
||||||
|
serialize_error
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if config_path_explicit {
|
||||||
|
if let Some(serialized) = serialized.as_ref() {
|
||||||
|
if let Err(write_error) = std::fs::write(&config_path, serialized) {
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] Error: failed to create explicit config at {}: {}",
|
||||||
|
config_path.display(),
|
||||||
|
write_error
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] Created default config at {}",
|
||||||
|
config_path.display()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] Warning: running with in-memory default config without writing to disk"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let runtime_config_path = runtime_base_dir.join("telemt.toml");
|
||||||
|
let fallback_config_path = runtime_base_dir.join("config.toml");
|
||||||
|
let mut persisted = false;
|
||||||
|
|
||||||
|
if let Some(serialized) = serialized.as_ref() {
|
||||||
|
match std::fs::create_dir_all(&runtime_base_dir) {
|
||||||
|
Ok(()) => match std::fs::write(&runtime_config_path, serialized) {
|
||||||
|
Ok(()) => {
|
||||||
|
config_path = runtime_config_path;
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] Created default config at {}",
|
||||||
|
config_path.display()
|
||||||
|
);
|
||||||
|
persisted = true;
|
||||||
|
}
|
||||||
|
Err(write_error) => {
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] Warning: failed to write default config at {}: {}",
|
||||||
|
runtime_config_path.display(),
|
||||||
|
write_error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(create_error) => {
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] Warning: failed to create {}: {}",
|
||||||
|
runtime_base_dir.display(),
|
||||||
|
create_error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !persisted {
|
||||||
|
match std::fs::write(&fallback_config_path, serialized) {
|
||||||
|
Ok(()) => {
|
||||||
|
config_path = fallback_config_path;
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] Created default config at {}",
|
||||||
|
config_path.display()
|
||||||
|
);
|
||||||
|
persisted = true;
|
||||||
|
}
|
||||||
|
Err(write_error) => {
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] Warning: failed to write default config at {}: {}",
|
||||||
|
fallback_config_path.display(),
|
||||||
|
write_error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !persisted {
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] Warning: running with in-memory default config without writing to disk"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = config.validate() {
|
||||||
|
eprintln!("[telemt] Invalid config: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
validate_synlimit_privilege_drop(&config, privilege_drop_requested)?;
|
||||||
|
|
||||||
|
if let Some(p) = data_path {
|
||||||
|
config.general.data_path = Some(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref data_path) = config.general.data_path {
|
||||||
|
if !data_path.is_absolute() {
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] data_path must be absolute: {}",
|
||||||
|
data_path.display()
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if data_path.exists() {
|
||||||
|
if !data_path.is_dir() {
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] data_path exists but is not a directory: {}",
|
||||||
|
data_path.display()
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
} else if let Err(e) = std::fs::create_dir_all(data_path) {
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] Can't create data_path {}: {}",
|
||||||
|
data_path.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = std::env::set_current_dir(data_path) {
|
||||||
|
eprintln!(
|
||||||
|
"[telemt] Can't use data_path {}: {}",
|
||||||
|
data_path.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = crate::network::dns_overrides::install_entries(&config.network.dns_overrides) {
|
||||||
|
eprintln!("[telemt] Invalid network.dns_overrides: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
set_maestro_colors_enabled(!config.general.disable_colors);
|
||||||
|
startup_tracker
|
||||||
|
.complete_component(COMPONENT_CONFIG_LOAD, Some("config is ready".to_string()))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let has_rust_log = std::env::var("RUST_LOG").is_ok();
|
||||||
|
let effective_log_level = if cli_silent {
|
||||||
|
LogLevel::Silent
|
||||||
|
} else if let Some(ref s) = cli_log_level {
|
||||||
|
LogLevel::from_str_loose(s)
|
||||||
|
} else {
|
||||||
|
config.general.log_level.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let initial_filter_spec = runtime_tasks::log_filter_spec(has_rust_log, &effective_log_level);
|
||||||
|
let log_destination =
|
||||||
|
match crate::logging::resolve_log_destination(&config.logging, &log_cli_options) {
|
||||||
|
Ok(destination) => destination,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("[telemt] {error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (filter_layer, filter_handle) =
|
||||||
|
tracing_reload::Layer::new(EnvFilter::new(initial_filter_spec.clone()));
|
||||||
|
startup_tracker
|
||||||
|
.start_component(
|
||||||
|
COMPONENT_TRACING_INIT,
|
||||||
|
Some("initialize tracing subscriber".to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let logging_guard: Option<crate::logging::LoggingGuard>;
|
||||||
|
match log_destination {
|
||||||
|
crate::logging::LogDestination::Stderr => {
|
||||||
|
let fmt_layer = if config.general.disable_colors {
|
||||||
|
fmt::Layer::default().with_ansi(false)
|
||||||
|
} else {
|
||||||
|
fmt::Layer::default().with_ansi(true)
|
||||||
|
};
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(filter_layer)
|
||||||
|
.with(fmt_layer)
|
||||||
|
.init();
|
||||||
|
logging_guard = None;
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
crate::logging::LogDestination::Syslog => {
|
||||||
|
let logging_opts = crate::logging::LoggingOptions {
|
||||||
|
destination: log_destination,
|
||||||
|
disable_colors: true,
|
||||||
|
};
|
||||||
|
let (_, guard) = crate::logging::init_logging(&logging_opts, &initial_filter_spec);
|
||||||
|
logging_guard = Some(guard);
|
||||||
|
}
|
||||||
|
crate::logging::LogDestination::File { .. } => {
|
||||||
|
let logging_opts = crate::logging::LoggingOptions {
|
||||||
|
destination: log_destination,
|
||||||
|
disable_colors: true,
|
||||||
|
};
|
||||||
|
let (_, guard) = crate::logging::init_logging(&logging_opts, &initial_filter_spec);
|
||||||
|
logging_guard = Some(guard);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let runtime_log_filter = runtime_tasks::RuntimeLogFilter::new(filter_handle);
|
||||||
|
|
||||||
|
startup_tracker
|
||||||
|
.complete_component(
|
||||||
|
COMPONENT_TRACING_INIT,
|
||||||
|
Some("tracing initialized".to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
print_maestro_line(format!("Telemt MTProxy v{}", env!("CARGO_PKG_VERSION")));
|
||||||
|
info!("Log level: {}", effective_log_level);
|
||||||
|
if config.general.disable_colors {
|
||||||
|
info!("Colors: disabled");
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
"Modes: classic={} secure={} tls={}",
|
||||||
|
config.general.modes.classic, config.general.modes.secure, config.general.modes.tls
|
||||||
|
);
|
||||||
|
if config.general.modes.classic {
|
||||||
|
warn!("Classic mode is vulnerable to DPI detection; enable only for legacy clients");
|
||||||
|
}
|
||||||
|
info!("TLS domain: {}", config.censorship.tls_domain);
|
||||||
|
if let Some(ref sock) = config.censorship.mask_unix_sock {
|
||||||
|
info!("Mask: {} -> unix:{}", config.censorship.mask, sock);
|
||||||
|
if !std::path::Path::new(sock).exists() {
|
||||||
|
warn!(
|
||||||
|
"Unix socket '{}' does not exist yet. Masking will fail until it appears.",
|
||||||
|
sock
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
info!(
|
||||||
|
"Mask: {} -> {}:{}",
|
||||||
|
config.censorship.mask,
|
||||||
|
config
|
||||||
|
.censorship
|
||||||
|
.mask_host
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(&config.censorship.tls_domain),
|
||||||
|
config.censorship.mask_port
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.censorship.tls_domain == "www.google.com" {
|
||||||
|
warn!("Using default tls_domain. Consider setting a custom domain.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(BootstrapState {
|
||||||
|
process_started_at,
|
||||||
|
process_started_at_epoch_secs,
|
||||||
|
startup_tracker,
|
||||||
|
config,
|
||||||
|
config_path,
|
||||||
|
has_rust_log,
|
||||||
|
effective_log_level,
|
||||||
|
runtime_log_filter,
|
||||||
|
logging_guard,
|
||||||
|
})
|
||||||
|
}
|
||||||
+15
-492
@@ -1,64 +1,22 @@
|
|||||||
use std::error::Error;
|
//! Client listener planning, binding, lifecycle control, and accept loops.
|
||||||
use std::net::{IpAddr, SocketAddr};
|
//!
|
||||||
use std::sync::Arc;
|
//! Submodules keep process-owned socket state separate from generation-owned
|
||||||
use std::time::Duration;
|
//! runtime state:
|
||||||
|
//! - `plan` derives deterministic bind intent from validated configuration.
|
||||||
use arc_swap::ArcSwap;
|
//! - `bind` prepares and activates sockets without partial startup binding.
|
||||||
use tokio::net::TcpListener;
|
//! - `accept` runs cancellation-aware TCP accept loops.
|
||||||
#[cfg(unix)]
|
//! - `control` coordinates reversible listener transitions and shutdown.
|
||||||
use tokio::net::UnixListener;
|
|
||||||
use tracing::{debug, error, info, warn};
|
|
||||||
|
|
||||||
use crate::config::{ProxyConfig, RstOnCloseMode};
|
|
||||||
use crate::proxy::ClientHandler;
|
|
||||||
use crate::startup::{COMPONENT_LISTENERS_BIND, StartupTracker};
|
|
||||||
use crate::transport::socket::set_linger_zero;
|
|
||||||
use crate::transport::{ListenOptions, create_listener, find_listener_processes};
|
|
||||||
|
|
||||||
use super::generation::RuntimeGeneration;
|
|
||||||
use super::helpers::{
|
|
||||||
expected_handshake_close_description, is_expected_handshake_eof, peer_close_description,
|
|
||||||
print_proxy_links,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
mod accept;
|
||||||
|
mod bind;
|
||||||
|
mod control;
|
||||||
|
mod plan;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
mod unix;
|
mod unix;
|
||||||
#[cfg(unix)]
|
|
||||||
/// Runs the Unix socket accept loop against the active runtime generation.
|
|
||||||
pub(crate) use unix::spawn_unix_accept_loop;
|
|
||||||
|
|
||||||
/// Owns the sockets bound during process startup.
|
pub(crate) use bind::bind_listeners;
|
||||||
pub(crate) struct BoundListeners {
|
pub(crate) use control::{ListenerManager, PreparedListenerTransition};
|
||||||
/// TCP listeners and their immutable bind-time settings.
|
pub(crate) use plan::listener_rebind_supported;
|
||||||
pub(crate) listeners: Vec<BoundTcpListener>,
|
|
||||||
#[cfg(unix)]
|
|
||||||
/// The optional Unix listener transferred to its accept loop after startup.
|
|
||||||
pub(crate) unix_listener: Option<UnixListener>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A TCP listener and the connection settings fixed when it was bound.
|
|
||||||
pub(crate) struct BoundTcpListener {
|
|
||||||
listener: TcpListener,
|
|
||||||
proxy_protocol: bool,
|
|
||||||
tls_response_fragment_size: Option<u16>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn listener_port_or_legacy(listener: &crate::config::ListenerConfig, config: &ProxyConfig) -> u16 {
|
|
||||||
listener.port.unwrap_or(config.server.port)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_link_port(config: &ProxyConfig) -> u16 {
|
|
||||||
config
|
|
||||||
.server
|
|
||||||
.listeners
|
|
||||||
.first()
|
|
||||||
.and_then(|listener| listener.port)
|
|
||||||
.unwrap_or(config.server.port)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mss_segment_multiplier(client_mss: u16) -> u16 {
|
|
||||||
1460u16.div_ceil(client_mss)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(target_os = "linux", test))]
|
#[cfg(any(target_os = "linux", test))]
|
||||||
fn tcp_mss_runtime_profile(
|
fn tcp_mss_runtime_profile(
|
||||||
@@ -72,441 +30,6 @@ fn tcp_mss_runtime_profile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
/// Binds configured TCP and Unix listeners without starting accept loops.
|
|
||||||
pub(crate) async fn bind_listeners(
|
|
||||||
config: &Arc<ProxyConfig>,
|
|
||||||
decision_ipv4_dc: bool,
|
|
||||||
decision_ipv6_dc: bool,
|
|
||||||
detected_ip_v4: Option<IpAddr>,
|
|
||||||
detected_ip_v6: Option<IpAddr>,
|
|
||||||
startup_tracker: &Arc<StartupTracker>,
|
|
||||||
) -> Result<BoundListeners, Box<dyn Error>> {
|
|
||||||
startup_tracker
|
|
||||||
.start_component(
|
|
||||||
COMPONENT_LISTENERS_BIND,
|
|
||||||
Some("bind TCP/Unix listeners".to_string()),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let mut listeners = Vec::new();
|
|
||||||
let bulk_client_mss = match config.server.client_mss_bulk_value() {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(error) => {
|
|
||||||
warn!(
|
|
||||||
error = %error,
|
|
||||||
"Invalid bulk client MSS after config validation; disabling bulk MSS"
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for listener_conf in &config.server.listeners {
|
|
||||||
let listener_port = listener_port_or_legacy(listener_conf, config);
|
|
||||||
let addr = SocketAddr::new(listener_conf.ip, listener_port);
|
|
||||||
if addr.is_ipv4() && !decision_ipv4_dc {
|
|
||||||
warn!(%addr, "Skipping IPv4 listener: IPv4 disabled by [network]");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if addr.is_ipv6() && !decision_ipv6_dc {
|
|
||||||
warn!(%addr, "Skipping IPv6 listener: IPv6 disabled by [network]");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let configured_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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
let (client_mss, tls_response_fragment_size) =
|
|
||||||
tcp_mss_runtime_profile(configured_client_mss, bulk_client_mss);
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
let (client_mss, tls_response_fragment_size) = (configured_client_mss, 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()
|
|
||||||
};
|
|
||||||
|
|
||||||
match create_listener(addr, &options) {
|
|
||||||
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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(fragment_size) = tls_response_fragment_size {
|
|
||||||
info!(
|
|
||||||
%addr,
|
|
||||||
fragment_size,
|
|
||||||
bulk_mss = client_mss,
|
|
||||||
"Initial FakeTLS response best-effort chunking configured"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let listener_proxy_protocol = listener_conf
|
|
||||||
.proxy_protocol
|
|
||||||
.unwrap_or(config.server.proxy_protocol);
|
|
||||||
|
|
||||||
let public_host = if let Some(ref announce) = listener_conf.announce {
|
|
||||||
announce.clone()
|
|
||||||
} else if listener_conf.ip.is_unspecified() {
|
|
||||||
if listener_conf.ip.is_ipv4() {
|
|
||||||
detected_ip_v4
|
|
||||||
.map(|ip| ip.to_string())
|
|
||||||
.unwrap_or_else(|| listener_conf.ip.to_string())
|
|
||||||
} else {
|
|
||||||
detected_ip_v6
|
|
||||||
.map(|ip| ip.to_string())
|
|
||||||
.unwrap_or_else(|| listener_conf.ip.to_string())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
listener_conf.ip.to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
if config.general.links.public_host.is_none()
|
|
||||||
&& !config.general.links.show.is_empty()
|
|
||||||
{
|
|
||||||
let link_port = config.general.links.public_port.unwrap_or(listener_port);
|
|
||||||
print_proxy_links(&public_host, link_port, config);
|
|
||||||
}
|
|
||||||
|
|
||||||
listeners.push(BoundTcpListener {
|
|
||||||
listener,
|
|
||||||
proxy_protocol: listener_proxy_protocol,
|
|
||||||
tls_response_fragment_size,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
if e.kind() == std::io::ErrorKind::AddrInUse {
|
|
||||||
let owners = find_listener_processes(addr);
|
|
||||||
if owners.is_empty() {
|
|
||||||
error!(
|
|
||||||
%addr,
|
|
||||||
"Failed to bind: address already in use (owner process unresolved)"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
for owner in owners {
|
|
||||||
error!(
|
|
||||||
%addr,
|
|
||||||
pid = owner.pid,
|
|
||||||
process = %owner.process,
|
|
||||||
"Failed to bind: address already in use"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !listener_conf.reuse_allow {
|
|
||||||
error!(
|
|
||||||
%addr,
|
|
||||||
"reuse_allow=false; set [[server.listeners]].reuse_allow=true to allow multi-instance listening"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
error!("Failed to bind to {}: {}", addr, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !config.general.links.show.is_empty()
|
|
||||||
&& (config.general.links.public_host.is_some() || listeners.is_empty())
|
|
||||||
{
|
|
||||||
let (host, port) = if let Some(ref h) = config.general.links.public_host {
|
|
||||||
(
|
|
||||||
h.clone(),
|
|
||||||
config
|
|
||||||
.general
|
|
||||||
.links
|
|
||||||
.public_port
|
|
||||||
.unwrap_or(default_link_port(config)),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
let ip = detected_ip_v4.or(detected_ip_v6).map(|ip| ip.to_string());
|
|
||||||
if ip.is_none() {
|
|
||||||
warn!(
|
|
||||||
"show_link is configured but public IP could not be detected. Set public_host in config."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
(
|
|
||||||
ip.unwrap_or_else(|| "UNKNOWN".to_string()),
|
|
||||||
config
|
|
||||||
.general
|
|
||||||
.links
|
|
||||||
.public_port
|
|
||||||
.unwrap_or(default_link_port(config)),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
print_proxy_links(&host, port, config);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
let mut unix_listener_out = None;
|
|
||||||
#[cfg(unix)]
|
|
||||||
if let Some(ref unix_path) = config.server.listen_unix_sock {
|
|
||||||
let _ = tokio::fs::remove_file(unix_path).await;
|
|
||||||
|
|
||||||
let unix_listener = UnixListener::bind(unix_path)?;
|
|
||||||
|
|
||||||
if let Some(ref perm_str) = config.server.listen_unix_sock_perm {
|
|
||||||
match u32::from_str_radix(perm_str.trim_start_matches('0'), 8) {
|
|
||||||
Ok(mode) => {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
let perms = std::fs::Permissions::from_mode(mode);
|
|
||||||
if let Err(e) = std::fs::set_permissions(unix_path, perms) {
|
|
||||||
error!(
|
|
||||||
"Failed to set unix socket permissions to {}: {}",
|
|
||||||
perm_str, e
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
info!("Listening on unix:{} (mode {})", unix_path, perm_str);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!(
|
|
||||||
"Invalid listen_unix_sock_perm '{}': {}. Ignoring.",
|
|
||||||
perm_str, e
|
|
||||||
);
|
|
||||||
info!("Listening on unix:{}", unix_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
info!("Listening on unix:{}", unix_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
unix_listener_out = Some(unix_listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
let has_unix_listener = unix_listener_out.is_some();
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
let has_unix_listener = false;
|
|
||||||
|
|
||||||
startup_tracker
|
|
||||||
.complete_component(
|
|
||||||
COMPONENT_LISTENERS_BIND,
|
|
||||||
Some(format!(
|
|
||||||
"listeners configured tcp={} unix={}",
|
|
||||||
listeners.len(),
|
|
||||||
has_unix_listener
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(BoundListeners {
|
|
||||||
listeners,
|
|
||||||
#[cfg(unix)]
|
|
||||||
unix_listener: unix_listener_out,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Starts one TCP accept loop per bound listener.
|
|
||||||
pub(crate) fn spawn_tcp_accept_loops(
|
|
||||||
listeners: Vec<BoundTcpListener>,
|
|
||||||
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
|
||||||
) {
|
|
||||||
for bound_listener in listeners {
|
|
||||||
let listener = bound_listener.listener;
|
|
||||||
let listener_proxy_protocol = bound_listener.proxy_protocol;
|
|
||||||
let tls_response_fragment_size = bound_listener.tls_response_fragment_size;
|
|
||||||
let active_runtime = active_runtime.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
match listener.accept().await {
|
|
||||||
Ok((stream, peer_addr)) => {
|
|
||||||
let runtime = active_runtime.load_full();
|
|
||||||
let config = runtime.config();
|
|
||||||
let rst_mode = config.general.rst_on_close;
|
|
||||||
#[cfg(unix)]
|
|
||||||
let raw_fd = {
|
|
||||||
use std::os::unix::io::AsRawFd;
|
|
||||||
stream.as_raw_fd()
|
|
||||||
};
|
|
||||||
if matches!(rst_mode, RstOnCloseMode::Errors | RstOnCloseMode::Always) {
|
|
||||||
let _ = set_linger_zero(&stream);
|
|
||||||
}
|
|
||||||
if !*runtime.admission_rx.borrow() {
|
|
||||||
debug!(peer = %peer_addr, "Admission gate closed, dropping connection");
|
|
||||||
drop(stream);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let accept_permit_timeout_ms = config.server.accept_permit_timeout_ms;
|
|
||||||
let permit = if accept_permit_timeout_ms == 0 {
|
|
||||||
match runtime.max_connections.clone().acquire_owned().await {
|
|
||||||
Ok(permit) => permit,
|
|
||||||
Err(_) => {
|
|
||||||
error!("Connection limiter is closed");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
match tokio::time::timeout(
|
|
||||||
Duration::from_millis(accept_permit_timeout_ms),
|
|
||||||
runtime.max_connections.clone().acquire_owned(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Ok(permit)) => permit,
|
|
||||||
Ok(Err(_)) => {
|
|
||||||
error!("Connection limiter is closed");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
runtime.stats.increment_accept_permit_timeout_total();
|
|
||||||
debug!(
|
|
||||||
peer = %peer_addr,
|
|
||||||
timeout_ms = accept_permit_timeout_ms,
|
|
||||||
"Dropping accepted connection: permit wait timeout"
|
|
||||||
);
|
|
||||||
drop(stream);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let stats = runtime.stats.clone();
|
|
||||||
let upstream_manager = runtime.upstream_manager.clone();
|
|
||||||
let replay_checker = runtime.replay_checker.clone();
|
|
||||||
let buffer_pool = runtime.buffer_pool.clone();
|
|
||||||
let rng = runtime.rng.clone();
|
|
||||||
let me_pool = runtime.me_pool.clone();
|
|
||||||
let me_pool_runtime = runtime.me_pool_runtime.clone();
|
|
||||||
let route_runtime = runtime.route_runtime.clone();
|
|
||||||
let tls_cache = runtime.tls_cache.clone();
|
|
||||||
let ip_tracker = runtime.ip_tracker.clone();
|
|
||||||
let beobachten = runtime.beobachten.clone();
|
|
||||||
let shared = runtime.proxy_shared.clone();
|
|
||||||
let proxy_protocol_enabled = listener_proxy_protocol;
|
|
||||||
let real_peer_report = Arc::new(std::sync::Mutex::new(None));
|
|
||||||
let real_peer_report_for_handler = real_peer_report.clone();
|
|
||||||
|
|
||||||
let _ = runtime.spawn_session(async move {
|
|
||||||
let _permit = permit;
|
|
||||||
if let Err(e) = ClientHandler::new_with_shared(
|
|
||||||
stream,
|
|
||||||
peer_addr,
|
|
||||||
config,
|
|
||||||
stats,
|
|
||||||
upstream_manager,
|
|
||||||
replay_checker,
|
|
||||||
buffer_pool,
|
|
||||||
rng,
|
|
||||||
me_pool,
|
|
||||||
Some(me_pool_runtime),
|
|
||||||
route_runtime,
|
|
||||||
tls_cache,
|
|
||||||
ip_tracker,
|
|
||||||
beobachten,
|
|
||||||
shared,
|
|
||||||
proxy_protocol_enabled,
|
|
||||||
real_peer_report_for_handler,
|
|
||||||
#[cfg(unix)]
|
|
||||||
raw_fd,
|
|
||||||
rst_mode,
|
|
||||||
tls_response_fragment_size,
|
|
||||||
)
|
|
||||||
.run()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
let real_peer = match real_peer_report.lock() {
|
|
||||||
Ok(guard) => *guard,
|
|
||||||
Err(_) => None,
|
|
||||||
};
|
|
||||||
let peer_close_reason = peer_close_description(&e);
|
|
||||||
let handshake_close_reason =
|
|
||||||
expected_handshake_close_description(&e);
|
|
||||||
|
|
||||||
let me_closed =
|
|
||||||
matches!(&e, crate::error::ProxyError::MiddleConnectionLost);
|
|
||||||
let route_switched =
|
|
||||||
matches!(&e, crate::error::ProxyError::RouteSwitched);
|
|
||||||
|
|
||||||
match (peer_close_reason, me_closed) {
|
|
||||||
(Some(reason), _) => {
|
|
||||||
if let Some(real_peer) = real_peer {
|
|
||||||
debug!(
|
|
||||||
peer = %peer_addr,
|
|
||||||
real_peer = %real_peer,
|
|
||||||
error = %e,
|
|
||||||
close_reason = reason,
|
|
||||||
"Connection closed by peer"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
debug!(
|
|
||||||
peer = %peer_addr,
|
|
||||||
error = %e,
|
|
||||||
close_reason = reason,
|
|
||||||
"Connection closed by peer"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(_, true) => {
|
|
||||||
if let Some(real_peer) = real_peer {
|
|
||||||
warn!(peer = %peer_addr, real_peer = %real_peer, error = %e, "Connection closed: Middle-End dropped session");
|
|
||||||
} else {
|
|
||||||
warn!(peer = %peer_addr, error = %e, "Connection closed: Middle-End dropped session");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ if route_switched => {
|
|
||||||
if let Some(real_peer) = real_peer {
|
|
||||||
info!(peer = %peer_addr, real_peer = %real_peer, error = %e, "Connection closed by controlled route cutover");
|
|
||||||
} else {
|
|
||||||
info!(peer = %peer_addr, error = %e, "Connection closed by controlled route cutover");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ if is_expected_handshake_eof(&e) => {
|
|
||||||
let reason = handshake_close_reason
|
|
||||||
.unwrap_or("Peer closed during initial handshake");
|
|
||||||
if let Some(real_peer) = real_peer {
|
|
||||||
info!(
|
|
||||||
peer = %peer_addr,
|
|
||||||
real_peer = %real_peer,
|
|
||||||
error = %e,
|
|
||||||
close_reason = reason,
|
|
||||||
"Connection closed during initial handshake"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
info!(
|
|
||||||
peer = %peer_addr,
|
|
||||||
error = %e,
|
|
||||||
close_reason = reason,
|
|
||||||
"Connection closed during initial handshake"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if let Some(real_peer) = real_peer {
|
|
||||||
warn!(peer = %peer_addr, real_peer = %real_peer, error = %e, "Connection closed with error");
|
|
||||||
} else {
|
|
||||||
warn!(peer = %peer_addr, error = %e, "Connection closed with error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("Accept error: {}", e);
|
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::tcp_mss_runtime_profile;
|
use super::tcp_mss_runtime_profile;
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio::sync::OwnedSemaphorePermit;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
|
use crate::config::RstOnCloseMode;
|
||||||
|
use crate::proxy::ClientHandler;
|
||||||
|
use crate::transport::socket::set_linger_zero;
|
||||||
|
|
||||||
|
use super::bind::BoundTcpListener;
|
||||||
|
use super::plan::ListenerBindSpec;
|
||||||
|
use crate::maestro::generation::RuntimeGeneration;
|
||||||
|
use crate::maestro::helpers::{
|
||||||
|
expected_handshake_close_description, is_expected_handshake_eof, peer_close_description,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(super) struct ListenerSlot {
|
||||||
|
pub(super) spec: ListenerBindSpec,
|
||||||
|
listener: Arc<TcpListener>,
|
||||||
|
cancellation: CancellationToken,
|
||||||
|
task: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PermitWait {
|
||||||
|
Acquired(OwnedSemaphorePermit),
|
||||||
|
TimedOut,
|
||||||
|
Closed,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_permit(
|
||||||
|
runtime: &Arc<RuntimeGeneration>,
|
||||||
|
cancellation: &CancellationToken,
|
||||||
|
) -> PermitWait {
|
||||||
|
let timeout_ms = runtime.config().server.accept_permit_timeout_ms;
|
||||||
|
let acquire = runtime.max_connections.clone().acquire_owned();
|
||||||
|
if timeout_ms == 0 {
|
||||||
|
return tokio::select! {
|
||||||
|
biased;
|
||||||
|
_ = cancellation.cancelled() => PermitWait::Cancelled,
|
||||||
|
permit = acquire => match permit {
|
||||||
|
Ok(permit) => PermitWait::Acquired(permit),
|
||||||
|
Err(_) => PermitWait::Closed,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
_ = cancellation.cancelled() => PermitWait::Cancelled,
|
||||||
|
result = tokio::time::timeout(Duration::from_millis(timeout_ms), acquire) => {
|
||||||
|
match result {
|
||||||
|
Ok(Ok(permit)) => PermitWait::Acquired(permit),
|
||||||
|
Ok(Err(_)) => PermitWait::Closed,
|
||||||
|
Err(_) => PermitWait::TimedOut,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_client_session(
|
||||||
|
stream: TcpStream,
|
||||||
|
peer_addr: std::net::SocketAddr,
|
||||||
|
runtime: Arc<RuntimeGeneration>,
|
||||||
|
permit: OwnedSemaphorePermit,
|
||||||
|
spec: &ListenerBindSpec,
|
||||||
|
) {
|
||||||
|
let config = runtime.config();
|
||||||
|
let rst_mode = config.general.rst_on_close;
|
||||||
|
#[cfg(unix)]
|
||||||
|
let raw_fd = {
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
stream.as_raw_fd()
|
||||||
|
};
|
||||||
|
if matches!(rst_mode, RstOnCloseMode::Errors | RstOnCloseMode::Always) {
|
||||||
|
let _ = set_linger_zero(&stream);
|
||||||
|
}
|
||||||
|
let stats = runtime.stats.clone();
|
||||||
|
let upstream_manager = runtime.upstream_manager.clone();
|
||||||
|
let replay_checker = runtime.replay_checker.clone();
|
||||||
|
let buffer_pool = runtime.buffer_pool.clone();
|
||||||
|
let rng = runtime.rng.clone();
|
||||||
|
let me_pool = runtime.me_pool.clone();
|
||||||
|
let me_pool_runtime = runtime.me_pool_runtime.clone();
|
||||||
|
let route_runtime = runtime.route_runtime.clone();
|
||||||
|
let tls_cache = runtime.tls_cache.clone();
|
||||||
|
let ip_tracker = runtime.ip_tracker.clone();
|
||||||
|
let beobachten = runtime.beobachten.clone();
|
||||||
|
let shared = runtime.proxy_shared.clone();
|
||||||
|
let proxy_protocol_enabled = spec.proxy_protocol;
|
||||||
|
let tls_response_fragment_size = spec.tls_response_fragment_size;
|
||||||
|
let real_peer_report = Arc::new(std::sync::Mutex::new(None));
|
||||||
|
let real_peer_report_for_handler = real_peer_report.clone();
|
||||||
|
|
||||||
|
let _ = runtime.spawn_session(async move {
|
||||||
|
let _permit = permit;
|
||||||
|
if let Err(error_value) = ClientHandler::new_with_shared(
|
||||||
|
stream,
|
||||||
|
peer_addr,
|
||||||
|
config,
|
||||||
|
stats,
|
||||||
|
upstream_manager,
|
||||||
|
replay_checker,
|
||||||
|
buffer_pool,
|
||||||
|
rng,
|
||||||
|
me_pool,
|
||||||
|
Some(me_pool_runtime),
|
||||||
|
route_runtime,
|
||||||
|
tls_cache,
|
||||||
|
ip_tracker,
|
||||||
|
beobachten,
|
||||||
|
shared,
|
||||||
|
proxy_protocol_enabled,
|
||||||
|
real_peer_report_for_handler,
|
||||||
|
#[cfg(unix)]
|
||||||
|
raw_fd,
|
||||||
|
rst_mode,
|
||||||
|
tls_response_fragment_size,
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let real_peer = real_peer_report.lock().ok().and_then(|guard| *guard);
|
||||||
|
let peer_close_reason = peer_close_description(&error_value);
|
||||||
|
let handshake_close_reason = expected_handshake_close_description(&error_value);
|
||||||
|
let me_closed = matches!(
|
||||||
|
&error_value,
|
||||||
|
crate::error::ProxyError::MiddleConnectionLost
|
||||||
|
);
|
||||||
|
let route_switched =
|
||||||
|
matches!(&error_value, crate::error::ProxyError::RouteSwitched);
|
||||||
|
|
||||||
|
match (peer_close_reason, me_closed) {
|
||||||
|
(Some(reason), _) => {
|
||||||
|
if let Some(real_peer) = real_peer {
|
||||||
|
debug!(peer = %peer_addr, real_peer = %real_peer, error = %error_value, close_reason = reason, "Connection closed by peer");
|
||||||
|
} else {
|
||||||
|
debug!(peer = %peer_addr, error = %error_value, close_reason = reason, "Connection closed by peer");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(_, true) => {
|
||||||
|
if let Some(real_peer) = real_peer {
|
||||||
|
warn!(peer = %peer_addr, real_peer = %real_peer, error = %error_value, "Connection closed: Middle-End dropped session");
|
||||||
|
} else {
|
||||||
|
warn!(peer = %peer_addr, error = %error_value, "Connection closed: Middle-End dropped session");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ if route_switched => {
|
||||||
|
if let Some(real_peer) = real_peer {
|
||||||
|
info!(peer = %peer_addr, real_peer = %real_peer, error = %error_value, "Connection closed by controlled route cutover");
|
||||||
|
} else {
|
||||||
|
info!(peer = %peer_addr, error = %error_value, "Connection closed by controlled route cutover");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ if is_expected_handshake_eof(&error_value) => {
|
||||||
|
let reason = handshake_close_reason
|
||||||
|
.unwrap_or("Peer closed during initial handshake");
|
||||||
|
if let Some(real_peer) = real_peer {
|
||||||
|
info!(peer = %peer_addr, real_peer = %real_peer, error = %error_value, close_reason = reason, "Connection closed during initial handshake");
|
||||||
|
} else {
|
||||||
|
info!(peer = %peer_addr, error = %error_value, close_reason = reason, "Connection closed during initial handshake");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if let Some(real_peer) = real_peer {
|
||||||
|
warn!(peer = %peer_addr, real_peer = %real_peer, error = %error_value, "Connection closed with error");
|
||||||
|
} else {
|
||||||
|
warn!(peer = %peer_addr, error = %error_value, "Connection closed with error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_accept_loop(
|
||||||
|
listener: Arc<TcpListener>,
|
||||||
|
spec: ListenerBindSpec,
|
||||||
|
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||||
|
cancellation: CancellationToken,
|
||||||
|
) {
|
||||||
|
loop {
|
||||||
|
let accepted = tokio::select! {
|
||||||
|
biased;
|
||||||
|
_ = cancellation.cancelled() => return,
|
||||||
|
accepted = listener.accept() => accepted,
|
||||||
|
};
|
||||||
|
match accepted {
|
||||||
|
Ok((stream, peer_addr)) => {
|
||||||
|
let runtime = active_runtime.load_full();
|
||||||
|
if !*runtime.admission_rx.borrow() {
|
||||||
|
debug!(peer = %peer_addr, "Admission gate closed, dropping connection");
|
||||||
|
drop(stream);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match wait_for_permit(&runtime, &cancellation).await {
|
||||||
|
PermitWait::Acquired(permit) => {
|
||||||
|
spawn_client_session(stream, peer_addr, runtime, permit, &spec);
|
||||||
|
}
|
||||||
|
PermitWait::TimedOut => {
|
||||||
|
runtime.stats.increment_accept_permit_timeout_total();
|
||||||
|
debug!(
|
||||||
|
peer = %peer_addr,
|
||||||
|
timeout_ms = runtime.config().server.accept_permit_timeout_ms,
|
||||||
|
"Dropping accepted connection: permit wait timeout"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
PermitWait::Closed => {
|
||||||
|
error!(addr = %spec.addr, "Connection limiter is closed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PermitWait::Cancelled => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error_value) => {
|
||||||
|
error!(addr = %spec.addr, error = %error_value, "TCP accept error");
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
_ = cancellation.cancelled() => return,
|
||||||
|
_ = tokio::time::sleep(Duration::from_millis(100)) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListenerSlot {
|
||||||
|
pub(super) fn start(
|
||||||
|
bound: BoundTcpListener,
|
||||||
|
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||||
|
) -> Self {
|
||||||
|
let cancellation = CancellationToken::new();
|
||||||
|
let task = tokio::spawn(run_accept_loop(
|
||||||
|
bound.listener.clone(),
|
||||||
|
bound.spec.clone(),
|
||||||
|
active_runtime,
|
||||||
|
cancellation.clone(),
|
||||||
|
));
|
||||||
|
Self {
|
||||||
|
spec: bound.spec,
|
||||||
|
listener: bound.listener,
|
||||||
|
cancellation,
|
||||||
|
task: Some(task),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn stop(&mut self) -> Result<(), String> {
|
||||||
|
self.cancellation.cancel();
|
||||||
|
if let Some(task) = self.task.take() {
|
||||||
|
task.await
|
||||||
|
.map_err(|error_value| format!("listener {} task failed: {error_value}", self.spec.addr))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn restart(&mut self, active_runtime: Arc<ArcSwap<RuntimeGeneration>>) {
|
||||||
|
self.cancellation = CancellationToken::new();
|
||||||
|
self.task = Some(tokio::spawn(run_accept_loop(
|
||||||
|
self.listener.clone(),
|
||||||
|
self.spec.clone(),
|
||||||
|
active_runtime,
|
||||||
|
self.cancellation.clone(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use socket2::Socket;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use tokio::net::UnixListener;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
use crate::config::ProxyConfig;
|
||||||
|
use crate::startup::{COMPONENT_LISTENERS_BIND, StartupTracker};
|
||||||
|
use crate::transport::socket::{activate_listener_socket, bind_listener_socket};
|
||||||
|
use crate::transport::find_listener_processes;
|
||||||
|
|
||||||
|
use super::plan::{ListenerBindSpec, listener_bind_plan};
|
||||||
|
use crate::maestro::helpers::print_proxy_links;
|
||||||
|
|
||||||
|
/// Owns sockets bound before process accept loops start.
|
||||||
|
pub(crate) struct BoundListeners {
|
||||||
|
pub(super) listeners: Vec<BoundTcpListener>,
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub(super) unix_listener: Option<UnixListener>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BoundListeners {
|
||||||
|
pub(crate) fn is_empty(&self) -> bool {
|
||||||
|
let tcp_empty = self.listeners.is_empty();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
tcp_empty && self.unix_listener.is_none()
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
tcp_empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Active socket and immutable connection policy for one endpoint.
|
||||||
|
pub(super) struct BoundTcpListener {
|
||||||
|
pub(super) listener: Arc<TcpListener>,
|
||||||
|
pub(super) spec: ListenerBindSpec,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Socket bound for a candidate transition but not yet listening.
|
||||||
|
pub(super) struct PreparedTcpListener {
|
||||||
|
socket: Socket,
|
||||||
|
spec: ListenerBindSpec,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mss_segment_multiplier(client_mss: u16) -> u16 {
|
||||||
|
1460u16.div_ceil(client_mss)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_link_port(config: &ProxyConfig) -> u16 {
|
||||||
|
config
|
||||||
|
.server
|
||||||
|
.listeners
|
||||||
|
.first()
|
||||||
|
.and_then(|listener| listener.port)
|
||||||
|
.unwrap_or(config.server.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_bind_error(addr: SocketAddr, reuse_allow: bool, error_value: &std::io::Error) {
|
||||||
|
if error_value.kind() == std::io::ErrorKind::AddrInUse {
|
||||||
|
let owners = find_listener_processes(addr);
|
||||||
|
if owners.is_empty() {
|
||||||
|
error!(%addr, "Failed to bind: address already in use (owner process unresolved)");
|
||||||
|
} else {
|
||||||
|
for owner in owners {
|
||||||
|
error!(
|
||||||
|
%addr,
|
||||||
|
pid = owner.pid,
|
||||||
|
process = %owner.process,
|
||||||
|
"Failed to bind: address already in use"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !reuse_allow {
|
||||||
|
error!(
|
||||||
|
%addr,
|
||||||
|
"reuse_allow=false; set [[server.listeners]].reuse_allow=true to allow multi-instance listening"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error!(%addr, error = %error_value, "Failed to bind listener");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn prepare_listener(
|
||||||
|
spec: ListenerBindSpec,
|
||||||
|
) -> std::io::Result<PreparedTcpListener> {
|
||||||
|
match bind_listener_socket(spec.addr, &spec.options) {
|
||||||
|
Ok(socket) => Ok(PreparedTcpListener { socket, spec }),
|
||||||
|
Err(error_value) => {
|
||||||
|
log_bind_error(spec.addr, spec.options.reuse_port, &error_value);
|
||||||
|
Err(error_value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PreparedTcpListener {
|
||||||
|
pub(super) fn activate(self) -> std::io::Result<BoundTcpListener> {
|
||||||
|
activate_listener_socket(&self.socket, self.spec.options.backlog)?;
|
||||||
|
let listener = TcpListener::from_std(self.socket.into())?;
|
||||||
|
Ok(BoundTcpListener {
|
||||||
|
listener: Arc::new(listener),
|
||||||
|
spec: self.spec,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_listener_profile(spec: &ListenerBindSpec) {
|
||||||
|
info!(addr = %spec.addr, "Listening on TCP endpoint");
|
||||||
|
if let Some(client_mss) = spec.options.client_mss {
|
||||||
|
info!(
|
||||||
|
addr = %spec.addr,
|
||||||
|
client_mss,
|
||||||
|
segment_multiplier = mss_segment_multiplier(client_mss),
|
||||||
|
"Client-facing TCP MSS configured"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(fragment_size) = spec.tls_response_fragment_size {
|
||||||
|
info!(
|
||||||
|
addr = %spec.addr,
|
||||||
|
fragment_size,
|
||||||
|
bulk_mss = spec.options.client_mss,
|
||||||
|
"Initial FakeTLS response best-effort chunking configured"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_configured_links(
|
||||||
|
config: &ProxyConfig,
|
||||||
|
plan: &std::collections::BTreeMap<SocketAddr, ListenerBindSpec>,
|
||||||
|
detected_ip_v4: Option<IpAddr>,
|
||||||
|
detected_ip_v6: Option<IpAddr>,
|
||||||
|
) {
|
||||||
|
for listener in &config.server.listeners {
|
||||||
|
let port = listener.port.unwrap_or(config.server.port);
|
||||||
|
let addr = SocketAddr::new(listener.ip, port);
|
||||||
|
if !plan.contains_key(&addr) || config.general.links.public_host.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let public_host = if let Some(announce) = &listener.announce {
|
||||||
|
announce.clone()
|
||||||
|
} else if listener.ip.is_unspecified() {
|
||||||
|
if listener.ip.is_ipv4() {
|
||||||
|
detected_ip_v4
|
||||||
|
} else {
|
||||||
|
detected_ip_v6
|
||||||
|
}
|
||||||
|
.map(|ip| ip.to_string())
|
||||||
|
.unwrap_or_else(|| listener.ip.to_string())
|
||||||
|
} else {
|
||||||
|
listener.ip.to_string()
|
||||||
|
};
|
||||||
|
if !config.general.links.show.is_empty() {
|
||||||
|
let link_port = config.general.links.public_port.unwrap_or(port);
|
||||||
|
print_proxy_links(&public_host, link_port, config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.general.links.show.is_empty() || config.general.links.public_host.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let host = config.general.links.public_host.as_deref().unwrap_or_default();
|
||||||
|
let port = config
|
||||||
|
.general
|
||||||
|
.links
|
||||||
|
.public_port
|
||||||
|
.unwrap_or_else(|| default_link_port(config));
|
||||||
|
print_proxy_links(host, port, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Binds every eligible configured listener or fails without a partial inventory.
|
||||||
|
pub(crate) async fn bind_listeners(
|
||||||
|
config: &Arc<ProxyConfig>,
|
||||||
|
detected_ip_v4: Option<IpAddr>,
|
||||||
|
detected_ip_v6: Option<IpAddr>,
|
||||||
|
startup_tracker: &Arc<StartupTracker>,
|
||||||
|
) -> Result<BoundListeners, Box<dyn Error>> {
|
||||||
|
startup_tracker
|
||||||
|
.start_component(
|
||||||
|
COMPONENT_LISTENERS_BIND,
|
||||||
|
Some("bind TCP/Unix listeners".to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let plan = listener_bind_plan(config).map_err(std::io::Error::other)?;
|
||||||
|
let mut prepared = Vec::with_capacity(plan.len());
|
||||||
|
for spec in plan.values().cloned() {
|
||||||
|
prepared.push(prepare_listener(spec)?);
|
||||||
|
}
|
||||||
|
let mut listeners = Vec::with_capacity(prepared.len());
|
||||||
|
for candidate in prepared {
|
||||||
|
let bound = candidate.activate()?;
|
||||||
|
log_listener_profile(&bound.spec);
|
||||||
|
listeners.push(bound);
|
||||||
|
}
|
||||||
|
print_configured_links(config, &plan, detected_ip_v4, detected_ip_v6);
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
let mut unix_listener_out = None;
|
||||||
|
#[cfg(unix)]
|
||||||
|
if let Some(unix_path) = &config.server.listen_unix_sock {
|
||||||
|
let _ = tokio::fs::remove_file(unix_path).await;
|
||||||
|
let unix_listener = UnixListener::bind(unix_path)?;
|
||||||
|
if let Some(perm_str) = &config.server.listen_unix_sock_perm {
|
||||||
|
match u32::from_str_radix(perm_str.trim_start_matches('0'), 8) {
|
||||||
|
Ok(mode) => {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let permissions = std::fs::Permissions::from_mode(mode);
|
||||||
|
if let Err(error_value) = std::fs::set_permissions(unix_path, permissions) {
|
||||||
|
error!(
|
||||||
|
path = %unix_path,
|
||||||
|
permissions = %perm_str,
|
||||||
|
error = %error_value,
|
||||||
|
"Failed to set Unix socket permissions"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
info!(path = %unix_path, permissions = %perm_str, "Listening on Unix socket");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error_value) => {
|
||||||
|
warn!(
|
||||||
|
path = %unix_path,
|
||||||
|
permissions = %perm_str,
|
||||||
|
error = %error_value,
|
||||||
|
"Invalid Unix socket permissions; keeping umask-derived mode"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
info!(path = %unix_path, "Listening on Unix socket");
|
||||||
|
}
|
||||||
|
unix_listener_out = Some(unix_listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
let has_unix_listener = unix_listener_out.is_some();
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let has_unix_listener = false;
|
||||||
|
startup_tracker
|
||||||
|
.complete_component(
|
||||||
|
COMPONENT_LISTENERS_BIND,
|
||||||
|
Some(format!(
|
||||||
|
"listeners configured tcp={} unix={}",
|
||||||
|
listeners.len(),
|
||||||
|
has_unix_listener
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(BoundListeners {
|
||||||
|
listeners,
|
||||||
|
#[cfg(unix)]
|
||||||
|
unix_listener: unix_listener_out,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
|
|
||||||
|
use crate::config::ProxyConfig;
|
||||||
|
use crate::maestro::generation::RuntimeGeneration;
|
||||||
|
|
||||||
|
use super::accept::ListenerSlot;
|
||||||
|
use super::bind::{
|
||||||
|
BoundListeners, BoundTcpListener, PreparedTcpListener, prepare_listener,
|
||||||
|
};
|
||||||
|
use super::plan::{ListenerBindSpec, listener_bind_plan};
|
||||||
|
#[cfg(unix)]
|
||||||
|
use super::unix::UnixAcceptHandle;
|
||||||
|
|
||||||
|
/// Process-owned listener inventory and accept-task lifecycle controller.
|
||||||
|
pub(crate) struct ListenerManager {
|
||||||
|
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||||
|
slots: BTreeMap<SocketAddr, ListenerSlot>,
|
||||||
|
#[cfg(unix)]
|
||||||
|
unix: Option<UnixAcceptHandle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct PreparedListenerTransition {
|
||||||
|
target_specs: BTreeMap<SocketAddr, ListenerBindSpec>,
|
||||||
|
additions: Vec<PreparedTcpListener>,
|
||||||
|
removals: Vec<SocketAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct PendingListenerTransition {
|
||||||
|
target_specs: BTreeMap<SocketAddr, ListenerBindSpec>,
|
||||||
|
additions: Vec<BoundTcpListener>,
|
||||||
|
removals: Vec<SocketAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListenerManager {
|
||||||
|
/// Starts accept loops for the complete startup-bound inventory.
|
||||||
|
pub(crate) fn start(
|
||||||
|
bound: BoundListeners,
|
||||||
|
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||||
|
) -> Self {
|
||||||
|
let mut slots = BTreeMap::new();
|
||||||
|
for listener in bound.listeners {
|
||||||
|
let addr = listener.spec.addr;
|
||||||
|
slots.insert(addr, ListenerSlot::start(listener, active_runtime.clone()));
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
let unix = bound
|
||||||
|
.unix_listener
|
||||||
|
.map(|listener| UnixAcceptHandle::start(listener, active_runtime.clone()));
|
||||||
|
Self {
|
||||||
|
active_runtime,
|
||||||
|
slots,
|
||||||
|
#[cfg(unix)]
|
||||||
|
unix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn empty(active_runtime: Arc<ArcSwap<RuntimeGeneration>>) -> Self {
|
||||||
|
Self {
|
||||||
|
active_runtime,
|
||||||
|
slots: BTreeMap::new(),
|
||||||
|
#[cfg(unix)]
|
||||||
|
unix: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Binds added endpoints without calling `listen(2)` or changing active tasks.
|
||||||
|
pub(crate) fn prepare_transition(
|
||||||
|
&self,
|
||||||
|
desired: &ProxyConfig,
|
||||||
|
) -> Result<Option<PreparedListenerTransition>, String> {
|
||||||
|
let target_specs = listener_bind_plan(desired)?;
|
||||||
|
let current_addresses: BTreeSet<_> = self.slots.keys().copied().collect();
|
||||||
|
let target_addresses: BTreeSet<_> = target_specs.keys().copied().collect();
|
||||||
|
if current_addresses == target_addresses
|
||||||
|
&& self
|
||||||
|
.slots
|
||||||
|
.iter()
|
||||||
|
.all(|(addr, slot)| target_specs.get(addr) == Some(&slot.spec))
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
for addr in current_addresses.intersection(&target_addresses) {
|
||||||
|
let current = &self.slots[addr].spec;
|
||||||
|
let desired_spec = &target_specs[addr];
|
||||||
|
if current != desired_spec {
|
||||||
|
return Err(format!(
|
||||||
|
"listener {addr} bind policy changed at the same endpoint; process restart required"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut additions = Vec::new();
|
||||||
|
for addr in target_addresses.difference(¤t_addresses) {
|
||||||
|
let spec = target_specs
|
||||||
|
.get(addr)
|
||||||
|
.expect("address originated from target listener plan")
|
||||||
|
.clone();
|
||||||
|
additions.push(prepare_listener(spec).map_err(|error_value| {
|
||||||
|
format!("failed to prepare listener {addr}: {error_value}")
|
||||||
|
})?);
|
||||||
|
}
|
||||||
|
let removals = current_addresses
|
||||||
|
.difference(&target_addresses)
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
Ok(Some(PreparedListenerTransition {
|
||||||
|
target_specs,
|
||||||
|
additions,
|
||||||
|
removals,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Activates additions and stops removed acceptors before the runtime swap.
|
||||||
|
pub(crate) async fn begin_transition(
|
||||||
|
&mut self,
|
||||||
|
prepared: PreparedListenerTransition,
|
||||||
|
) -> Result<PendingListenerTransition, String> {
|
||||||
|
let mut additions = Vec::with_capacity(prepared.additions.len());
|
||||||
|
for candidate in prepared.additions {
|
||||||
|
additions.push(candidate.activate().map_err(|error_value| {
|
||||||
|
format!("failed to activate prepared listener: {error_value}")
|
||||||
|
})?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stopped = Vec::new();
|
||||||
|
for addr in &prepared.removals {
|
||||||
|
let stop_result = self
|
||||||
|
.slots
|
||||||
|
.get_mut(addr)
|
||||||
|
.expect("removal originated from active listener inventory")
|
||||||
|
.stop()
|
||||||
|
.await;
|
||||||
|
if let Err(error_value) = stop_result {
|
||||||
|
for stopped_addr in stopped {
|
||||||
|
if let Some(stopped_slot) = self.slots.get_mut(&stopped_addr) {
|
||||||
|
stopped_slot.restart(self.active_runtime.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.slots
|
||||||
|
.get_mut(addr)
|
||||||
|
.expect("failed slot remains in active listener inventory")
|
||||||
|
.restart(self.active_runtime.clone());
|
||||||
|
return Err(error_value);
|
||||||
|
}
|
||||||
|
stopped.push(*addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(PendingListenerTransition {
|
||||||
|
target_specs: prepared.target_specs,
|
||||||
|
additions,
|
||||||
|
removals: prepared.removals,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publishes new acceptors after the runtime generation has been swapped.
|
||||||
|
pub(crate) fn finish_transition(&mut self, pending: PendingListenerTransition) {
|
||||||
|
for addr in pending.removals {
|
||||||
|
self.slots.remove(&addr);
|
||||||
|
}
|
||||||
|
for listener in pending.additions {
|
||||||
|
let addr = listener.spec.addr;
|
||||||
|
self.slots.insert(
|
||||||
|
addr,
|
||||||
|
ListenerSlot::start(listener, self.active_runtime.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
debug_assert_eq!(
|
||||||
|
self.slots
|
||||||
|
.iter()
|
||||||
|
.map(|(addr, slot)| (*addr, slot.spec.clone()))
|
||||||
|
.collect::<BTreeMap<_, _>>(),
|
||||||
|
pending.target_specs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops and joins every accept task before sockets are released.
|
||||||
|
pub(crate) async fn shutdown(&mut self) -> Result<(), String> {
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
for slot in self.slots.values_mut() {
|
||||||
|
if let Err(error_value) = slot.stop().await {
|
||||||
|
errors.push(error_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
if let Some(unix) = &mut self.unix
|
||||||
|
&& let Err(error_value) = unix.stop().await
|
||||||
|
{
|
||||||
|
errors.push(error_value);
|
||||||
|
}
|
||||||
|
self.slots.clear();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
self.unix = None;
|
||||||
|
}
|
||||||
|
if errors.is_empty() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(errors.join("; "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::{ListenerConfig, SynLimitMode};
|
||||||
|
use crate::maestro::generation::test_runtime_generation;
|
||||||
|
use crate::transport::ListenOptions;
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
|
||||||
|
fn listener_config(addr: SocketAddr) -> ListenerConfig {
|
||||||
|
ListenerConfig {
|
||||||
|
ip: addr.ip(),
|
||||||
|
port: Some(addr.port()),
|
||||||
|
client_mss: None,
|
||||||
|
synlimit: SynLimitMode::Off,
|
||||||
|
synlimit_seconds: 60,
|
||||||
|
synlimit_hitcount: 48,
|
||||||
|
synlimit_burst: 24,
|
||||||
|
synlimit_ios_seconds: 1,
|
||||||
|
synlimit_ios_hitcount: 12,
|
||||||
|
synlimit_ios_burst: 24,
|
||||||
|
synlimit_hashlimit_expire_ms: 60_000,
|
||||||
|
synlimit_hashlimit_size: 32_768,
|
||||||
|
announce: None,
|
||||||
|
announce_ip: None,
|
||||||
|
proxy_protocol: None,
|
||||||
|
reuse_allow: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bound_listener() -> (BoundTcpListener, SocketAddr) {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let spec = ListenerBindSpec {
|
||||||
|
addr,
|
||||||
|
options: ListenOptions {
|
||||||
|
reuse_port: false,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
proxy_protocol: false,
|
||||||
|
tls_response_fragment_size: None,
|
||||||
|
};
|
||||||
|
(
|
||||||
|
BoundTcpListener {
|
||||||
|
listener: Arc::new(listener),
|
||||||
|
spec,
|
||||||
|
},
|
||||||
|
addr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn candidate_bind_failure_keeps_old_listener_accepting() {
|
||||||
|
let runtime = test_runtime_generation(1, ProxyConfig::default());
|
||||||
|
let active_runtime = Arc::new(ArcSwap::from(runtime.clone()));
|
||||||
|
let (old_listener, old_addr) = bound_listener().await;
|
||||||
|
let bound = BoundListeners {
|
||||||
|
listeners: vec![old_listener],
|
||||||
|
#[cfg(unix)]
|
||||||
|
unix_listener: None,
|
||||||
|
};
|
||||||
|
let mut manager = ListenerManager::start(bound, active_runtime);
|
||||||
|
let blocker = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let blocked_addr = blocker.local_addr().unwrap();
|
||||||
|
let mut desired = ProxyConfig::default();
|
||||||
|
desired.server.listeners = vec![listener_config(blocked_addr)];
|
||||||
|
|
||||||
|
assert!(manager.prepare_transition(&desired).is_err());
|
||||||
|
TcpStream::connect(old_addr).await.unwrap();
|
||||||
|
|
||||||
|
manager.shutdown().await.unwrap();
|
||||||
|
runtime.stop_sessions().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn added_listener_is_dormant_until_transition_begins() {
|
||||||
|
let runtime = test_runtime_generation(1, ProxyConfig::default());
|
||||||
|
let active_runtime = Arc::new(ArcSwap::from(runtime.clone()));
|
||||||
|
let (old_listener, _old_addr) = bound_listener().await;
|
||||||
|
let bound = BoundListeners {
|
||||||
|
listeners: vec![old_listener],
|
||||||
|
#[cfg(unix)]
|
||||||
|
unix_listener: None,
|
||||||
|
};
|
||||||
|
let mut manager = ListenerManager::start(bound, active_runtime);
|
||||||
|
let reservation = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let new_addr = reservation.local_addr().unwrap();
|
||||||
|
drop(reservation);
|
||||||
|
let mut desired = ProxyConfig::default();
|
||||||
|
desired.server.listeners = vec![listener_config(new_addr)];
|
||||||
|
|
||||||
|
let prepared = manager.prepare_transition(&desired).unwrap().unwrap();
|
||||||
|
assert!(TcpStream::connect(new_addr).await.is_err());
|
||||||
|
let pending = manager.begin_transition(prepared).await.unwrap();
|
||||||
|
manager.finish_transition(pending);
|
||||||
|
TcpStream::connect(new_addr).await.unwrap();
|
||||||
|
|
||||||
|
manager.shutdown().await.unwrap();
|
||||||
|
runtime.stop_sessions().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use crate::config::{ProxyConfig, ServerConfig, SynLimitMode};
|
||||||
|
use crate::transport::ListenOptions;
|
||||||
|
|
||||||
|
use super::tcp_mss_runtime_profile;
|
||||||
|
|
||||||
|
/// Immutable socket and connection policy for one listener endpoint.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(super) struct ListenerBindSpec {
|
||||||
|
pub(super) addr: SocketAddr,
|
||||||
|
pub(super) options: ListenOptions,
|
||||||
|
pub(super) proxy_protocol: bool,
|
||||||
|
pub(super) tls_response_fragment_size: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn listener_port_or_legacy(
|
||||||
|
listener: &crate::config::ListenerConfig,
|
||||||
|
server: &ServerConfig,
|
||||||
|
) -> u16 {
|
||||||
|
listener.port.unwrap_or(server.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives inbound listener intent without consulting transient outbound probes.
|
||||||
|
pub(crate) fn listener_bind_plan(
|
||||||
|
config: &ProxyConfig,
|
||||||
|
) -> Result<BTreeMap<SocketAddr, ListenerBindSpec>, String> {
|
||||||
|
let mut plan = BTreeMap::new();
|
||||||
|
let bulk_client_mss = config
|
||||||
|
.server
|
||||||
|
.client_mss_bulk_value()
|
||||||
|
.map_err(|error| format!("invalid server.client_mss_bulk: {error}"))?;
|
||||||
|
|
||||||
|
for listener in &config.server.listeners {
|
||||||
|
let addr = SocketAddr::new(
|
||||||
|
listener.ip,
|
||||||
|
listener_port_or_legacy(listener, &config.server),
|
||||||
|
);
|
||||||
|
if addr.is_ipv4() && !config.network.ipv4 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if addr.is_ipv6() && config.network.ipv6 == Some(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let configured_client_mss = listener
|
||||||
|
.effective_client_mss(&config.server)
|
||||||
|
.map_err(|error| format!("invalid client MSS for listener {addr}: {error}"))?;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let (client_mss, tls_response_fragment_size) =
|
||||||
|
tcp_mss_runtime_profile(configured_client_mss, bulk_client_mss);
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
let (client_mss, tls_response_fragment_size) = (configured_client_mss, None);
|
||||||
|
let spec = ListenerBindSpec {
|
||||||
|
addr,
|
||||||
|
options: ListenOptions {
|
||||||
|
reuse_port: listener.reuse_allow,
|
||||||
|
ipv6_only: listener.ip.is_ipv6(),
|
||||||
|
backlog: config.server.listen_backlog,
|
||||||
|
client_mss,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
proxy_protocol: listener
|
||||||
|
.proxy_protocol
|
||||||
|
.unwrap_or(config.server.proxy_protocol),
|
||||||
|
tls_response_fragment_size,
|
||||||
|
};
|
||||||
|
if plan.insert(addr, spec).is_some() {
|
||||||
|
return Err(format!("duplicate effective listener endpoint: {addr}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn any_synlimit_enabled(config: &ProxyConfig) -> bool {
|
||||||
|
config
|
||||||
|
.server
|
||||||
|
.listeners
|
||||||
|
.iter()
|
||||||
|
.any(|listener| listener.synlimit != SynLimitMode::Off)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether an endpoint-only change can use coordinated process rebind.
|
||||||
|
pub(crate) fn listener_rebind_supported(old: &ProxyConfig, desired: &ProxyConfig) -> bool {
|
||||||
|
if any_synlimit_enabled(old) || any_synlimit_enabled(desired) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(old_plan) = listener_bind_plan(old) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(desired_plan) = listener_bind_plan(desired) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let retained: BTreeSet<_> = old_plan
|
||||||
|
.keys()
|
||||||
|
.filter(|addr| desired_plan.contains_key(addr))
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
retained
|
||||||
|
.iter()
|
||||||
|
.all(|addr| old_plan.get(addr) == desired_plan.get(addr))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::ListenerConfig;
|
||||||
|
|
||||||
|
fn listener(ip: &str, port: u16) -> ListenerConfig {
|
||||||
|
ListenerConfig {
|
||||||
|
ip: ip.parse().unwrap(),
|
||||||
|
port: Some(port),
|
||||||
|
client_mss: None,
|
||||||
|
synlimit: SynLimitMode::Off,
|
||||||
|
synlimit_seconds: 60,
|
||||||
|
synlimit_hitcount: 48,
|
||||||
|
synlimit_burst: 24,
|
||||||
|
synlimit_ios_seconds: 1,
|
||||||
|
synlimit_ios_hitcount: 12,
|
||||||
|
synlimit_ios_burst: 24,
|
||||||
|
synlimit_hashlimit_expire_ms: 60_000,
|
||||||
|
synlimit_hashlimit_size: 32_768,
|
||||||
|
announce: None,
|
||||||
|
announce_ip: None,
|
||||||
|
proxy_protocol: None,
|
||||||
|
reuse_allow: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_depends_on_inbound_family_policy_only() {
|
||||||
|
let mut config = ProxyConfig::default();
|
||||||
|
config.server.listeners = vec![listener("0.0.0.0", 443), listener("::", 443)];
|
||||||
|
config.network.ipv4 = true;
|
||||||
|
config.network.ipv6 = None;
|
||||||
|
|
||||||
|
let plan = listener_bind_plan(&config).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(plan.len(), 2);
|
||||||
|
config.network.ipv6 = Some(false);
|
||||||
|
let plan = listener_bind_plan(&config).unwrap();
|
||||||
|
assert_eq!(plan.len(), 1);
|
||||||
|
assert!(plan.keys().all(SocketAddr::is_ipv4));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_effective_endpoint_is_rejected() {
|
||||||
|
let mut config = ProxyConfig::default();
|
||||||
|
config.server.listeners = vec![listener("127.0.0.1", 443), listener("127.0.0.1", 443)];
|
||||||
|
|
||||||
|
assert!(listener_bind_plan(&config).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retained_policy_change_is_not_rebindable() {
|
||||||
|
let mut old = ProxyConfig::default();
|
||||||
|
old.server.listeners = vec![listener("127.0.0.1", 443)];
|
||||||
|
let mut desired = old.clone();
|
||||||
|
desired.server.listeners[0].proxy_protocol = Some(true);
|
||||||
|
|
||||||
|
assert!(!listener_rebind_supported(&old, &desired));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn endpoint_move_without_synlimit_is_rebindable() {
|
||||||
|
let mut old = ProxyConfig::default();
|
||||||
|
old.server.listeners = vec![listener("127.0.0.1", 443)];
|
||||||
|
let mut desired = old.clone();
|
||||||
|
desired.server.listeners[0].port = Some(444);
|
||||||
|
|
||||||
|
assert!(listener_rebind_supported(&old, &desired));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,59 +5,69 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
use tokio::net::UnixListener;
|
use tokio::net::UnixListener;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::{debug, error};
|
use tracing::{debug, error};
|
||||||
|
|
||||||
use super::RuntimeGeneration;
|
use crate::maestro::generation::RuntimeGeneration;
|
||||||
|
|
||||||
pub(crate) fn spawn_unix_accept_loop(
|
pub(super) struct UnixAcceptHandle {
|
||||||
listener: Option<UnixListener>,
|
_listener: Arc<UnixListener>,
|
||||||
|
cancellation: CancellationToken,
|
||||||
|
task: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_unix_accept_loop(
|
||||||
|
listener: Arc<UnixListener>,
|
||||||
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||||
|
cancellation: CancellationToken,
|
||||||
) {
|
) {
|
||||||
let Some(listener) = listener else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let connection_counter = AtomicU64::new(1);
|
let connection_counter = AtomicU64::new(1);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match listener.accept().await {
|
let accepted = tokio::select! {
|
||||||
|
biased;
|
||||||
|
_ = cancellation.cancelled() => return,
|
||||||
|
accepted = listener.accept() => accepted,
|
||||||
|
};
|
||||||
|
match accepted {
|
||||||
Ok((stream, _)) => {
|
Ok((stream, _)) => {
|
||||||
let runtime = active_runtime.load_full();
|
let runtime = active_runtime.load_full();
|
||||||
if !*runtime.admission_rx.borrow() {
|
if !*runtime.admission_rx.borrow() {
|
||||||
drop(stream);
|
drop(stream);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = runtime.config();
|
let config = runtime.config();
|
||||||
let timeout_ms = config.server.accept_permit_timeout_ms;
|
let timeout_ms = config.server.accept_permit_timeout_ms;
|
||||||
|
let acquire = runtime.max_connections.clone().acquire_owned();
|
||||||
let permit = if timeout_ms == 0 {
|
let permit = if timeout_ms == 0 {
|
||||||
match runtime.max_connections.clone().acquire_owned().await {
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
_ = cancellation.cancelled() => return,
|
||||||
|
permit = acquire => match permit {
|
||||||
Ok(permit) => permit,
|
Ok(permit) => permit,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
error!("Connection limiter is closed");
|
error!("Connection limiter is closed");
|
||||||
break;
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match tokio::time::timeout(
|
match tokio::select! {
|
||||||
Duration::from_millis(timeout_ms),
|
biased;
|
||||||
runtime.max_connections.clone().acquire_owned(),
|
_ = cancellation.cancelled() => return,
|
||||||
)
|
result = tokio::time::timeout(Duration::from_millis(timeout_ms), acquire) => result,
|
||||||
.await
|
} {
|
||||||
{
|
|
||||||
Ok(Ok(permit)) => permit,
|
Ok(Ok(permit)) => permit,
|
||||||
Ok(Err(_)) => {
|
Ok(Err(_)) => {
|
||||||
error!("Connection limiter is closed");
|
error!("Connection limiter is closed");
|
||||||
break;
|
return;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
runtime.stats.increment_accept_permit_timeout_total();
|
runtime.stats.increment_accept_permit_timeout_total();
|
||||||
debug!(
|
debug!(
|
||||||
timeout_ms,
|
timeout_ms,
|
||||||
"Dropping accepted unix connection: permit wait timeout"
|
"Dropping accepted Unix connection: permit wait timeout"
|
||||||
);
|
);
|
||||||
drop(stream);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,7 +92,7 @@ pub(crate) fn spawn_unix_accept_loop(
|
|||||||
|
|
||||||
let _ = runtime.spawn_session(async move {
|
let _ = runtime.spawn_session(async move {
|
||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
if let Err(error) =
|
if let Err(error_value) =
|
||||||
crate::proxy::client::handle_client_stream_with_shared_and_pool_runtime(
|
crate::proxy::client::handle_client_stream_with_shared_and_pool_runtime(
|
||||||
stream,
|
stream,
|
||||||
fake_peer,
|
fake_peer,
|
||||||
@@ -103,15 +113,47 @@ pub(crate) fn spawn_unix_accept_loop(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
debug!(error = %error, "Unix socket connection error");
|
debug!(error = %error_value, "Unix socket connection error");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error_value) => {
|
||||||
error!(error = %error, "Unix socket accept error");
|
error!(error = %error_value, "Unix socket accept error");
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
_ = cancellation.cancelled() => return,
|
||||||
|
_ = tokio::time::sleep(Duration::from_millis(100)) => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
impl UnixAcceptHandle {
|
||||||
|
pub(super) fn start(
|
||||||
|
listener: UnixListener,
|
||||||
|
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||||
|
) -> Self {
|
||||||
|
let listener = Arc::new(listener);
|
||||||
|
let cancellation = CancellationToken::new();
|
||||||
|
let task = tokio::spawn(run_unix_accept_loop(
|
||||||
|
listener.clone(),
|
||||||
|
active_runtime,
|
||||||
|
cancellation.clone(),
|
||||||
|
));
|
||||||
|
Self {
|
||||||
|
_listener: listener,
|
||||||
|
cancellation,
|
||||||
|
task: Some(task),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn stop(&mut self) -> Result<(), String> {
|
||||||
|
self.cancellation.cancel();
|
||||||
|
if let Some(task) = self.task.take() {
|
||||||
|
task.await
|
||||||
|
.map_err(|error_value| format!("Unix listener task failed: {error_value}"))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-979
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,342 @@
|
|||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
|
use tokio::sync::{RwLock, watch};
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
use crate::api;
|
||||||
|
use crate::ip_tracker::UserIpTracker;
|
||||||
|
use crate::network::probe::{decide_network_capabilities, log_probe_result, run_probe};
|
||||||
|
use crate::proxy::direct_buffer_budget::{
|
||||||
|
DirectBufferBudget, resolve_direct_buffer_hard_limit,
|
||||||
|
};
|
||||||
|
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
|
||||||
|
use crate::proxy::shared_state::ProxySharedState;
|
||||||
|
use crate::startup::{COMPONENT_API_BOOTSTRAP, COMPONENT_NETWORK_PROBE};
|
||||||
|
use crate::stats::telemetry::TelemetryPolicy;
|
||||||
|
use crate::stats::{QuotaStore, Stats};
|
||||||
|
use crate::synlimit_control;
|
||||||
|
use crate::transport::UpstreamManager;
|
||||||
|
use crate::transport::middle_proxy::MePool;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
bootstrap, generation, listeners, reload, reload_supervisor, runtime_startup, runtime_tasks,
|
||||||
|
shutdown, tls_bootstrap,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Shared maestro startup and main loop. `drop_after_bind` runs on Unix after listeners are bound
|
||||||
|
// and privileged firewall setup completes; it is a no-op on other platforms.
|
||||||
|
pub(super) async fn run_telemt_core(
|
||||||
|
privilege_drop_requested: bool,
|
||||||
|
drop_after_bind: impl FnOnce(),
|
||||||
|
) -> std::result::Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let bootstrap::BootstrapState {
|
||||||
|
process_started_at,
|
||||||
|
process_started_at_epoch_secs,
|
||||||
|
startup_tracker,
|
||||||
|
config,
|
||||||
|
config_path,
|
||||||
|
has_rust_log,
|
||||||
|
effective_log_level,
|
||||||
|
runtime_log_filter,
|
||||||
|
logging_guard: _logging_guard,
|
||||||
|
} = bootstrap::bootstrap(privilege_drop_requested).await?;
|
||||||
|
|
||||||
|
let quota_store = Arc::new(QuotaStore::default());
|
||||||
|
let stats = Arc::new(Stats::with_quota_store(quota_store.clone()));
|
||||||
|
let runtime_task_scope = generation::RuntimeTaskScope::new();
|
||||||
|
stats.apply_telemetry_policy(TelemetryPolicy::from_config(&config.general.telemetry));
|
||||||
|
let quota_state_path = config.general.quota_state_path.clone();
|
||||||
|
crate::quota_state::load_quota_state("a_state_path, stats.as_ref()).await;
|
||||||
|
|
||||||
|
let upstream_manager = Arc::new(
|
||||||
|
UpstreamManager::new(
|
||||||
|
config.upstreams.clone(),
|
||||||
|
config.general.upstream_connect_retry_attempts,
|
||||||
|
config.general.upstream_connect_retry_backoff_ms,
|
||||||
|
config.general.upstream_connect_budget_ms,
|
||||||
|
config.general.tg_connect,
|
||||||
|
config.general.upstream_unhealthy_fail_threshold,
|
||||||
|
config.general.upstream_connect_failfast_hard_errors,
|
||||||
|
stats.clone(),
|
||||||
|
)
|
||||||
|
.with_dns_overrides(&config.network.dns_overrides)?,
|
||||||
|
);
|
||||||
|
let ip_tracker = Arc::new(UserIpTracker::new());
|
||||||
|
ip_tracker
|
||||||
|
.load_limits(
|
||||||
|
config.access.user_max_unique_ips_global_each,
|
||||||
|
&config.access.user_max_unique_ips,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
ip_tracker
|
||||||
|
.set_limit_policy(
|
||||||
|
config.access.user_max_unique_ips_mode,
|
||||||
|
config.access.user_max_unique_ips_window_secs,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if config.access.user_max_unique_ips_global_each > 0
|
||||||
|
|| !config.access.user_max_unique_ips.is_empty()
|
||||||
|
{
|
||||||
|
info!(
|
||||||
|
global_each_limit = config.access.user_max_unique_ips_global_each,
|
||||||
|
explicit_user_limits = config.access.user_max_unique_ips.len(),
|
||||||
|
"User unique IP limits configured"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !config.network.dns_overrides.is_empty() {
|
||||||
|
info!(
|
||||||
|
"Runtime DNS overrides configured: {} entries",
|
||||||
|
config.network.dns_overrides.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let direct_buffer_hard_limit =
|
||||||
|
resolve_direct_buffer_hard_limit(config.general.direct_relay_buffer_budget_max_bytes).await;
|
||||||
|
let direct_buffer_budget = DirectBufferBudget::new(direct_buffer_hard_limit);
|
||||||
|
info!(
|
||||||
|
hard_limit_bytes = direct_buffer_hard_limit,
|
||||||
|
configured_override_bytes = config.general.direct_relay_buffer_budget_max_bytes,
|
||||||
|
"Direct relay buffer budget initialized"
|
||||||
|
);
|
||||||
|
let shared_state =
|
||||||
|
ProxySharedState::new_with_direct_buffer_budget(direct_buffer_budget.clone());
|
||||||
|
shared_state.apply_user_enabled_config(&config.access.user_enabled);
|
||||||
|
shared_state.traffic_limiter.apply_policy(
|
||||||
|
config.access.user_rate_limits.clone(),
|
||||||
|
config.access.cidr_rate_limits.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (detected_ips_tx, detected_ips_rx) = watch::channel((None::<IpAddr>, None::<IpAddr>));
|
||||||
|
let initial_direct_first = config.general.use_middle_proxy && config.general.me2dc_fallback;
|
||||||
|
let initial_admission_open = !config.general.use_middle_proxy || initial_direct_first;
|
||||||
|
let (admission_tx, admission_rx) = watch::channel(initial_admission_open);
|
||||||
|
let (reload_control, reload_commands) = reload::ReloadControl::channel(1);
|
||||||
|
let (active_runtime_tx, active_runtime_rx) =
|
||||||
|
watch::channel(None::<Arc<ArcSwap<generation::RuntimeGeneration>>>);
|
||||||
|
let (runtime_watch_tx, runtime_watch_rx) =
|
||||||
|
watch::channel(None::<generation::RuntimeWatchState>);
|
||||||
|
let initial_route_mode = if !config.general.use_middle_proxy || initial_direct_first {
|
||||||
|
RelayRouteMode::Direct
|
||||||
|
} else {
|
||||||
|
RelayRouteMode::Middle
|
||||||
|
};
|
||||||
|
let route_runtime = Arc::new(RouteRuntimeController::new(initial_route_mode));
|
||||||
|
let api_me_pool = Arc::new(RwLock::new(None::<Arc<MePool>>));
|
||||||
|
startup_tracker
|
||||||
|
.start_component(
|
||||||
|
COMPONENT_API_BOOTSTRAP,
|
||||||
|
Some("spawn API listener task".to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if config.server.api.enabled {
|
||||||
|
let listen = match config.server.api.listen.parse::<SocketAddr>() {
|
||||||
|
Ok(listen) => listen,
|
||||||
|
Err(error) => {
|
||||||
|
warn!(
|
||||||
|
error = %error,
|
||||||
|
listen = %config.server.api.listen,
|
||||||
|
"Invalid server.api.listen; API is disabled"
|
||||||
|
);
|
||||||
|
SocketAddr::from(([127, 0, 0, 1], 0))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if listen.port() != 0 {
|
||||||
|
let stats_api = stats.clone();
|
||||||
|
let ip_tracker_api = ip_tracker.clone();
|
||||||
|
let me_pool_api = api_me_pool.clone();
|
||||||
|
let upstream_manager_api = upstream_manager.clone();
|
||||||
|
let route_runtime_api = route_runtime.clone();
|
||||||
|
let proxy_shared_api = shared_state.clone();
|
||||||
|
let config_path_api = config_path.clone();
|
||||||
|
let quota_state_path_api = quota_state_path.clone();
|
||||||
|
let startup_tracker_api = startup_tracker.clone();
|
||||||
|
let detected_ips_rx_api = detected_ips_rx.clone();
|
||||||
|
let reload_control_api = reload_control.clone();
|
||||||
|
let active_runtime_rx_api = active_runtime_rx.clone();
|
||||||
|
let runtime_watch_rx_api = runtime_watch_rx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
api::serve(
|
||||||
|
listen,
|
||||||
|
stats_api,
|
||||||
|
ip_tracker_api,
|
||||||
|
me_pool_api,
|
||||||
|
route_runtime_api,
|
||||||
|
proxy_shared_api,
|
||||||
|
upstream_manager_api,
|
||||||
|
config_path_api,
|
||||||
|
quota_state_path_api,
|
||||||
|
detected_ips_rx_api,
|
||||||
|
process_started_at_epoch_secs,
|
||||||
|
startup_tracker_api,
|
||||||
|
reload_control_api,
|
||||||
|
active_runtime_rx_api,
|
||||||
|
runtime_watch_rx_api,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
startup_tracker
|
||||||
|
.complete_component(
|
||||||
|
COMPONENT_API_BOOTSTRAP,
|
||||||
|
Some(format!("api task spawned on {}", listen)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
} else {
|
||||||
|
startup_tracker
|
||||||
|
.skip_component(
|
||||||
|
COMPONENT_API_BOOTSTRAP,
|
||||||
|
Some("server.api.listen has zero port".to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
startup_tracker
|
||||||
|
.skip_component(
|
||||||
|
COMPONENT_API_BOOTSTRAP,
|
||||||
|
Some("server.api.enabled is false".to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tls_domains = Vec::with_capacity(1 + config.censorship.tls_domains.len());
|
||||||
|
tls_domains.push(config.censorship.tls_domain.clone());
|
||||||
|
for domain in &config.censorship.tls_domains {
|
||||||
|
if !tls_domains.contains(domain) {
|
||||||
|
tls_domains.push(domain.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tls_cache = tls_bootstrap::bootstrap_tls_front(
|
||||||
|
&config,
|
||||||
|
&tls_domains,
|
||||||
|
upstream_manager.clone(),
|
||||||
|
&startup_tracker,
|
||||||
|
runtime_task_scope.clone(),
|
||||||
|
tls_bootstrap::TlsBootstrapPolicy::BestEffort,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
startup_tracker
|
||||||
|
.start_component(
|
||||||
|
COMPONENT_NETWORK_PROBE,
|
||||||
|
Some("probe network capabilities".to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let probe = run_probe(
|
||||||
|
&config.network,
|
||||||
|
&config.upstreams,
|
||||||
|
config.general.middle_proxy_nat_probe,
|
||||||
|
config.general.stun_nat_probe_concurrency,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
detected_ips_tx.send_replace((
|
||||||
|
probe.detected_ipv4.map(IpAddr::V4),
|
||||||
|
probe.detected_ipv6.map(IpAddr::V6),
|
||||||
|
));
|
||||||
|
let decision =
|
||||||
|
decide_network_capabilities(&config.network, &probe, config.general.middle_proxy_nat_ip);
|
||||||
|
log_probe_result(&probe, &decision);
|
||||||
|
startup_tracker
|
||||||
|
.complete_component(
|
||||||
|
COMPONENT_NETWORK_PROBE,
|
||||||
|
Some("network capabilities determined".to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let runtime = runtime_startup::prepare_runtime(
|
||||||
|
config,
|
||||||
|
&config_path,
|
||||||
|
&probe,
|
||||||
|
&decision,
|
||||||
|
process_started_at,
|
||||||
|
&startup_tracker,
|
||||||
|
stats.clone(),
|
||||||
|
upstream_manager.clone(),
|
||||||
|
ip_tracker.clone(),
|
||||||
|
shared_state.clone(),
|
||||||
|
direct_buffer_budget,
|
||||||
|
route_runtime.clone(),
|
||||||
|
api_me_pool.clone(),
|
||||||
|
runtime_task_scope.clone(),
|
||||||
|
admission_tx,
|
||||||
|
&runtime_log_filter,
|
||||||
|
has_rust_log,
|
||||||
|
&effective_log_level,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let _admission_tx_hold = runtime.admission_tx;
|
||||||
|
|
||||||
|
let runtime_generation = generation::RuntimeGeneration::new(
|
||||||
|
1,
|
||||||
|
runtime.config_rx.clone(),
|
||||||
|
admission_rx,
|
||||||
|
stats.clone(),
|
||||||
|
upstream_manager.clone(),
|
||||||
|
runtime.replay_checker,
|
||||||
|
runtime.buffer_pool,
|
||||||
|
runtime.rng,
|
||||||
|
runtime.me_pool,
|
||||||
|
api_me_pool,
|
||||||
|
route_runtime,
|
||||||
|
tls_cache,
|
||||||
|
ip_tracker,
|
||||||
|
runtime.beobachten,
|
||||||
|
shared_state,
|
||||||
|
runtime.max_connections,
|
||||||
|
runtime_task_scope,
|
||||||
|
);
|
||||||
|
let active_runtime = Arc::new(ArcSwap::from(runtime_generation));
|
||||||
|
let bound = listeners::bind_listeners(
|
||||||
|
&runtime.config,
|
||||||
|
runtime.detected_ip_v4,
|
||||||
|
runtime.detected_ip_v6,
|
||||||
|
&startup_tracker,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if bound.is_empty() {
|
||||||
|
error!("No listeners. Exiting.");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
synlimit_control::reconcile_synlimit_rules(&runtime.config)
|
||||||
|
.await
|
||||||
|
.map_err(std::io::Error::other)?;
|
||||||
|
|
||||||
|
drop_after_bind();
|
||||||
|
|
||||||
|
runtime_tasks::spawn_metrics_if_configured(
|
||||||
|
&runtime.config,
|
||||||
|
&startup_tracker,
|
||||||
|
active_runtime.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
runtime_watch_tx.send_replace(Some(active_runtime.load_full().watch_state()));
|
||||||
|
active_runtime_tx.send_replace(Some(active_runtime.clone()));
|
||||||
|
runtime_tasks::mark_runtime_ready(&startup_tracker).await;
|
||||||
|
|
||||||
|
let listener_manager = listeners::ListenerManager::start(bound, active_runtime.clone());
|
||||||
|
let reload_supervisor = reload_supervisor::ReloadSupervisor::spawn(
|
||||||
|
active_runtime.clone(),
|
||||||
|
reload_control,
|
||||||
|
reload_commands,
|
||||||
|
config_path,
|
||||||
|
quota_store,
|
||||||
|
detected_ips_tx,
|
||||||
|
runtime_log_filter,
|
||||||
|
runtime_watch_tx,
|
||||||
|
listener_manager,
|
||||||
|
);
|
||||||
|
|
||||||
|
shutdown::spawn_signal_handlers(active_runtime.clone(), process_started_at);
|
||||||
|
shutdown::wait_for_shutdown(
|
||||||
|
process_started_at,
|
||||||
|
active_runtime,
|
||||||
|
quota_state_path,
|
||||||
|
reload_supervisor,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -4,12 +4,14 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::stats::QuotaStore;
|
use crate::stats::QuotaStore;
|
||||||
|
|
||||||
use super::generation::{RuntimeGeneration, RuntimeWatchState};
|
use super::generation::{RuntimeGeneration, RuntimeWatchState};
|
||||||
|
use super::listeners::{ListenerManager, PreparedListenerTransition};
|
||||||
use super::reload::{
|
use super::reload::{
|
||||||
ReloadCommand, ReloadCommandReceiver, ReloadControl, ReloadFailurePolicy, ReloadMode,
|
ReloadCommand, ReloadCommandReceiver, ReloadControl, ReloadFailurePolicy, ReloadMode,
|
||||||
ReloadPhase,
|
ReloadPhase,
|
||||||
@@ -26,6 +28,7 @@ pub(crate) struct ReloadSupervisor {
|
|||||||
detected_ips_tx: watch::Sender<(Option<std::net::IpAddr>, Option<std::net::IpAddr>)>,
|
detected_ips_tx: watch::Sender<(Option<std::net::IpAddr>, Option<std::net::IpAddr>)>,
|
||||||
runtime_log_filter: RuntimeLogFilter,
|
runtime_log_filter: RuntimeLogFilter,
|
||||||
runtime_watch_tx: watch::Sender<Option<RuntimeWatchState>>,
|
runtime_watch_tx: watch::Sender<Option<RuntimeWatchState>>,
|
||||||
|
listener_manager: Arc<Mutex<ListenerManager>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process-owned handle that quiesces reloads before shutdown snapshots the runtime.
|
/// Process-owned handle that quiesces reloads before shutdown snapshots the runtime.
|
||||||
@@ -33,16 +36,18 @@ pub(crate) struct ReloadSupervisorHandle {
|
|||||||
control: ReloadControl,
|
control: ReloadControl,
|
||||||
shutdown: CancellationToken,
|
shutdown: CancellationToken,
|
||||||
join: tokio::task::JoinHandle<()>,
|
join: tokio::task::JoinHandle<()>,
|
||||||
|
listener_manager: Arc<Mutex<ListenerManager>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReloadSupervisorHandle {
|
impl ReloadSupervisorHandle {
|
||||||
/// Stops new submissions and waits for the accepted reload to finish.
|
/// Stops new submissions and waits for the accepted reload to finish.
|
||||||
pub(crate) async fn quiesce(self) {
|
pub(crate) async fn quiesce(self) -> Arc<Mutex<ListenerManager>> {
|
||||||
self.control.begin_shutdown().await;
|
self.control.begin_shutdown().await;
|
||||||
self.shutdown.cancel();
|
self.shutdown.cancel();
|
||||||
if let Err(error) = self.join.await {
|
if let Err(error) = self.join.await {
|
||||||
warn!(error = %error, "Reload supervisor failed while quiescing");
|
warn!(error = %error, "Reload supervisor failed while quiescing");
|
||||||
}
|
}
|
||||||
|
self.listener_manager
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +104,9 @@ impl ReloadSupervisor {
|
|||||||
detected_ips_tx: watch::Sender<(Option<std::net::IpAddr>, Option<std::net::IpAddr>)>,
|
detected_ips_tx: watch::Sender<(Option<std::net::IpAddr>, Option<std::net::IpAddr>)>,
|
||||||
runtime_log_filter: RuntimeLogFilter,
|
runtime_log_filter: RuntimeLogFilter,
|
||||||
runtime_watch_tx: watch::Sender<Option<RuntimeWatchState>>,
|
runtime_watch_tx: watch::Sender<Option<RuntimeWatchState>>,
|
||||||
|
listener_manager: ListenerManager,
|
||||||
) -> ReloadSupervisorHandle {
|
) -> ReloadSupervisorHandle {
|
||||||
|
let listener_manager = Arc::new(Mutex::new(listener_manager));
|
||||||
let supervisor = Self {
|
let supervisor = Self {
|
||||||
active_runtime,
|
active_runtime,
|
||||||
control,
|
control,
|
||||||
@@ -109,6 +116,7 @@ impl ReloadSupervisor {
|
|||||||
detected_ips_tx,
|
detected_ips_tx,
|
||||||
runtime_log_filter,
|
runtime_log_filter,
|
||||||
runtime_watch_tx,
|
runtime_watch_tx,
|
||||||
|
listener_manager: listener_manager.clone(),
|
||||||
};
|
};
|
||||||
let control = supervisor.control.clone();
|
let control = supervisor.control.clone();
|
||||||
let shutdown = CancellationToken::new();
|
let shutdown = CancellationToken::new();
|
||||||
@@ -117,6 +125,7 @@ impl ReloadSupervisor {
|
|||||||
control,
|
control,
|
||||||
shutdown,
|
shutdown,
|
||||||
join,
|
join,
|
||||||
|
listener_manager,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,18 +180,41 @@ impl ReloadSupervisor {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let listener_transition = match self
|
||||||
|
.listener_manager
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.prepare_transition(prepared.generation.config().as_ref())
|
||||||
|
{
|
||||||
|
Ok(transition) => transition,
|
||||||
|
Err(error) => {
|
||||||
|
let _ = cleanup_candidate(&prepared.generation).await;
|
||||||
|
self.runtime_log_filter
|
||||||
|
.apply_reload(&old_runtime.config().general.log_level);
|
||||||
|
self.control.fail(command.reload_id, error).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
let revision_action = revision_gate_action(
|
let revision_action = revision_gate_action(
|
||||||
&command.config_revision,
|
&command.config_revision,
|
||||||
crate::api::config_store::current_revision_for_maestro(&self.config_path).await,
|
crate::api::config_store::current_revision_for_maestro(&self.config_path).await,
|
||||||
command.request.failure_policy,
|
command.request.failure_policy,
|
||||||
);
|
);
|
||||||
self.activate_prepared(command, old_runtime, prepared, revision_action, |entries| {
|
self.activate_prepared_with_transition(
|
||||||
|
command,
|
||||||
|
old_runtime,
|
||||||
|
prepared,
|
||||||
|
listener_transition,
|
||||||
|
revision_action,
|
||||||
|
|entries| {
|
||||||
crate::network::dns_overrides::install_entries(entries)
|
crate::network::dns_overrides::install_entries(entries)
|
||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
async fn activate_prepared<InstallDns>(
|
async fn activate_prepared<InstallDns>(
|
||||||
&self,
|
&self,
|
||||||
command: ReloadCommand,
|
command: ReloadCommand,
|
||||||
@@ -192,6 +224,41 @@ impl ReloadSupervisor {
|
|||||||
install_dns: InstallDns,
|
install_dns: InstallDns,
|
||||||
) where
|
) where
|
||||||
InstallDns: FnOnce(&[String]) -> Result<(), String>,
|
InstallDns: FnOnce(&[String]) -> Result<(), String>,
|
||||||
|
{
|
||||||
|
let listener_transition = match self
|
||||||
|
.listener_manager
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.prepare_transition(prepared.generation.config().as_ref())
|
||||||
|
{
|
||||||
|
Ok(transition) => transition,
|
||||||
|
Err(error) => {
|
||||||
|
let _ = cleanup_candidate(&prepared.generation).await;
|
||||||
|
self.control.fail(command.reload_id, error).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.activate_prepared_with_transition(
|
||||||
|
command,
|
||||||
|
old_runtime,
|
||||||
|
prepared,
|
||||||
|
listener_transition,
|
||||||
|
revision_action,
|
||||||
|
install_dns,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn activate_prepared_with_transition<InstallDns>(
|
||||||
|
&self,
|
||||||
|
command: ReloadCommand,
|
||||||
|
old_runtime: Arc<RuntimeGeneration>,
|
||||||
|
prepared: PreparedRuntime,
|
||||||
|
listener_transition: Option<PreparedListenerTransition>,
|
||||||
|
revision_action: RevisionGateAction,
|
||||||
|
install_dns: InstallDns,
|
||||||
|
) where
|
||||||
|
InstallDns: FnOnce(&[String]) -> Result<(), String>,
|
||||||
{
|
{
|
||||||
match revision_action {
|
match revision_action {
|
||||||
RevisionGateAction::Proceed => {}
|
RevisionGateAction::Proceed => {}
|
||||||
@@ -211,7 +278,6 @@ impl ReloadSupervisor {
|
|||||||
.mark_phase(command.reload_id, ReloadPhase::Activating)
|
.mark_phase(command.reload_id, ReloadPhase::Activating)
|
||||||
.await;
|
.await;
|
||||||
let new_runtime = prepared.generation;
|
let new_runtime = prepared.generation;
|
||||||
old_runtime.stop_accepting_sessions();
|
|
||||||
if let Err(error) = install_dns(&new_runtime.config().network.dns_overrides) {
|
if let Err(error) = install_dns(&new_runtime.config().network.dns_overrides) {
|
||||||
let message = format!("runtime DNS activation failed: {}", error);
|
let message = format!("runtime DNS activation failed: {}", error);
|
||||||
if command.request.failure_policy == ReloadFailurePolicy::Rollback {
|
if command.request.failure_policy == ReloadFailurePolicy::Rollback {
|
||||||
@@ -224,7 +290,34 @@ impl ReloadSupervisor {
|
|||||||
}
|
}
|
||||||
self.control.add_warning(command.reload_id, message).await;
|
self.control.add_warning(command.reload_id, message).await;
|
||||||
}
|
}
|
||||||
|
let pending_listener_transition = if let Some(listener_transition) = listener_transition {
|
||||||
|
match self
|
||||||
|
.listener_manager
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.begin_transition(listener_transition)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(pending) => Some(pending),
|
||||||
|
Err(error) => {
|
||||||
|
let _ = cleanup_candidate(&new_runtime).await;
|
||||||
|
self.runtime_log_filter
|
||||||
|
.apply_reload(&old_runtime.config().general.log_level);
|
||||||
|
self.control.fail(command.reload_id, error).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
old_runtime.stop_accepting_sessions();
|
||||||
let replaced = self.active_runtime.swap(new_runtime.clone());
|
let replaced = self.active_runtime.swap(new_runtime.clone());
|
||||||
|
if let Some(pending) = pending_listener_transition {
|
||||||
|
self.listener_manager
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.finish_transition(pending);
|
||||||
|
}
|
||||||
self.detected_ips_tx.send_replace(prepared.detected_ips);
|
self.detected_ips_tx.send_replace(prepared.detected_ips);
|
||||||
self.runtime_log_filter
|
self.runtime_log_filter
|
||||||
.apply_reload(&new_runtime.config().general.log_level);
|
.apply_reload(&new_runtime.config().general.log_level);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ async fn fixture(request: ReloadRequest) -> ReloadFixture {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let (detected_ips_tx, _detected_ips_rx) = watch::channel((None, None));
|
let (detected_ips_tx, _detected_ips_rx) = watch::channel((None, None));
|
||||||
let (runtime_watch_tx, runtime_watch_rx) = watch::channel(Some(old_runtime.watch_state()));
|
let (runtime_watch_tx, runtime_watch_rx) = watch::channel(Some(old_runtime.watch_state()));
|
||||||
|
let listener_manager = Arc::new(Mutex::new(ListenerManager::empty(active_runtime.clone())));
|
||||||
let supervisor = Arc::new(ReloadSupervisor {
|
let supervisor = Arc::new(ReloadSupervisor {
|
||||||
active_runtime,
|
active_runtime,
|
||||||
control: control.clone(),
|
control: control.clone(),
|
||||||
@@ -42,6 +43,7 @@ async fn fixture(request: ReloadRequest) -> ReloadFixture {
|
|||||||
detected_ips_tx,
|
detected_ips_tx,
|
||||||
runtime_log_filter: runtime_log_filter(),
|
runtime_log_filter: runtime_log_filter(),
|
||||||
runtime_watch_tx,
|
runtime_watch_tx,
|
||||||
|
listener_manager,
|
||||||
});
|
});
|
||||||
let command = ReloadCommand {
|
let command = ReloadCommand {
|
||||||
reload_id: accepted.reload_id,
|
reload_id: accepted.reload_id,
|
||||||
@@ -293,6 +295,7 @@ async fn quiesce_joins_idle_supervisor_and_rejects_later_submissions() {
|
|||||||
let (control, commands) = ReloadControl::channel(runtime.id);
|
let (control, commands) = ReloadControl::channel(runtime.id);
|
||||||
let (detected_ips_tx, _detected_ips_rx) = watch::channel((None, None));
|
let (detected_ips_tx, _detected_ips_rx) = watch::channel((None, None));
|
||||||
let (runtime_watch_tx, _runtime_watch_rx) = watch::channel(Some(runtime.watch_state()));
|
let (runtime_watch_tx, _runtime_watch_rx) = watch::channel(Some(runtime.watch_state()));
|
||||||
|
let listener_manager = ListenerManager::empty(active_runtime.clone());
|
||||||
let handle = ReloadSupervisor::spawn(
|
let handle = ReloadSupervisor::spawn(
|
||||||
active_runtime,
|
active_runtime,
|
||||||
control.clone(),
|
control.clone(),
|
||||||
@@ -302,6 +305,7 @@ async fn quiesce_joins_idle_supervisor_and_rejects_later_submissions() {
|
|||||||
detected_ips_tx,
|
detected_ips_tx,
|
||||||
runtime_log_filter(),
|
runtime_log_filter(),
|
||||||
runtime_watch_tx,
|
runtime_watch_tx,
|
||||||
|
listener_manager,
|
||||||
);
|
);
|
||||||
|
|
||||||
tokio::time::timeout(Duration::from_secs(1), handle.quiesce())
|
tokio::time::timeout(Duration::from_secs(1), handle.quiesce())
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ use crate::transport::middle_proxy::MePool;
|
|||||||
|
|
||||||
use super::admission;
|
use super::admission;
|
||||||
use super::generation::{RuntimeGeneration, RuntimeTaskScope};
|
use super::generation::{RuntimeGeneration, RuntimeTaskScope};
|
||||||
|
use super::listeners::listener_rebind_supported;
|
||||||
use super::runtime_tasks::RuntimeLogFilter;
|
use super::runtime_tasks::RuntimeLogFilter;
|
||||||
use super::{me_startup, runtime_tasks, tls_bootstrap};
|
use super::{me_startup, runtime_tasks, tls_bootstrap};
|
||||||
|
|
||||||
@@ -326,18 +327,19 @@ pub(crate) fn resolve_reload_config(
|
|||||||
let mut effective = desired.clone();
|
let mut effective = desired.clone();
|
||||||
let mut fields = Vec::new();
|
let mut fields = Vec::new();
|
||||||
let listener_identity_matches = listeners_have_same_bind_identity(&old.server, &desired.server);
|
let listener_identity_matches = listeners_have_same_bind_identity(&old.server, &desired.server);
|
||||||
let listener_process_fields_changed = !listener_identity_matches
|
let global_listener_policy_changed = old.server.port != desired.server.port
|
||||||
|| !listener_process_fields_equal(&old.server, &desired.server);
|
|
||||||
if old.server.port != desired.server.port
|
|
||||||
|| old.server.listen_addr_ipv4 != desired.server.listen_addr_ipv4
|
|| old.server.listen_addr_ipv4 != desired.server.listen_addr_ipv4
|
||||||
|| old.server.listen_addr_ipv6 != desired.server.listen_addr_ipv6
|
|| old.server.listen_addr_ipv6 != desired.server.listen_addr_ipv6
|
||||||
|| old.server.listen_tcp != desired.server.listen_tcp
|
|| old.server.listen_tcp != desired.server.listen_tcp
|
||||||
|| old.server.client_mss != desired.server.client_mss
|
|| old.server.client_mss != desired.server.client_mss
|
||||||
|| old.server.client_mss_bulk != desired.server.client_mss_bulk
|
|| old.server.client_mss_bulk != desired.server.client_mss_bulk
|
||||||
|| old.server.proxy_protocol != desired.server.proxy_protocol
|
|| old.server.proxy_protocol != desired.server.proxy_protocol
|
||||||
|| old.server.listen_backlog != desired.server.listen_backlog
|
|| old.server.listen_backlog != desired.server.listen_backlog;
|
||||||
|| listener_process_fields_changed
|
let listener_policy_changed =
|
||||||
{
|
listener_identity_matches && !listener_process_fields_equal(&old.server, &desired.server);
|
||||||
|
let unsupported_identity_change = !listener_identity_matches
|
||||||
|
&& !listener_rebind_supported(old, desired);
|
||||||
|
if global_listener_policy_changed || listener_policy_changed || unsupported_identity_change {
|
||||||
fields.push("server.listeners".to_string());
|
fields.push("server.listeners".to_string());
|
||||||
effective.server.port = old.server.port;
|
effective.server.port = old.server.port;
|
||||||
effective.server.listen_addr_ipv4 = old.server.listen_addr_ipv4.clone();
|
effective.server.listen_addr_ipv4 = old.server.listen_addr_ipv4.clone();
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn test_listener(port: u16) -> crate::config::ListenerConfig {
|
||||||
|
crate::config::ListenerConfig {
|
||||||
|
ip: "127.0.0.1".parse().unwrap(),
|
||||||
|
port: Some(port),
|
||||||
|
client_mss: None,
|
||||||
|
synlimit: crate::config::SynLimitMode::Off,
|
||||||
|
synlimit_seconds: 60,
|
||||||
|
synlimit_hitcount: 48,
|
||||||
|
synlimit_burst: 24,
|
||||||
|
synlimit_ios_seconds: 1,
|
||||||
|
synlimit_ios_hitcount: 12,
|
||||||
|
synlimit_ios_burst: 24,
|
||||||
|
synlimit_hashlimit_expire_ms: 60_000,
|
||||||
|
synlimit_hashlimit_size: 32_768,
|
||||||
|
announce: None,
|
||||||
|
announce_ip: None,
|
||||||
|
proxy_protocol: None,
|
||||||
|
reuse_allow: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn process_socket_and_logging_changes_are_deferred() {
|
fn process_socket_and_logging_changes_are_deferred() {
|
||||||
let old = ProxyConfig::default();
|
let old = ProxyConfig::default();
|
||||||
@@ -129,3 +150,35 @@ fn strict_middle_proxy_requires_a_prepared_pool() {
|
|||||||
assert!(!strict_middle_proxy_unavailable(true, true, false));
|
assert!(!strict_middle_proxy_unavailable(true, true, false));
|
||||||
assert!(!strict_middle_proxy_unavailable(false, false, false));
|
assert!(!strict_middle_proxy_unavailable(false, false, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn endpoint_only_listener_move_is_runtime_rebindable() {
|
||||||
|
let mut old = ProxyConfig::default();
|
||||||
|
old.server.listeners = vec![test_listener(443)];
|
||||||
|
let mut desired = old.clone();
|
||||||
|
desired.server.listeners[0].port = Some(8443);
|
||||||
|
|
||||||
|
let resolved = resolve_reload_config(&old, &desired);
|
||||||
|
|
||||||
|
assert!(resolved.deferred_process_fields.is_empty());
|
||||||
|
assert_eq!(resolved.effective.server.listeners[0].port, Some(8443));
|
||||||
|
assert!(resolved.runtime_changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn synlimited_endpoint_move_remains_restart_only() {
|
||||||
|
let mut old = ProxyConfig::default();
|
||||||
|
old.server.listeners = vec![test_listener(443)];
|
||||||
|
old.server.listeners[0].synlimit = crate::config::SynLimitMode::Nftables;
|
||||||
|
let mut desired = old.clone();
|
||||||
|
desired.server.listeners[0].port = Some(8443);
|
||||||
|
|
||||||
|
let resolved = resolve_reload_config(&old, &desired);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolved.deferred_process_fields,
|
||||||
|
vec!["server.listeners".to_string()]
|
||||||
|
);
|
||||||
|
assert_eq!(resolved.effective.server.listeners[0].port, Some(443));
|
||||||
|
assert!(!resolved.runtime_changed);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
use std::net::IpAddr;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use tokio::sync::{RwLock, Semaphore, watch};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::config::{LogLevel, ProxyConfig};
|
||||||
|
use crate::conntrack_control;
|
||||||
|
use crate::crypto::SecureRandom;
|
||||||
|
use crate::ip_tracker::UserIpTracker;
|
||||||
|
use crate::network::probe::{NetworkDecision, NetworkProbe};
|
||||||
|
use crate::proxy::direct_buffer_budget::{
|
||||||
|
DirectBufferBudget, run_direct_buffer_budget_controller,
|
||||||
|
};
|
||||||
|
use crate::proxy::route_mode::RouteRuntimeController;
|
||||||
|
use crate::proxy::shared_state::ProxySharedState;
|
||||||
|
use crate::startup::{
|
||||||
|
COMPONENT_DC_CONNECTIVITY_PING, COMPONENT_ME_CONNECTIVITY_PING,
|
||||||
|
COMPONENT_ME_POOL_CONSTRUCT, COMPONENT_ME_POOL_INIT_STAGE1, COMPONENT_ME_PROXY_CONFIG_V4,
|
||||||
|
COMPONENT_ME_PROXY_CONFIG_V6, COMPONENT_ME_SECRET_FETCH, StartupMeStatus, StartupTracker,
|
||||||
|
};
|
||||||
|
use crate::stats::beobachten::BeobachtenStore;
|
||||||
|
use crate::stats::{ReplayChecker, Stats};
|
||||||
|
use crate::stream::BufferPool;
|
||||||
|
use crate::transport::UpstreamManager;
|
||||||
|
use crate::transport::middle_proxy::MePool;
|
||||||
|
|
||||||
|
use super::admission;
|
||||||
|
use super::generation::RuntimeTaskScope;
|
||||||
|
use super::{connectivity, me_startup, runtime_tasks};
|
||||||
|
|
||||||
|
pub(super) struct RuntimeStartupState {
|
||||||
|
pub(super) config: Arc<ProxyConfig>,
|
||||||
|
pub(super) beobachten: Arc<BeobachtenStore>,
|
||||||
|
pub(super) rng: Arc<SecureRandom>,
|
||||||
|
pub(super) max_connections: Arc<Semaphore>,
|
||||||
|
pub(super) me_pool: Option<Arc<MePool>>,
|
||||||
|
pub(super) replay_checker: Arc<ReplayChecker>,
|
||||||
|
pub(super) buffer_pool: Arc<BufferPool>,
|
||||||
|
pub(super) config_rx: watch::Receiver<Arc<ProxyConfig>>,
|
||||||
|
pub(super) detected_ip_v4: Option<IpAddr>,
|
||||||
|
pub(super) detected_ip_v6: Option<IpAddr>,
|
||||||
|
pub(super) admission_tx: watch::Sender<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) async fn prepare_runtime(
|
||||||
|
mut config: ProxyConfig,
|
||||||
|
config_path: &Path,
|
||||||
|
probe: &NetworkProbe,
|
||||||
|
decision: &NetworkDecision,
|
||||||
|
process_started_at: Instant,
|
||||||
|
startup_tracker: &Arc<StartupTracker>,
|
||||||
|
stats: Arc<Stats>,
|
||||||
|
upstream_manager: Arc<UpstreamManager>,
|
||||||
|
ip_tracker: Arc<UserIpTracker>,
|
||||||
|
shared_state: Arc<ProxySharedState>,
|
||||||
|
direct_buffer_budget: Arc<DirectBufferBudget>,
|
||||||
|
route_runtime: Arc<RouteRuntimeController>,
|
||||||
|
api_me_pool: Arc<RwLock<Option<Arc<MePool>>>>,
|
||||||
|
runtime_task_scope: RuntimeTaskScope,
|
||||||
|
admission_tx: watch::Sender<bool>,
|
||||||
|
runtime_log_filter: &runtime_tasks::RuntimeLogFilter,
|
||||||
|
has_rust_log: bool,
|
||||||
|
effective_log_level: &LogLevel,
|
||||||
|
) -> RuntimeStartupState {
|
||||||
|
let prefer_ipv6 = decision.prefer_ipv6();
|
||||||
|
let mut use_middle_proxy = config.general.use_middle_proxy;
|
||||||
|
let beobachten = Arc::new(BeobachtenStore::new());
|
||||||
|
let rng = Arc::new(SecureRandom::new());
|
||||||
|
|
||||||
|
let max_connections_limit = if config.server.max_connections == 0 {
|
||||||
|
Semaphore::MAX_PERMITS
|
||||||
|
} else {
|
||||||
|
config.server.max_connections as usize
|
||||||
|
};
|
||||||
|
let max_connections = Arc::new(Semaphore::new(max_connections_limit));
|
||||||
|
|
||||||
|
let me2dc_fallback = config.general.me2dc_fallback;
|
||||||
|
let me_init_retry_attempts = config.general.me_init_retry_attempts;
|
||||||
|
if use_middle_proxy && !decision.ipv4_me && !decision.ipv6_me {
|
||||||
|
if me2dc_fallback {
|
||||||
|
warn!(
|
||||||
|
"No usable IP family for Middle Proxy detected; Direct-DC startup fallback is active while ME init retries continue"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
"No usable IP family for Middle Proxy detected; me2dc_fallback=false, ME init retries stay active"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if use_middle_proxy {
|
||||||
|
startup_tracker
|
||||||
|
.set_me_status(StartupMeStatus::Initializing, COMPONENT_ME_SECRET_FETCH)
|
||||||
|
.await;
|
||||||
|
startup_tracker
|
||||||
|
.start_component(
|
||||||
|
COMPONENT_ME_SECRET_FETCH,
|
||||||
|
Some("fetch proxy-secret from source/cache".to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
startup_tracker
|
||||||
|
.set_me_retry_limit(if !me2dc_fallback || me_init_retry_attempts == 0 {
|
||||||
|
"unlimited".to_string()
|
||||||
|
} else {
|
||||||
|
me_init_retry_attempts.to_string()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
} else {
|
||||||
|
startup_tracker
|
||||||
|
.set_me_status(StartupMeStatus::Skipped, "skipped")
|
||||||
|
.await;
|
||||||
|
startup_tracker
|
||||||
|
.skip_component(
|
||||||
|
COMPONENT_ME_SECRET_FETCH,
|
||||||
|
Some("middle proxy mode disabled".to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
startup_tracker
|
||||||
|
.skip_component(
|
||||||
|
COMPONENT_ME_PROXY_CONFIG_V4,
|
||||||
|
Some("middle proxy mode disabled".to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
startup_tracker
|
||||||
|
.skip_component(
|
||||||
|
COMPONENT_ME_PROXY_CONFIG_V6,
|
||||||
|
Some("middle proxy mode disabled".to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
startup_tracker
|
||||||
|
.skip_component(
|
||||||
|
COMPONENT_ME_POOL_CONSTRUCT,
|
||||||
|
Some("middle proxy mode disabled".to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
startup_tracker
|
||||||
|
.skip_component(
|
||||||
|
COMPONENT_ME_POOL_INIT_STAGE1,
|
||||||
|
Some("middle proxy mode disabled".to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (me_ready_tx, me_ready_rx) = watch::channel(0_u64);
|
||||||
|
let direct_first_startup = use_middle_proxy && me2dc_fallback;
|
||||||
|
|
||||||
|
let me_pool: Option<Arc<MePool>> = if direct_first_startup {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
me_startup::initialize_me_pool(
|
||||||
|
use_middle_proxy,
|
||||||
|
&config,
|
||||||
|
decision,
|
||||||
|
probe,
|
||||||
|
startup_tracker,
|
||||||
|
upstream_manager.clone(),
|
||||||
|
rng.clone(),
|
||||||
|
stats.clone(),
|
||||||
|
api_me_pool.clone(),
|
||||||
|
me_ready_tx.clone(),
|
||||||
|
runtime_task_scope.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
};
|
||||||
|
|
||||||
|
if direct_first_startup {
|
||||||
|
startup_tracker.set_transport_mode("direct").await;
|
||||||
|
startup_tracker.set_degraded(true).await;
|
||||||
|
info!(
|
||||||
|
"Transport: Direct DC startup fallback active; Middle-End bootstrap continues in background"
|
||||||
|
);
|
||||||
|
} else if me_pool.is_some() {
|
||||||
|
startup_tracker.set_transport_mode("middle_proxy").await;
|
||||||
|
startup_tracker.set_degraded(false).await;
|
||||||
|
info!("Transport: Middle-End Proxy - all DC-over-RPC");
|
||||||
|
} else {
|
||||||
|
let _ = use_middle_proxy;
|
||||||
|
use_middle_proxy = false;
|
||||||
|
config.general.use_middle_proxy = false;
|
||||||
|
startup_tracker.set_transport_mode("direct").await;
|
||||||
|
startup_tracker.set_degraded(true).await;
|
||||||
|
if me2dc_fallback {
|
||||||
|
startup_tracker
|
||||||
|
.set_me_status(StartupMeStatus::Failed, "fallback_to_direct")
|
||||||
|
.await;
|
||||||
|
} else {
|
||||||
|
startup_tracker
|
||||||
|
.set_me_status(StartupMeStatus::Skipped, "skipped")
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
info!("Transport: Direct DC - TCP - standard DC-over-TCP");
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = Arc::new(config);
|
||||||
|
let replay_checker = Arc::new(ReplayChecker::new(
|
||||||
|
config.access.replay_check_len,
|
||||||
|
Duration::from_secs(config.access.replay_window_secs),
|
||||||
|
));
|
||||||
|
let buffer_pool = Arc::new(BufferPool::with_config(64 * 1024, 4096));
|
||||||
|
|
||||||
|
if direct_first_startup {
|
||||||
|
startup_tracker
|
||||||
|
.skip_component(
|
||||||
|
COMPONENT_ME_CONNECTIVITY_PING,
|
||||||
|
Some("deferred by direct-first startup".to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
startup_tracker
|
||||||
|
.skip_component(
|
||||||
|
COMPONENT_DC_CONNECTIVITY_PING,
|
||||||
|
Some("background health checks active".to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
} else {
|
||||||
|
connectivity::run_startup_connectivity(
|
||||||
|
&config,
|
||||||
|
&me_pool,
|
||||||
|
rng.clone(),
|
||||||
|
startup_tracker,
|
||||||
|
upstream_manager.clone(),
|
||||||
|
prefer_ipv6,
|
||||||
|
decision,
|
||||||
|
process_started_at,
|
||||||
|
api_me_pool.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtime_watches = runtime_tasks::spawn_runtime_tasks(
|
||||||
|
&config,
|
||||||
|
config_path,
|
||||||
|
probe,
|
||||||
|
prefer_ipv6,
|
||||||
|
decision.ipv4_dc,
|
||||||
|
decision.ipv6_dc,
|
||||||
|
startup_tracker,
|
||||||
|
stats.clone(),
|
||||||
|
upstream_manager.clone(),
|
||||||
|
replay_checker.clone(),
|
||||||
|
me_pool.clone(),
|
||||||
|
rng.clone(),
|
||||||
|
ip_tracker.clone(),
|
||||||
|
beobachten.clone(),
|
||||||
|
me_pool.clone(),
|
||||||
|
shared_state.clone(),
|
||||||
|
me_ready_tx.clone(),
|
||||||
|
runtime_task_scope.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let config_rx = runtime_watches.config_rx;
|
||||||
|
let log_level_rx = runtime_watches.log_level_rx;
|
||||||
|
let detected_ip_v4 = runtime_watches.detected_ip_v4;
|
||||||
|
let detected_ip_v6 = runtime_watches.detected_ip_v6;
|
||||||
|
runtime_log_filter.start(
|
||||||
|
has_rust_log,
|
||||||
|
effective_log_level,
|
||||||
|
log_level_rx,
|
||||||
|
runtime_task_scope.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if direct_first_startup {
|
||||||
|
let config_bg = config.clone();
|
||||||
|
let decision_bg = decision.clone();
|
||||||
|
let probe_bg = probe.clone();
|
||||||
|
let startup_tracker_bg = startup_tracker.clone();
|
||||||
|
let upstream_manager_bg = upstream_manager.clone();
|
||||||
|
let rng_bg = rng.clone();
|
||||||
|
let stats_bg = stats.clone();
|
||||||
|
let api_me_pool_bg = api_me_pool.clone();
|
||||||
|
let me_ready_tx_bg = me_ready_tx.clone();
|
||||||
|
let config_rx_bg = config_rx.clone();
|
||||||
|
let task_scope_bg = runtime_task_scope.clone();
|
||||||
|
runtime_task_scope.spawn(async move {
|
||||||
|
let mut bootstrap_attempt: u32 = 0;
|
||||||
|
loop {
|
||||||
|
bootstrap_attempt = bootstrap_attempt.saturating_add(1);
|
||||||
|
let pool = me_startup::initialize_me_pool(
|
||||||
|
true,
|
||||||
|
config_bg.as_ref(),
|
||||||
|
&decision_bg,
|
||||||
|
&probe_bg,
|
||||||
|
&startup_tracker_bg,
|
||||||
|
upstream_manager_bg.clone(),
|
||||||
|
rng_bg.clone(),
|
||||||
|
stats_bg.clone(),
|
||||||
|
api_me_pool_bg.clone(),
|
||||||
|
me_ready_tx_bg.clone(),
|
||||||
|
task_scope_bg.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if let Some(pool) = pool {
|
||||||
|
runtime_tasks::spawn_middle_proxy_runtime_tasks(
|
||||||
|
config_bg.as_ref(),
|
||||||
|
config_rx_bg,
|
||||||
|
pool,
|
||||||
|
rng_bg,
|
||||||
|
me_ready_tx_bg,
|
||||||
|
task_scope_bg,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if me_init_retry_attempts > 0 && bootstrap_attempt >= me_init_retry_attempts {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let startup_tracker_ready = startup_tracker.clone();
|
||||||
|
let api_me_pool_ready = api_me_pool.clone();
|
||||||
|
let mut me_ready_rx_transport = me_ready_tx.subscribe();
|
||||||
|
runtime_task_scope.spawn(async move {
|
||||||
|
if me_ready_rx_transport.changed().await.is_ok() {
|
||||||
|
if let Some(pool) = api_me_pool_ready.read().await.as_ref() {
|
||||||
|
pool.set_runtime_ready(true);
|
||||||
|
}
|
||||||
|
startup_tracker_ready
|
||||||
|
.set_transport_mode("middle_proxy")
|
||||||
|
.await;
|
||||||
|
startup_tracker_ready.set_degraded(false).await;
|
||||||
|
info!("Transport: Middle-End Proxy restored for new sessions");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
admission::configure_admission_gate(
|
||||||
|
&config,
|
||||||
|
me_pool.clone(),
|
||||||
|
api_me_pool,
|
||||||
|
route_runtime,
|
||||||
|
&admission_tx,
|
||||||
|
config_rx.clone(),
|
||||||
|
me_ready_rx,
|
||||||
|
runtime_task_scope.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let conntrack_scope = runtime_task_scope.clone();
|
||||||
|
runtime_task_scope.spawn(conntrack_control::run_conntrack_controller(
|
||||||
|
config_rx.clone(),
|
||||||
|
stats.clone(),
|
||||||
|
shared_state.clone(),
|
||||||
|
conntrack_scope.cancellation_token(),
|
||||||
|
));
|
||||||
|
runtime_task_scope.spawn(run_direct_buffer_budget_controller(
|
||||||
|
direct_buffer_budget,
|
||||||
|
buffer_pool.clone(),
|
||||||
|
stats,
|
||||||
|
shared_state,
|
||||||
|
config.server.max_connections,
|
||||||
|
));
|
||||||
|
|
||||||
|
RuntimeStartupState {
|
||||||
|
config,
|
||||||
|
beobachten,
|
||||||
|
rng,
|
||||||
|
max_connections,
|
||||||
|
me_pool,
|
||||||
|
replay_checker,
|
||||||
|
buffer_pool,
|
||||||
|
config_rx,
|
||||||
|
detected_ip_v4,
|
||||||
|
detected_ip_v6,
|
||||||
|
admission_tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,7 +95,7 @@ async fn perform_shutdown(
|
|||||||
let shutdown_started_at = Instant::now();
|
let shutdown_started_at = Instant::now();
|
||||||
info!(signal = %signal, "Received shutdown signal");
|
info!(signal = %signal, "Received shutdown signal");
|
||||||
|
|
||||||
reload_supervisor.quiesce().await;
|
let listener_manager = reload_supervisor.quiesce().await;
|
||||||
let runtime = active_runtime.load_full();
|
let runtime = active_runtime.load_full();
|
||||||
let stats = runtime.stats.as_ref();
|
let stats = runtime.stats.as_ref();
|
||||||
|
|
||||||
@@ -108,6 +108,10 @@ async fn perform_shutdown(
|
|||||||
let uptime_secs = process_started_at.elapsed().as_secs();
|
let uptime_secs = process_started_at.elapsed().as_secs();
|
||||||
info!("Uptime: {}", format_uptime(uptime_secs));
|
info!("Uptime: {}", format_uptime(uptime_secs));
|
||||||
|
|
||||||
|
if let Err(error) = listener_manager.lock().await.shutdown().await {
|
||||||
|
warn!(error = %error, "Failed to stop one or more listener tasks cleanly");
|
||||||
|
}
|
||||||
|
|
||||||
// Graceful ME pool shutdown
|
// Graceful ME pool shutdown
|
||||||
runtime.stop_sessions().await;
|
runtime.stop_sessions().await;
|
||||||
runtime.stop_background_tasks().await;
|
runtime.stop_background_tasks().await;
|
||||||
|
|||||||
+55
-2011
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,292 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) struct MtprotoCandidateValidation {
|
||||||
|
pub(super) proto_tag: ProtoTag,
|
||||||
|
pub(super) dc_idx: i16,
|
||||||
|
pub(super) dec_key: [u8; 32],
|
||||||
|
pub(super) dec_iv: u128,
|
||||||
|
pub(super) enc_key: [u8; 32],
|
||||||
|
pub(super) enc_iv: u128,
|
||||||
|
pub(super) decryptor: AesCtr,
|
||||||
|
pub(super) encryptor: AesCtr,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn sni_hint_hash(sni: &str) -> u64 {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
for byte in sni.bytes() {
|
||||||
|
hasher.write_u8(byte.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn ip_prefix_hint_key(peer_ip: IpAddr) -> u64 {
|
||||||
|
match peer_ip {
|
||||||
|
// Keep /24 granularity for IPv4 to avoid over-merging unrelated clients.
|
||||||
|
IpAddr::V4(ip) => {
|
||||||
|
let [a, b, c, _] = ip.octets();
|
||||||
|
u64::from_be_bytes([0x04, a, b, c, 0, 0, 0, 0])
|
||||||
|
}
|
||||||
|
// Keep /56 granularity for IPv6 to retain stability while limiting bucket size.
|
||||||
|
IpAddr::V6(ip) => {
|
||||||
|
let octets = ip.octets();
|
||||||
|
u64::from_be_bytes([
|
||||||
|
0x06, octets[0], octets[1], octets[2], octets[3], octets[4], octets[5], octets[6],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn sticky_hint_get_by_ip(shared: &ProxySharedState, peer_ip: IpAddr) -> Option<u32> {
|
||||||
|
shared
|
||||||
|
.handshake
|
||||||
|
.sticky_user_by_ip
|
||||||
|
.get(&peer_ip)
|
||||||
|
.map(|entry| *entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn sticky_hint_get_by_ip_prefix(shared: &ProxySharedState, peer_ip: IpAddr) -> Option<u32> {
|
||||||
|
shared
|
||||||
|
.handshake
|
||||||
|
.sticky_user_by_ip_prefix
|
||||||
|
.get(&ip_prefix_hint_key(peer_ip))
|
||||||
|
.map(|entry| *entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn sticky_hint_get_by_sni(shared: &ProxySharedState, sni: &str) -> Option<u32> {
|
||||||
|
let key = sni_hint_hash(sni);
|
||||||
|
shared
|
||||||
|
.handshake
|
||||||
|
.sticky_user_by_sni_hash
|
||||||
|
.get(&key)
|
||||||
|
.map(|entry| *entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn sticky_hint_record_success_in(
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
peer_ip: IpAddr,
|
||||||
|
user_id: u32,
|
||||||
|
sni: Option<&str>,
|
||||||
|
) {
|
||||||
|
if shared.handshake.sticky_user_by_ip.len() > STICKY_HINT_MAX_ENTRIES {
|
||||||
|
shared.handshake.sticky_user_by_ip.clear();
|
||||||
|
}
|
||||||
|
shared.handshake.sticky_user_by_ip.insert(peer_ip, user_id);
|
||||||
|
|
||||||
|
if shared.handshake.sticky_user_by_ip_prefix.len() > STICKY_HINT_MAX_ENTRIES {
|
||||||
|
shared.handshake.sticky_user_by_ip_prefix.clear();
|
||||||
|
}
|
||||||
|
shared
|
||||||
|
.handshake
|
||||||
|
.sticky_user_by_ip_prefix
|
||||||
|
.insert(ip_prefix_hint_key(peer_ip), user_id);
|
||||||
|
|
||||||
|
if let Some(sni) = sni {
|
||||||
|
if shared.handshake.sticky_user_by_sni_hash.len() > STICKY_HINT_MAX_ENTRIES {
|
||||||
|
shared.handshake.sticky_user_by_sni_hash.clear();
|
||||||
|
}
|
||||||
|
shared
|
||||||
|
.handshake
|
||||||
|
.sticky_user_by_sni_hash
|
||||||
|
.insert(sni_hint_hash(sni), user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn record_recent_user_success_in(shared: &ProxySharedState, user_id: u32) {
|
||||||
|
let ring = &shared.handshake.recent_user_ring;
|
||||||
|
if ring.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let seq = shared
|
||||||
|
.handshake
|
||||||
|
.recent_user_ring_seq
|
||||||
|
.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let idx = (seq as usize) % ring.len();
|
||||||
|
ring[idx].store(user_id.saturating_add(1), Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn mark_candidate_if_new(tried_user_ids: &mut [u32], tried_len: &mut usize, user_id: u32) -> bool {
|
||||||
|
if tried_user_ids[..*tried_len].contains(&user_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if *tried_len < tried_user_ids.len() {
|
||||||
|
tried_user_ids[*tried_len] = user_id;
|
||||||
|
*tried_len += 1;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn budget_for_validation(total_users: usize, overload: bool, has_hint: bool) -> usize {
|
||||||
|
if total_users == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if !overload {
|
||||||
|
return total_users;
|
||||||
|
}
|
||||||
|
let cap = if has_hint {
|
||||||
|
OVERLOAD_CANDIDATE_BUDGET_HINTED
|
||||||
|
} else {
|
||||||
|
OVERLOAD_CANDIDATE_BUDGET_UNHINTED
|
||||||
|
};
|
||||||
|
total_users.min(cap.max(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn validate_mtproto_secret_candidate(
|
||||||
|
handshake: &[u8; HANDSHAKE_LEN],
|
||||||
|
dec_prekey: &[u8; PREKEY_LEN],
|
||||||
|
dec_iv: u128,
|
||||||
|
enc_prekey: &[u8; PREKEY_LEN],
|
||||||
|
enc_iv: u128,
|
||||||
|
secret: &[u8; ACCESS_SECRET_BYTES],
|
||||||
|
config: &ProxyConfig,
|
||||||
|
is_tls: bool,
|
||||||
|
) -> Option<MtprotoCandidateValidation> {
|
||||||
|
let mut dec_key_input = Zeroizing::new(Vec::with_capacity(PREKEY_LEN + secret.len()));
|
||||||
|
dec_key_input.extend_from_slice(dec_prekey);
|
||||||
|
dec_key_input.extend_from_slice(secret);
|
||||||
|
let dec_key = Zeroizing::new(sha256(&dec_key_input));
|
||||||
|
|
||||||
|
let mut decryptor = AesCtr::new(&dec_key, dec_iv);
|
||||||
|
let mut decrypted = *handshake;
|
||||||
|
decryptor.apply(&mut decrypted);
|
||||||
|
|
||||||
|
let tag_bytes: [u8; 4] = [
|
||||||
|
decrypted[PROTO_TAG_POS],
|
||||||
|
decrypted[PROTO_TAG_POS + 1],
|
||||||
|
decrypted[PROTO_TAG_POS + 2],
|
||||||
|
decrypted[PROTO_TAG_POS + 3],
|
||||||
|
];
|
||||||
|
let proto_tag = ProtoTag::from_bytes(tag_bytes)?;
|
||||||
|
if !mode_enabled_for_proto(config, proto_tag, is_tls) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dc_idx = i16::from_le_bytes([decrypted[DC_IDX_POS], decrypted[DC_IDX_POS + 1]]);
|
||||||
|
|
||||||
|
let mut enc_key_input = Zeroizing::new(Vec::with_capacity(PREKEY_LEN + secret.len()));
|
||||||
|
enc_key_input.extend_from_slice(enc_prekey);
|
||||||
|
enc_key_input.extend_from_slice(secret);
|
||||||
|
let enc_key = Zeroizing::new(sha256(&enc_key_input));
|
||||||
|
|
||||||
|
let encryptor = AesCtr::new(&enc_key, enc_iv);
|
||||||
|
|
||||||
|
Some(MtprotoCandidateValidation {
|
||||||
|
proto_tag,
|
||||||
|
dc_idx,
|
||||||
|
dec_key: *dec_key,
|
||||||
|
dec_iv,
|
||||||
|
enc_key: *enc_key,
|
||||||
|
enc_iv,
|
||||||
|
decryptor,
|
||||||
|
encryptor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn warn_invalid_secret_once_in(
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
name: &str,
|
||||||
|
reason: &str,
|
||||||
|
expected: usize,
|
||||||
|
got: Option<usize>,
|
||||||
|
) {
|
||||||
|
let key = (name.to_string(), reason.to_string());
|
||||||
|
let should_warn = match shared.handshake.invalid_secret_warned.lock() {
|
||||||
|
Ok(mut guard) => {
|
||||||
|
if !guard.contains(&key) && guard.len() >= WARNED_SECRET_MAX_ENTRIES {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
guard.insert(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !should_warn {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match got {
|
||||||
|
Some(actual) => {
|
||||||
|
warn!(
|
||||||
|
user = %name,
|
||||||
|
expected = expected,
|
||||||
|
got = actual,
|
||||||
|
"Skipping user: access secret has unexpected length"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
warn!(
|
||||||
|
user = %name,
|
||||||
|
"Skipping user: access secret is not valid hex"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn decode_user_secret(shared: &ProxySharedState, name: &str, secret_hex: &str) -> Option<Vec<u8>> {
|
||||||
|
match hex::decode(secret_hex) {
|
||||||
|
Ok(bytes) if bytes.len() == ACCESS_SECRET_BYTES => Some(bytes),
|
||||||
|
Ok(bytes) => {
|
||||||
|
warn_invalid_secret_once_in(
|
||||||
|
shared,
|
||||||
|
name,
|
||||||
|
"invalid_length",
|
||||||
|
ACCESS_SECRET_BYTES,
|
||||||
|
Some(bytes.len()),
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
warn_invalid_secret_once_in(shared, name, "invalid_hex", ACCESS_SECRET_BYTES, None);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide whether a client-supplied proto tag is allowed given the configured
|
||||||
|
// proxy modes and the transport that carried the handshake.
|
||||||
|
//
|
||||||
|
// A common mistake is to treat `modes.tls` and `modes.secure` as interchangeable
|
||||||
|
// even though they correspond to different transport profiles: `modes.tls` is
|
||||||
|
// for the TLS-fronted (EE-TLS) path, while `modes.secure` is for direct MTProto
|
||||||
|
// over TCP (DD). Enforcing this separation prevents an attacker from using a
|
||||||
|
// TLS-capable client to bypass the operator intent for the direct MTProto mode,
|
||||||
|
// and vice versa.
|
||||||
|
pub(super) fn mode_enabled_for_proto(config: &ProxyConfig, proto_tag: ProtoTag, is_tls: bool) -> bool {
|
||||||
|
match proto_tag {
|
||||||
|
ProtoTag::Secure => {
|
||||||
|
if is_tls {
|
||||||
|
config.general.modes.tls
|
||||||
|
} else {
|
||||||
|
config.general.modes.secure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProtoTag::Intermediate | ProtoTag::Abridged => config.general.modes.classic,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn decode_user_secrets_in(
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
config: &ProxyConfig,
|
||||||
|
preferred_user: Option<&str>,
|
||||||
|
) -> Vec<(String, Vec<u8>)> {
|
||||||
|
let mut secrets = Vec::with_capacity(config.access.users.len());
|
||||||
|
|
||||||
|
if let Some(preferred) = preferred_user
|
||||||
|
&& let Some(secret_hex) = config.access.users.get(preferred)
|
||||||
|
&& let Some(bytes) = decode_user_secret(shared, preferred, secret_hex)
|
||||||
|
{
|
||||||
|
secrets.push((preferred.to_string(), bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (name, secret_hex) in &config.access.users {
|
||||||
|
if preferred_user.is_some_and(|preferred| preferred == name.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(bytes) = decode_user_secret(shared, name, secret_hex) {
|
||||||
|
secrets.push((name.clone(), bytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
secrets
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(crate) struct AuthProbeState {
|
||||||
|
pub(super) fail_streak: u32,
|
||||||
|
pub(super) blocked_until: Instant,
|
||||||
|
pub(super) last_seen: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(crate) struct AuthProbeSaturationState {
|
||||||
|
pub(super) fail_streak: u32,
|
||||||
|
pub(super) blocked_until: Instant,
|
||||||
|
pub(super) last_seen: Instant,
|
||||||
|
}
|
||||||
|
pub(super) fn unknown_sni_warn_state_lock_in(
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
) -> std::sync::MutexGuard<'_, Option<Instant>> {
|
||||||
|
shared
|
||||||
|
.handshake
|
||||||
|
.unknown_sni_warn_next_allowed
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn should_emit_unknown_sni_warn_in(shared: &ProxySharedState, now: Instant) -> bool {
|
||||||
|
let mut guard = unknown_sni_warn_state_lock_in(shared);
|
||||||
|
if let Some(next_allowed) = *guard
|
||||||
|
&& now < next_allowed
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*guard = Some(now + Duration::from_secs(UNKNOWN_SNI_WARN_COOLDOWN_SECS));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn normalize_auth_probe_ip(peer_ip: IpAddr) -> IpAddr {
|
||||||
|
match peer_ip {
|
||||||
|
IpAddr::V4(ip) => IpAddr::V4(ip),
|
||||||
|
IpAddr::V6(ip) => {
|
||||||
|
let [a, b, c, d, _, _, _, _] = ip.segments();
|
||||||
|
IpAddr::V6(Ipv6Addr::new(a, b, c, d, 0, 0, 0, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn auth_probe_backoff(fail_streak: u32) -> Duration {
|
||||||
|
if fail_streak < AUTH_PROBE_BACKOFF_START_FAILS {
|
||||||
|
return Duration::ZERO;
|
||||||
|
}
|
||||||
|
let shift = (fail_streak - AUTH_PROBE_BACKOFF_START_FAILS).min(10);
|
||||||
|
let multiplier = 1u64.checked_shl(shift).unwrap_or(u64::MAX);
|
||||||
|
let ms = AUTH_PROBE_BACKOFF_BASE_MS
|
||||||
|
.saturating_mul(multiplier)
|
||||||
|
.min(AUTH_PROBE_BACKOFF_MAX_MS);
|
||||||
|
Duration::from_millis(ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn auth_probe_state_expired(state: &AuthProbeState, now: Instant) -> bool {
|
||||||
|
let retention = Duration::from_secs(AUTH_PROBE_TRACK_RETENTION_SECS);
|
||||||
|
now.duration_since(state.last_seen) > retention
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn auth_probe_eviction_offset_in(
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
peer_ip: IpAddr,
|
||||||
|
now: Instant,
|
||||||
|
) -> usize {
|
||||||
|
let hasher_state = &shared.handshake.auth_probe_eviction_hasher;
|
||||||
|
let mut hasher = hasher_state.build_hasher();
|
||||||
|
peer_ip.hash(&mut hasher);
|
||||||
|
now.hash(&mut hasher);
|
||||||
|
hasher.finish() as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn auth_probe_scan_start_offset_in(
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
peer_ip: IpAddr,
|
||||||
|
now: Instant,
|
||||||
|
state_len: usize,
|
||||||
|
scan_limit: usize,
|
||||||
|
) -> usize {
|
||||||
|
if state_len == 0 || scan_limit == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_probe_eviction_offset_in(shared, peer_ip, now) % state_len
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn auth_probe_is_throttled_in(shared: &ProxySharedState, peer_ip: IpAddr, now: Instant) -> bool {
|
||||||
|
let peer_ip = normalize_auth_probe_ip(peer_ip);
|
||||||
|
let state = &shared.handshake.auth_probe;
|
||||||
|
let Some(entry) = state.get(&peer_ip) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if auth_probe_state_expired(&entry, now) {
|
||||||
|
drop(entry);
|
||||||
|
state.remove(&peer_ip);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
now < entry.blocked_until
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn auth_probe_saturation_grace_exhausted_in(
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
peer_ip: IpAddr,
|
||||||
|
now: Instant,
|
||||||
|
) -> bool {
|
||||||
|
let peer_ip = normalize_auth_probe_ip(peer_ip);
|
||||||
|
let state = &shared.handshake.auth_probe;
|
||||||
|
let Some(entry) = state.get(&peer_ip) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if auth_probe_state_expired(&entry, now) {
|
||||||
|
drop(entry);
|
||||||
|
state.remove(&peer_ip);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.fail_streak >= AUTH_PROBE_BACKOFF_START_FAILS + AUTH_PROBE_SATURATION_GRACE_FAILS
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn auth_probe_should_apply_preauth_throttle_in(
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
peer_ip: IpAddr,
|
||||||
|
now: Instant,
|
||||||
|
) -> bool {
|
||||||
|
if !auth_probe_is_throttled_in(shared, peer_ip, now) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !auth_probe_saturation_is_throttled_in(shared, now) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_probe_saturation_grace_exhausted_in(shared, peer_ip, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn auth_probe_saturation_is_throttled_in(shared: &ProxySharedState, now: Instant) -> bool {
|
||||||
|
let mut guard = shared
|
||||||
|
.handshake
|
||||||
|
.auth_probe_saturation
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
|
||||||
|
let Some(state) = guard.as_mut() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if now.duration_since(state.last_seen) > Duration::from_secs(AUTH_PROBE_TRACK_RETENTION_SECS) {
|
||||||
|
*guard = None;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if now < state.blocked_until {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn auth_probe_note_saturation_in(shared: &ProxySharedState, now: Instant) {
|
||||||
|
let mut guard = shared
|
||||||
|
.handshake
|
||||||
|
.auth_probe_saturation
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
|
||||||
|
match guard.as_mut() {
|
||||||
|
Some(state)
|
||||||
|
if now.duration_since(state.last_seen)
|
||||||
|
<= Duration::from_secs(AUTH_PROBE_TRACK_RETENTION_SECS) =>
|
||||||
|
{
|
||||||
|
state.fail_streak = state.fail_streak.saturating_add(1);
|
||||||
|
state.last_seen = now;
|
||||||
|
state.blocked_until = now + auth_probe_backoff(state.fail_streak);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let fail_streak = AUTH_PROBE_BACKOFF_START_FAILS;
|
||||||
|
*guard = Some(AuthProbeSaturationState {
|
||||||
|
fail_streak,
|
||||||
|
blocked_until: now + auth_probe_backoff(fail_streak),
|
||||||
|
last_seen: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn auth_probe_note_expensive_invalid_scan_in(
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
now: Instant,
|
||||||
|
validation_checks: usize,
|
||||||
|
overload: bool,
|
||||||
|
) {
|
||||||
|
if overload || validation_checks < EXPENSIVE_INVALID_SCAN_SATURATION_THRESHOLD {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_probe_note_saturation_in(shared, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn auth_probe_record_failure_in(shared: &ProxySharedState, peer_ip: IpAddr, now: Instant) {
|
||||||
|
let peer_ip = normalize_auth_probe_ip(peer_ip);
|
||||||
|
let state = &shared.handshake.auth_probe;
|
||||||
|
auth_probe_record_failure_with_state_in(shared, state, peer_ip, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn auth_probe_record_failure_with_state_in(
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
state: &DashMap<IpAddr, AuthProbeState>,
|
||||||
|
peer_ip: IpAddr,
|
||||||
|
now: Instant,
|
||||||
|
) {
|
||||||
|
let make_new_state = || AuthProbeState {
|
||||||
|
fail_streak: 1,
|
||||||
|
blocked_until: now + auth_probe_backoff(1),
|
||||||
|
last_seen: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
let update_existing = |entry: &mut AuthProbeState| {
|
||||||
|
if auth_probe_state_expired(entry, now) {
|
||||||
|
*entry = make_new_state();
|
||||||
|
} else {
|
||||||
|
entry.fail_streak = entry.fail_streak.saturating_add(1);
|
||||||
|
entry.last_seen = now;
|
||||||
|
entry.blocked_until = now + auth_probe_backoff(entry.fail_streak);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match state.entry(peer_ip) {
|
||||||
|
Entry::Occupied(mut entry) => {
|
||||||
|
update_existing(entry.get_mut());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Entry::Vacant(_) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.len() >= AUTH_PROBE_TRACK_MAX_ENTRIES {
|
||||||
|
let mut rounds = 0usize;
|
||||||
|
while state.len() >= AUTH_PROBE_TRACK_MAX_ENTRIES {
|
||||||
|
rounds += 1;
|
||||||
|
if rounds > 8 {
|
||||||
|
auth_probe_note_saturation_in(shared, now);
|
||||||
|
let mut eviction_candidate: Option<(IpAddr, u32, Instant)> = None;
|
||||||
|
for entry in state.iter().take(AUTH_PROBE_PRUNE_SCAN_LIMIT) {
|
||||||
|
let key = *entry.key();
|
||||||
|
let fail_streak = entry.value().fail_streak;
|
||||||
|
let last_seen = entry.value().last_seen;
|
||||||
|
match eviction_candidate {
|
||||||
|
Some((_, current_fail, current_seen))
|
||||||
|
if fail_streak > current_fail
|
||||||
|
|| (fail_streak == current_fail && last_seen >= current_seen) => {}
|
||||||
|
_ => eviction_candidate = Some((key, fail_streak, last_seen)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some((evict_key, _, _)) = eviction_candidate else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
state.remove(&evict_key);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stale_keys = Vec::new();
|
||||||
|
let mut eviction_candidate: Option<(IpAddr, u32, Instant)> = None;
|
||||||
|
let state_len = state.len();
|
||||||
|
let scan_limit = state_len.min(AUTH_PROBE_PRUNE_SCAN_LIMIT);
|
||||||
|
|
||||||
|
if state_len <= AUTH_PROBE_PRUNE_SCAN_LIMIT {
|
||||||
|
for entry in state.iter() {
|
||||||
|
let key = *entry.key();
|
||||||
|
let fail_streak = entry.value().fail_streak;
|
||||||
|
let last_seen = entry.value().last_seen;
|
||||||
|
match eviction_candidate {
|
||||||
|
Some((_, current_fail, current_seen))
|
||||||
|
if fail_streak > current_fail
|
||||||
|
|| (fail_streak == current_fail && last_seen >= current_seen) => {}
|
||||||
|
_ => eviction_candidate = Some((key, fail_streak, last_seen)),
|
||||||
|
}
|
||||||
|
if auth_probe_state_expired(entry.value(), now) {
|
||||||
|
stale_keys.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let start_offset =
|
||||||
|
auth_probe_scan_start_offset_in(shared, peer_ip, now, state_len, scan_limit);
|
||||||
|
let mut scanned = 0usize;
|
||||||
|
for entry in state.iter().skip(start_offset) {
|
||||||
|
let key = *entry.key();
|
||||||
|
let fail_streak = entry.value().fail_streak;
|
||||||
|
let last_seen = entry.value().last_seen;
|
||||||
|
match eviction_candidate {
|
||||||
|
Some((_, current_fail, current_seen))
|
||||||
|
if fail_streak > current_fail
|
||||||
|
|| (fail_streak == current_fail && last_seen >= current_seen) => {}
|
||||||
|
_ => eviction_candidate = Some((key, fail_streak, last_seen)),
|
||||||
|
}
|
||||||
|
if auth_probe_state_expired(entry.value(), now) {
|
||||||
|
stale_keys.push(key);
|
||||||
|
}
|
||||||
|
scanned += 1;
|
||||||
|
if scanned >= scan_limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if scanned < scan_limit {
|
||||||
|
for entry in state.iter().take(scan_limit - scanned) {
|
||||||
|
let key = *entry.key();
|
||||||
|
let fail_streak = entry.value().fail_streak;
|
||||||
|
let last_seen = entry.value().last_seen;
|
||||||
|
match eviction_candidate {
|
||||||
|
Some((_, current_fail, current_seen))
|
||||||
|
if fail_streak > current_fail
|
||||||
|
|| (fail_streak == current_fail
|
||||||
|
&& last_seen >= current_seen) => {}
|
||||||
|
_ => eviction_candidate = Some((key, fail_streak, last_seen)),
|
||||||
|
}
|
||||||
|
if auth_probe_state_expired(entry.value(), now) {
|
||||||
|
stale_keys.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for stale_key in stale_keys {
|
||||||
|
state.remove(&stale_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.len() < AUTH_PROBE_TRACK_MAX_ENTRIES {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some((evict_key, _, _)) = eviction_candidate else {
|
||||||
|
auth_probe_note_saturation_in(shared, now);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
state.remove(&evict_key);
|
||||||
|
auth_probe_note_saturation_in(shared, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match state.entry(peer_ip) {
|
||||||
|
Entry::Occupied(mut entry) => {
|
||||||
|
update_existing(entry.get_mut());
|
||||||
|
}
|
||||||
|
Entry::Vacant(entry) => {
|
||||||
|
entry.insert(make_new_state());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn auth_probe_record_success_in(shared: &ProxySharedState, peer_ip: IpAddr) {
|
||||||
|
let peer_ip = normalize_auth_probe_ip(peer_ip);
|
||||||
|
let state = &shared.handshake.auth_probe;
|
||||||
|
state.remove(&peer_ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn auth_probe_record_failure_for_testing(
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
peer_ip: IpAddr,
|
||||||
|
now: Instant,
|
||||||
|
) {
|
||||||
|
auth_probe_record_failure_in(shared, peer_ip, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn auth_probe_fail_streak_for_testing_in_shared(
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
peer_ip: IpAddr,
|
||||||
|
) -> Option<u32> {
|
||||||
|
let peer_ip = normalize_auth_probe_ip(peer_ip);
|
||||||
|
shared
|
||||||
|
.handshake
|
||||||
|
.auth_probe
|
||||||
|
.get(&peer_ip)
|
||||||
|
.map(|entry| entry.fail_streak)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn clear_auth_probe_state_for_testing_in_shared(shared: &ProxySharedState) {
|
||||||
|
shared.handshake.auth_probe.clear();
|
||||||
|
match shared.handshake.auth_probe_saturation.lock() {
|
||||||
|
Ok(mut saturation) => {
|
||||||
|
*saturation = None;
|
||||||
|
}
|
||||||
|
Err(poisoned) => {
|
||||||
|
let mut saturation = poisoned.into_inner();
|
||||||
|
*saturation = None;
|
||||||
|
shared.handshake.auth_probe_saturation.clear_poison();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn auth_probe_state_for_testing_in_shared(
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
) -> &DashMap<IpAddr, AuthProbeState> {
|
||||||
|
&shared.handshake.auth_probe
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn auth_probe_saturation_state_for_testing_in_shared(
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
) -> &Mutex<Option<AuthProbeSaturationState>> {
|
||||||
|
&shared.handshake.auth_probe_saturation
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn auth_probe_saturation_state_lock_for_testing_in_shared(
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
) -> std::sync::MutexGuard<'_, Option<AuthProbeSaturationState>> {
|
||||||
|
shared
|
||||||
|
.handshake
|
||||||
|
.auth_probe_saturation
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn clear_unknown_sni_warn_state_for_testing_in_shared(shared: &ProxySharedState) {
|
||||||
|
let mut guard = shared
|
||||||
|
.handshake
|
||||||
|
.unknown_sni_warn_next_allowed
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
*guard = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn should_emit_unknown_sni_warn_for_testing_in_shared(
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
now: Instant,
|
||||||
|
) -> bool {
|
||||||
|
should_emit_unknown_sni_warn_in(shared, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn clear_warned_secrets_for_testing_in_shared(shared: &ProxySharedState) {
|
||||||
|
if let Ok(mut guard) = shared.handshake.invalid_secret_warned.lock() {
|
||||||
|
guard.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn warned_secrets_for_testing_in_shared(
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
) -> &Mutex<HashSet<(String, String)>> {
|
||||||
|
&shared.handshake.invalid_secret_warned
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn auth_probe_is_throttled_for_testing_in_shared(
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
peer_ip: IpAddr,
|
||||||
|
) -> bool {
|
||||||
|
auth_probe_is_throttled_in(shared, peer_ip, Instant::now())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn auth_probe_saturation_is_throttled_for_testing_in_shared(
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
) -> bool {
|
||||||
|
auth_probe_saturation_is_throttled_in(shared, Instant::now())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn auth_probe_saturation_is_throttled_at_for_testing_in_shared(
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
now: Instant,
|
||||||
|
) -> bool {
|
||||||
|
auth_probe_saturation_is_throttled_in(shared, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub(super) fn find_matching_tls_domain<'a>(config: &'a ProxyConfig, sni: &str) -> Option<&'a str> {
|
||||||
|
if config.censorship.tls_domain.eq_ignore_ascii_case(sni) {
|
||||||
|
return Some(config.censorship.tls_domain.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
for domain in &config.censorship.tls_domains {
|
||||||
|
if domain.eq_ignore_ascii_case(sni) {
|
||||||
|
return Some(domain.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn maybe_apply_server_hello_delay(config: &ProxyConfig) {
|
||||||
|
if config.censorship.server_hello_delay_max_ms == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let min = config.censorship.server_hello_delay_min_ms;
|
||||||
|
let max = config.censorship.server_hello_delay_max_ms.max(min);
|
||||||
|
let delay_ms = if max == min {
|
||||||
|
max
|
||||||
|
} else {
|
||||||
|
crate::proxy::masking::sample_lognormal_percentile_bounded(min, max, &mut rand::rng())
|
||||||
|
};
|
||||||
|
|
||||||
|
if delay_ms > 0 {
|
||||||
|
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Handle MTProto obfuscation handshake
|
||||||
|
#[cfg(test)]
|
||||||
|
pub async fn handle_mtproto_handshake<R, W>(
|
||||||
|
handshake: &[u8; HANDSHAKE_LEN],
|
||||||
|
reader: R,
|
||||||
|
writer: W,
|
||||||
|
peer: SocketAddr,
|
||||||
|
config: &ProxyConfig,
|
||||||
|
replay_checker: &ReplayChecker,
|
||||||
|
is_tls: bool,
|
||||||
|
preferred_user: Option<&str>,
|
||||||
|
) -> HandshakeResult<(CryptoReader<R>, CryptoWriter<W>, HandshakeSuccess), R, W>
|
||||||
|
where
|
||||||
|
R: AsyncRead + Unpin + Send,
|
||||||
|
W: AsyncWrite + Unpin + Send,
|
||||||
|
{
|
||||||
|
let shared = ProxySharedState::new();
|
||||||
|
handle_mtproto_handshake_impl(
|
||||||
|
handshake,
|
||||||
|
reader,
|
||||||
|
writer,
|
||||||
|
peer,
|
||||||
|
config,
|
||||||
|
replay_checker,
|
||||||
|
is_tls,
|
||||||
|
preferred_user,
|
||||||
|
shared.as_ref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_mtproto_handshake_with_shared<R, W>(
|
||||||
|
handshake: &[u8; HANDSHAKE_LEN],
|
||||||
|
reader: R,
|
||||||
|
writer: W,
|
||||||
|
peer: SocketAddr,
|
||||||
|
config: &ProxyConfig,
|
||||||
|
replay_checker: &ReplayChecker,
|
||||||
|
is_tls: bool,
|
||||||
|
preferred_user: Option<&str>,
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
) -> HandshakeResult<(CryptoReader<R>, CryptoWriter<W>, HandshakeSuccess), R, W>
|
||||||
|
where
|
||||||
|
R: AsyncRead + Unpin + Send,
|
||||||
|
W: AsyncWrite + Unpin + Send,
|
||||||
|
{
|
||||||
|
handle_mtproto_handshake_impl(
|
||||||
|
handshake,
|
||||||
|
reader,
|
||||||
|
writer,
|
||||||
|
peer,
|
||||||
|
config,
|
||||||
|
replay_checker,
|
||||||
|
is_tls,
|
||||||
|
preferred_user,
|
||||||
|
shared,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_mtproto_handshake_impl<R, W>(
|
||||||
|
handshake: &[u8; HANDSHAKE_LEN],
|
||||||
|
reader: R,
|
||||||
|
writer: W,
|
||||||
|
peer: SocketAddr,
|
||||||
|
config: &ProxyConfig,
|
||||||
|
replay_checker: &ReplayChecker,
|
||||||
|
is_tls: bool,
|
||||||
|
preferred_user: Option<&str>,
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
) -> HandshakeResult<(CryptoReader<R>, CryptoWriter<W>, HandshakeSuccess), R, W>
|
||||||
|
where
|
||||||
|
R: AsyncRead + Unpin + Send,
|
||||||
|
W: AsyncWrite + Unpin + Send,
|
||||||
|
{
|
||||||
|
let handshake_fingerprint = {
|
||||||
|
let digest = sha256(&handshake[..8]);
|
||||||
|
hex::encode(&digest[..4])
|
||||||
|
};
|
||||||
|
trace!(
|
||||||
|
peer = %peer,
|
||||||
|
handshake_fingerprint = %handshake_fingerprint,
|
||||||
|
"MTProto handshake prefix"
|
||||||
|
);
|
||||||
|
|
||||||
|
let throttle_now = Instant::now();
|
||||||
|
if auth_probe_should_apply_preauth_throttle_in(shared, peer.ip(), throttle_now) {
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
debug!(peer = %peer, "MTProto handshake rejected by pre-auth probe throttle");
|
||||||
|
return HandshakeResult::BadClient { reader, writer };
|
||||||
|
}
|
||||||
|
|
||||||
|
let dec_prekey_iv = &handshake[SKIP_LEN..SKIP_LEN + PREKEY_LEN + IV_LEN];
|
||||||
|
let mut dec_prekey = [0u8; PREKEY_LEN];
|
||||||
|
dec_prekey.copy_from_slice(&dec_prekey_iv[..PREKEY_LEN]);
|
||||||
|
let mut dec_iv_arr = [0u8; IV_LEN];
|
||||||
|
dec_iv_arr.copy_from_slice(&dec_prekey_iv[PREKEY_LEN..]);
|
||||||
|
let dec_iv = u128::from_be_bytes(dec_iv_arr);
|
||||||
|
|
||||||
|
let mut enc_prekey_iv = [0u8; PREKEY_LEN + IV_LEN];
|
||||||
|
for idx in 0..enc_prekey_iv.len() {
|
||||||
|
enc_prekey_iv[idx] = dec_prekey_iv[dec_prekey_iv.len() - 1 - idx];
|
||||||
|
}
|
||||||
|
let mut enc_prekey = [0u8; PREKEY_LEN];
|
||||||
|
enc_prekey.copy_from_slice(&enc_prekey_iv[..PREKEY_LEN]);
|
||||||
|
let mut enc_iv_arr = [0u8; IV_LEN];
|
||||||
|
enc_iv_arr.copy_from_slice(&enc_prekey_iv[PREKEY_LEN..]);
|
||||||
|
let enc_iv = u128::from_be_bytes(enc_iv_arr);
|
||||||
|
|
||||||
|
if let Some(snapshot) = config.runtime_user_auth() {
|
||||||
|
let sticky_ip_hint = sticky_hint_get_by_ip(shared, peer.ip());
|
||||||
|
let sticky_prefix_hint = sticky_hint_get_by_ip_prefix(shared, peer.ip());
|
||||||
|
let preferred_user_id = preferred_user.and_then(|user| snapshot.user_id_by_name(user));
|
||||||
|
let has_hint =
|
||||||
|
sticky_ip_hint.is_some() || sticky_prefix_hint.is_some() || preferred_user_id.is_some();
|
||||||
|
let overload = auth_probe_saturation_is_throttled_in(shared, Instant::now());
|
||||||
|
let candidate_budget = budget_for_validation(snapshot.entries().len(), overload, has_hint);
|
||||||
|
|
||||||
|
let mut tried_user_ids = [u32::MAX; CANDIDATE_HINT_TRACK_CAP];
|
||||||
|
let mut tried_len = 0usize;
|
||||||
|
let mut validation_checks = 0usize;
|
||||||
|
let mut budget_exhausted = false;
|
||||||
|
|
||||||
|
let mut matched_user = String::new();
|
||||||
|
let mut matched_user_id = None;
|
||||||
|
let mut matched_validation = None;
|
||||||
|
|
||||||
|
macro_rules! try_user_id {
|
||||||
|
($user_id:expr) => {{
|
||||||
|
if validation_checks >= candidate_budget {
|
||||||
|
budget_exhausted = true;
|
||||||
|
false
|
||||||
|
} else if !mark_candidate_if_new(&mut tried_user_ids, &mut tried_len, $user_id) {
|
||||||
|
false
|
||||||
|
} else if let Some(entry) = snapshot.entry_by_id($user_id) {
|
||||||
|
validation_checks = validation_checks.saturating_add(1);
|
||||||
|
if let Some(validation) = validate_mtproto_secret_candidate(
|
||||||
|
handshake,
|
||||||
|
&dec_prekey,
|
||||||
|
dec_iv,
|
||||||
|
&enc_prekey,
|
||||||
|
enc_iv,
|
||||||
|
&entry.secret,
|
||||||
|
config,
|
||||||
|
is_tls,
|
||||||
|
) {
|
||||||
|
matched_user = entry.user.clone();
|
||||||
|
matched_user_id = Some($user_id);
|
||||||
|
matched_validation = Some(validation);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut matched = false;
|
||||||
|
if let Some(user_id) = sticky_ip_hint {
|
||||||
|
matched = try_user_id!(user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matched && let Some(user_id) = preferred_user_id {
|
||||||
|
matched = try_user_id!(user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matched && let Some(user_id) = sticky_prefix_hint {
|
||||||
|
matched = try_user_id!(user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matched && !budget_exhausted {
|
||||||
|
let ring = &shared.handshake.recent_user_ring;
|
||||||
|
if !ring.is_empty() {
|
||||||
|
let next_seq = shared
|
||||||
|
.handshake
|
||||||
|
.recent_user_ring_seq
|
||||||
|
.load(Ordering::Relaxed);
|
||||||
|
let scan_limit = ring.len().min(RECENT_USER_RING_SCAN_LIMIT);
|
||||||
|
for offset in 0..scan_limit {
|
||||||
|
let idx = (next_seq as usize + ring.len() - 1 - offset) % ring.len();
|
||||||
|
let encoded_user_id = ring[idx].load(Ordering::Relaxed);
|
||||||
|
if encoded_user_id == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if try_user_id!(encoded_user_id - 1) {
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if budget_exhausted {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matched && !budget_exhausted {
|
||||||
|
for idx in 0..snapshot.entries().len() {
|
||||||
|
let Some(user_id) = u32::try_from(idx).ok() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if try_user_id!(user_id) {
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if budget_exhausted {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shared
|
||||||
|
.handshake
|
||||||
|
.auth_expensive_checks_total
|
||||||
|
.fetch_add(validation_checks as u64, Ordering::Relaxed);
|
||||||
|
if budget_exhausted {
|
||||||
|
shared
|
||||||
|
.handshake
|
||||||
|
.auth_budget_exhausted_total
|
||||||
|
.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matched {
|
||||||
|
let failure_now = Instant::now();
|
||||||
|
auth_probe_note_expensive_invalid_scan_in(
|
||||||
|
shared,
|
||||||
|
failure_now,
|
||||||
|
validation_checks,
|
||||||
|
overload,
|
||||||
|
);
|
||||||
|
auth_probe_record_failure_in(shared, peer.ip(), failure_now);
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
debug!(
|
||||||
|
peer = %peer,
|
||||||
|
budget_exhausted = budget_exhausted,
|
||||||
|
candidate_budget = candidate_budget,
|
||||||
|
validation_checks = validation_checks,
|
||||||
|
"MTProto handshake: no matching user found"
|
||||||
|
);
|
||||||
|
return HandshakeResult::BadClient { reader, writer };
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(validation) = matched_validation else {
|
||||||
|
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
warn!(
|
||||||
|
peer = %peer,
|
||||||
|
user = %matched_user,
|
||||||
|
"MTProto handshake matched user without validation material"
|
||||||
|
);
|
||||||
|
return HandshakeResult::BadClient { reader, writer };
|
||||||
|
};
|
||||||
|
|
||||||
|
if config
|
||||||
|
.access
|
||||||
|
.is_user_source_ip_denied(matched_user.as_str(), peer.ip())
|
||||||
|
{
|
||||||
|
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
warn!(
|
||||||
|
peer = %peer,
|
||||||
|
user = %matched_user,
|
||||||
|
"MTProto handshake rejected: client source IP on per-user deny list (access.user_source_deny)"
|
||||||
|
);
|
||||||
|
return HandshakeResult::BadClient { reader, writer };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply replay tracking only after successful authentication.
|
||||||
|
//
|
||||||
|
// This ordering prevents an attacker from producing invalid handshakes that
|
||||||
|
// still collide with a valid handshake's replay slot and thus evict a valid
|
||||||
|
// entry from the cache. We accept the cost of performing the full
|
||||||
|
// authentication check first to avoid poisoning the replay cache.
|
||||||
|
if replay_checker.check_and_add_handshake(dec_prekey_iv) {
|
||||||
|
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
warn!(peer = %peer, user = %matched_user, "MTProto replay attack detected");
|
||||||
|
return HandshakeResult::BadClient { reader, writer };
|
||||||
|
}
|
||||||
|
|
||||||
|
let dec_key = Zeroizing::new(validation.dec_key);
|
||||||
|
let enc_key = Zeroizing::new(validation.enc_key);
|
||||||
|
let success = HandshakeSuccess {
|
||||||
|
user: matched_user.clone(),
|
||||||
|
dc_idx: validation.dc_idx,
|
||||||
|
proto_tag: validation.proto_tag,
|
||||||
|
dec_key: *dec_key,
|
||||||
|
dec_iv: validation.dec_iv,
|
||||||
|
enc_key: *enc_key,
|
||||||
|
enc_iv: validation.enc_iv,
|
||||||
|
peer,
|
||||||
|
is_tls,
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
peer = %peer,
|
||||||
|
user = %matched_user,
|
||||||
|
dc = validation.dc_idx,
|
||||||
|
proto = ?validation.proto_tag,
|
||||||
|
tls = is_tls,
|
||||||
|
"MTProto handshake successful"
|
||||||
|
);
|
||||||
|
|
||||||
|
auth_probe_record_success_in(shared, peer.ip());
|
||||||
|
if let Some(user_id) = matched_user_id {
|
||||||
|
sticky_hint_record_success_in(shared, peer.ip(), user_id, None);
|
||||||
|
record_recent_user_success_in(shared, user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_pending = config.general.crypto_pending_buffer;
|
||||||
|
return HandshakeResult::Success((
|
||||||
|
CryptoReader::new(reader, validation.decryptor),
|
||||||
|
CryptoWriter::new(writer, validation.encryptor, max_pending),
|
||||||
|
success,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
let decoded_users = decode_user_secrets_in(shared, config, preferred_user);
|
||||||
|
let mut validation_checks = 0usize;
|
||||||
|
|
||||||
|
for (user, secret) in decoded_users {
|
||||||
|
if secret.len() != ACCESS_SECRET_BYTES {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
validation_checks = validation_checks.saturating_add(1);
|
||||||
|
|
||||||
|
let mut secret_arr = [0u8; ACCESS_SECRET_BYTES];
|
||||||
|
secret_arr.copy_from_slice(&secret);
|
||||||
|
let Some(validation) = validate_mtproto_secret_candidate(
|
||||||
|
handshake,
|
||||||
|
&dec_prekey,
|
||||||
|
dec_iv,
|
||||||
|
&enc_prekey,
|
||||||
|
enc_iv,
|
||||||
|
&secret_arr,
|
||||||
|
config,
|
||||||
|
is_tls,
|
||||||
|
) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
shared
|
||||||
|
.handshake
|
||||||
|
.auth_expensive_checks_total
|
||||||
|
.fetch_add(validation_checks as u64, Ordering::Relaxed);
|
||||||
|
|
||||||
|
if config
|
||||||
|
.access
|
||||||
|
.is_user_source_ip_denied(user.as_str(), peer.ip())
|
||||||
|
{
|
||||||
|
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
warn!(
|
||||||
|
peer = %peer,
|
||||||
|
user = %user,
|
||||||
|
"MTProto handshake rejected: client source IP on per-user deny list (access.user_source_deny)"
|
||||||
|
);
|
||||||
|
return HandshakeResult::BadClient { reader, writer };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply replay tracking only after successful authentication.
|
||||||
|
//
|
||||||
|
// This ordering prevents an attacker from producing invalid handshakes that
|
||||||
|
// still collide with a valid handshake's replay slot and thus evict a valid
|
||||||
|
// entry from the cache. We accept the cost of performing the full
|
||||||
|
// authentication check first to avoid poisoning the replay cache.
|
||||||
|
if replay_checker.check_and_add_handshake(dec_prekey_iv) {
|
||||||
|
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
warn!(peer = %peer, user = %user, "MTProto replay attack detected");
|
||||||
|
return HandshakeResult::BadClient { reader, writer };
|
||||||
|
}
|
||||||
|
|
||||||
|
let dec_key = Zeroizing::new(validation.dec_key);
|
||||||
|
let enc_key = Zeroizing::new(validation.enc_key);
|
||||||
|
let success = HandshakeSuccess {
|
||||||
|
user: user.clone(),
|
||||||
|
dc_idx: validation.dc_idx,
|
||||||
|
proto_tag: validation.proto_tag,
|
||||||
|
dec_key: *dec_key,
|
||||||
|
dec_iv: validation.dec_iv,
|
||||||
|
enc_key: *enc_key,
|
||||||
|
enc_iv: validation.enc_iv,
|
||||||
|
peer,
|
||||||
|
is_tls,
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
peer = %peer,
|
||||||
|
user = %user,
|
||||||
|
dc = validation.dc_idx,
|
||||||
|
proto = ?validation.proto_tag,
|
||||||
|
tls = is_tls,
|
||||||
|
"MTProto handshake successful"
|
||||||
|
);
|
||||||
|
|
||||||
|
auth_probe_record_success_in(shared, peer.ip());
|
||||||
|
|
||||||
|
let max_pending = config.general.crypto_pending_buffer;
|
||||||
|
return HandshakeResult::Success((
|
||||||
|
CryptoReader::new(reader, validation.decryptor),
|
||||||
|
CryptoWriter::new(writer, validation.encryptor, max_pending),
|
||||||
|
success,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
shared
|
||||||
|
.handshake
|
||||||
|
.auth_expensive_checks_total
|
||||||
|
.fetch_add(validation_checks as u64, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
debug!(peer = %peer, "MTProto handshake: no matching user found");
|
||||||
|
HandshakeResult::BadClient { reader, writer }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub fn generate_tg_nonce(
|
||||||
|
proto_tag: ProtoTag,
|
||||||
|
dc_idx: i16,
|
||||||
|
client_enc_key: &[u8; 32],
|
||||||
|
client_enc_iv: u128,
|
||||||
|
rng: &SecureRandom,
|
||||||
|
fast_mode: bool,
|
||||||
|
) -> ([u8; HANDSHAKE_LEN], [u8; 32], u128, [u8; 32], u128) {
|
||||||
|
loop {
|
||||||
|
let bytes = rng.bytes(HANDSHAKE_LEN);
|
||||||
|
let Ok(mut nonce): Result<[u8; HANDSHAKE_LEN], _> = bytes.try_into() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if RESERVED_NONCE_FIRST_BYTES.contains(&nonce[0]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let first_four: [u8; 4] = [nonce[0], nonce[1], nonce[2], nonce[3]];
|
||||||
|
if RESERVED_NONCE_BEGINNINGS.contains(&first_four) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let continue_four: [u8; 4] = [nonce[4], nonce[5], nonce[6], nonce[7]];
|
||||||
|
if RESERVED_NONCE_CONTINUES.contains(&continue_four) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce[PROTO_TAG_POS..PROTO_TAG_POS + 4].copy_from_slice(&proto_tag.to_bytes());
|
||||||
|
// CRITICAL: write dc_idx so upstream DC knows where to route
|
||||||
|
nonce[DC_IDX_POS..DC_IDX_POS + 2].copy_from_slice(&dc_idx.to_le_bytes());
|
||||||
|
|
||||||
|
if fast_mode {
|
||||||
|
let mut key_iv = Zeroizing::new(Vec::with_capacity(KEY_LEN + IV_LEN));
|
||||||
|
key_iv.extend_from_slice(client_enc_key);
|
||||||
|
key_iv.extend_from_slice(&client_enc_iv.to_be_bytes());
|
||||||
|
// Python/C compatibility requires reversed enc_key+enc_iv nonce bytes.
|
||||||
|
key_iv.reverse();
|
||||||
|
nonce[SKIP_LEN..SKIP_LEN + KEY_LEN + IV_LEN].copy_from_slice(&key_iv);
|
||||||
|
}
|
||||||
|
|
||||||
|
let enc_key_iv = &nonce[SKIP_LEN..SKIP_LEN + KEY_LEN + IV_LEN];
|
||||||
|
let dec_key_iv = Zeroizing::new(enc_key_iv.iter().rev().copied().collect::<Vec<u8>>());
|
||||||
|
|
||||||
|
let mut tg_enc_key = [0u8; 32];
|
||||||
|
tg_enc_key.copy_from_slice(&enc_key_iv[..KEY_LEN]);
|
||||||
|
let mut tg_enc_iv_arr = [0u8; IV_LEN];
|
||||||
|
tg_enc_iv_arr.copy_from_slice(&enc_key_iv[KEY_LEN..]);
|
||||||
|
let tg_enc_iv = u128::from_be_bytes(tg_enc_iv_arr);
|
||||||
|
|
||||||
|
let mut tg_dec_key = [0u8; 32];
|
||||||
|
tg_dec_key.copy_from_slice(&dec_key_iv[..KEY_LEN]);
|
||||||
|
let mut tg_dec_iv_arr = [0u8; IV_LEN];
|
||||||
|
tg_dec_iv_arr.copy_from_slice(&dec_key_iv[KEY_LEN..]);
|
||||||
|
let tg_dec_iv = u128::from_be_bytes(tg_dec_iv_arr);
|
||||||
|
|
||||||
|
return (nonce, tg_enc_key, tg_enc_iv, tg_dec_key, tg_dec_iv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt nonce for sending to Telegram and return cipher objects with correct counter state
|
||||||
|
pub fn encrypt_tg_nonce_with_ciphers(nonce: &[u8; HANDSHAKE_LEN]) -> (Vec<u8>, AesCtr, AesCtr) {
|
||||||
|
let enc_key_iv = &nonce[SKIP_LEN..SKIP_LEN + KEY_LEN + IV_LEN];
|
||||||
|
let dec_key_iv = Zeroizing::new(enc_key_iv.iter().rev().copied().collect::<Vec<u8>>());
|
||||||
|
|
||||||
|
let mut enc_key = [0u8; 32];
|
||||||
|
enc_key.copy_from_slice(&enc_key_iv[..KEY_LEN]);
|
||||||
|
let mut enc_iv_arr = [0u8; IV_LEN];
|
||||||
|
enc_iv_arr.copy_from_slice(&enc_key_iv[KEY_LEN..]);
|
||||||
|
let enc_iv = u128::from_be_bytes(enc_iv_arr);
|
||||||
|
|
||||||
|
let mut dec_key = [0u8; 32];
|
||||||
|
dec_key.copy_from_slice(&dec_key_iv[..KEY_LEN]);
|
||||||
|
let mut dec_iv_arr = [0u8; IV_LEN];
|
||||||
|
dec_iv_arr.copy_from_slice(&dec_key_iv[KEY_LEN..]);
|
||||||
|
let dec_iv = u128::from_be_bytes(dec_iv_arr);
|
||||||
|
|
||||||
|
let mut encryptor = AesCtr::new(&enc_key, enc_iv);
|
||||||
|
// Encryption advances the nonce counter from zero to four.
|
||||||
|
let encrypted_full = encryptor.encrypt(nonce);
|
||||||
|
|
||||||
|
let mut result = nonce[..PROTO_TAG_POS].to_vec();
|
||||||
|
result.extend_from_slice(&encrypted_full[PROTO_TAG_POS..]);
|
||||||
|
|
||||||
|
let decryptor = AesCtr::new(&dec_key, dec_iv);
|
||||||
|
enc_key.zeroize();
|
||||||
|
dec_key.zeroize();
|
||||||
|
|
||||||
|
(result, encryptor, decryptor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt nonce for sending to Telegram (legacy function for compatibility)
|
||||||
|
pub fn encrypt_tg_nonce(nonce: &[u8; HANDSHAKE_LEN]) -> Vec<u8> {
|
||||||
|
let (encrypted, _, _) = encrypt_tg_nonce_with_ciphers(nonce);
|
||||||
|
encrypted
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Result of successful handshake
|
||||||
|
///
|
||||||
|
/// Key material (`dec_key`, `dec_iv`, `enc_key`, `enc_iv`) is
|
||||||
|
/// zeroized on drop.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct HandshakeSuccess {
|
||||||
|
/// Authenticated user name
|
||||||
|
pub user: String,
|
||||||
|
/// Target datacenter index
|
||||||
|
pub dc_idx: i16,
|
||||||
|
/// Protocol variant (abridged/intermediate/secure)
|
||||||
|
pub proto_tag: ProtoTag,
|
||||||
|
/// Decryption key and IV (for reading from client)
|
||||||
|
pub dec_key: [u8; 32],
|
||||||
|
pub dec_iv: u128,
|
||||||
|
/// Encryption key and IV (for writing to client)
|
||||||
|
pub enc_key: [u8; 32],
|
||||||
|
pub enc_iv: u128,
|
||||||
|
/// Client address
|
||||||
|
pub peer: SocketAddr,
|
||||||
|
/// Whether TLS was used
|
||||||
|
pub is_tls: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for HandshakeSuccess {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.dec_key.zeroize();
|
||||||
|
self.dec_iv.zeroize();
|
||||||
|
self.enc_key.zeroize();
|
||||||
|
self.enc_iv.zeroize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Handle fake TLS handshake
|
||||||
|
#[cfg(test)]
|
||||||
|
pub async fn handle_tls_handshake<R, W>(
|
||||||
|
handshake: &[u8],
|
||||||
|
reader: R,
|
||||||
|
mut writer: W,
|
||||||
|
peer: SocketAddr,
|
||||||
|
config: &ProxyConfig,
|
||||||
|
replay_checker: &ReplayChecker,
|
||||||
|
rng: &SecureRandom,
|
||||||
|
tls_cache: Option<Arc<TlsFrontCache>>,
|
||||||
|
) -> HandshakeResult<(FakeTlsReader<R>, FakeTlsWriter<W>, String), R, W>
|
||||||
|
where
|
||||||
|
R: AsyncRead + Unpin,
|
||||||
|
W: AsyncWrite + Unpin,
|
||||||
|
{
|
||||||
|
let shared = ProxySharedState::new();
|
||||||
|
handle_tls_handshake_impl(
|
||||||
|
handshake,
|
||||||
|
reader,
|
||||||
|
writer,
|
||||||
|
peer,
|
||||||
|
config,
|
||||||
|
replay_checker,
|
||||||
|
rng,
|
||||||
|
tls_cache,
|
||||||
|
shared.as_ref(),
|
||||||
|
TlsResponseWriteOptions::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_tls_handshake_with_shared<R, W>(
|
||||||
|
handshake: &[u8],
|
||||||
|
reader: R,
|
||||||
|
writer: W,
|
||||||
|
peer: SocketAddr,
|
||||||
|
config: &ProxyConfig,
|
||||||
|
replay_checker: &ReplayChecker,
|
||||||
|
rng: &SecureRandom,
|
||||||
|
tls_cache: Option<Arc<TlsFrontCache>>,
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
) -> HandshakeResult<(FakeTlsReader<R>, FakeTlsWriter<W>, String), R, W>
|
||||||
|
where
|
||||||
|
R: AsyncRead + Unpin,
|
||||||
|
W: AsyncWrite + Unpin,
|
||||||
|
{
|
||||||
|
handle_tls_handshake_impl(
|
||||||
|
handshake,
|
||||||
|
reader,
|
||||||
|
writer,
|
||||||
|
peer,
|
||||||
|
config,
|
||||||
|
replay_checker,
|
||||||
|
rng,
|
||||||
|
tls_cache,
|
||||||
|
shared,
|
||||||
|
TlsResponseWriteOptions::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles FakeTLS with optional best-effort initial-response chunking.
|
||||||
|
pub(crate) async fn handle_tls_handshake_with_shared_and_options<R, W>(
|
||||||
|
handshake: &[u8],
|
||||||
|
reader: R,
|
||||||
|
writer: W,
|
||||||
|
peer: SocketAddr,
|
||||||
|
config: &ProxyConfig,
|
||||||
|
replay_checker: &ReplayChecker,
|
||||||
|
rng: &SecureRandom,
|
||||||
|
tls_cache: Option<Arc<TlsFrontCache>>,
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
response_write_options: TlsResponseWriteOptions,
|
||||||
|
) -> HandshakeResult<(FakeTlsReader<R>, FakeTlsWriter<W>, String), R, W>
|
||||||
|
where
|
||||||
|
R: AsyncRead + Unpin,
|
||||||
|
W: AsyncWrite + Unpin,
|
||||||
|
{
|
||||||
|
handle_tls_handshake_impl(
|
||||||
|
handshake,
|
||||||
|
reader,
|
||||||
|
writer,
|
||||||
|
peer,
|
||||||
|
config,
|
||||||
|
replay_checker,
|
||||||
|
rng,
|
||||||
|
tls_cache,
|
||||||
|
shared,
|
||||||
|
response_write_options,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_tls_handshake_impl<R, W>(
|
||||||
|
handshake: &[u8],
|
||||||
|
reader: R,
|
||||||
|
mut writer: W,
|
||||||
|
peer: SocketAddr,
|
||||||
|
config: &ProxyConfig,
|
||||||
|
replay_checker: &ReplayChecker,
|
||||||
|
rng: &SecureRandom,
|
||||||
|
tls_cache: Option<Arc<TlsFrontCache>>,
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
response_write_options: TlsResponseWriteOptions,
|
||||||
|
) -> HandshakeResult<(FakeTlsReader<R>, FakeTlsWriter<W>, String), R, W>
|
||||||
|
where
|
||||||
|
R: AsyncRead + Unpin,
|
||||||
|
W: AsyncWrite + Unpin,
|
||||||
|
{
|
||||||
|
debug!(peer = %peer, handshake_len = handshake.len(), "Processing TLS handshake");
|
||||||
|
|
||||||
|
let throttle_now = Instant::now();
|
||||||
|
if auth_probe_should_apply_preauth_throttle_in(shared, peer.ip(), throttle_now) {
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
debug!(peer = %peer, "TLS handshake rejected by pre-auth probe throttle");
|
||||||
|
return HandshakeResult::BadClient { reader, writer };
|
||||||
|
}
|
||||||
|
|
||||||
|
if handshake.len() < tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN + 1 {
|
||||||
|
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
debug!(peer = %peer, "TLS handshake too short");
|
||||||
|
return HandshakeResult::BadClient { reader, writer };
|
||||||
|
}
|
||||||
|
|
||||||
|
let client_sni = tls::extract_sni_from_client_hello(handshake);
|
||||||
|
let preferred_user_hint = client_sni
|
||||||
|
.as_deref()
|
||||||
|
.filter(|sni| config.access.users.contains_key(*sni));
|
||||||
|
let matched_tls_domain = client_sni
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|sni| find_matching_tls_domain(config, sni));
|
||||||
|
|
||||||
|
let alpn_list = if config.censorship.alpn_enforce {
|
||||||
|
tls::extract_alpn_from_client_hello(handshake)
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
let selected_alpn = if config.censorship.alpn_enforce {
|
||||||
|
if alpn_list.iter().any(|p| p == b"h2") {
|
||||||
|
Some(b"h2".to_vec())
|
||||||
|
} else if alpn_list.iter().any(|p| p == b"http/1.1") {
|
||||||
|
Some(b"http/1.1".to_vec())
|
||||||
|
} else if !alpn_list.is_empty() {
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
debug!(peer = %peer, "Client ALPN list has no supported protocol; using masking fallback");
|
||||||
|
return HandshakeResult::BadClient { reader, writer };
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
// Fail-closed to TLS 1.3 semantics when ClientHello version is ambiguous:
|
||||||
|
// this avoids leaking certificate payload on malformed probes.
|
||||||
|
let client_tls_version = tls::detect_client_hello_tls_version(handshake)
|
||||||
|
.unwrap_or(tls::ClientHelloTlsVersion::Tls13);
|
||||||
|
|
||||||
|
if client_sni.is_some() && matched_tls_domain.is_none() && preferred_user_hint.is_none() {
|
||||||
|
let sni = client_sni.as_deref().unwrap_or_default();
|
||||||
|
match config.censorship.unknown_sni_action {
|
||||||
|
UnknownSniAction::Accept => {
|
||||||
|
debug!(
|
||||||
|
peer = %peer,
|
||||||
|
sni = %sni,
|
||||||
|
unknown_sni = true,
|
||||||
|
unknown_sni_action = ?config.censorship.unknown_sni_action,
|
||||||
|
"TLS handshake accepted by unknown SNI policy"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
action @ (UnknownSniAction::Drop
|
||||||
|
| UnknownSniAction::Mask
|
||||||
|
| UnknownSniAction::RejectHandshake) => {
|
||||||
|
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
|
||||||
|
// For Drop/Mask we apply the synthetic ServerHello delay so
|
||||||
|
// the fail-closed path is timing-indistinguishable from the
|
||||||
|
// success path. For RejectHandshake we deliberately skip the
|
||||||
|
// delay: a stock modern nginx with `ssl_reject_handshake on;`
|
||||||
|
// responds with the alert essentially immediately, so
|
||||||
|
// injecting 8-24ms here would itself become a distinguisher
|
||||||
|
// against the public baseline we are trying to blend into.
|
||||||
|
if !matches!(action, UnknownSniAction::RejectHandshake) {
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
}
|
||||||
|
let log_now = Instant::now();
|
||||||
|
if should_emit_unknown_sni_warn_in(shared, log_now) {
|
||||||
|
warn!(
|
||||||
|
peer = %peer,
|
||||||
|
sni = %sni,
|
||||||
|
unknown_sni = true,
|
||||||
|
unknown_sni_action = ?action,
|
||||||
|
"TLS handshake rejected by unknown SNI policy"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
info!(
|
||||||
|
peer = %peer,
|
||||||
|
sni = %sni,
|
||||||
|
unknown_sni = true,
|
||||||
|
unknown_sni_action = ?action,
|
||||||
|
"TLS handshake rejected by unknown SNI policy"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if matches!(action, UnknownSniAction::RejectHandshake) {
|
||||||
|
// TLS alert record layer:
|
||||||
|
// 0x15 ContentType.alert
|
||||||
|
// 0x03 0x03 legacy_record_version = TLS 1.2
|
||||||
|
// (matches what modern nginx emits in
|
||||||
|
// the first server -> client record,
|
||||||
|
// per RFC 8446 5.1 guidance)
|
||||||
|
// 0x00 0x02 length = 2
|
||||||
|
// Alert payload:
|
||||||
|
// 0x02 AlertLevel.fatal
|
||||||
|
// 0x70 AlertDescription.unrecognized_name (112, RFC 6066)
|
||||||
|
const TLS_ALERT_UNRECOGNIZED_NAME: [u8; 7] =
|
||||||
|
[0x15, 0x03, 0x03, 0x00, 0x02, 0x02, 0x70];
|
||||||
|
if let Err(e) = writer.write_all(&TLS_ALERT_UNRECOGNIZED_NAME).await {
|
||||||
|
debug!(
|
||||||
|
peer = %peer,
|
||||||
|
error = %e,
|
||||||
|
"Failed to write unrecognized_name TLS alert"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let _ = writer.flush().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return match action {
|
||||||
|
UnknownSniAction::Drop | UnknownSniAction::RejectHandshake => {
|
||||||
|
HandshakeResult::Error(ProxyError::UnknownTlsSni)
|
||||||
|
}
|
||||||
|
UnknownSniAction::Mask => HandshakeResult::BadClient { reader, writer },
|
||||||
|
UnknownSniAction::Accept => unreachable!(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let Some(validation) = tls_validation::validate_tls_client(
|
||||||
|
handshake,
|
||||||
|
peer,
|
||||||
|
config,
|
||||||
|
shared,
|
||||||
|
preferred_user_hint,
|
||||||
|
&client_sni,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return HandshakeResult::BadClient { reader, writer };
|
||||||
|
};
|
||||||
|
let tls_validation::TlsClientValidation {
|
||||||
|
digest: validation_digest,
|
||||||
|
session_id: validation_session_id,
|
||||||
|
session_id_len: validation_session_id_len,
|
||||||
|
user: validated_user,
|
||||||
|
secret: validated_secret,
|
||||||
|
user_id: validated_user_id,
|
||||||
|
} = validation;
|
||||||
|
// Reject known replay digests before expensive cache/domain/ALPN policy work.
|
||||||
|
let digest_half = &validation_digest[..tls::TLS_DIGEST_HALF_LEN];
|
||||||
|
if replay_checker.check_tls_digest(digest_half) {
|
||||||
|
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
warn!(peer = %peer, "TLS replay attack detected (duplicate digest)");
|
||||||
|
return HandshakeResult::BadClient { reader, writer };
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached_entry = if config.censorship.tls_emulation {
|
||||||
|
if let Some(cache) = tls_cache.as_ref() {
|
||||||
|
let selected_domain =
|
||||||
|
matched_tls_domain.unwrap_or(config.censorship.tls_domain.as_str());
|
||||||
|
let cached_entry = cache.get(selected_domain).await;
|
||||||
|
Some(cached_entry)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let preferred_key_share_group = cached_entry
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|cached_entry| emulator::profiled_server_hello_key_share_group(cached_entry));
|
||||||
|
let Some(server_key_share) =
|
||||||
|
tls::build_server_hello_key_share(handshake, preferred_key_share_group, rng)
|
||||||
|
else {
|
||||||
|
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
debug!(
|
||||||
|
peer = %peer,
|
||||||
|
"TLS handshake rejected: ClientHello did not offer a usable TLS 1.3 key_share"
|
||||||
|
);
|
||||||
|
return HandshakeResult::BadClient { reader, writer };
|
||||||
|
};
|
||||||
|
|
||||||
|
let preferred_cipher_suite = if let Some(cached_entry) = cached_entry.as_ref() {
|
||||||
|
if cached_entry.server_hello_template.cipher_suite == [0, 0] {
|
||||||
|
[0x13, 0x01]
|
||||||
|
} else {
|
||||||
|
cached_entry.server_hello_template.cipher_suite
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
[0x13, 0x01]
|
||||||
|
};
|
||||||
|
let Some(selected_cipher_suite) =
|
||||||
|
tls::select_server_hello_cipher_suite(handshake, preferred_cipher_suite)
|
||||||
|
else {
|
||||||
|
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
debug!(
|
||||||
|
peer = %peer,
|
||||||
|
"TLS handshake rejected: ClientHello did not offer a supported TLS 1.3 cipher suite"
|
||||||
|
);
|
||||||
|
return HandshakeResult::BadClient { reader, writer };
|
||||||
|
};
|
||||||
|
|
||||||
|
let cached = if let Some(cached_entry) = cached_entry {
|
||||||
|
let use_full_cert_payload = if config.censorship.serverhello_compact
|
||||||
|
&& matches!(client_tls_version, tls::ClientHelloTlsVersion::Tls12)
|
||||||
|
{
|
||||||
|
if let Some(cache) = tls_cache.as_ref() {
|
||||||
|
cache
|
||||||
|
.take_full_cert_budget_for_ip(
|
||||||
|
peer.ip(),
|
||||||
|
Duration::from_secs(config.censorship.tls_full_cert_ttl_secs),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
};
|
||||||
|
Some((cached_entry, use_full_cert_payload))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add replay digest only for policy-valid handshakes.
|
||||||
|
replay_checker.add_tls_digest(digest_half);
|
||||||
|
|
||||||
|
let validation_session_id_slice = &validation_session_id[..validation_session_id_len];
|
||||||
|
|
||||||
|
let response = if let Some((cached_entry, use_full_cert_payload)) = cached {
|
||||||
|
emulator::build_emulated_server_hello(
|
||||||
|
&validated_secret,
|
||||||
|
&validation_digest,
|
||||||
|
validation_session_id_slice,
|
||||||
|
&cached_entry,
|
||||||
|
use_full_cert_payload,
|
||||||
|
config.censorship.serverhello_compact,
|
||||||
|
client_tls_version,
|
||||||
|
selected_cipher_suite,
|
||||||
|
&server_key_share,
|
||||||
|
rng,
|
||||||
|
selected_alpn.clone(),
|
||||||
|
config.censorship.tls_new_session_tickets,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
tls::build_server_hello_with_cipher(
|
||||||
|
&validated_secret,
|
||||||
|
&validation_digest,
|
||||||
|
validation_session_id_slice,
|
||||||
|
config.censorship.fake_cert_len,
|
||||||
|
rng,
|
||||||
|
selected_cipher_suite,
|
||||||
|
&server_key_share,
|
||||||
|
selected_alpn.clone(),
|
||||||
|
config.censorship.tls_new_session_tickets,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply the same optional delay budget used by reject paths to reduce
|
||||||
|
// distinguishability between success and fail-closed handshakes.
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
|
||||||
|
debug!(peer = %peer, response_len = response.len(), "Sending TLS ServerHello");
|
||||||
|
|
||||||
|
if let Err(e) = write_tls_response(&mut writer, &response, response_write_options).await {
|
||||||
|
warn!(peer = %peer, error = %e, "Failed to write TLS ServerHello");
|
||||||
|
return HandshakeResult::Error(ProxyError::Io(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
peer = %peer,
|
||||||
|
user = %validated_user,
|
||||||
|
"TLS handshake successful"
|
||||||
|
);
|
||||||
|
|
||||||
|
auth_probe_record_success_in(shared, peer.ip());
|
||||||
|
|
||||||
|
if let Some(user_id) = validated_user_id {
|
||||||
|
sticky_hint_record_success_in(shared, peer.ip(), user_id, client_sni.as_deref());
|
||||||
|
record_recent_user_success_in(shared, user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
HandshakeResult::Success((
|
||||||
|
FakeTlsReader::new(reader),
|
||||||
|
FakeTlsWriter::new(writer),
|
||||||
|
validated_user,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_tls_response<W: AsyncWrite + Unpin>(
|
||||||
|
writer: &mut W,
|
||||||
|
response: &[u8],
|
||||||
|
options: TlsResponseWriteOptions,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if let (Some(fd), Some(fragment_size)) = (options.socket_fd, options.fragment_size) {
|
||||||
|
return crate::transport::socket::send_tcp_fragmented_fd(
|
||||||
|
fd,
|
||||||
|
response,
|
||||||
|
usize::from(fragment_size),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = options;
|
||||||
|
writer.write_all(response).await?;
|
||||||
|
writer.flush().await
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) struct TlsClientValidation {
|
||||||
|
pub(super) digest: [u8; tls::TLS_DIGEST_LEN],
|
||||||
|
pub(super) session_id: [u8; 32],
|
||||||
|
pub(super) session_id_len: usize,
|
||||||
|
pub(super) user: String,
|
||||||
|
pub(super) secret: [u8; ACCESS_SECRET_BYTES],
|
||||||
|
pub(super) user_id: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn validate_tls_client(
|
||||||
|
handshake: &[u8],
|
||||||
|
peer: SocketAddr,
|
||||||
|
config: &ProxyConfig,
|
||||||
|
shared: &ProxySharedState,
|
||||||
|
preferred_user_hint: Option<&str>,
|
||||||
|
client_sni: &Option<String>,
|
||||||
|
) -> Option<TlsClientValidation> {
|
||||||
|
let mut validation_digest = [0u8; tls::TLS_DIGEST_LEN];
|
||||||
|
let mut validation_session_id = [0u8; 32];
|
||||||
|
let mut validation_session_id_len = 0usize;
|
||||||
|
let mut validated_user = String::new();
|
||||||
|
let mut validated_secret = [0u8; ACCESS_SECRET_BYTES];
|
||||||
|
let mut validated_user_id: Option<u32> = None;
|
||||||
|
|
||||||
|
if let Some(snapshot) = config.runtime_user_auth() {
|
||||||
|
let parsed = match parse_tls_auth_material(
|
||||||
|
handshake,
|
||||||
|
config.access.ignore_time_skew,
|
||||||
|
config.access.replay_window_secs,
|
||||||
|
) {
|
||||||
|
Some(parsed) => parsed,
|
||||||
|
None => {
|
||||||
|
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
debug!(peer = %peer, "TLS handshake auth material parsing failed");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let sticky_ip_hint = sticky_hint_get_by_ip(shared, peer.ip());
|
||||||
|
let preferred_user_id = preferred_user_hint.and_then(|user| snapshot.user_id_by_name(user));
|
||||||
|
let sticky_sni_hint = client_sni
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|sni| sticky_hint_get_by_sni(shared, sni));
|
||||||
|
let sticky_prefix_hint = sticky_hint_get_by_ip_prefix(shared, peer.ip());
|
||||||
|
let sni_candidates = client_sni
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|sni| snapshot.sni_candidates(sni));
|
||||||
|
let sni_initial_candidates = client_sni
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|sni| snapshot.sni_initial_candidates(sni));
|
||||||
|
|
||||||
|
let has_hint = sticky_ip_hint.is_some()
|
||||||
|
|| preferred_user_id.is_some()
|
||||||
|
|| sticky_sni_hint.is_some()
|
||||||
|
|| sticky_prefix_hint.is_some()
|
||||||
|
|| sni_candidates.is_some_and(|ids| !ids.is_empty())
|
||||||
|
|| sni_initial_candidates.is_some_and(|ids| !ids.is_empty());
|
||||||
|
let overload = auth_probe_saturation_is_throttled_in(shared, Instant::now());
|
||||||
|
let candidate_budget = budget_for_validation(snapshot.entries().len(), overload, has_hint);
|
||||||
|
|
||||||
|
let mut tried_user_ids = [u32::MAX; CANDIDATE_HINT_TRACK_CAP];
|
||||||
|
let mut tried_len = 0usize;
|
||||||
|
let mut validation_checks = 0usize;
|
||||||
|
let mut budget_exhausted = false;
|
||||||
|
|
||||||
|
macro_rules! try_user_id {
|
||||||
|
($user_id:expr) => {{
|
||||||
|
if validation_checks >= candidate_budget {
|
||||||
|
budget_exhausted = true;
|
||||||
|
false
|
||||||
|
} else if !mark_candidate_if_new(&mut tried_user_ids, &mut tried_len, $user_id) {
|
||||||
|
false
|
||||||
|
} else if let Some(entry) = snapshot.entry_by_id($user_id) {
|
||||||
|
validation_checks = validation_checks.saturating_add(1);
|
||||||
|
if let Some(candidate) =
|
||||||
|
validate_tls_secret_candidate(&parsed, handshake, &entry.secret)
|
||||||
|
{
|
||||||
|
validation_digest = candidate.digest;
|
||||||
|
validation_session_id = candidate.session_id;
|
||||||
|
validation_session_id_len = candidate.session_id_len;
|
||||||
|
validated_secret.copy_from_slice(&entry.secret);
|
||||||
|
validated_user = entry.user.clone();
|
||||||
|
validated_user_id = Some($user_id);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut matched = false;
|
||||||
|
if let Some(user_id) = sticky_ip_hint {
|
||||||
|
matched = try_user_id!(user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matched && let Some(user_id) = preferred_user_id {
|
||||||
|
matched = try_user_id!(user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matched && let Some(user_id) = sticky_sni_hint {
|
||||||
|
matched = try_user_id!(user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matched && let Some(user_id) = sticky_prefix_hint {
|
||||||
|
matched = try_user_id!(user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matched
|
||||||
|
&& !budget_exhausted
|
||||||
|
&& let Some(candidate_ids) = sni_candidates
|
||||||
|
{
|
||||||
|
for &user_id in candidate_ids {
|
||||||
|
if try_user_id!(user_id) {
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if budget_exhausted {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matched
|
||||||
|
&& !budget_exhausted
|
||||||
|
&& let Some(candidate_ids) = sni_initial_candidates
|
||||||
|
{
|
||||||
|
for &user_id in candidate_ids {
|
||||||
|
if try_user_id!(user_id) {
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if budget_exhausted {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matched && !budget_exhausted {
|
||||||
|
let ring = &shared.handshake.recent_user_ring;
|
||||||
|
if !ring.is_empty() {
|
||||||
|
let next_seq = shared
|
||||||
|
.handshake
|
||||||
|
.recent_user_ring_seq
|
||||||
|
.load(Ordering::Relaxed);
|
||||||
|
let scan_limit = ring.len().min(RECENT_USER_RING_SCAN_LIMIT);
|
||||||
|
for offset in 0..scan_limit {
|
||||||
|
let idx = (next_seq as usize + ring.len() - 1 - offset) % ring.len();
|
||||||
|
let encoded_user_id = ring[idx].load(Ordering::Relaxed);
|
||||||
|
if encoded_user_id == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if try_user_id!(encoded_user_id - 1) {
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if budget_exhausted {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matched && !budget_exhausted {
|
||||||
|
for idx in 0..snapshot.entries().len() {
|
||||||
|
let Some(user_id) = u32::try_from(idx).ok() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if try_user_id!(user_id) {
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if budget_exhausted {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shared
|
||||||
|
.handshake
|
||||||
|
.auth_expensive_checks_total
|
||||||
|
.fetch_add(validation_checks as u64, Ordering::Relaxed);
|
||||||
|
if budget_exhausted {
|
||||||
|
shared
|
||||||
|
.handshake
|
||||||
|
.auth_budget_exhausted_total
|
||||||
|
.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matched {
|
||||||
|
let failure_now = Instant::now();
|
||||||
|
auth_probe_note_expensive_invalid_scan_in(
|
||||||
|
shared,
|
||||||
|
failure_now,
|
||||||
|
validation_checks,
|
||||||
|
overload,
|
||||||
|
);
|
||||||
|
auth_probe_record_failure_in(shared, peer.ip(), failure_now);
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
debug!(
|
||||||
|
peer = %peer,
|
||||||
|
ignore_time_skew = config.access.ignore_time_skew,
|
||||||
|
budget_exhausted = budget_exhausted,
|
||||||
|
candidate_budget = candidate_budget,
|
||||||
|
validation_checks = validation_checks,
|
||||||
|
"TLS handshake validation failed - no matching user, time skew, or budget exhausted"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let secrets = decode_user_secrets_in(shared, config, preferred_user_hint);
|
||||||
|
let validation = match tls::validate_tls_handshake_with_replay_window(
|
||||||
|
handshake,
|
||||||
|
&secrets,
|
||||||
|
config.access.ignore_time_skew,
|
||||||
|
config.access.replay_window_secs,
|
||||||
|
) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
debug!(
|
||||||
|
peer = %peer,
|
||||||
|
ignore_time_skew = config.access.ignore_time_skew,
|
||||||
|
"TLS handshake validation failed - no matching user or time skew"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let secret = match secrets.iter().find(|(name, _)| *name == validation.user) {
|
||||||
|
Some((_, s)) if s.len() == ACCESS_SECRET_BYTES => s,
|
||||||
|
_ => {
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
validation_digest = validation.digest;
|
||||||
|
validation_session_id_len = validation.session_id.len();
|
||||||
|
if validation_session_id_len > validation_session_id.len() {
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
validation_session_id[..validation_session_id_len].copy_from_slice(&validation.session_id);
|
||||||
|
validated_user = validation.user;
|
||||||
|
validated_secret.copy_from_slice(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
if config
|
||||||
|
.access
|
||||||
|
.is_user_source_ip_denied(validated_user.as_str(), peer.ip())
|
||||||
|
{
|
||||||
|
auth_probe_record_failure_in(shared, peer.ip(), Instant::now());
|
||||||
|
maybe_apply_server_hello_delay(config).await;
|
||||||
|
warn!(
|
||||||
|
peer = %peer,
|
||||||
|
user = %validated_user,
|
||||||
|
"TLS handshake rejected: client source IP on per-user deny list (access.user_source_deny)"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(TlsClientValidation {
|
||||||
|
digest: validation_digest,
|
||||||
|
session_id: validation_session_id,
|
||||||
|
session_id_len: validation_session_id_len,
|
||||||
|
user: validated_user,
|
||||||
|
secret: validated_secret,
|
||||||
|
user_id: validated_user_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn handshake_source() -> &'static str {
|
fn handshake_source() -> &'static str {
|
||||||
include_str!("../handshake.rs")
|
include_str!("../handshake/auth_candidates.rs")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -125,3 +125,7 @@ mod tests {
|
|||||||
assert!(!is_pf_anchor_hook_line("anchor \"other\" all"));
|
assert!(!is_pf_anchor_hook_line("anchor \"other\" all"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "pf_runtime_tests.rs"]
|
||||||
|
mod runtime_tests;
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::synlimit_control::model::{
|
||||||
|
SynLimitNamespace, SynLimitRule, SynLimitTargets, synlimit_namespace,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn rule(ip: IpAddr, hitcount: u32) -> SynLimitRule {
|
||||||
|
SynLimitRule {
|
||||||
|
ip: Some(ip),
|
||||||
|
port: 24443,
|
||||||
|
generic_seconds: 60,
|
||||||
|
generic_hitcount: hitcount,
|
||||||
|
generic_burst: 24,
|
||||||
|
ios_seconds: 1,
|
||||||
|
ios_hitcount: 12,
|
||||||
|
ios_burst: 24,
|
||||||
|
hashlimit_expire_ms: 60_000,
|
||||||
|
hashlimit_size: 32_768,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn targets(low_family: &str) -> SynLimitTargets {
|
||||||
|
let (v4_rate, v6_rate) = match low_family {
|
||||||
|
"v4" => (2, 100),
|
||||||
|
"v6" => (100, 2),
|
||||||
|
_ => panic!("TELEMT_PF_LOW_FAMILY must be v4 or v6"),
|
||||||
|
};
|
||||||
|
SynLimitTargets {
|
||||||
|
pf_v4: vec![rule(
|
||||||
|
IpAddr::V4(Ipv4Addr::new(198, 18, 1, 1)),
|
||||||
|
v4_rate,
|
||||||
|
)],
|
||||||
|
pf_v6: vec![rule(
|
||||||
|
IpAddr::V6("fd00:18:1::1".parse::<Ipv6Addr>().unwrap()),
|
||||||
|
v6_rate,
|
||||||
|
)],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_metadata(path: &str, namespace: &SynLimitNamespace) {
|
||||||
|
std::fs::write(path, format!("anchor={}\n", namespace.pf_anchor)).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_file(path: &Path) {
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), async {
|
||||||
|
while !path.exists() {
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("PF runtime-test release barrier timed out");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires native FreeBSD PF and VNET test harness"]
|
||||||
|
async fn production_pf_runtime_role() {
|
||||||
|
let role = std::env::var("TELEMT_PF_ROLE").expect("TELEMT_PF_ROLE is required");
|
||||||
|
let low_family = std::env::var("TELEMT_PF_LOW_FAMILY").unwrap_or_else(|_| "v4".to_string());
|
||||||
|
let targets = targets(&low_family);
|
||||||
|
let namespace = synlimit_namespace(&targets).expect("PF namespace missing");
|
||||||
|
let metadata = std::env::var("TELEMT_PF_META").expect("TELEMT_PF_META is required");
|
||||||
|
match role.as_str() {
|
||||||
|
"render" => {
|
||||||
|
let script_path = std::env::var("TELEMT_PF_SCRIPT").unwrap();
|
||||||
|
std::fs::write(script_path, pf_synlimit_script(&targets)).unwrap();
|
||||||
|
write_metadata(&metadata, &namespace);
|
||||||
|
}
|
||||||
|
"apply-wait" => {
|
||||||
|
apply_synlimit_rules(&targets, &namespace).await.unwrap();
|
||||||
|
write_metadata(&metadata, &namespace);
|
||||||
|
let barrier = std::env::var("TELEMT_PF_BARRIER").unwrap();
|
||||||
|
let release = std::env::var("TELEMT_PF_RELEASE").unwrap();
|
||||||
|
std::fs::write(&barrier, b"ready").unwrap();
|
||||||
|
wait_for_file(Path::new(&release)).await;
|
||||||
|
assert!(clear_rules(&namespace).await.unwrap());
|
||||||
|
}
|
||||||
|
_ => panic!("TELEMT_PF_ROLE must be render or apply-wait"),
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-4
@@ -278,7 +278,7 @@ pub fn normalize_ip(addr: SocketAddr) -> SocketAddr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Socket options for server listening
|
/// Socket options for server listening
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ListenOptions {
|
pub struct ListenOptions {
|
||||||
/// Enable SO_REUSEADDR
|
/// Enable SO_REUSEADDR
|
||||||
pub reuse_addr: bool,
|
pub reuse_addr: bool,
|
||||||
@@ -304,8 +304,8 @@ impl Default for ListenOptions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a listening socket with the specified options
|
/// Binds a server socket without making it externally accepting.
|
||||||
pub fn create_listener(addr: SocketAddr, options: &ListenOptions) -> Result<Socket> {
|
pub(crate) fn bind_listener_socket(addr: SocketAddr, options: &ListenOptions) -> Result<Socket> {
|
||||||
let domain = if addr.is_ipv4() {
|
let domain = if addr.is_ipv4() {
|
||||||
Domain::IPV4
|
Domain::IPV4
|
||||||
} else {
|
} else {
|
||||||
@@ -342,7 +342,21 @@ pub fn create_listener(addr: SocketAddr, options: &ListenOptions) -> Result<Sock
|
|||||||
|
|
||||||
socket.set_nonblocking(true)?;
|
socket.set_nonblocking(true)?;
|
||||||
socket.bind(&addr.into())?;
|
socket.bind(&addr.into())?;
|
||||||
socket.listen(options.backlog as i32)?;
|
|
||||||
|
debug!(addr = %addr, "Bound server socket");
|
||||||
|
|
||||||
|
Ok(socket)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Activates a previously bound server socket with the configured backlog.
|
||||||
|
pub(crate) fn activate_listener_socket(socket: &Socket, backlog: u32) -> Result<()> {
|
||||||
|
socket.listen(backlog as i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a listening socket with the specified options
|
||||||
|
pub fn create_listener(addr: SocketAddr, options: &ListenOptions) -> Result<Socket> {
|
||||||
|
let socket = bind_listener_socket(addr, options)?;
|
||||||
|
activate_listener_socket(&socket, options.backlog)?;
|
||||||
|
|
||||||
debug!(addr = %addr, "Created listening socket");
|
debug!(addr = %addr, "Created listening socket");
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,83 @@ use std::os::fd::{AsRawFd, BorrowedFd, RawFd};
|
|||||||
use tokio::io::Interest;
|
use tokio::io::Interest;
|
||||||
use tokio::io::unix::AsyncFd;
|
use tokio::io::unix::AsyncFd;
|
||||||
|
|
||||||
|
struct TcpMaxSegmentGuard {
|
||||||
|
fd: RawFd,
|
||||||
|
original: libc::c_int,
|
||||||
|
changed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tcp_max_segment(fd: RawFd) -> Result<libc::c_int> {
|
||||||
|
let mut value: libc::c_int = 0;
|
||||||
|
let mut length = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
|
||||||
|
let rc = unsafe {
|
||||||
|
libc::getsockopt(
|
||||||
|
fd,
|
||||||
|
libc::IPPROTO_TCP,
|
||||||
|
libc::TCP_MAXSEG,
|
||||||
|
&mut value as *mut libc::c_int as *mut libc::c_void,
|
||||||
|
&mut length,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc != 0 {
|
||||||
|
return Err(Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_tcp_max_segment(fd: RawFd, value: libc::c_int) -> Result<()> {
|
||||||
|
let rc = unsafe {
|
||||||
|
libc::setsockopt(
|
||||||
|
fd,
|
||||||
|
libc::IPPROTO_TCP,
|
||||||
|
libc::TCP_MAXSEG,
|
||||||
|
&value as *const libc::c_int as *const libc::c_void,
|
||||||
|
std::mem::size_of::<libc::c_int>() as libc::socklen_t,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc != 0 {
|
||||||
|
return Err(Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TcpMaxSegmentGuard {
|
||||||
|
fn install(fd: RawFd, requested: usize) -> Result<Self> {
|
||||||
|
let requested = libc::c_int::try_from(requested).map_err(|_| {
|
||||||
|
Error::new(
|
||||||
|
ErrorKind::InvalidInput,
|
||||||
|
"TCP fragment size exceeds the platform integer range",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let original = tcp_max_segment(fd)?;
|
||||||
|
let changed = requested < original;
|
||||||
|
if changed {
|
||||||
|
set_tcp_max_segment(fd, requested)?;
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
fd,
|
||||||
|
original,
|
||||||
|
changed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore(mut self) -> Result<()> {
|
||||||
|
if self.changed {
|
||||||
|
set_tcp_max_segment(self.fd, self.original)?;
|
||||||
|
self.changed = false;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TcpMaxSegmentGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.changed {
|
||||||
|
let _ = set_tcp_max_segment(self.fd, self.original);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn force_tcp_push(fd: RawFd) -> Result<()> {
|
fn force_tcp_push(fd: RawFd) -> Result<()> {
|
||||||
let enabled: libc::c_int = 1;
|
let enabled: libc::c_int = 1;
|
||||||
let rc = unsafe {
|
let rc = unsafe {
|
||||||
@@ -25,8 +102,9 @@ fn force_tcp_push(fd: RawFd) -> Result<()> {
|
|||||||
///
|
///
|
||||||
/// `fd` must refer to a connected, nonblocking TCP socket and remain valid for
|
/// `fd` must refer to a connected, nonblocking TCP socket and remain valid for
|
||||||
/// the duration of this call. The caller retains ownership of the original fd.
|
/// the duration of this call. The caller retains ownership of the original fd.
|
||||||
/// `MSG_EOR` is only a best-effort Linux hint for TCP: offloads, loss, and
|
/// The accepted socket is clamped to the requested `TCP_MAXSEG` for this send
|
||||||
/// retransmission may coalesce these write boundaries on the wire.
|
/// and restored on success, error, or task cancellation. `MSG_EOR` remains only
|
||||||
|
/// a best-effort Linux hint: offloads may still coalesce capture boundaries.
|
||||||
pub(crate) async fn send_tcp_fragmented_fd(
|
pub(crate) async fn send_tcp_fragmented_fd(
|
||||||
fd: RawFd,
|
fd: RawFd,
|
||||||
data: &[u8],
|
data: &[u8],
|
||||||
@@ -49,7 +127,9 @@ pub(crate) async fn send_tcp_fragmented_fd(
|
|||||||
let borrowed_fd = unsafe { BorrowedFd::borrow_raw(fd) };
|
let borrowed_fd = unsafe { BorrowedFd::borrow_raw(fd) };
|
||||||
let duplicated_fd = borrowed_fd.try_clone_to_owned()?;
|
let duplicated_fd = borrowed_fd.try_clone_to_owned()?;
|
||||||
let async_fd = AsyncFd::with_interest(duplicated_fd, Interest::WRITABLE)?;
|
let async_fd = AsyncFd::with_interest(duplicated_fd, Interest::WRITABLE)?;
|
||||||
|
let mss_guard = TcpMaxSegmentGuard::install(async_fd.get_ref().as_raw_fd(), fragment_size)?;
|
||||||
|
|
||||||
|
let send_result = async {
|
||||||
for fragment in data.chunks(fragment_size) {
|
for fragment in data.chunks(fragment_size) {
|
||||||
let mut offset = 0;
|
let mut offset = 0;
|
||||||
while offset < fragment.len() {
|
while offset < fragment.len() {
|
||||||
@@ -85,6 +165,14 @@ pub(crate) async fn send_tcp_fragmented_fd(
|
|||||||
force_tcp_push(async_fd.get_ref().as_raw_fd())?;
|
force_tcp_push(async_fd.get_ref().as_raw_fd())?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let restore_result = mss_guard.restore();
|
||||||
|
send_result.and(restore_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "fragmented_send_wire_tests.rs"]
|
||||||
|
mod wire_tests;
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::os::fd::{AsRawFd, BorrowedFd};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
|
||||||
|
use crate::config::ProxyConfig;
|
||||||
|
use crate::crypto::{SecureRandom, sha256_hmac};
|
||||||
|
use crate::error::HandshakeResult;
|
||||||
|
use crate::protocol::constants::TLS_VERSION;
|
||||||
|
use crate::protocol::tls;
|
||||||
|
use crate::proxy::handshake::{
|
||||||
|
TlsResponseWriteOptions, handle_tls_handshake_with_shared_and_options,
|
||||||
|
};
|
||||||
|
use crate::proxy::shared_state::ProxySharedState;
|
||||||
|
use crate::stats::ReplayChecker;
|
||||||
|
use crate::transport::socket::{ListenOptions, create_listener};
|
||||||
|
|
||||||
|
const SECRET: [u8; 16] = [0x56; 16];
|
||||||
|
const SECRET_HEX: &str = "56565656565656565656565656565656";
|
||||||
|
const BULK_PAYLOAD_LEN: usize = 8192;
|
||||||
|
|
||||||
|
fn make_valid_tls_client_hello(tls_len: usize) -> Vec<u8> {
|
||||||
|
const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01];
|
||||||
|
const TLS_EXTENSION_KEY_SHARE: u16 = 0x0033;
|
||||||
|
const TLS_EXTENSION_PADDING: u16 = 0x0015;
|
||||||
|
const X25519_KEY_SHARE_LEN: usize = 32;
|
||||||
|
let fill = 0x42_u8;
|
||||||
|
let session_id_len = 32_usize;
|
||||||
|
let mut extensions = Vec::new();
|
||||||
|
let mut key_share = Vec::new();
|
||||||
|
key_share.extend_from_slice(&tls::TLS_NAMED_GROUP_X25519.to_be_bytes());
|
||||||
|
key_share.extend_from_slice(&(X25519_KEY_SHARE_LEN as u16).to_be_bytes());
|
||||||
|
key_share.push(9);
|
||||||
|
key_share.resize(key_share.len() + X25519_KEY_SHARE_LEN - 1, 0);
|
||||||
|
let mut key_share_extension = Vec::new();
|
||||||
|
key_share_extension.extend_from_slice(&(key_share.len() as u16).to_be_bytes());
|
||||||
|
key_share_extension.extend_from_slice(&key_share);
|
||||||
|
extensions.extend_from_slice(&TLS_EXTENSION_KEY_SHARE.to_be_bytes());
|
||||||
|
extensions.extend_from_slice(&(key_share_extension.len() as u16).to_be_bytes());
|
||||||
|
extensions.extend_from_slice(&key_share_extension);
|
||||||
|
let base_tls_len = 4
|
||||||
|
+ 2
|
||||||
|
+ 32
|
||||||
|
+ 1
|
||||||
|
+ session_id_len
|
||||||
|
+ 2
|
||||||
|
+ TLS_AES_128_GCM_SHA256.len()
|
||||||
|
+ 1
|
||||||
|
+ 1
|
||||||
|
+ 2
|
||||||
|
+ extensions.len();
|
||||||
|
let padding_len = tls_len
|
||||||
|
.checked_sub(base_tls_len + 4)
|
||||||
|
.expect("wire ClientHello must leave room for padding");
|
||||||
|
extensions.extend_from_slice(&TLS_EXTENSION_PADDING.to_be_bytes());
|
||||||
|
extensions.extend_from_slice(&(padding_len as u16).to_be_bytes());
|
||||||
|
extensions.resize(extensions.len() + padding_len, fill);
|
||||||
|
|
||||||
|
let body_len = tls_len - 4;
|
||||||
|
let mut body = Vec::with_capacity(body_len);
|
||||||
|
body.extend_from_slice(&TLS_VERSION);
|
||||||
|
body.extend_from_slice(&[fill; 32]);
|
||||||
|
body.push(session_id_len as u8);
|
||||||
|
body.extend_from_slice(&[fill; 32]);
|
||||||
|
body.extend_from_slice(&(TLS_AES_128_GCM_SHA256.len() as u16).to_be_bytes());
|
||||||
|
body.extend_from_slice(&TLS_AES_128_GCM_SHA256);
|
||||||
|
body.push(1);
|
||||||
|
body.push(0);
|
||||||
|
body.extend_from_slice(&(extensions.len() as u16).to_be_bytes());
|
||||||
|
body.extend_from_slice(&extensions);
|
||||||
|
let mut handshake = Vec::with_capacity(5 + tls_len);
|
||||||
|
handshake.push(0x16);
|
||||||
|
handshake.extend_from_slice(&[0x03, 0x01]);
|
||||||
|
handshake.extend_from_slice(&(tls_len as u16).to_be_bytes());
|
||||||
|
handshake.push(0x01);
|
||||||
|
handshake.extend_from_slice(&(body_len as u32).to_be_bytes()[1..].as_ref());
|
||||||
|
handshake.extend_from_slice(&body);
|
||||||
|
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN].fill(0);
|
||||||
|
let digest = sha256_hmac(&SECRET, &handshake);
|
||||||
|
handshake[tls::TLS_DIGEST_POS..tls::TLS_DIGEST_POS + tls::TLS_DIGEST_LEN]
|
||||||
|
.copy_from_slice(&digest);
|
||||||
|
handshake
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_file(path: &Path) {
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), async {
|
||||||
|
while !path.exists() {
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("wire-test release barrier timed out");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_server(addr: SocketAddr, fragment_size: u16, fake_cert_len: usize) {
|
||||||
|
let socket = create_listener(
|
||||||
|
addr,
|
||||||
|
&ListenOptions {
|
||||||
|
reuse_port: false,
|
||||||
|
client_mss: Some(1400),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let listener = TcpListener::from_std(socket.into()).unwrap();
|
||||||
|
let (mut server, peer) = listener.accept().await.unwrap();
|
||||||
|
let mut header = [0_u8; 5];
|
||||||
|
server.read_exact(&mut header).await.unwrap();
|
||||||
|
let body_len = u16::from_be_bytes([header[3], header[4]]) as usize;
|
||||||
|
let mut client_hello = Vec::with_capacity(5 + body_len);
|
||||||
|
client_hello.extend_from_slice(&header);
|
||||||
|
client_hello.resize(5 + body_len, 0);
|
||||||
|
server.read_exact(&mut client_hello[5..]).await.unwrap();
|
||||||
|
let barrier = std::env::var("TELEMT_WIRE_BARRIER").unwrap();
|
||||||
|
let release = std::env::var("TELEMT_WIRE_RELEASE").unwrap();
|
||||||
|
std::fs::write(&barrier, b"ready").unwrap();
|
||||||
|
wait_for_file(Path::new(&release)).await;
|
||||||
|
|
||||||
|
let raw_fd = server.as_raw_fd();
|
||||||
|
let mss_before = socket2::SockRef::from(&server).tcp_mss().unwrap();
|
||||||
|
let (read_half, write_half) = server.into_split();
|
||||||
|
let mut config = ProxyConfig::default();
|
||||||
|
config.general.beobachten = false;
|
||||||
|
config.access.ignore_time_skew = true;
|
||||||
|
config.censorship.fake_cert_len = fake_cert_len;
|
||||||
|
config
|
||||||
|
.access
|
||||||
|
.users
|
||||||
|
.insert("wire".to_string(), SECRET_HEX.to_string());
|
||||||
|
let replay_checker = ReplayChecker::new(128, Duration::from_secs(60));
|
||||||
|
let rng = SecureRandom::new();
|
||||||
|
let shared = ProxySharedState::new();
|
||||||
|
let (tls_reader, mut tls_writer, user) = match
|
||||||
|
handle_tls_handshake_with_shared_and_options(
|
||||||
|
&client_hello,
|
||||||
|
read_half,
|
||||||
|
write_half,
|
||||||
|
peer,
|
||||||
|
&config,
|
||||||
|
&replay_checker,
|
||||||
|
&rng,
|
||||||
|
None,
|
||||||
|
&shared,
|
||||||
|
TlsResponseWriteOptions::tcp(raw_fd, Some(fragment_size)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
HandshakeResult::Success(result) => result,
|
||||||
|
_ => panic!("wire-test FakeTLS authentication failed"),
|
||||||
|
};
|
||||||
|
assert_eq!(user, "wire");
|
||||||
|
tls_writer.write_all(&vec![0xA5; BULK_PAYLOAD_LEN]).await.unwrap();
|
||||||
|
tls_writer.shutdown().await.unwrap();
|
||||||
|
drop(tls_reader);
|
||||||
|
// SAFETY: the write half still owns the accepted socket while it is borrowed.
|
||||||
|
let borrowed_fd = unsafe { BorrowedFd::borrow_raw(raw_fd) };
|
||||||
|
let mss_after = socket2::SockRef::from(&borrowed_fd).tcp_mss().unwrap();
|
||||||
|
let metadata = std::env::var("TELEMT_WIRE_SERVER_META").unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
metadata,
|
||||||
|
format!("configured_bulk_mss=1400\nmss_before={mss_before}\nmss_after={mss_after}\n"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_client(addr: SocketAddr) {
|
||||||
|
let mut client = loop {
|
||||||
|
match TcpStream::connect(addr).await {
|
||||||
|
Ok(stream) => break stream,
|
||||||
|
Err(_) => tokio::time::sleep(Duration::from_millis(20)).await,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
client
|
||||||
|
.write_all(&make_valid_tls_client_hello(600))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut response = Vec::new();
|
||||||
|
client.read_to_end(&mut response).await.unwrap();
|
||||||
|
let mut offset = 0;
|
||||||
|
let mut bulk_record_start = None;
|
||||||
|
while offset < response.len() {
|
||||||
|
assert!(response.len() - offset >= 5, "truncated TLS record header");
|
||||||
|
let payload_len = u16::from_be_bytes([response[offset + 3], response[offset + 4]]) as usize;
|
||||||
|
let end = offset + 5 + payload_len;
|
||||||
|
assert!(end <= response.len(), "truncated TLS record payload");
|
||||||
|
if response[offset] == 0x17
|
||||||
|
&& payload_len == BULK_PAYLOAD_LEN
|
||||||
|
&& response[offset + 5..end].iter().all(|byte| *byte == 0xA5)
|
||||||
|
{
|
||||||
|
bulk_record_start = Some(offset);
|
||||||
|
}
|
||||||
|
offset = end;
|
||||||
|
}
|
||||||
|
let initial_response_bytes = bulk_record_start.expect("bulk TLS record missing");
|
||||||
|
let metadata = std::env::var("TELEMT_WIRE_CLIENT_META").unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
metadata,
|
||||||
|
format!(
|
||||||
|
"initial_response_bytes={initial_response_bytes}\ntotal_response_bytes={}\n",
|
||||||
|
response.len()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires privileged netns/veth packet-capture harness"]
|
||||||
|
async fn fake_tls_fragmentation_wire_role() {
|
||||||
|
let role = std::env::var("TELEMT_WIRE_ROLE").expect("TELEMT_WIRE_ROLE is required");
|
||||||
|
let addr = std::env::var("TELEMT_WIRE_ADDR")
|
||||||
|
.unwrap_or_else(|_| "198.18.0.1:24443".to_string())
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
match role.as_str() {
|
||||||
|
"server" => {
|
||||||
|
let fragment_size = std::env::var("TELEMT_WIRE_FRAGMENT")
|
||||||
|
.unwrap()
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
let fake_cert_len = std::env::var("TELEMT_WIRE_FAKE_CERT_LEN")
|
||||||
|
.unwrap()
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
run_server(addr, fragment_size, fake_cert_len).await;
|
||||||
|
}
|
||||||
|
"client" => run_client(addr).await,
|
||||||
|
_ => panic!("TELEMT_WIRE_ROLE must be server or client"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -249,7 +249,34 @@ async fn test_chunked_send_preserves_stream_and_configured_mss() {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
mss_after, mss_before,
|
mss_after, mss_before,
|
||||||
"chunked send must not change the configured socket MSS"
|
"chunked send must restore the configured socket MSS"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_chunked_send_restores_mss_after_send_error() {
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let _client = TcpStream::connect(addr).await.unwrap();
|
||||||
|
let (server, _) = listener.accept().await.unwrap();
|
||||||
|
let mss_before = socket2::SockRef::from(&server).tcp_mss().unwrap();
|
||||||
|
let shutdown_result = unsafe { libc::shutdown(server.as_raw_fd(), libc::SHUT_WR) };
|
||||||
|
assert_eq!(shutdown_result, 0);
|
||||||
|
|
||||||
|
let error = send_tcp_fragmented_fd(server.as_raw_fd(), &[0xA5; 4096], 92)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
error.kind(),
|
||||||
|
ErrorKind::BrokenPipe | ErrorKind::ConnectionReset | ErrorKind::NotConnected
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
socket2::SockRef::from(&server).tcp_mss().unwrap(),
|
||||||
|
mss_before
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,17 +306,24 @@ async fn test_chunked_send_has_no_fd_growth_after_success_and_cancellation_stres
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
let baseline_fds = std::fs::read_dir("/proc/self/fd").unwrap().count();
|
let baseline_fds = std::fs::read_dir("/proc/self/fd").unwrap().count();
|
||||||
|
let payload = Arc::new(vec![0xA5; 1024 * 1024]);
|
||||||
|
|
||||||
let blocked_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let options = ListenOptions {
|
||||||
|
reuse_port: false,
|
||||||
|
client_mss: Some(1400),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let blocked_socket = create_listener("127.0.0.1:0".parse().unwrap(), &options).unwrap();
|
||||||
|
let blocked_listener = TcpListener::from_std(blocked_socket.into()).unwrap();
|
||||||
let blocked_addr = blocked_listener.local_addr().unwrap();
|
let blocked_addr = blocked_listener.local_addr().unwrap();
|
||||||
let _blocked_client = TcpStream::connect(blocked_addr).await.unwrap();
|
for _ in 0..5_000 {
|
||||||
|
let blocked_client = TcpStream::connect(blocked_addr).await.unwrap();
|
||||||
let (blocked_server, _) = blocked_listener.accept().await.unwrap();
|
let (blocked_server, _) = blocked_listener.accept().await.unwrap();
|
||||||
|
let blocked_mss_before = socket2::SockRef::from(&blocked_server).tcp_mss().unwrap();
|
||||||
socket2::SockRef::from(&blocked_server)
|
socket2::SockRef::from(&blocked_server)
|
||||||
.set_send_buffer_size(4 * 1024)
|
.set_send_buffer_size(4 * 1024)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let blocked_fd = blocked_server.as_raw_fd();
|
let blocked_fd = blocked_server.as_raw_fd();
|
||||||
let payload = Arc::new(vec![0xA5; 1024 * 1024]);
|
|
||||||
for _ in 0..5_000 {
|
|
||||||
let payload = payload.clone();
|
let payload = payload.clone();
|
||||||
let sender = tokio::spawn(async move {
|
let sender = tokio::spawn(async move {
|
||||||
send_tcp_fragmented_fd(blocked_fd, payload.as_slice(), 92).await
|
send_tcp_fragmented_fd(blocked_fd, payload.as_slice(), 92).await
|
||||||
@@ -297,29 +331,43 @@ async fn test_chunked_send_has_no_fd_growth_after_success_and_cancellation_stres
|
|||||||
tokio::task::yield_now().await;
|
tokio::task::yield_now().await;
|
||||||
sender.abort();
|
sender.abort();
|
||||||
let _ = sender.await;
|
let _ = sender.await;
|
||||||
|
assert_eq!(
|
||||||
|
socket2::SockRef::from(&blocked_server).tcp_mss().unwrap(),
|
||||||
|
blocked_mss_before,
|
||||||
|
"cancellation must restore the accepted socket MSS"
|
||||||
|
);
|
||||||
|
drop(blocked_server);
|
||||||
|
drop(blocked_client);
|
||||||
}
|
}
|
||||||
|
|
||||||
let success_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let success_socket = create_listener("127.0.0.1:0".parse().unwrap(), &options).unwrap();
|
||||||
|
let success_listener = TcpListener::from_std(success_socket.into()).unwrap();
|
||||||
let success_addr = success_listener.local_addr().unwrap();
|
let success_addr = success_listener.local_addr().unwrap();
|
||||||
|
for iteration in 0..5_000 {
|
||||||
let mut success_client = TcpStream::connect(success_addr).await.unwrap();
|
let mut success_client = TcpStream::connect(success_addr).await.unwrap();
|
||||||
let (success_server, _) = success_listener.accept().await.unwrap();
|
let (success_server, _) = success_listener.accept().await.unwrap();
|
||||||
|
let success_mss_before = socket2::SockRef::from(&success_server).tcp_mss().unwrap();
|
||||||
let success_fd = success_server.as_raw_fd();
|
let success_fd = success_server.as_raw_fd();
|
||||||
let reader = tokio::spawn(async move {
|
|
||||||
let mut received = vec![0_u8; 5_000];
|
|
||||||
success_client.read_exact(&mut received).await.unwrap();
|
|
||||||
received
|
|
||||||
});
|
|
||||||
for _ in 0..5_000 {
|
|
||||||
send_tcp_fragmented_fd(success_fd, &[0x5A], 92)
|
send_tcp_fragmented_fd(success_fd, &[0x5A], 92)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap_or_else(|error| {
|
||||||
}
|
panic!(
|
||||||
assert!(reader.await.unwrap().iter().all(|byte| *byte == 0x5A));
|
"success cycle {iteration} failed with MSS {success_mss_before}: {error}"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let mut received = [0_u8; 1];
|
||||||
|
success_client.read_exact(&mut received).await.unwrap();
|
||||||
|
assert_eq!(received, [0x5A]);
|
||||||
|
assert_eq!(
|
||||||
|
socket2::SockRef::from(&success_server).tcp_mss().unwrap(),
|
||||||
|
success_mss_before,
|
||||||
|
"successful send must restore the accepted socket MSS"
|
||||||
|
);
|
||||||
drop(success_server);
|
drop(success_server);
|
||||||
|
drop(success_client);
|
||||||
|
}
|
||||||
|
|
||||||
drop(success_listener);
|
drop(success_listener);
|
||||||
drop(blocked_server);
|
|
||||||
drop(_blocked_client);
|
|
||||||
drop(blocked_listener);
|
drop(blocked_listener);
|
||||||
|
|
||||||
let final_fds = std::fs::read_dir("/proc/self/fd").unwrap().count();
|
let final_fds = std::fs::read_dir("/proc/self/fd").unwrap().count();
|
||||||
|
|||||||
Reference in New Issue
Block a user