Files
clash-verge-rev/src-tauri/src/core/sysopt.rs
T
Tunglies 9a904af3aa refactor: backend cleanup (#8436)
* refactor(utils): drop dead reset helper and tidy init_resources

- remove resolve_reset_async, which had no callers
- drop the duplicated time.*.com entry from the default fake-ip-filter
- init_resources no longer recreates app_home (ensure_directories during
  init_config already created it) and now propagates res_dir creation errors

* refactor(profile): remove identical blind retry when switching node

The second select_node_for_group call in switch_proxy_node was a
zero-interval retry with identical arguments. Its success path was a
degraded copy of the first (missing the Refresh::Proxies announcement
ever since the old force-refresh emit was removed), and its distinct
"回退/最终失败" log wording misled maintainers. Keep a single call,
the primary success aftermath, and one error log.

* refactor(enhance): simplify tun mode check and lowercase helpers

- Drop the always-true second clause in use_tun's enhanced-mode check:
  the key is absent only when the unwrap_or default already makes
  current_mode == "fake-ip" true.
- Collapse use_lowercase into a delegation to use_lowercase_owned.

* refactor(core): drop unreachable hotkey dtor and dedupe tray predicate

Remove impl Drop for Hotkey: the instance lives in a OnceLock static via
the singleton! macro and is never dropped; exit-time unregistration is
already handled by Hotkey::reset().

Spell the include_proxy_groups/display-mode predicate identically at
both evaluation sites in tray menu building; behavior is unchanged
(Option != Some("disable") is equivalent to unwrap_or("default") != "disable").

* refactor(config): fold port guards into single helper

The five IClashTemp port guards duplicated the same read/parse/default
logic with drifting defaults: some hardcoded 7895/7898/7899 while others
referenced network::ports constants. Route them all through one
guard_port_value helper and use the constants everywhere, keeping each
guard's name, key and cfg attributes.

* refactor(config): dedupe prfitem factories and companion seeding

from_rules/from_proxies/from_groups differed only in uid prefix, type
string and template, so they now delegate to a shared from_seq_item.
The five current_* accessors collapse into a local macro. The identical
five-block companion seeding in from_local and from_url moves into
ensure_companion_items, preserving seeding order and error propagation.

* refactor(config): share patch macro and profiles helpers

IVerge::patch_config, IProfiles::patch_item and PrfOption::merge each
hand-rolled the same copy-Some-overwrite pattern; they now share one
patch_opt_field macro (the or-else merge form is field-for-field
equivalent for Option fields). The four profiles *_safe wrappers that
inlined Config::profiles().with_data_modify boilerplate delegate to a
with_profiles_edit helper, and record/forget_selected_node share the
current-item lookup prelude via current_item_mut.

* refactor(enhance): merge chain processing and drop unused builtin js payload

- collapse the identical process_global_items/process_profile_items into
  one parameterized process_override_items (entry uid sources unchanged)
- fold the identical rules/proxies/groups arms of ChainItem::from_async
  into a single arm with an inner dispatch
- stop embedding builtin JS payloads never executed by any path: the
  enhance loop dispatches builtin items by uid to native implementations,
  so ChainItem::builtin now carries empty script data (uids unchanged)
- source ProfileItems fallback literals from Default instead of a second
  hand-written copy in collect_profile_items

* refactor(net): reuse webpki tls config and fallback

Extract build_webpki_rooted_tls_config so the delay-test TLS connector
and the reqwest clients share one static-webpki-roots builder; ALPN
stays caller-specific (only reqwest offers h2).

Collapse the duplicated platform-verifier -> static-webpki-roots retry
in get/get_bytes into request_with_static_webpki_fallback, gated by the
caller's certificate-verification setting.

* refactor(app): extract common exit cleanup

restart_app and quit duplicate the whole exit preparation: set the
exiting flag, persist configs, run interactive cleanup, and on a failed
core stop clear the flag, restore public DNS under the lifecycle lock
on macOS, and send the stop-failed notice.

Move that flow into run_exit_cleanup_flow next to clean_async, taking
the notice variant as a parameter and returning the CleanupResult so
each caller keeps its own tail: restart aborts before shutdown, quit
always shuts down and exits with the cleanup code.

* refactor(backup): share restore finalize path

restore_webdav_backup and restore_local_backup duplicated the
credential extraction (backup creation strips the WebDAV credentials,
so both re-inject the live ones), the archive extraction and the
finalize tail. Move that tail into a private restore_backup_archive
helper so the two entries keep only their download/locate logic; the
config-write lock scope and the off-runtime extraction land in one
place. The webdav entry keeps its downloaded-file cleanup.

* refactor(sysopt): drop redundant lock and busy-wait

The proxy target pair sat behind an Arc<RwLock> with no readers and an
Arc that was never cloned; both writers already serialize on
update_lock, so store the pair inside that mutex and delete the RwLock.

refresh_guard waited for the guard to become ready with a yield_now spin
at full CPU while holding guard_operation_lock; a 10 ms sleep keeps the
same wait condition and lock scope without the spin.

* refactor(sysopt): share macos bypass formatting

The macOS default bypass list and its three-way composition (empty ->
default, default + custom, custom only) were maintained twice: in
sysopt's format_bypass and inline in proxy_control's service_bypass.
Keep the literal once in constants and compose both paths through
format_bypass; service_bypass keeps its NUL check and UTF-8 truncation.

* refactor(core): dedupe window listeners, elevated runners, backup settings

- Collapse the close/focus window listener pairs in lightweight.rs onto a shared WindowListener with per-listener event and log label.
- Extract the windows runas-or-direct execution into service::run_maybe_elevated, shared by win_uwp and the service uninstaller, and factor the common uninstaller path resolution and status check shared across platforms.
- Make the auto-backup watch channel the single settings source, replacing the duplicate Arc<RwLock> copy with a stored receiver.

* refactor(enhance): fold lowercase helpers into single owned function

use_lowercase_owned disappears; the surviving use_lowercase takes the
owned Mapping and borrowed callers clone at the call site.

* refactor(enhance): rename ChainSupport variants to Mihomo names

Clash is a legacy name; the variants match the verge-mihomo and
verge-mihomo-alpha cores they gate.

* refactor(enhance): lowercase takes a reference and chain support is copy

use_lowercase borrows the mapping and clones only the values; call
sites no longer clone whole configs. ChainSupport is a fieldless enum
and now Copy; builtin() registers each support pair without clones.

* refactor(config): name prfitem constructor for what it builds

new_seq_item states that a fresh item is built from a template; the
from_ prefix wrongly implied an existing source. append_companion_item
returns the uid directly: append_item bails on a missing uid, so the
Option was always Some on the Ok path.

* refactor(sysopt): name the proxy state lock for its full role

The lock now owns the (Sysproxy, Autoproxy) state, so proxy_state_lock
says what it guards; the guard locals follow. constants::proxy held a
single sysproxy constant and is renamed sysproxy.

* refactor: replace remaining clash meta naming with mihomo

Doc comment, generated bindings and the prebuild sidecar helpers stop
referring to the legacy Clash Meta name; installer legacy-process
cleanup keeps the old executable names on purpose.

* refactor(enhance): drop builtin core filtering that never discriminates

Both valid cores matched a different ChainSupport variant, yet each
variant registered the same uid pair, so the filtered builtin list was
identical for stable and alpha. Invalid cores are coerced at startup
(validate_and_fix_config / get_valid_clash_core) and the None arm of
is_support was unreachable. builtin() now returns the items directly
and the clash_core threading through ConfigValues goes away; re-add
core discrimination when a builtin actually differs per core.

* refactor(enhance): drop unreachable mode script guard

merge_default_config overwrites the profile's mode with the verge-owned
clash config, whose mode is always present (template seeds rule and
patch semantics never clear it) and restricted to rule/global/direct;
change_clash_mode is rejected by the core before persisting anything
else. mode:script can therefore only arrive through a hand-crafted
patch_clash call, and for that path an explicit invalid-mode validation
error is more honest than silently rewriting to rule. mihomo rejects
mode:script outright (invalid mode), and user merge/script chains run
after the builtins, so the guard never covered them anyway.

* refactor(enhance): call the hysteria alpn builtin directly

builtin() existed to feed the uid-dispatch loop; with a single
builtin the vec and match are ceremony. apply_builtin_scripts calls
builtin_hy_alpn directly and to_script goes away; tests construct
ChainItem literals the way the merge items already did.
2026-11-03 10:48:44 +00:00

1087 lines
37 KiB
Rust

use crate::{
config::{Config, MixedPort},
core::proxy_control::{self, 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 should follow the last in-flight guard write, but a guard that will not
// stop must not keep the proxy on: disable again either way.
if !drain_again().await {
logging!(
warn,
Type::Core,
"the system proxy guard did not finish within {GUARD_DRAIN_TIMEOUT:?}; disabling anyway"
);
}
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.
///
/// Enabling still fails: the guard is opt-in and nothing else re-applies the proxy when a
/// service appears, so a skipped enable would claim a proxy the OS never received.
///
/// Reports whether the write reached the OS, so recovery does not mistake a skip for evidence.
fn skip_without_network_service(enabling: bool, outcome: sysproxy::Result<()>) -> Result<bool> {
match outcome {
Err(error) if !enabling && proxy_control::is_missing_network_service(&error) => {
logging!(warn, Type::Core, "no active network service, nothing to turn off");
Ok(false)
}
other => other.map(|()| true).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(
|| skip_without_network_service(sys.enable, sys.set_system_proxy()).map(|_reached_os| ()),
|| skip_without_network_service(auto.enable, auto.set_auto_proxy()).map(|_reached_os| ()),
)
})
.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 {
// Owns the proxy target pair; every reader and writer goes through this lock.
proxy_state_lock: TokioMutex<(Sysproxy, Autoproxy)>,
guard_operation_lock: TokioMutex<()>,
reset_sysproxy: AtomicBool,
guard: Arc<RwLock<GuardMonitor>>,
}
impl Default for Sysopt {
fn default() -> Self {
Self {
proxy_state_lock: TokioMutex::new((Sysproxy::default(), Autoproxy::default())),
guard_operation_lock: TokioMutex::new(()),
reset_sysproxy: AtomicBool::new(false),
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;<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 = crate::constants::sysproxy::MACOS_DEFAULT_BYPASS;
pub(super) 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::time::sleep(Duration::from_millis(10)).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.proxy_state_lock.lock().await;
}
/// init the sysproxy
#[allow(
clippy::significant_drop_tightening,
reason = "the proxy state lock must stay held until the OS write finishes; wait_idle relies on it"
)]
pub(super) async fn update_sysproxy(&self) -> Result<()> {
let mut proxy_state = self.proxy_state_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()?;
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 *proxy_state;
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;
// A slow guard does not stop the write, but it does make the OS read untrustworthy.
if cfg!(target_os = "macos")
&& drained
&& 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)
};
// Only a step that actually wrote is evidence the OS changed; a skipped one is not.
let applied = tokio::task::spawn_blocking(move || {
let mut earlier_step_reached_os = false;
for step in apply_steps {
let written = match step {
ProxyApplyStep::Autoproxy => skip_without_network_service(auto.enable, auto.set_auto_proxy()),
ProxyApplyStep::Sysproxy => skip_without_network_service(sys.enable, sys.set_system_proxy()),
};
match written {
Ok(reached_os) => earlier_step_reached_os |= reached_os,
Err(error) => return Err((earlier_step_reached_os, 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
#[allow(
clippy::significant_drop_tightening,
reason = "the proxy state lock must stay held until the disable finishes; wait_idle relies on it"
)]
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 mut proxy_state = self.proxy_state_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 *proxy_state;
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};
#[cfg(target_os = "macos")]
use super::skip_without_network_service;
#[cfg(target_os = "macos")]
#[test]
fn a_machine_with_no_network_service_has_nothing_to_turn_off() {
for missing in [
sysproxy::Error::NoActiveNetworkService,
sysproxy::Error::NetworkInterface,
] {
assert_eq!(skip_without_network_service(false, Err(missing)).ok(), Some(false));
}
assert_eq!(skip_without_network_service(false, Ok(())).ok(), Some(true));
assert!(skip_without_network_service(false, Err(sysproxy::Error::RequiresAdminPrivileges)).is_err());
}
/// Enabling the global proxy disables PAC first; that skipped step must not look like a write.
#[cfg(target_os = "macos")]
#[test]
fn a_skipped_step_is_not_evidence_that_the_os_was_touched() {
let skipped = skip_without_network_service(false, Err(sysproxy::Error::NoActiveNetworkService));
let earlier_step_reached_os = skipped.unwrap_or(true);
assert!(!earlier_step_reached_os);
let failed = skip_without_network_service(true, Err(sysproxy::Error::NoActiveNetworkService))
.err()
.unwrap_or_else(|| anyhow::anyhow!("enabling without a network service must fail"));
assert_eq!(
authoritative_state(&failed, earlier_step_reached_os),
AuthoritativeState::Unchanged
);
}
#[cfg(target_os = "macos")]
#[test]
fn turning_the_proxy_on_without_a_network_service_still_fails() {
assert!(skip_without_network_service(true, Err(sysproxy::Error::NoActiveNetworkService)).is_err());
assert!(skip_without_network_service(true, Err(sysproxy::Error::NetworkInterface)).is_err());
}
#[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_still_gets_the_proxy_disabled() {
assert_eq!(
record_disable_sequence(false, false, &[true, true]).await,
(vec!["disable", "drain", "disable"], true)
);
}
#[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(),
&nothing_wanted,
&pac_off()
));
assert!(!target_is_already_in_place(
&holding_global(7897),
&nothing_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]
);
}
}