mirror of
https://github.com/telemt/telemt.git
synced 2026-09-25 12:05:58 +03:00
Hardened listener reload + config persistence + SYN Limit startup safety
This commit is contained in:
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user