Hardened listener reload + config persistence + SYN Limit startup safety

This commit is contained in:
Alexey
2026-08-22 13:45:57 +03:00
parent 96d467a400
commit 189e10800a
28 changed files with 2749 additions and 1900 deletions
+2 -3
View File
@@ -152,8 +152,7 @@ pub(crate) async fn bind_listeners(
%addr,
fragment_size,
bulk_mss = client_mss,
segment_multiplier = mss_segment_multiplier(fragment_size),
"Initial FakeTLS response fragmentation configured"
"Initial FakeTLS response best-effort chunking configured"
);
}
let listener_proxy_protocol = listener_conf
@@ -518,7 +517,7 @@ mod tests {
}
#[test]
fn client_mss_with_bulk_uses_bulk_listener_and_fragments_initial_response() {
fn client_mss_with_bulk_uses_bulk_listener_and_chunks_initial_response() {
assert_eq!(
tcp_mss_runtime_profile(Some(92), Some(1400)),
(Some(1400), Some(92))
+67 -7
View File
@@ -33,7 +33,7 @@ use tracing::{error, info, warn};
use tracing_subscriber::{EnvFilter, fmt, prelude::*, reload as tracing_reload};
use crate::api;
use crate::config::{LogLevel, ProxyConfig};
use crate::config::{LogLevel, ProxyConfig, SynLimitMode};
use crate::conntrack_control;
use crate::crypto::SecureRandom;
use crate::ip_tracker::UserIpTracker;
@@ -96,6 +96,7 @@ pub async fn run() -> std::result::Result<(), Box<dyn std::error::Error>> {
// Shared maestro startup and main loop. `drop_after_bind` runs on Unix after listeners are bound
// (for privilege drop); it is a no-op on other platforms.
async fn run_telemt_core(
privilege_drop_requested: bool,
drop_after_bind: impl FnOnce(),
) -> std::result::Result<(), Box<dyn std::error::Error>> {
let process_started_at = Instant::now();
@@ -279,6 +280,7 @@ async fn run_telemt_core(
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);
@@ -987,13 +989,13 @@ async fn run_telemt_core(
std::process::exit(1);
}
synlimit_control::reconcile_synlimit_rules(&config).await;
synlimit_control::reconcile_synlimit_rules(&config)
.await
.map_err(std::io::Error::other)?;
// On Unix, caller supplies privilege drop after bind and privileged firewall setup.
drop_after_bind();
let synlimit_controller = synlimit_control::spawn_synlimit_controller(runtime_watch_rx);
runtime_tasks::spawn_metrics_if_configured(&config, &startup_tracker, active_runtime.clone())
.await;
@@ -1012,7 +1014,6 @@ async fn run_telemt_core(
process_started_at,
active_runtime,
quota_state_path,
synlimit_controller,
reload_supervisor,
)
.await;
@@ -1020,6 +1021,24 @@ async fn run_telemt_core(
Ok(())
}
fn validate_synlimit_privilege_drop(
config: &ProxyConfig,
privilege_drop_requested: bool,
) -> std::io::Result<()> {
if privilege_drop_requested
&& config
.server
.listeners
.iter()
.any(|listener| listener.synlimit != SynLimitMode::Off)
{
return Err(std::io::Error::other(
"SYN limiter cannot be combined with --run-as-user or --run-as-group without a privileged firewall helper",
));
}
Ok(())
}
#[cfg(unix)]
async fn run_inner(
daemon_opts: DaemonOptions,
@@ -1040,7 +1059,7 @@ async fn run_inner(
let user = daemon_opts.user.clone();
let group = daemon_opts.group.clone();
run_telemt_core(|| {
run_telemt_core(user.is_some() || group.is_some(), || {
if user.is_some() || group.is_some() {
if let Err(e) = drop_privileges(user.as_deref(), group.as_deref(), _pid_file.as_ref()) {
error!(error = %e, "Failed to drop privileges");
@@ -1053,5 +1072,46 @@ async fn run_inner(
#[cfg(not(unix))]
async fn run_inner() -> std::result::Result<(), Box<dyn std::error::Error>> {
run_telemt_core(|| {}).await
run_telemt_core(false, || {}).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ListenerConfig;
fn listener_with_synlimit(synlimit: SynLimitMode) -> ListenerConfig {
ListenerConfig {
ip: "127.0.0.1".parse().unwrap(),
port: Some(443),
client_mss: None,
synlimit,
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 privilege_drop_rejects_enabled_synlimit_only() {
let mut config = ProxyConfig::default();
config
.server
.listeners
.push(listener_with_synlimit(SynLimitMode::Iptables));
assert!(validate_synlimit_privilege_drop(&config, true).is_err());
assert!(validate_synlimit_privilege_drop(&config, false).is_ok());
config.server.listeners[0].synlimit = SynLimitMode::Off;
assert!(validate_synlimit_privilege_drop(&config, true).is_ok());
}
}
+100 -53
View File
@@ -4,7 +4,8 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use tokio::sync::{Mutex, mpsc};
use parking_lot::Mutex;
use tokio::sync::mpsc;
use crate::config::ProxyConfig;
@@ -188,6 +189,14 @@ pub(crate) struct ReloadCommandReceiver {
command_rx: mpsc::Receiver<ReloadCommand>,
}
/// Capacity and status reservation held while a config mutation is committed.
pub(crate) struct ReloadReservation {
permit: Option<mpsc::OwnedPermit<ReloadCommand>>,
status: ReloadStatus,
request: ReloadRequest,
status_store: Arc<ReloadStatusStore>,
}
struct ReloadStatusState {
next_reload_id: u64,
active_reload_id: Option<u64>,
@@ -232,95 +241,121 @@ impl ReloadControl {
config_revision: String,
request: ReloadRequest,
) -> Result<ReloadAccepted, ReloadSubmitError> {
let reservation = self.reserve(config_revision, request).await?;
Ok(reservation.enqueue(config))
}
/// Reserves coordinator capacity before an external config mutation commits.
pub(crate) async fn reserve(
&self,
config_revision: String,
request: ReloadRequest,
) -> Result<ReloadReservation, ReloadSubmitError> {
let permit = self
.command_tx
.clone()
.try_reserve_owned()
.map_err(|_| ReloadSubmitError::MaestroUnavailable)?;
let target_generation = self
.active_generation
.load(Ordering::Acquire)
.saturating_add(1);
let status = self
.status_store
.reserve(target_generation, config_revision, request.clone())
.await?;
let command = ReloadCommand {
reload_id: status.reload_id,
target_generation,
config,
config_revision: status.config_revision.clone(),
.reserve(target_generation, config_revision, request.clone())?;
Ok(ReloadReservation {
permit: Some(permit),
status,
request,
};
if self.command_tx.try_send(command).is_err() {
self.status_store
.finish(
status.reload_id,
ReloadPhase::Failed,
Some("maestro command channel is closed".to_string()),
)
.await;
return Err(ReloadSubmitError::MaestroUnavailable);
}
Ok(ReloadAccepted {
reload_id: status.reload_id,
target_generation,
config_revision: status.config_revision,
state: ReloadPhase::Accepted,
mode: status.mode,
failure_policy: status.failure_policy,
status_store: self.status_store.clone(),
})
}
/// Returns a retained reload status by identifier.
pub(crate) async fn status(&self, reload_id: u64) -> Option<ReloadStatus> {
self.status_store.get(reload_id).await
self.status_store.get(reload_id)
}
/// Returns the identifier of the currently active reload.
pub(crate) async fn in_progress(&self) -> Option<u64> {
self.status_store.state.lock().await.active_reload_id
self.status_store.state.lock().active_reload_id
}
/// Rejects new commands while preserving an already accepted operation.
pub(crate) async fn begin_shutdown(&self) {
self.status_store.state.lock().await.accepting_commands = false;
self.status_store.state.lock().accepting_commands = false;
}
/// Records a non-terminal lifecycle phase.
pub(crate) async fn mark_phase(&self, reload_id: u64, phase: ReloadPhase) {
self.status_store.mark_phase(reload_id, phase).await;
self.status_store.mark_phase(reload_id, phase);
}
/// Records process-owned fields deferred until the next process restart.
pub(crate) async fn set_deferred_fields(&self, reload_id: u64, fields: Vec<String>) {
self.status_store
.update(reload_id, |status| status.deferred_fields = fields)
.await;
.update(reload_id, |status| status.deferred_fields = fields);
}
/// Commits the active generation and completes the matching reload.
pub(crate) async fn succeed(&self, reload_id: u64, generation: u64) {
self.status_store
.finish_success(reload_id, generation, &self.active_generation)
.await;
.finish_success(reload_id, generation, &self.active_generation);
}
/// Marks the matching reload as failed.
pub(crate) async fn fail(&self, reload_id: u64, error: impl Into<String>) {
self.status_store
.finish(reload_id, ReloadPhase::Failed, Some(error.into()))
.await;
.finish(reload_id, ReloadPhase::Failed, Some(error.into()));
}
/// Marks the matching reload as rolled back.
pub(crate) async fn rolled_back(&self, reload_id: u64, error: impl Into<String>) {
self.status_store
.finish(reload_id, ReloadPhase::RolledBack, Some(error.into()))
.await;
.finish(reload_id, ReloadPhase::RolledBack, Some(error.into()));
}
/// Appends a non-fatal warning to the matching reload status.
pub(crate) async fn add_warning(&self, reload_id: u64, warning: impl Into<String>) {
let warning = warning.into();
self.status_store
.update(reload_id, |status| status.warnings.push(warning))
.await;
.update(reload_id, |status| status.warnings.push(warning));
}
}
impl ReloadReservation {
/// Enqueues the already reserved command without another fallible step.
pub(crate) fn enqueue(mut self, config: Arc<ProxyConfig>) -> ReloadAccepted {
let target_generation = self.status.target_generation;
let command = ReloadCommand {
reload_id: self.status.reload_id,
target_generation,
config,
config_revision: self.status.config_revision.clone(),
request: self.request.clone(),
};
// This consuming transition is the only path that removes the permit.
let permit = self
.permit
.take()
.expect("reload reservation always owns one channel permit");
permit.send(command);
ReloadAccepted {
reload_id: self.status.reload_id,
target_generation,
config_revision: self.status.config_revision.clone(),
state: ReloadPhase::Accepted,
mode: self.status.mode,
failure_policy: self.status.failure_policy,
}
}
}
impl Drop for ReloadReservation {
fn drop(&mut self) {
if self.permit.is_some() {
self.status_store.cancel_reservation(self.status.reload_id);
}
}
}
@@ -332,13 +367,13 @@ impl ReloadCommandReceiver {
}
impl ReloadStatusStore {
async fn reserve(
fn reserve(
&self,
target_generation: u64,
config_revision: String,
request: ReloadRequest,
) -> Result<ReloadStatus, ReloadSubmitError> {
let mut state = self.state.lock().await;
let mut state = self.state.lock();
if !state.accepting_commands {
return Err(ReloadSubmitError::MaestroUnavailable);
}
@@ -369,29 +404,27 @@ impl ReloadStatusStore {
Ok(status)
}
async fn get(&self, reload_id: u64) -> Option<ReloadStatus> {
fn get(&self, reload_id: u64) -> Option<ReloadStatus> {
self.state
.lock()
.await
.statuses
.iter()
.find(|status| status.reload_id == reload_id)
.cloned()
}
async fn mark_phase(&self, reload_id: u64, phase: ReloadPhase) {
fn mark_phase(&self, reload_id: u64, phase: ReloadPhase) {
self.update(reload_id, |status| {
status.state = phase;
if status.started_at_epoch_secs.is_none() && phase != ReloadPhase::Accepted {
status.started_at_epoch_secs = Some(now_epoch_secs());
}
})
.await;
});
}
async fn finish(&self, reload_id: u64, phase: ReloadPhase, error: Option<String>) {
fn finish(&self, reload_id: u64, phase: ReloadPhase, error: Option<String>) {
debug_assert!(phase.is_terminal());
let mut state = self.state.lock().await;
let mut state = self.state.lock();
if let Some(status) = state
.statuses
.iter_mut()
@@ -406,8 +439,8 @@ impl ReloadStatusStore {
}
}
async fn finish_success(&self, reload_id: u64, generation: u64, active_generation: &AtomicU64) {
let mut state = self.state.lock().await;
fn finish_success(&self, reload_id: u64, generation: u64, active_generation: &AtomicU64) {
let mut state = self.state.lock();
if state.active_reload_id != Some(reload_id) {
return;
}
@@ -425,8 +458,8 @@ impl ReloadStatusStore {
state.active_reload_id = None;
}
async fn update(&self, reload_id: u64, update: impl FnOnce(&mut ReloadStatus)) {
let mut state = self.state.lock().await;
fn update(&self, reload_id: u64, update: impl FnOnce(&mut ReloadStatus)) {
let mut state = self.state.lock();
if let Some(status) = state
.statuses
.iter_mut()
@@ -435,6 +468,20 @@ impl ReloadStatusStore {
update(status);
}
}
fn cancel_reservation(&self, reload_id: u64) {
let mut state = self.state.lock();
if state.active_reload_id == Some(reload_id) {
state.active_reload_id = None;
}
if let Some(index) = state
.statuses
.iter()
.position(|status| status.reload_id == reload_id)
{
state.statuses.remove(index);
}
}
}
fn now_epoch_secs() -> u64 {
+7 -4
View File
@@ -14,7 +14,7 @@ use super::reload::{
ReloadCommand, ReloadCommandReceiver, ReloadControl, ReloadFailurePolicy, ReloadMode,
ReloadPhase,
};
use super::runtime_build::{PreparedRuntime, deferred_process_fields, prepare_runtime};
use super::runtime_build::{PreparedRuntime, prepare_runtime, resolve_reload_config};
use super::runtime_tasks::RuntimeLogFilter;
pub(crate) struct ReloadSupervisor {
@@ -147,14 +147,17 @@ impl ReloadSupervisor {
.mark_phase(command.reload_id, ReloadPhase::Preparing)
.await;
let old_runtime = self.active_runtime.load_full();
let deferred = deferred_process_fields(&old_runtime.config(), &command.config);
let resolved = resolve_reload_config(&old_runtime.config(), &command.config);
self.control
.set_deferred_fields(command.reload_id, deferred)
.set_deferred_fields(
command.reload_id,
resolved.deferred_process_fields.clone(),
)
.await;
let prepared = match prepare_runtime(
command.target_generation,
command.config.as_ref().clone(),
resolved.effective,
&self.config_path,
self.quota_store.clone(),
self.runtime_log_filter.clone(),
+45 -6
View File
@@ -220,12 +220,51 @@ async fn closed_command_channel_marks_reload_failed_and_releases_slot() {
assert_eq!(result, Err(ReloadSubmitError::MaestroUnavailable));
assert_eq!(control.in_progress().await, None);
let status = control.status(1).await.unwrap();
assert_eq!(status.state, ReloadPhase::Failed);
assert_eq!(
status.error.as_deref(),
Some("maestro command channel is closed")
);
assert!(control.status(1).await.is_none());
}
#[tokio::test]
async fn dropped_reservation_releases_status_and_channel_capacity() {
let (control, mut receiver) = ReloadControl::channel(1);
let reservation = control
.reserve("rev-reserved".to_string(), ReloadRequest::default())
.await
.unwrap();
let reload_id = reservation.status.reload_id;
drop(reservation);
assert_eq!(control.in_progress().await, None);
assert!(control.status(reload_id).await.is_none());
let accepted = control
.submit(
Arc::new(ProxyConfig::default()),
"rev-next".to_string(),
ReloadRequest::default(),
)
.await
.unwrap();
assert!(receiver.recv().await.is_some());
assert!(accepted.reload_id > reload_id);
}
#[tokio::test]
async fn reservation_preserves_the_complete_reload_request() {
let (control, mut receiver) = ReloadControl::channel(1);
let request = ReloadRequest {
mode: ReloadMode::Drain,
timeout_secs: Some(30),
failure_policy: ReloadFailurePolicy::Rollback,
};
let reservation = control
.reserve("rev-drain".to_string(), request.clone())
.await
.unwrap();
reservation.enqueue(Arc::new(ProxyConfig::default()));
let command = receiver.recv().await.unwrap();
assert_eq!(command.request, request);
}
#[tokio::test]
+121 -49
View File
@@ -5,7 +5,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::{RwLock, Semaphore, watch};
use crate::config::ProxyConfig;
use crate::config::{ProxyConfig, ServerConfig};
use crate::crypto::SecureRandom;
use crate::ip_tracker::UserIpTracker;
use crate::network::probe::{decide_network_capabilities, run_probe};
@@ -309,75 +309,147 @@ fn strict_middle_proxy_unavailable(
use_middle_proxy && !direct_first_startup && !pool_available
}
pub(crate) fn deferred_process_fields(old: &ProxyConfig, new: &ProxyConfig) -> Vec<String> {
pub(crate) struct ResolvedReloadConfig {
/// Runtime-safe candidate with process-owned values retained from active state.
pub(crate) effective: ProxyConfig,
/// Stable public labels for desired fields deferred until process restart.
pub(crate) deferred_process_fields: Vec<String>,
/// Whether activating the effective candidate changes runtime-owned state.
pub(crate) runtime_changed: bool,
}
/// Resolves desired configuration into effective runtime and deferred process state.
pub(crate) fn resolve_reload_config(
old: &ProxyConfig,
desired: &ProxyConfig,
) -> ResolvedReloadConfig {
let mut effective = desired.clone();
let mut fields = Vec::new();
if old.server.port != new.server.port
|| old.server.proxy_protocol != new.server.proxy_protocol
|| old.server.listen_backlog != new.server.listen_backlog
|| serde_json::to_value(&old.server.listeners).ok()
!= serde_json::to_value(&new.server.listeners).ok()
let listener_identity_matches = listeners_have_same_bind_identity(&old.server, &desired.server);
let listener_process_fields_changed = !listener_identity_matches
|| !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_ipv6 != desired.server.listen_addr_ipv6
|| old.server.listen_tcp != desired.server.listen_tcp
|| old.server.client_mss != desired.server.client_mss
|| old.server.client_mss_bulk != desired.server.client_mss_bulk
|| old.server.proxy_protocol != desired.server.proxy_protocol
|| old.server.listen_backlog != desired.server.listen_backlog
|| listener_process_fields_changed
{
fields.push("server.listeners".to_string());
effective.server.port = old.server.port;
effective.server.listen_addr_ipv4 = old.server.listen_addr_ipv4.clone();
effective.server.listen_addr_ipv6 = old.server.listen_addr_ipv6.clone();
effective.server.listen_tcp = old.server.listen_tcp;
effective.server.client_mss = old.server.client_mss.clone();
effective.server.client_mss_bulk = old.server.client_mss_bulk.clone();
effective.server.proxy_protocol = old.server.proxy_protocol;
effective.server.listen_backlog = old.server.listen_backlog;
effective.server.listeners = old.server.listeners.clone();
if listener_identity_matches {
for (effective_listener, desired_listener) in effective
.server
.listeners
.iter_mut()
.zip(&desired.server.listeners)
{
effective_listener.announce = desired_listener.announce.clone();
effective_listener.announce_ip = desired_listener.announce_ip;
}
}
}
if old.server.listen_unix_sock != new.server.listen_unix_sock
|| old.server.listen_unix_sock_perm != new.server.listen_unix_sock_perm
if old.server.listen_unix_sock != desired.server.listen_unix_sock
|| old.server.listen_unix_sock_perm != desired.server.listen_unix_sock_perm
{
fields.push("server.listen_unix_sock".to_string());
effective.server.listen_unix_sock = old.server.listen_unix_sock.clone();
effective.server.listen_unix_sock_perm = old.server.listen_unix_sock_perm.clone();
}
if old.server.api.listen != new.server.api.listen
|| old.server.api.enabled != new.server.api.enabled
if old.server.api.listen != desired.server.api.listen
|| old.server.api.enabled != desired.server.api.enabled
{
fields.push("server.api.listen".to_string());
effective.server.api.listen = old.server.api.listen.clone();
effective.server.api.enabled = old.server.api.enabled;
}
if old.server.metrics_listen != new.server.metrics_listen
|| old.server.metrics_port != new.server.metrics_port
if old.server.api.runtime_edge_events_capacity
!= desired.server.api.runtime_edge_events_capacity
{
fields.push("server.api.runtime_edge_events_capacity".to_string());
effective.server.api.runtime_edge_events_capacity =
old.server.api.runtime_edge_events_capacity;
}
if old.server.metrics_listen != desired.server.metrics_listen
|| old.server.metrics_port != desired.server.metrics_port
{
fields.push("server.metrics_listen".to_string());
effective.server.metrics_listen = old.server.metrics_listen.clone();
effective.server.metrics_port = old.server.metrics_port;
}
if old.general.quota_state_path != new.general.quota_state_path {
if old.general.quota_state_path != desired.general.quota_state_path {
fields.push("general.quota_state_path".to_string());
effective.general.quota_state_path = old.general.quota_state_path.clone();
}
if old.general.disable_colors != new.general.disable_colors {
if old.general.disable_colors != desired.general.disable_colors {
fields.push("general.disable_colors".to_string());
effective.general.disable_colors = old.general.disable_colors;
}
if old.general.data_path != new.general.data_path {
if old.general.data_path != desired.general.data_path {
fields.push("general.data_path".to_string());
effective.general.data_path = old.general.data_path.clone();
}
if serde_json::to_value(&old.logging).ok() != serde_json::to_value(&new.logging).ok() {
if serde_json::to_value(&old.logging).ok()
!= serde_json::to_value(&desired.logging).ok()
{
fields.push("logging".to_string());
effective.logging = old.logging.clone();
}
fields
let runtime_changed = !configs_equal(old, &effective);
ResolvedReloadConfig {
effective,
deferred_process_fields: fields,
runtime_changed,
}
}
fn listeners_have_same_bind_identity(old: &ServerConfig, desired: &ServerConfig) -> bool {
old.listeners.len() == desired.listeners.len()
&& old
.listeners
.iter()
.zip(&desired.listeners)
.all(|(old_listener, desired_listener)| {
old_listener.ip == desired_listener.ip
&& old_listener.port.unwrap_or(old.port)
== desired_listener.port.unwrap_or(desired.port)
})
}
fn listener_process_fields_equal(old: &ServerConfig, desired: &ServerConfig) -> bool {
let mut old_listeners = old.listeners.clone();
let mut desired_listeners = desired.listeners.clone();
for listener in &mut old_listeners {
listener.announce = None;
listener.announce_ip = None;
}
for listener in &mut desired_listeners {
listener.announce = None;
listener.announce_ip = None;
}
serde_json::to_value(old_listeners).ok() == serde_json::to_value(desired_listeners).ok()
}
/// Returns process-owned fields that cannot change in the current generation.
pub(crate) fn deferred_process_fields(old: &ProxyConfig, new: &ProxyConfig) -> Vec<String> {
resolve_reload_config(old, new).deferred_process_fields
}
fn configs_equal(old: &ProxyConfig, new: &ProxyConfig) -> bool {
serde_json::to_value(old).ok() == serde_json::to_value(new).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn process_socket_and_logging_changes_are_deferred() {
let old = ProxyConfig::default();
let mut new = old.clone();
new.server.listen_backlog = new.server.listen_backlog.saturating_add(1);
new.general.disable_colors = !new.general.disable_colors;
let fields = deferred_process_fields(&old, &new);
assert!(fields.contains(&"server.listeners".to_string()));
assert!(fields.contains(&"general.disable_colors".to_string()));
}
#[test]
fn runtime_only_change_does_not_require_process_rebind() {
let old = ProxyConfig::default();
let mut new = old.clone();
new.censorship.tls_domain = "reload.example".to_string();
assert!(deferred_process_fields(&old, &new).is_empty());
}
#[test]
fn strict_middle_proxy_requires_a_prepared_pool() {
assert!(strict_middle_proxy_unavailable(true, false, false));
assert!(!strict_middle_proxy_unavailable(true, false, true));
assert!(!strict_middle_proxy_unavailable(true, true, false));
assert!(!strict_middle_proxy_unavailable(false, false, false));
}
}
#[path = "runtime_build_tests.rs"]
mod tests;
+131
View File
@@ -0,0 +1,131 @@
use super::*;
#[test]
fn process_socket_and_logging_changes_are_deferred() {
let old = ProxyConfig::default();
let mut new = old.clone();
new.server.listen_backlog = new.server.listen_backlog.saturating_add(1);
new.general.disable_colors = !new.general.disable_colors;
let fields = deferred_process_fields(&old, &new);
assert!(fields.contains(&"server.listeners".to_string()));
assert!(fields.contains(&"general.disable_colors".to_string()));
}
#[test]
fn global_mss_profiles_are_deferred_with_the_listener_socket_group() {
let old = ProxyConfig::default();
let mut desired = old.clone();
desired.server.client_mss = Some("92".to_string());
desired.server.client_mss_bulk = Some("1400".to_string());
let resolved = resolve_reload_config(&old, &desired);
assert_eq!(
resolved.deferred_process_fields,
vec!["server.listeners".to_string()]
);
assert_eq!(resolved.effective.server.client_mss, old.server.client_mss);
assert_eq!(
resolved.effective.server.client_mss_bulk,
old.server.client_mss_bulk
);
assert!(!resolved.runtime_changed);
}
#[test]
fn mixed_reload_retains_process_state_and_applies_runtime_state() {
let old = ProxyConfig::default();
let mut desired = old.clone();
desired.server.client_mss = Some("92".to_string());
desired.censorship.tls_domain = "reload.example".to_string();
let resolved = resolve_reload_config(&old, &desired);
assert_eq!(resolved.effective.server.client_mss, old.server.client_mss);
assert_eq!(
resolved.effective.censorship.tls_domain,
desired.censorship.tls_domain
);
assert!(resolved.runtime_changed);
assert_eq!(
resolved.deferred_process_fields,
vec!["server.listeners".to_string()]
);
}
#[test]
fn listener_announcement_is_runtime_owned_when_bind_identity_is_stable() {
let mut old = ProxyConfig::default();
old.server.listeners.push(crate::config::ListenerConfig {
ip: "0.0.0.0".parse().unwrap(),
port: Some(443),
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,
});
let mut desired = old.clone();
desired.server.listeners[0].announce = Some("proxy.example".to_string());
let resolved = resolve_reload_config(&old, &desired);
assert!(resolved.deferred_process_fields.is_empty());
assert_eq!(
resolved.effective.server.listeners[0].announce.as_deref(),
Some("proxy.example")
);
assert!(resolved.runtime_changed);
}
#[test]
fn process_field_labels_are_stable_ordered_and_unique() {
let old = ProxyConfig::default();
let mut desired = old.clone();
desired.server.listen_backlog = desired.server.listen_backlog.saturating_add(1);
desired.server.api.enabled = !desired.server.api.enabled;
desired.server.api.runtime_edge_events_capacity = desired
.server
.api
.runtime_edge_events_capacity
.saturating_add(1);
desired.general.disable_colors = !desired.general.disable_colors;
let resolved = resolve_reload_config(&old, &desired);
assert_eq!(
resolved.deferred_process_fields,
vec![
"server.listeners".to_string(),
"server.api.listen".to_string(),
"server.api.runtime_edge_events_capacity".to_string(),
"general.disable_colors".to_string(),
]
);
}
#[test]
fn runtime_only_change_does_not_require_process_rebind() {
let old = ProxyConfig::default();
let mut new = old.clone();
new.censorship.tls_domain = "reload.example".to_string();
assert!(deferred_process_fields(&old, &new).is_empty());
}
#[test]
fn strict_middle_proxy_requires_a_prepared_pool() {
assert!(strict_middle_proxy_unavailable(true, false, false));
assert!(!strict_middle_proxy_unavailable(true, false, true));
assert!(!strict_middle_proxy_unavailable(true, true, false));
assert!(!strict_middle_proxy_unavailable(false, false, false));
}
-4
View File
@@ -51,7 +51,6 @@ pub(crate) async fn wait_for_shutdown(
process_started_at: Instant,
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
quota_state_path: PathBuf,
synlimit_controller: synlimit_control::SynlimitController,
reload_supervisor: ReloadSupervisorHandle,
) {
let signal = wait_for_shutdown_signal().await;
@@ -60,7 +59,6 @@ pub(crate) async fn wait_for_shutdown(
process_started_at,
active_runtime,
quota_state_path,
synlimit_controller,
reload_supervisor,
)
.await;
@@ -92,7 +90,6 @@ async fn perform_shutdown(
process_started_at: Instant,
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
quota_state_path: PathBuf,
synlimit_controller: synlimit_control::SynlimitController,
reload_supervisor: ReloadSupervisorHandle,
) {
let shutdown_started_at = Instant::now();
@@ -130,7 +127,6 @@ async fn perform_shutdown(
}
}
synlimit_controller.shutdown().await;
if let Err(error) = synlimit_control::clear_synlimit_rules_all_backends().await {
warn!(error = %error, "Failed to clear SYN limiter rules during shutdown");
}