From f076d4cd5de03eaf0c9d4fca72dce2843ca5760f Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:29:44 +0800 Subject: [PATCH] fix(macos): preserve DNS and proxy state across network changes --- Changelog.md | 9 +++ scripts/set_dns.sh | 67 ++++++++++++----- scripts/unset_dns.sh | 99 ++++++++++++++++--------- src-tauri/src/core/manager/lifecycle.rs | 24 ++++-- src-tauri/src/core/network_watch.rs | 1 + src-tauri/src/core/proxy_control.rs | 28 ++++++- src-tauri/src/utils/resolve/dns.rs | 78 ++++++------------- 7 files changed, 190 insertions(+), 116 deletions(-) diff --git a/Changelog.md b/Changelog.md index d6db6c761..e1812bc00 100644 --- a/Changelog.md +++ b/Changelog.md @@ -27,6 +27,11 @@ - 修复检测更新后又发布新版时,应用内更新失败 - 修复全局模式默认停在 `DIRECT` 或 `REJECT` 导致流量未经代理,现自动切换到第一个策略组 +**🍎 macOS** + +- 修复 macOS TUN 模式下原有 DNS 被清除、关闭后 DNS 未恢复及联网后设置未生效 +- 修复 macOS 断网时误报代理已回退直连,恢复联网后自动重试系统代理 +
@@ -40,4 +45,8 @@ - 优化全局模式代理列表:仅显示 `GLOBAL` 节点,选中其中的策略组时才显示该组 +**🍎 macOS** + +- 优化 macOS TUN 模式的系统 DNS,增加阿里和 Cloudflare 地址 +
diff --git a/scripts/set_dns.sh b/scripts/set_dns.sh index 2b3469fcc..be836cf6f 100644 --- a/scripts/set_dns.sh +++ b/scripts/set_dns.sh @@ -32,7 +32,9 @@ function is_valid_ip() { # Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent. function sc_value() { - printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p" + local dictionary + dictionary=$(printf 'show %s\nquit\n' "$1" | scutil) || return 1 + sed -n "s/^ *$2 : //p" <<<"$dictionary" } # Prints the service's DNS servers, or "empty" when none is set; fails on any other answer. @@ -50,31 +52,62 @@ function dns_of() { echo "$dns" } -[ $# -lt 1 ] && echo "Usage: $0 " && exit 1 -! is_valid_ip "$1" && echo "$1 is not a valid IP address." && exit 1 +[ $# -lt 1 ] && echo "Usage: $0 [legacy state directory]" >&2 && exit 1 +dns_servers=() +while read -r server; do + ! is_valid_ip "$server" && echo "$server is not a valid IP address." >&2 && exit 1 + dns_servers+=("$server") +done <<<"$1" # Backups are keyed by service ID, which survives renaming the service and renumbering its interface. -service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService) -hardware_port=$(sc_value "Setup:/Network/Service/$service_id" UserDefinedName) -[ -z "$hardware_port" ] && echo "No primary network service." && exit 1 +service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService) || exit 1 +hardware_port=$(sc_value "Setup:/Network/Service/$service_id" UserDefinedName) || exit 1 +[ -z "$hardware_port" ] && echo "No primary network service; waiting for the network." >&2 && exit 75 original_dns=$(dns_of "$hardware_port") || { echo "Cannot read DNS servers of $hardware_port." >&2; exit 1; } backup=".original_dns/$service_id" mkdir -p .original_dns || exit 1 -# Older releases kept a single backup for whichever service was primary. -if [ -f .original_dns.txt ]; then - if [ -f "$backup" ]; then - rm -f .original_dns.txt || exit 1 - else - mv .original_dns.txt "$backup" || exit 1 +# Key old single-slot backups before import so a later network switch cannot reassign them. +for state_dir in "$PWD" "${2:-$PWD}"; do + if [ -f "$state_dir/.original_dns.txt" ]; then + mkdir -p "$state_dir/.original_dns" || exit 1 + if [ -f "$state_dir/.original_dns/$service_id" ]; then + rm -f "$state_dir/.original_dns.txt" || exit 1 + else + mv "$state_dir/.original_dns.txt" "$state_dir/.original_dns/$service_id" || exit 1 + fi fi +done +if [ ! -f "$backup" ] && [ -n "$2" ] && [ -f "$2/$backup" ]; then + cp "$2/$backup" ".original_dns/.$service_id" || exit 1 + mv ".original_dns/.$service_id" "$backup" || exit 1 +fi +backup_header='# clash-verge-dns-managed' +function save_backup() { + printf '%s\n%s\n' "$backup_header" "$1" >".original_dns/.$service_id" && + mv ".original_dns/.$service_id" "$backup" +} + +saved_dns= +legacy_backup=false +if [ -f "$backup" ]; then + saved_dns=$(cat "$backup") || exit 1 + case "$saved_dns" in + "$backup_header"$'\n'*) saved_dns=${saved_dns#*$'\n'} ;; + *) legacy_backup=true ;; + esac fi -# The placeholder is ours, not the user's: keep the backup taken before it was applied. if [ "$original_dns" != "$1" ]; then - echo "$original_dns" >"$backup" || exit 1 - networksetup -setdnsservers "$hardware_port" "$1" + # Only an unmarked historical backup owns the old single-address placeholder. + if [ "$original_dns" != "114.114.114.114" ] || [ "$legacy_backup" != true ]; then + save_backup "$original_dns" || exit 1 + legacy_backup=false + fi + networksetup -setdnsservers "$hardware_port" "${dns_servers[@]}" [ "$(dns_of "$hardware_port")" = "$1" ] || { echo "Failed to set DNS for $hardware_port." >&2; exit 1; } -elif [ ! -f "$backup" ]; then - echo "empty" >"$backup" || exit 1 +fi +# Keep legacy ownership until the new list is verified, including a retry after a failed marker write. +if [ "$legacy_backup" = true ]; then + save_backup "$saved_dns" || exit 1 fi diff --git a/scripts/unset_dns.sh b/scripts/unset_dns.sh index 03f925e7d..29b8e9543 100644 --- a/scripts/unset_dns.sh +++ b/scripts/unset_dns.sh @@ -1,9 +1,11 @@ #!/bin/bash -[ $# -lt 1 ] && echo "Usage: $0 " && exit 1 +[ $# -lt 1 ] && echo "Usage: $0 [legacy state directory]" >&2 && exit 1 # Prints one value of a SystemConfiguration dictionary, or nothing when the key is absent. function sc_value() { - printf 'show %s\nquit\n' "$1" | scutil | sed -n "s/^ *$2 : //p" + local dictionary + dictionary=$(printf 'show %s\nquit\n' "$1" | scutil) || return 1 + sed -n "s/^ *$2 : //p" <<<"$dictionary" } # Prints the service's DNS servers, or "empty" when none is set; fails on any other answer. @@ -21,42 +23,69 @@ function dns_of() { echo "$dns" } -result=0 -# Older releases kept a single backup for whichever service was primary. -if [ -f .original_dns.txt ]; then - service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService) - if [ -z "$service_id" ] || ! mkdir -p .original_dns; then - result=1 - elif [ -f ".original_dns/$service_id" ]; then - rm -f .original_dns.txt || result=1 - elif ! mv .original_dns.txt ".original_dns/$service_id"; then - result=1 - fi -fi +backup_header='# clash-verge-dns-managed' +completed_backups=() -for backup in .original_dns/*; do - [ -f "$backup" ] || continue - hardware_port=$(sc_value "Setup:/Network/Service/${backup##*/}" UserDefinedName) - # Kept for a service that is gone: there is nothing to write it back to. - [ -n "$hardware_port" ] || continue - if ! current_dns=$(dns_of "$hardware_port"); then - echo "Cannot read DNS servers of $hardware_port." >&2 - result=1 - continue +function restore_dns() { + local result=0 + # Older releases kept a single backup for whichever service was primary. + if [ -f .original_dns.txt ]; then + service_id=$(sc_value State:/Network/Global/IPv4 PrimaryService) + if [ -z "$service_id" ] || ! mkdir -p .original_dns; then + result=1 + elif [ -f ".original_dns/$service_id" ]; then + rm -f .original_dns.txt || result=1 + elif ! mv .original_dns.txt ".original_dns/$service_id"; then + result=1 + fi fi - # A service no longer on the placeholder was changed by someone else, and that change stands. - if [ "$current_dns" = "$1" ]; then - original_dns=$(cat "$backup") || { result=1; continue; } - # Older releases could back up the placeholder itself. - [ "$original_dns" = "$1" ] && original_dns=empty - networksetup -setdnsservers "$hardware_port" $original_dns - if [ "$(dns_of "$hardware_port")" != "$original_dns" ]; then - echo "Failed to restore DNS for $hardware_port." >&2 + + for backup in .original_dns/*; do + [ -f "$backup" ] || continue + # Current backups take precedence even after their DNS has already been restored. + if [ "$PWD" != "$state_dir" ] && [ -f "$state_dir/$backup" ]; then + completed_backups+=("$PWD/$backup") + continue + fi + hardware_port=$(sc_value "Setup:/Network/Service/${backup##*/}" UserDefinedName) || { result=1; continue; } + # Kept for a service that is gone: there is nothing to write it back to. + [ -n "$hardware_port" ] || continue + if ! current_dns=$(dns_of "$hardware_port"); then + echo "Cannot read DNS servers of $hardware_port." >&2 result=1 continue fi - fi - rm -f "$backup" || result=1 -done -rmdir .original_dns 2>/dev/null + original_dns=$(cat "$backup") || { result=1; continue; } + legacy_backup=false + case "$original_dns" in + "$backup_header"$'\n'*) original_dns=${original_dns#*$'\n'} ;; + *) legacy_backup=true ;; + esac + # A service no longer on its recorded placeholder was changed by someone else. + if [ "$current_dns" = "$1" ] || { [ "$legacy_backup" = true ] && [ "$current_dns" = "114.114.114.114" ]; }; then + networksetup -setdnsservers "$hardware_port" $original_dns + if [ "$(dns_of "$hardware_port")" != "$original_dns" ]; then + echo "Failed to restore DNS for $hardware_port." >&2 + result=1 + continue + fi + fi + completed_backups+=("$PWD/$backup") + done + return $result +} + +result=0 +state_dir=$PWD +restore_dns "$1" || result=1 +if [ -n "$2" ] && [ "$2" != "$PWD" ]; then + { cd "$2" && restore_dns "$1"; } || result=1 +fi +# Remove superseded legacy backups first; a cleanup failure must leave current authority intact. +if [ "$result" -eq 0 ]; then + for ((index=${#completed_backups[@]}-1; index>=0; index--)); do + rm -f "${completed_backups[index]}" || { result=1; break; } + done +fi +rmdir "$state_dir/.original_dns" "${2:-$state_dir}/.original_dns" 2>/dev/null exit $result diff --git a/src-tauri/src/core/manager/lifecycle.rs b/src-tauri/src/core/manager/lifecycle.rs index 62a8776b4..7fa46c610 100644 --- a/src-tauri/src/core/manager/lifecycle.rs +++ b/src-tauri/src/core/manager/lifecycle.rs @@ -559,12 +559,13 @@ impl CoreManager { })?; #[cfg(target_os = "macos")] crate::utils::resolve::dns::sync_public_dns().await; - // At login the app usually beats the network. Leave the write to the network watcher - // rather than fail — only if the watcher is actually live; a user toggling while - // offline still fails fast. + // Only automatic restores can wait for the watcher; explicit requests must report failure. #[cfg(target_os = "macos")] - if crate::core::network_watch::is_armed() - && !crate::utils::resolve::is_resolve_done() + let defer_until_network = crate::core::network_watch::is_armed() + && crate::core::notification::what_was_asked() + == crate::core::notification::FailedOperation::SystemProxyRestore; + #[cfg(target_os = "macos")] + if defer_until_network && Config::verge() .await .latest_arc() @@ -579,7 +580,18 @@ impl CoreManager { ); return Ok(()); } - proxy_control::apply().await?; + if let Err(error) = proxy_control::apply().await { + #[cfg(target_os = "macos")] + if defer_until_network && proxy_control::is_missing_network_service_failure(&error) { + logging!( + debug, + Type::Core, + "network service disappeared; the system proxy waits for its return" + ); + return Ok(()); + } + return Err(error); + } if !expectation.is_valid( self.get_running_mode().as_ref(), self.current_core_readiness_generation(), diff --git a/src-tauri/src/core/network_watch.rs b/src-tauri/src/core/network_watch.rs index 1cfa36139..eca89604b 100644 --- a/src-tauri/src/core/network_watch.rs +++ b/src-tauri/src/core/network_watch.rs @@ -49,6 +49,7 @@ async fn reapply() { .enable_system_proxy .unwrap_or_default() { + crate::utils::resolve::dns::sync_public_dns().await; return; } if !proxy_control::has_network_service().await { diff --git a/src-tauri/src/core/proxy_control.rs b/src-tauri/src/core/proxy_control.rs index 19bc09456..097c7a840 100644 --- a/src-tauri/src/core/proxy_control.rs +++ b/src-tauri/src/core/proxy_control.rs @@ -180,6 +180,14 @@ pub const fn is_missing_network_service(_error: &sysproxy::Error) -> bool { false } +pub fn is_missing_network_service_failure(error: &anyhow::Error) -> bool { + !SystemProxyStateUnknown::is_in(error) + && SysproxyFailure::from_chain(error).is_none() + && error + .downcast_ref::() + .is_some_and(is_missing_network_service) +} + /// Distinguish a misplaced sidecar from a missing privileged service. async fn classify_local_apply_failure(error: anyhow::Error) -> anyhow::Error { if SysproxyFailure::from_chain(&error).is_some() || !was_refused_locally(&error) { @@ -359,6 +367,13 @@ fn service_apply_result(requested: &MacosProxyConfig, outcome: ProxyApplyOutcome ProxyApplyOutcome::Applied | ProxyApplyOutcome::NotRequested => Ok(()), // Direct fallback satisfies a disable request. ProxyApplyOutcome::DirectFallback { .. } if matches!(requested, MacosProxyConfig::Disabled) => Ok(()), + // Older helpers encode a failed service lookup as fallback even when no OS write occurred. + #[cfg(target_os = "macos")] + ProxyApplyOutcome::DirectFallback { message } + if message == sysproxy::Error::NoActiveNetworkService.to_string() => + { + Err(sysproxy::Error::NoActiveNetworkService.into()) + } ProxyApplyOutcome::DirectFallback { message } => { Err(SysproxyFailure::DirectFallback { detail: message }.into()) } @@ -609,6 +624,10 @@ pub async fn refresh_guard() -> Result<()> { logging!(debug, Type::Core, "proxy guard superseded (generation {generation})"); break; } + Err(error) if is_missing_network_service_failure(&error) => { + consecutive_failures = 0; + logging!(debug, Type::Core, "system proxy guard waits for a network service"); + } Err(error) => { consecutive_failures += 1; logging!( @@ -1110,10 +1129,11 @@ mod tests { .err() .unwrap_or_else(|| anyhow::anyhow!("an enable that never reached the OS must fail")); - assert_eq!( - SysproxyFailure::from_chain(&error).map(SysproxyFailure::code), - Some("SYSPROXY_DIRECT_FALLBACK") - ); + assert!(super::is_missing_network_service_failure(&error)); + assert!(SysproxyFailure::from_chain(&error).is_none()); + assert!(!super::is_missing_network_service_failure( + &error.context(SystemProxyStateUnknown) + )); } #[cfg(target_os = "macos")] diff --git a/src-tauri/src/utils/resolve/dns.rs b/src-tauri/src/utils/resolve/dns.rs index 0a27acfaa..ac1b1bda1 100644 --- a/src-tauri/src/utils/resolve/dns.rs +++ b/src-tauri/src/utils/resolve/dns.rs @@ -4,16 +4,14 @@ use serde_yaml_ng::{Mapping, Value}; use std::{ io::Read as _, os::unix::process::CommandExt as _, - path::{Path, PathBuf}, + path::PathBuf, process::{Command, Stdio}, time::{Duration, Instant}, }; use tokio::sync::Mutex; -const DNS_STATE_FILE: &str = ".original_dns.txt"; -const DNS_BACKUP_DIR: &str = ".original_dns"; // Any address routed into TUN works, since dns-hijack answers it; the value only matters if left behind. -const PLACEHOLDER_DNS: &str = "114.114.114.114"; +const PLACEHOLDER_DNS: &str = "114.114.114.114\n223.5.5.5\n1.1.1.1"; // Scripts run under lifecycle_lock, and networksetup waits for other preference writers without a limit. pub(crate) const DNS_SCRIPT_TIMEOUT: Duration = Duration::from_secs(5); @@ -26,14 +24,6 @@ fn dns_state_dir() -> anyhow::Result { Ok(dir) } -fn restore_dns_state_dir(resource_dir: &Path, state_dir: PathBuf) -> PathBuf { - if resource_dir.join(DNS_STATE_FILE).exists() || resource_dir.join(DNS_BACKUP_DIR).exists() { - resource_dir.to_path_buf() - } else { - state_dir - } -} - // Without the placeholder, a LAN resolver is reached outside TUN and fake-ip is bypassed. fn needs_public_dns(config: &Mapping) -> bool { let section = |name: &str| config.get(name).and_then(Value::as_mapping); @@ -79,25 +69,19 @@ pub async fn restore_public_dns() -> anyhow::Result<()> { async fn set_public_dns() { logging!(debug, Type::Config, "try to set system dns"); - let result = async { - let script = crate::utils::dirs::app_resources_dir()?.join("set_dns.sh"); - run_dns_script(&script, dns_state_dir()?).await - } - .await; + let result = run_dns_script("set_dns.sh", true).await; match result { - Ok(()) => logging!(info, Type::Config, "set system dns successfully"), + Ok(DnsScriptOutcome::Complete) => logging!(info, Type::Config, "set system dns successfully"), + Ok(DnsScriptOutcome::Deferred) => { + logging!(debug, Type::Config, "system DNS waits for a primary network service") + } Err(err) => logging!(error, Type::Config, "set system dns failed: {err:#}"), } } async fn unset_public_dns() -> anyhow::Result<()> { logging!(debug, Type::Config, "try to unset system dns"); - let result = async { - let resource_dir = crate::utils::dirs::app_resources_dir()?; - let state_dir = restore_dns_state_dir(&resource_dir, dns_state_dir()?); - run_dns_script(&resource_dir.join("unset_dns.sh"), state_dir).await - } - .await; + let result = run_dns_script("unset_dns.sh", false).await.map(|_| ()); match &result { Ok(()) => logging!(info, Type::Config, "unset system dns successfully"), Err(err) => logging!(error, Type::Config, "unset system dns failed: {err:#}"), @@ -105,12 +89,21 @@ async fn unset_public_dns() -> anyhow::Result<()> { result } +enum DnsScriptOutcome { + Complete, + Deferred, +} + /// On timeout the script's whole process group is killed, so no networksetup it started writes late. -async fn run_dns_script(script: &Path, dir: PathBuf) -> anyhow::Result<()> { +async fn run_dns_script(script_name: &str, allow_deferred: bool) -> anyhow::Result { + let resource_dir = crate::utils::dirs::app_resources_dir()?; + let script = resource_dir.join(script_name); + let dir = dns_state_dir()?; ensure!(script.exists(), "DNS script not found: {}", script.display()); let mut child = Command::new("bash") - .arg(script) + .arg(&script) .arg(PLACEHOLDER_DNS) + .arg(resource_dir) .current_dir(dir) .stdout(Stdio::null()) .stderr(Stdio::piped()) @@ -134,41 +127,18 @@ async fn run_dns_script(script: &Path, dir: PathBuf) -> anyhow::Result<()> { if let Some(mut pipe) = child.stderr.take() { pipe.read_to_string(&mut stderr)?; } + // EX_TEMPFAIL is reserved by set_dns.sh for a network service that is not ready. + if allow_deferred && status.code() == Some(75) { + return Ok(DnsScriptOutcome::Deferred); + } ensure!( status.success(), "exit code {}: {}", status.code().unwrap_or(-1), stderr.trim() ); - Ok(()) + Ok(DnsScriptOutcome::Complete) }) .await .context("DNS script task failed")? } - -#[cfg(test)] -mod tests { - - #[test] - #[allow(clippy::expect_used)] - fn restore_dns_state_dir_defaults_to_app_data_but_honors_legacy_file() { - use super::restore_dns_state_dir; - - let root = std::env::temp_dir().join(format!("clash-verge-dns-{}", nanoid::nanoid!())); - let resource_dir = root.join("resources"); - let state_dir = root.join("app-data"); - std::fs::create_dir_all(&resource_dir).expect("create test resource directory"); - - assert_eq!(restore_dns_state_dir(&resource_dir, state_dir.clone()), state_dir); - - std::fs::write(resource_dir.join(".original_dns.txt"), "empty").expect("create legacy DNS state"); - assert_eq!(restore_dns_state_dir(&resource_dir, state_dir.clone()), resource_dir); - - // A migrated legacy backup whose restore failed must still be retried there. - std::fs::remove_file(resource_dir.join(".original_dns.txt")).expect("remove legacy DNS state"); - std::fs::create_dir(resource_dir.join(".original_dns")).expect("create migrated DNS state"); - assert_eq!(restore_dns_state_dir(&resource_dir, state_dir), resource_dir); - - std::fs::remove_dir_all(root).expect("remove test directory"); - } -}