mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-14 21:20:48 +08:00
1038 lines
34 KiB
Rust
1038 lines
34 KiB
Rust
use crate::{
|
|
config::{Config, MixedPort},
|
|
core::proxy_control::SystemProxyStateUnknown,
|
|
singleton,
|
|
utils::server,
|
|
};
|
|
use anyhow::Result;
|
|
use clash_verge_logging::{Type, logging};
|
|
use parking_lot::RwLock;
|
|
use scopeguard::defer;
|
|
use smartstring::alias::String;
|
|
use std::{
|
|
sync::{
|
|
Arc,
|
|
atomic::{AtomicBool, Ordering},
|
|
},
|
|
time::Duration,
|
|
};
|
|
use sysproxy::{Autoproxy, GuardMonitor, GuardType, Sysproxy};
|
|
use tokio::sync::Mutex as TokioMutex;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum ProxyApplyStep {
|
|
Sysproxy,
|
|
Autoproxy,
|
|
}
|
|
|
|
const fn proxy_apply_steps(sys_enabled: bool, auto_enabled: bool) -> [ProxyApplyStep; 2] {
|
|
// Disabling PAC clears WinINET proxy flags on Windows, so pure global
|
|
// proxy mode must clear PAC before enabling Sysproxy.
|
|
if sys_enabled && !auto_enabled {
|
|
[ProxyApplyStep::Autoproxy, ProxyApplyStep::Sysproxy]
|
|
} else {
|
|
[ProxyApplyStep::Sysproxy, ProxyApplyStep::Autoproxy]
|
|
}
|
|
}
|
|
|
|
/// Maximum guard drain time before OS state is unknown.
|
|
const GUARD_DRAIN_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
/// Authoritative OS state after a failed proxy write.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum AuthoritativeState {
|
|
/// No write is known to have landed.
|
|
Unchanged,
|
|
/// A partial write left no known target.
|
|
Unknown,
|
|
}
|
|
|
|
/// Choose state from reliable evidence that a write landed.
|
|
fn authoritative_state(error: &anyhow::Error, earlier_step_completed: bool) -> AuthoritativeState {
|
|
// Linux setter success is not reliable write evidence.
|
|
authoritative_state_from(!cfg!(target_os = "linux"), error, earlier_step_completed)
|
|
}
|
|
|
|
/// Testable form with explicit write-evidence reliability.
|
|
fn authoritative_state_from(
|
|
write_evidence_is_reliable: bool,
|
|
error: &anyhow::Error,
|
|
earlier_step_completed: bool,
|
|
) -> AuthoritativeState {
|
|
if !write_evidence_is_reliable {
|
|
return AuthoritativeState::Unchanged;
|
|
}
|
|
// A completed earlier setter is evidence outside the current error.
|
|
if earlier_step_completed {
|
|
return AuthoritativeState::Unknown;
|
|
}
|
|
match error.downcast_ref::<sysproxy::Error>() {
|
|
Some(sysproxy::Error::ProxyWrite { progress, .. }) if !progress.nothing_written() => {
|
|
AuthoritativeState::Unknown
|
|
}
|
|
_ => AuthoritativeState::Unchanged,
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum OsProxyState {
|
|
AlreadyApplied,
|
|
DifferentOrUnknown,
|
|
}
|
|
|
|
#[cfg(any(target_os = "macos", test))]
|
|
fn target_is_already_in_place(snapshot: &sysproxy::ProxySnapshot, sys: &Sysproxy, auto: &Autoproxy) -> bool {
|
|
if auto.enable {
|
|
// PAC writes bypass state too.
|
|
snapshot.matches_pac(auto) && snapshot.bypass_matches(&sys.bypass)
|
|
} else if sys.enable {
|
|
snapshot.matches_global(sys)
|
|
} else {
|
|
// Bypass state is inert while every proxy is disabled.
|
|
snapshot.is_all_disabled()
|
|
}
|
|
}
|
|
|
|
/// Treat failed reads as different so writes are skipped only with proof.
|
|
fn classify_os_proxy_state(snapshot: Result<bool>) -> OsProxyState {
|
|
match snapshot {
|
|
Ok(true) => OsProxyState::AlreadyApplied,
|
|
_ => OsProxyState::DifferentOrUnknown,
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn read_os_proxy_state(sys: &Sysproxy, auto: &Autoproxy) -> OsProxyState {
|
|
// Use the per-protocol snapshot rather than the synthesized proxy view.
|
|
let already_applied = Sysproxy::snapshot()
|
|
.map(|snapshot| target_is_already_in_place(&snapshot, sys, auto))
|
|
.map_err(anyhow::Error::from);
|
|
|
|
if let Err(err) = &already_applied {
|
|
logging!(warn, Type::Core, "failed to read OS proxy snapshot: {err:#}");
|
|
}
|
|
|
|
classify_os_proxy_state(already_applied)
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
fn read_os_proxy_state(_sys: &Sysproxy, _auto: &Autoproxy) -> OsProxyState {
|
|
classify_os_proxy_state(Err(anyhow::anyhow!("per-protocol proxy snapshots are macOS only")))
|
|
}
|
|
|
|
/// Disable, drain a racing guard if needed, then disable again.
|
|
async fn disable_until_the_last_write_is_ours<Disable, DisableFuture, Drain, DrainFuture>(
|
|
drained: bool,
|
|
disable: Disable,
|
|
drain_again: Drain,
|
|
) -> Result<()>
|
|
where
|
|
Disable: Fn() -> DisableFuture,
|
|
DisableFuture: std::future::Future<Output = Result<()>>,
|
|
Drain: FnOnce() -> DrainFuture,
|
|
DrainFuture: std::future::Future<Output = bool>,
|
|
{
|
|
disable().await?;
|
|
if drained {
|
|
return Ok(());
|
|
}
|
|
|
|
// The final disable must happen after the last in-flight guard write.
|
|
if !drain_again().await {
|
|
anyhow::bail!(
|
|
"the system proxy guard did not finish within {GUARD_DRAIN_TIMEOUT:?}; the OS proxy state is unknown"
|
|
);
|
|
}
|
|
disable().await
|
|
}
|
|
|
|
/// Return the first failure after logging every attempted write.
|
|
fn first_failure<const N: usize>(attempts: [(&'static str, Result<()>); N]) -> Result<()> {
|
|
let mut first: Option<anyhow::Error> = None;
|
|
for (what, outcome) in attempts {
|
|
let Err(error) = outcome else { continue };
|
|
logging!(warn, Type::Core, "failed to turn the {what} off: {error:#}");
|
|
if first.is_none() {
|
|
first = Some(error);
|
|
}
|
|
}
|
|
first.map_or(Ok(()), Err)
|
|
}
|
|
|
|
/// Attempt to disable both proxy kinds even if the first fails.
|
|
fn disable_both<Global, Pac>(disable_global: Global, disable_pac: Pac) -> Result<()>
|
|
where
|
|
Global: FnOnce() -> Result<()>,
|
|
Pac: FnOnce() -> Result<()>,
|
|
{
|
|
let global = disable_global();
|
|
let pac = disable_pac();
|
|
first_failure([("global proxy", global), ("PAC", pac)])
|
|
}
|
|
|
|
/// Treats a missing network service as a completed disable: with nothing to write to, the
|
|
/// requested "no proxy" state already holds. Turning a proxy on must still fail.
|
|
fn tolerate_missing_network_service(_enabling: bool, outcome: sysproxy::Result<()>) -> Result<()> {
|
|
match outcome {
|
|
#[cfg(target_os = "macos")]
|
|
Err(sysproxy::Error::NoActiveNetworkService) if !_enabling => {
|
|
logging!(warn, Type::Core, "no active network service, nothing to turn off");
|
|
Ok(())
|
|
}
|
|
other => other.map_err(anyhow::Error::from),
|
|
}
|
|
}
|
|
|
|
/// Force both proxy kinds off, in one blocking hop.
|
|
async fn disable_all_proxies(sys: Sysproxy, auto: Autoproxy) -> Result<()> {
|
|
tokio::task::spawn_blocking(move || {
|
|
disable_both(
|
|
|| tolerate_missing_network_service(sys.enable, sys.set_system_proxy()),
|
|
|| tolerate_missing_network_service(auto.enable, auto.set_auto_proxy()),
|
|
)
|
|
})
|
|
.await?
|
|
}
|
|
|
|
/// Reconcile OS and guard state while preserving the original failure.
|
|
async fn recover_from_failed_write<Guard, Compensate, CompensateFuture>(
|
|
error: anyhow::Error,
|
|
earlier_step_completed: bool,
|
|
recover_guard: Guard,
|
|
compensate: Compensate,
|
|
) -> anyhow::Error
|
|
where
|
|
Guard: FnOnce(AuthoritativeState),
|
|
Compensate: FnOnce() -> CompensateFuture,
|
|
CompensateFuture: std::future::Future<Output = Result<()>>,
|
|
{
|
|
let state = authoritative_state(&error, earlier_step_completed);
|
|
recover_guard(state);
|
|
|
|
match state {
|
|
AuthoritativeState::Unchanged => error,
|
|
AuthoritativeState::Unknown => {
|
|
if let Err(compensation) = compensate().await {
|
|
logging!(
|
|
warn,
|
|
Type::Core,
|
|
"failed to force the system proxy off after a partial write: {compensation:#}"
|
|
);
|
|
}
|
|
error.context(SystemProxyStateUnknown)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn current_os_proxy_state(sys: Sysproxy, auto: Autoproxy) -> OsProxyState {
|
|
tokio::task::spawn_blocking(move || read_os_proxy_state(&sys, &auto))
|
|
.await
|
|
.unwrap_or_else(|join_error| {
|
|
logging!(warn, Type::Core, "failed to read OS proxy state: {join_error}");
|
|
OsProxyState::DifferentOrUnknown
|
|
})
|
|
}
|
|
|
|
pub(crate) struct Sysopt {
|
|
update_lock: TokioMutex<()>,
|
|
guard_operation_lock: TokioMutex<()>,
|
|
reset_sysproxy: AtomicBool,
|
|
inner_proxy: Arc<RwLock<(Sysproxy, Autoproxy)>>,
|
|
guard: Arc<RwLock<GuardMonitor>>,
|
|
}
|
|
|
|
impl Default for Sysopt {
|
|
fn default() -> Self {
|
|
Self {
|
|
update_lock: TokioMutex::new(()),
|
|
guard_operation_lock: TokioMutex::new(()),
|
|
reset_sysproxy: AtomicBool::new(false),
|
|
inner_proxy: Arc::new(RwLock::new((Sysproxy::default(), Autoproxy::default()))),
|
|
guard: Arc::new(RwLock::new(GuardMonitor::new(GuardType::None, Duration::from_secs(30)))),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
static DEFAULT_BYPASS: &str = "localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;<local>";
|
|
#[cfg(target_os = "windows")]
|
|
static BYPASS_SEPARATOR: &str = ";";
|
|
#[cfg(target_os = "linux")]
|
|
static DEFAULT_BYPASS: &str = "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,::1";
|
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
|
static BYPASS_SEPARATOR: &str = ",";
|
|
#[cfg(target_os = "macos")]
|
|
static DEFAULT_BYPASS: &str =
|
|
"127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,<local>";
|
|
|
|
fn format_bypass(use_default: bool, custom_bypass: &str) -> String {
|
|
if custom_bypass.is_empty() {
|
|
DEFAULT_BYPASS.into()
|
|
} else if use_default {
|
|
format!("{DEFAULT_BYPASS}{BYPASS_SEPARATOR}{custom_bypass}").into()
|
|
} else {
|
|
custom_bypass.into()
|
|
}
|
|
}
|
|
|
|
async fn get_bypass() -> String {
|
|
let verge = Config::verge().await.latest_arc();
|
|
let use_default = verge.use_default_bypass.unwrap_or(true);
|
|
let custom_bypass = verge.system_proxy_bypass.as_deref().unwrap_or("");
|
|
|
|
format_bypass(use_default, custom_bypass)
|
|
}
|
|
|
|
singleton!(Sysopt, SYSOPT);
|
|
|
|
impl Sysopt {
|
|
fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
fn access_guard(&self) -> Arc<RwLock<GuardMonitor>> {
|
|
Arc::clone(&self.guard)
|
|
}
|
|
|
|
/// Stop the guard and return whether it drained.
|
|
async fn stop_proxy_guard_locked(&self) -> bool {
|
|
// Drop the parking_lot read guard before awaiting.
|
|
let idle = self.access_guard().read().shutdown();
|
|
let drained = idle.wait_timeout(GUARD_DRAIN_TIMEOUT).await;
|
|
if !drained {
|
|
logging!(
|
|
warn,
|
|
Type::Core,
|
|
"the system proxy guard did not finish within {GUARD_DRAIN_TIMEOUT:?}"
|
|
);
|
|
}
|
|
drained
|
|
}
|
|
|
|
/// Stop the guard before handing OS proxy ownership elsewhere.
|
|
pub(super) async fn stop_proxy_guard(&self) -> bool {
|
|
let _operation = self.guard_operation_lock.lock().await;
|
|
self.stop_proxy_guard_locked().await
|
|
}
|
|
|
|
/// Reconcile guard state with configuration and report success.
|
|
pub(super) async fn refresh_guard(&self) -> bool {
|
|
logging!(info, Type::Core, "Refreshing system proxy guard...");
|
|
let verge = Config::verge().await.latest_arc();
|
|
let _operation = self.guard_operation_lock.lock().await;
|
|
if !verge.enable_system_proxy.unwrap_or_default() {
|
|
logging!(info, Type::Core, "System proxy is disabled.");
|
|
let _drained = self.stop_proxy_guard_locked().await;
|
|
return true;
|
|
}
|
|
if !verge.enable_proxy_guard.unwrap_or_default() {
|
|
logging!(info, Type::Core, "System proxy guard is disabled.");
|
|
let _drained = self.stop_proxy_guard_locked().await;
|
|
return true;
|
|
}
|
|
logging!(
|
|
info,
|
|
Type::Core,
|
|
"Updating system proxy with duration: {} seconds",
|
|
verge.proxy_guard_duration.unwrap_or(30)
|
|
);
|
|
{
|
|
let guard = self.access_guard();
|
|
guard
|
|
.write()
|
|
.set_interval(Duration::from_secs(verge.proxy_guard_duration.unwrap_or(30)));
|
|
}
|
|
logging!(info, Type::Core, "Starting system proxy guard...");
|
|
{
|
|
let guard = self.access_guard();
|
|
if !guard.read().start() {
|
|
logging!(
|
|
warn,
|
|
Type::Core,
|
|
"the system proxy guard refused to start; a previous run has not finished"
|
|
);
|
|
}
|
|
}
|
|
while self.access_guard().read().get_state().is_pendding() {
|
|
tokio::task::yield_now().await;
|
|
}
|
|
// `start()` false may also mean a guard was already running.
|
|
!self.access_guard().read().get_state().is_stopped()
|
|
}
|
|
|
|
/// Recover OS and guard state after a failed write.
|
|
async fn recover_from_failed_write(
|
|
&self,
|
|
error: anyhow::Error,
|
|
earlier_step_completed: bool,
|
|
guard_was_running: bool,
|
|
off: (Sysproxy, Autoproxy),
|
|
) -> anyhow::Error {
|
|
recover_from_failed_write(
|
|
error,
|
|
earlier_step_completed,
|
|
|state| self.recover_guard_after_failure(state, guard_was_running),
|
|
|| disable_all_proxies(off.0, off.1),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Reconcile the guard with the authoritative state.
|
|
fn recover_guard_after_failure(&self, state: AuthoritativeState, was_running: bool) {
|
|
match state {
|
|
AuthoritativeState::Unchanged if was_running => {
|
|
let restarted = self.access_guard().read().start();
|
|
if !restarted {
|
|
logging!(
|
|
warn,
|
|
Type::Core,
|
|
"the system proxy guard refused to start again after a failed write; it is not running"
|
|
);
|
|
}
|
|
}
|
|
AuthoritativeState::Unchanged => {}
|
|
AuthoritativeState::Unknown => {
|
|
self.access_guard().write().set_guard_type(GuardType::None);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Wait for any in-progress `update_sysproxy` to finish, so that a
|
|
/// subsequent read of OS-level sysproxy state sees a fully applied
|
|
/// configuration instead of a partially-applied one (e.g. SOCKS already
|
|
/// disabled but HTTP still enabled mid-transition).
|
|
pub(crate) async fn wait_idle(&self) {
|
|
let _ = self.update_lock.lock().await;
|
|
}
|
|
|
|
/// init the sysproxy
|
|
pub(super) async fn update_sysproxy(&self) -> Result<()> {
|
|
let _lock = self.update_lock.lock().await;
|
|
let verge = Config::verge().await.latest_arc();
|
|
// Configured, not live: this runs while the Core is being started or restarted, and
|
|
// asking a Core that is not up yet would only fall back here anyway.
|
|
let port = MixedPort::desired().await;
|
|
let pac_port = server::embedded_server_port()?;
|
|
// 先 await, 避免持有锁导致的 Send 问题
|
|
let bypass = get_bypass().await;
|
|
|
|
let (sys_enable, pac_enable, proxy_host, proxy_guard) = (
|
|
verge.enable_system_proxy.unwrap_or_default(),
|
|
verge.proxy_auto_config.unwrap_or_default(),
|
|
verge.proxy_host.as_deref().unwrap_or("127.0.0.1"),
|
|
verge.enable_proxy_guard.unwrap_or_default(),
|
|
);
|
|
|
|
let (sys, auto, guard_type) = {
|
|
let (sys, auto) = &mut *self.inner_proxy.write();
|
|
sys.host = proxy_host.into();
|
|
sys.port = port;
|
|
sys.bypass = bypass.into();
|
|
auto.url = format!("http://{proxy_host}:{pac_port}/commands/pac");
|
|
|
|
// `enable_system_proxy` is the master switch.
|
|
// When disabled, force clear both global proxy and PAC at OS level.
|
|
let guard_type = if !sys_enable {
|
|
sys.enable = false;
|
|
auto.enable = false;
|
|
GuardType::None
|
|
} else if pac_enable {
|
|
sys.enable = false;
|
|
auto.enable = true;
|
|
if proxy_guard {
|
|
GuardType::Autoproxy(auto.clone())
|
|
} else {
|
|
GuardType::None
|
|
}
|
|
} else {
|
|
sys.enable = true;
|
|
auto.enable = false;
|
|
if proxy_guard {
|
|
GuardType::Sysproxy(sys.clone())
|
|
} else {
|
|
GuardType::None
|
|
}
|
|
};
|
|
|
|
(sys.clone(), auto.clone(), guard_type)
|
|
};
|
|
|
|
let _guard_operation = self.guard_operation_lock.lock().await;
|
|
|
|
// Drain the guard before any OS read or write.
|
|
let guard_was_running = !self.access_guard().read().get_state().is_stopped();
|
|
let idle = self.access_guard().read().shutdown();
|
|
let drained = idle.wait_timeout(GUARD_DRAIN_TIMEOUT).await;
|
|
if !drained {
|
|
// A pending guard write makes OS state unknown.
|
|
self.access_guard().write().set_guard_type(GuardType::None);
|
|
anyhow::bail!(
|
|
"the system proxy guard did not finish within {GUARD_DRAIN_TIMEOUT:?}; the OS proxy state is unknown"
|
|
);
|
|
}
|
|
|
|
// Skip writes only when macOS exactly matches the target.
|
|
if cfg!(target_os = "macos")
|
|
&& current_os_proxy_state(sys.clone(), auto.clone()).await == OsProxyState::AlreadyApplied
|
|
{
|
|
self.access_guard().write().set_guard_type(guard_type);
|
|
return Ok(());
|
|
}
|
|
|
|
let apply_steps = proxy_apply_steps(sys.enable, auto.enable);
|
|
|
|
// Prepare the disabled state used to compensate a partial write.
|
|
let compensation = {
|
|
let (mut off_sys, mut off_auto) = (sys.clone(), auto.clone());
|
|
off_sys.enable = false;
|
|
off_auto.enable = false;
|
|
(off_sys, off_auto)
|
|
};
|
|
|
|
// Track whether an earlier setter already changed the OS.
|
|
let applied = tokio::task::spawn_blocking(move || {
|
|
for (index, step) in apply_steps.into_iter().enumerate() {
|
|
let written = match step {
|
|
ProxyApplyStep::Autoproxy => tolerate_missing_network_service(auto.enable, auto.set_auto_proxy()),
|
|
ProxyApplyStep::Sysproxy => tolerate_missing_network_service(sys.enable, sys.set_system_proxy()),
|
|
};
|
|
if let Err(error) = written {
|
|
return Err((index > 0, error));
|
|
}
|
|
}
|
|
Ok(())
|
|
})
|
|
.await;
|
|
|
|
match applied {
|
|
Ok(Ok(())) => {}
|
|
Ok(Err((earlier_step_completed, error))) => {
|
|
return Err(self
|
|
.recover_from_failed_write(error, earlier_step_completed, guard_was_running, compensation)
|
|
.await);
|
|
}
|
|
Err(join_error) => {
|
|
let error = anyhow::Error::from(join_error).context("the system proxy write task did not finish");
|
|
return Err(self
|
|
.recover_from_failed_write(error, false, guard_was_running, compensation)
|
|
.await);
|
|
}
|
|
}
|
|
|
|
// Never point the guard at a target that failed to reach the OS.
|
|
self.access_guard().write().set_guard_type(guard_type);
|
|
Ok(())
|
|
}
|
|
|
|
/// reset the sysproxy
|
|
pub(super) async fn reset_sysproxy(&self) -> Result<()> {
|
|
if self
|
|
.reset_sysproxy
|
|
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
|
.is_err()
|
|
{
|
|
return Ok(());
|
|
}
|
|
defer! {
|
|
self.reset_sysproxy.store(false, Ordering::SeqCst);
|
|
}
|
|
let _lock = self.update_lock.lock().await;
|
|
let _guard_operation = self.guard_operation_lock.lock().await;
|
|
let drained = self.stop_proxy_guard_locked().await;
|
|
|
|
// 直接关闭所有代理
|
|
let (sys, auto) = {
|
|
let (sys, auto) = &mut *self.inner_proxy.write();
|
|
sys.enable = false;
|
|
auto.enable = false;
|
|
(sys.clone(), auto.clone())
|
|
};
|
|
|
|
// The match is trustworthy only after the guard drained.
|
|
if cfg!(target_os = "macos")
|
|
&& drained
|
|
&& current_os_proxy_state(sys.clone(), auto.clone()).await == OsProxyState::AlreadyApplied
|
|
{
|
|
return Ok(());
|
|
}
|
|
|
|
disable_until_the_last_write_is_ours(
|
|
drained,
|
|
|| disable_all_proxies(sys.clone(), auto.clone()),
|
|
|| self.stop_proxy_guard_locked(),
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
AuthoritativeState, BYPASS_SEPARATOR, DEFAULT_BYPASS, OsProxyState, ProxyApplyStep, SystemProxyStateUnknown,
|
|
authoritative_state, authoritative_state_from, classify_os_proxy_state, disable_both,
|
|
disable_until_the_last_write_is_ours, first_failure, format_bypass, proxy_apply_steps,
|
|
recover_from_failed_write, target_is_already_in_place,
|
|
};
|
|
use parking_lot::Mutex;
|
|
use std::collections::VecDeque;
|
|
use sysproxy::{Autoproxy, Sysproxy};
|
|
|
|
#[test]
|
|
fn empty_custom_bypass_uses_defaults() {
|
|
assert_eq!(format_bypass(false, ""), DEFAULT_BYPASS);
|
|
}
|
|
|
|
#[test]
|
|
fn custom_bypass_can_replace_defaults() {
|
|
assert_eq!(format_bypass(false, "example.com"), "example.com");
|
|
}
|
|
|
|
#[test]
|
|
fn default_and_custom_bypass_use_platform_separator() {
|
|
assert_eq!(
|
|
format_bypass(true, "example.com"),
|
|
format!("{DEFAULT_BYPASS}{BYPASS_SEPARATOR}example.com")
|
|
);
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
#[test]
|
|
fn windows_default_and_custom_bypass_use_semicolon() {
|
|
assert_eq!(
|
|
format_bypass(true, "example.com"),
|
|
format!("{DEFAULT_BYPASS};example.com")
|
|
);
|
|
}
|
|
|
|
async fn record_disable_sequence(
|
|
drained: bool,
|
|
second_drain: bool,
|
|
disable_answers: &[bool],
|
|
) -> (Vec<&'static str>, bool) {
|
|
let calls = Mutex::new(Vec::new());
|
|
let remaining = Mutex::new(disable_answers.iter().copied().collect::<VecDeque<bool>>());
|
|
let result = disable_until_the_last_write_is_ours(
|
|
drained,
|
|
|| {
|
|
calls.lock().push("disable");
|
|
let succeeds = remaining.lock().pop_front().unwrap_or(true);
|
|
async move {
|
|
if succeeds {
|
|
Ok(())
|
|
} else {
|
|
anyhow::bail!("networksetup refused")
|
|
}
|
|
}
|
|
},
|
|
|| {
|
|
calls.lock().push("drain");
|
|
async move { second_drain }
|
|
},
|
|
)
|
|
.await;
|
|
(calls.into_inner(), result.is_ok())
|
|
}
|
|
|
|
fn failure_text(result: anyhow::Result<()>) -> std::string::String {
|
|
result.map_or_else(|error| format!("{error:#}"), |()| "no failure".to_owned())
|
|
}
|
|
|
|
#[test]
|
|
fn every_proxy_kind_is_attempted_even_after_one_of_them_failed() {
|
|
let attempted = Mutex::new(Vec::new());
|
|
|
|
let failed = disable_both(
|
|
|| {
|
|
attempted.lock().push("global proxy");
|
|
Err(anyhow::anyhow!("global refused"))
|
|
},
|
|
|| {
|
|
attempted.lock().push("PAC");
|
|
Err(anyhow::anyhow!("PAC refused"))
|
|
},
|
|
);
|
|
|
|
assert_eq!(&*attempted.lock(), &["global proxy", "PAC"]);
|
|
assert_eq!(failure_text(failed), "global refused");
|
|
}
|
|
|
|
#[test]
|
|
fn a_later_failure_is_still_reported_when_the_first_one_succeeded() {
|
|
let failed = first_failure([("global proxy", Ok(())), ("PAC", Err(anyhow::anyhow!("PAC refused")))]);
|
|
|
|
assert_eq!(failure_text(failed), "PAC refused");
|
|
}
|
|
|
|
#[test]
|
|
fn nothing_is_reported_when_every_kind_went_through() {
|
|
assert!(first_failure([("global proxy", Ok(())), ("PAC", Ok(()))]).is_ok());
|
|
}
|
|
|
|
struct Recovery {
|
|
guard_told: Option<AuthoritativeState>,
|
|
compensated: bool,
|
|
state_unknown: bool,
|
|
#[cfg(not(target_os = "linux"))]
|
|
message: String,
|
|
}
|
|
|
|
async fn record_recovery(error: anyhow::Error, earlier_step_completed: bool, compensation_works: bool) -> Recovery {
|
|
let guard_told = Mutex::new(None);
|
|
let compensated = Mutex::new(false);
|
|
let recovered = recover_from_failed_write(
|
|
error,
|
|
earlier_step_completed,
|
|
|state| *guard_told.lock() = Some(state),
|
|
|| {
|
|
*compensated.lock() = true;
|
|
async move {
|
|
if compensation_works {
|
|
Ok(())
|
|
} else {
|
|
anyhow::bail!("the compensation was refused too")
|
|
}
|
|
}
|
|
},
|
|
)
|
|
.await;
|
|
|
|
Recovery {
|
|
guard_told: *guard_told.lock(),
|
|
compensated: *compensated.lock(),
|
|
state_unknown: SystemProxyStateUnknown::is_in(&recovered),
|
|
#[cfg(not(target_os = "linux"))]
|
|
message: format!("{recovered:#}"),
|
|
}
|
|
}
|
|
|
|
fn refused() -> anyhow::Error {
|
|
anyhow::Error::new(sysproxy::Error::ProxyWrite {
|
|
progress: sysproxy::WriteProgress::new(0, 7),
|
|
source: Box::new(sysproxy::Error::RequiresAdminPrivileges),
|
|
})
|
|
}
|
|
|
|
fn partly_written() -> anyhow::Error {
|
|
anyhow::Error::new(sysproxy::Error::ProxyWrite {
|
|
progress: sysproxy::WriteProgress::new(3, 7),
|
|
source: Box::new(sysproxy::Error::RequiresAdminPrivileges),
|
|
})
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_failure_that_wrote_nothing_is_not_compensated_for() {
|
|
let recovery = record_recovery(refused(), false, true).await;
|
|
|
|
assert!(!recovery.compensated);
|
|
assert_eq!(recovery.guard_told, Some(AuthoritativeState::Unchanged));
|
|
assert!(!recovery.state_unknown);
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[tokio::test]
|
|
async fn nothing_on_linux_ever_reaches_the_destructive_row() {
|
|
for earlier_step_completed in [false, true] {
|
|
let recovery = record_recovery(partly_written(), earlier_step_completed, true).await;
|
|
|
|
assert!(!recovery.compensated, "{earlier_step_completed}");
|
|
assert_eq!(recovery.guard_told, Some(AuthoritativeState::Unchanged));
|
|
assert!(!recovery.state_unknown);
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
#[tokio::test]
|
|
async fn a_failure_that_wrote_something_forces_the_proxy_off() {
|
|
let recovery = record_recovery(partly_written(), false, true).await;
|
|
|
|
assert!(recovery.compensated);
|
|
assert_eq!(recovery.guard_told, Some(AuthoritativeState::Unknown));
|
|
assert!(recovery.state_unknown);
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
#[tokio::test]
|
|
async fn a_compensation_that_fails_does_not_replace_the_diagnosis() {
|
|
let recovery = record_recovery(partly_written(), false, false).await;
|
|
|
|
assert!(recovery.compensated);
|
|
assert!(recovery.message.contains("admin privileges"));
|
|
assert!(!recovery.message.contains("the compensation was refused too"));
|
|
assert!(recovery.state_unknown);
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
#[tokio::test]
|
|
async fn an_earlier_setter_that_finished_reaches_the_second_row_too() {
|
|
let recovery = record_recovery(refused(), true, true).await;
|
|
|
|
assert!(recovery.compensated);
|
|
assert_eq!(recovery.guard_told, Some(AuthoritativeState::Unknown));
|
|
assert!(recovery.state_unknown);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_drained_guard_needs_only_one_write() {
|
|
assert_eq!(
|
|
record_disable_sequence(true, true, &[true]).await,
|
|
(vec!["disable"], true)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_write_that_raced_the_guard_is_repeated_after_it_finishes() {
|
|
assert_eq!(
|
|
record_disable_sequence(false, true, &[true, true]).await,
|
|
(vec!["disable", "drain", "disable"], true)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_second_write_that_fails_on_its_own_is_still_reported() {
|
|
assert_eq!(
|
|
record_disable_sequence(false, true, &[true, false]).await,
|
|
(vec!["disable", "drain", "disable"], false)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_guard_that_never_finishes_is_reported_rather_than_written_over() {
|
|
assert_eq!(
|
|
record_disable_sequence(false, false, &[true]).await,
|
|
(vec!["disable", "drain"], false)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_refused_write_stops_before_waiting_on_anything() {
|
|
assert_eq!(
|
|
record_disable_sequence(false, true, &[false]).await,
|
|
(vec!["disable"], false)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_failure_with_nothing_written_leaves_the_old_guard_worth_restoring() {
|
|
let refused = anyhow::Error::new(sysproxy::Error::RequiresAdminPrivileges);
|
|
|
|
assert_eq!(authoritative_state(&refused, false), AuthoritativeState::Unchanged);
|
|
}
|
|
|
|
#[test]
|
|
fn where_a_successful_setter_proves_nothing_no_failure_is_destructive() {
|
|
let strongest_evidence = anyhow::Error::new(sysproxy::Error::ProxyWrite {
|
|
progress: sysproxy::WriteProgress::new(3, 7),
|
|
source: Box::new(sysproxy::Error::RequiresAdminPrivileges),
|
|
});
|
|
|
|
for earlier_step_completed in [false, true] {
|
|
assert_eq!(
|
|
authoritative_state_from(false, &strongest_evidence, earlier_step_completed),
|
|
AuthoritativeState::Unchanged,
|
|
"{earlier_step_completed}"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn holding_global(port: u16) -> sysproxy::ProxySnapshot {
|
|
let endpoint = || sysproxy::ProxyEndpoint {
|
|
host: "127.0.0.1".to_owned(),
|
|
port,
|
|
enable: true,
|
|
switched_on: true,
|
|
};
|
|
sysproxy::ProxySnapshot {
|
|
socks: endpoint(),
|
|
http: endpoint(),
|
|
https: endpoint(),
|
|
auto: sysproxy::Autoproxy {
|
|
url: std::string::String::new(),
|
|
enable: false,
|
|
},
|
|
auto_switched_on: false,
|
|
bypass: "localhost".to_owned(),
|
|
}
|
|
}
|
|
|
|
fn holding_nothing() -> sysproxy::ProxySnapshot {
|
|
let off = || sysproxy::ProxyEndpoint {
|
|
host: std::string::String::new(),
|
|
port: 0,
|
|
enable: false,
|
|
switched_on: false,
|
|
};
|
|
sysproxy::ProxySnapshot {
|
|
socks: off(),
|
|
http: off(),
|
|
https: off(),
|
|
auto: sysproxy::Autoproxy {
|
|
url: std::string::String::new(),
|
|
enable: false,
|
|
},
|
|
auto_switched_on: false,
|
|
bypass: std::string::String::new(),
|
|
}
|
|
}
|
|
|
|
fn global_target(port: u16) -> Sysproxy {
|
|
Sysproxy {
|
|
enable: true,
|
|
host: "127.0.0.1".to_owned(),
|
|
port,
|
|
bypass: "localhost".to_owned(),
|
|
}
|
|
}
|
|
|
|
fn pac_mode_target() -> Sysproxy {
|
|
Sysproxy {
|
|
enable: false,
|
|
..global_target(7897)
|
|
}
|
|
}
|
|
|
|
fn pac_off() -> Autoproxy {
|
|
Autoproxy {
|
|
url: std::string::String::new(),
|
|
enable: false,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_proxy_the_os_already_points_at_needs_no_write() {
|
|
assert!(target_is_already_in_place(
|
|
&holding_global(7897),
|
|
&global_target(7897),
|
|
&pac_off()
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn a_proxy_pointing_at_a_different_port_still_has_to_be_written() {
|
|
assert!(!target_is_already_in_place(
|
|
&holding_global(7897),
|
|
&global_target(7898),
|
|
&pac_off()
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn a_pac_target_asks_the_pac_question_and_not_the_global_one() {
|
|
let pac = Autoproxy {
|
|
url: "http://127.0.0.1:33333/commands/pac".to_owned(),
|
|
enable: true,
|
|
};
|
|
let mut os_holds_pac = holding_nothing();
|
|
os_holds_pac.auto = pac.clone();
|
|
os_holds_pac.auto_switched_on = true;
|
|
os_holds_pac.bypass = "localhost".to_owned();
|
|
|
|
assert!(target_is_already_in_place(&os_holds_pac, &pac_mode_target(), &pac));
|
|
}
|
|
|
|
#[test]
|
|
fn a_pac_target_whose_bypass_changed_still_has_to_be_written() {
|
|
let pac = Autoproxy {
|
|
url: "http://127.0.0.1:33333/commands/pac".to_owned(),
|
|
enable: true,
|
|
};
|
|
let mut os_holds_pac = holding_nothing();
|
|
os_holds_pac.auto = pac.clone();
|
|
os_holds_pac.auto_switched_on = true;
|
|
os_holds_pac.bypass = "localhost,example.com".to_owned();
|
|
|
|
assert!(!target_is_already_in_place(&os_holds_pac, &pac_mode_target(), &pac));
|
|
}
|
|
|
|
#[test]
|
|
fn a_switch_left_on_over_nothing_is_not_already_disabled() {
|
|
let mut stranded = holding_nothing();
|
|
stranded.http = sysproxy::ProxyEndpoint {
|
|
host: std::string::String::new(),
|
|
port: 0,
|
|
enable: false,
|
|
switched_on: true,
|
|
};
|
|
|
|
assert!(!target_is_already_in_place(
|
|
&stranded,
|
|
&Sysproxy {
|
|
enable: false,
|
|
..global_target(7897)
|
|
},
|
|
&pac_off()
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn a_target_of_nothing_does_not_ask_about_the_bypass_list() {
|
|
let mut os_holds_nothing = holding_nothing();
|
|
os_holds_nothing.bypass = "something.else".to_owned();
|
|
|
|
assert!(target_is_already_in_place(
|
|
&os_holds_nothing,
|
|
&Sysproxy {
|
|
enable: false,
|
|
..global_target(7897)
|
|
},
|
|
&pac_off()
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn a_target_of_nothing_asks_whether_the_os_holds_nothing() {
|
|
let nothing_wanted = Sysproxy {
|
|
enable: false,
|
|
..global_target(7897)
|
|
};
|
|
|
|
assert!(target_is_already_in_place(
|
|
&holding_nothing(),
|
|
¬hing_wanted,
|
|
&pac_off()
|
|
));
|
|
assert!(!target_is_already_in_place(
|
|
&holding_global(7897),
|
|
¬hing_wanted,
|
|
&pac_off()
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn only_a_snapshot_that_agrees_with_the_target_skips_the_write() {
|
|
assert_eq!(classify_os_proxy_state(Ok(true)), OsProxyState::AlreadyApplied);
|
|
assert_eq!(classify_os_proxy_state(Ok(false)), OsProxyState::DifferentOrUnknown);
|
|
}
|
|
|
|
#[test]
|
|
fn a_failed_read_never_counts_as_agreement() {
|
|
assert_eq!(
|
|
classify_os_proxy_state(Err(anyhow::anyhow!("read failed"))),
|
|
OsProxyState::DifferentOrUnknown
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pure_sysproxy_mode_clears_pac_before_enabling_global_proxy() {
|
|
assert_eq!(
|
|
proxy_apply_steps(true, false),
|
|
[ProxyApplyStep::Autoproxy, ProxyApplyStep::Sysproxy]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pac_mode_clears_global_proxy_before_enabling_pac() {
|
|
assert_eq!(
|
|
proxy_apply_steps(false, true),
|
|
[ProxyApplyStep::Sysproxy, ProxyApplyStep::Autoproxy]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_mode_clears_global_proxy_before_pac() {
|
|
assert_eq!(
|
|
proxy_apply_steps(false, false),
|
|
[ProxyApplyStep::Sysproxy, ProxyApplyStep::Autoproxy]
|
|
);
|
|
}
|
|
}
|