From ecb7ac37e7f39f3c7c1bdc78ba3af17a1ba923b5 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:34:09 +0100 Subject: [PATCH] fix: parallel DNS resolution and robust domain addition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DNS resolution was sequential: dig timeouts (~8s) blocked DoH from running when VPN blocks UDP. Now dig and DoH race in parallel with a trust hierarchy — dig-based resolvers fire immediately, DoH fires after a 200ms grace period. First success wins. Resolves in ~2s on VPN instead of 8+. Also fixes addDomain: DNS cache, disk cache, and /etc/hosts are now updated immediately on success (was only done during periodic refresh). DNS failures trigger a tracked 15s auto-retry with fresh gateway lookup, deduplication guards, and proper cancellation on domain removal or VPN disconnect. Additional hardening: - failedDomains changed to Set (prevents unbounded growth) - setAllDomainsEnabled uses single disk write instead of N - toggleDomain now updates hosts file on enable - Redundant MainActor.run wrappers removed (already on MainActor) - isApplyingRoutes properly managed during retries --- Casks/vpn-bypass.rb | 2 +- Info.plist | 2 +- README.md | 2 +- Sources/RouteManager.swift | 186 ++++++++++++++++++++++++------------- Sources/SettingsView.swift | 4 +- docs/CHANGELOG.md | 17 ++++ 6 files changed, 141 insertions(+), 72 deletions(-) diff --git a/Casks/vpn-bypass.rb b/Casks/vpn-bypass.rb index 59d1665..10bd75d 100644 --- a/Casks/vpn-bypass.rb +++ b/Casks/vpn-bypass.rb @@ -3,7 +3,7 @@ # Or if using local tap: brew install --cask --no-quarantine ./Casks/vpn-bypass.rb cask "vpn-bypass" do - version "1.7.1" + version "1.8.0" sha256 "37b127a55aec0bdb80e824e59e840ce5b529c09086aac7fc24dc4616abb817bd" url "https://github.com/GeiserX/VPN-Bypass/releases/download/v#{version}/VPN-Bypass-#{version}.dmg" diff --git a/Info.plist b/Info.plist index b573d25..6709a02 100644 --- a/Info.plist +++ b/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.7.1 + 1.8.0 CFBundleVersion 19 LSMinimumSystemVersion diff --git a/README.md b/README.md index 2b26e7a..3c1aff5 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@

macOS 13+ Swift 5.9 - Version + Version

## Why? diff --git a/Sources/RouteManager.swift b/Sources/RouteManager.swift index fbd801e..603ad6c 100644 --- a/Sources/RouteManager.swift +++ b/Sources/RouteManager.swift @@ -599,11 +599,10 @@ final class RouteManager: ObservableObject { } } - // Log disconnection - only if we're confident (interface actually changed) if !isVPNConnected && wasVPNConnected && vpnInterface != oldInterface { log(.warning, "VPN disconnected (was: \(oldInterface ?? "unknown"))") NotificationManager.shared.notifyVPNDisconnected(wasInterface: oldInterface) - // Clear routes when VPN disconnects + cancelAllRetries() activeRoutes.removeAll() routeVerificationResults.removeAll() } @@ -1003,8 +1002,7 @@ final class RouteManager: ObservableObject { routesToAdd.append((destination: ip, gateway: gateway, isNetwork: false, source: result.source)) } } else { - // DNS failed and no cache - domain truly failed - failedDomains.append(result.domain) + failedDomains.insert(result.domain) failedCount += 1 } } @@ -1101,9 +1099,10 @@ final class RouteManager: ObservableObject { } } + cancelAllRetries() activeRoutes.removeAll() routeVerificationResults.removeAll() - dnsCache.removeAll() // Clear DNS cache + dnsCache.removeAll() lastUpdate = Date() if config.manageHostsFile { @@ -1490,35 +1489,78 @@ final class RouteManager: ObservableObject { saveConfig() log(.success, "Added domain: \(cleaned)") - // Apply route immediately if VPN connected if isVPNConnected, let gateway = localGateway { isApplyingRoutes = true Task { if let routes = await applyRoutesForDomain(cleaned, gateway: gateway) { - await MainActor.run { - activeRoutes.append(contentsOf: routes) + activeRoutes.append(contentsOf: routes) + if config.manageHostsFile { + await updateHostsFile() } + } else { + log(.warning, "DNS resolution failed for \(cleaned), retrying in 15s...") + scheduleRetry(for: cleaned) } - await MainActor.run { - isApplyingRoutes = false - } + isApplyingRoutes = false + } + } + } + + private func scheduleRetry(for domain: String) { + pendingRetryTasks[domain]?.cancel() + pendingRetryTasks[domain] = Task { [weak self] in + do { + try await Task.sleep(nanoseconds: Self.retryDelayNs) + } catch { + return + } + guard let self else { return } + await self.retryFailedDomain(domain) + self.pendingRetryTasks.removeValue(forKey: domain) + } + } + + private func retryFailedDomain(_ domain: String) async { + guard config.domains.contains(where: { $0.domain == domain && $0.enabled }) else { return } + guard isVPNConnected, let gateway = localGateway else { return } + guard !activeRoutes.contains(where: { $0.source == domain }) else { + log(.info, "Skipping retry for \(domain) — routes already exist") + return + } + + isApplyingRoutes = true + defer { isApplyingRoutes = false } + + log(.info, "Retrying DNS for \(domain)...") + if let routes = await applyRoutesForDomain(domain, gateway: gateway) { + activeRoutes.append(contentsOf: routes) + if config.manageHostsFile { + await updateHostsFile() } + log(.success, "Retry succeeded for \(domain): \(routes.count) routes added") + } else { + log(.warning, "Retry failed for \(domain) — will resolve on next DNS refresh") } } + private func cancelAllRetries() { + pendingRetryTasks.values.forEach { $0.cancel() } + pendingRetryTasks.removeAll() + } + func removeDomain(_ domain: DomainEntry) { + pendingRetryTasks[domain.domain]?.cancel() + pendingRetryTasks.removeValue(forKey: domain.domain) + isApplyingRoutes = true - // Actually remove system routes for this domain Task { await removeRoutesForSource(domain.domain) - await MainActor.run { - config.domains.removeAll { $0.id == domain.id } - saveConfig() - log(.info, "Removed domain: \(domain.domain)") - isApplyingRoutes = false - } + config.domains.removeAll { $0.id == domain.id } + saveConfig() + log(.info, "Removed domain: \(domain.domain)") + isApplyingRoutes = false } } @@ -1531,24 +1573,20 @@ final class RouteManager: ObservableObject { let domain = config.domains[index] log(.info, "\(domain.domain) \(domain.enabled ? "enabled" : "disabled")") - // Apply or remove routes if isVPNConnected, let gateway = localGateway { isApplyingRoutes = true Task { if domain.enabled { - // Domain was just enabled - add its routes if let routes = await applyRoutesForDomain(domain.domain, gateway: gateway) { - await MainActor.run { - activeRoutes.append(contentsOf: routes) + activeRoutes.append(contentsOf: routes) + if config.manageHostsFile { + await updateHostsFile() } } } else { - // Domain was just disabled - remove its routes await removeRoutesForSource(domain.domain) } - await MainActor.run { - isApplyingRoutes = false - } + isApplyingRoutes = false } } } @@ -1568,23 +1606,22 @@ final class RouteManager: ObservableObject { log(.info, enabled ? "Enabled all domains" : "Disabled all domains") - // Incrementally apply/remove routes for changed domains only if isVPNConnected, let gateway = localGateway { Task { for domain in domainsToChange { if enabled { - if let routes = await applyRoutesForDomain(domain.domain, gateway: gateway) { - await MainActor.run { - activeRoutes.append(contentsOf: routes) - } + if let routes = await applyRoutesForDomain(domain.domain, gateway: gateway, persistCache: false) { + activeRoutes.append(contentsOf: routes) } } else { await removeRoutesForSource(domain.domain) } } - await MainActor.run { - isApplyingRoutes = false + saveDNSCache() + if enabled && config.manageHostsFile { + await updateHostsFile() } + isApplyingRoutes = false } } else { isApplyingRoutes = false @@ -1923,15 +1960,26 @@ final class RouteManager: ObservableObject { return nil } - private var failedDomains: [String] = [] // Track failed domains for debugging + private var failedDomains: Set = [] - private func applyRoutesForDomain(_ domain: String, gateway: String, source: String? = nil) async -> [ActiveRoute]? { - // Resolve domain IPs + private static let retryDelayNs: UInt64 = 15_000_000_000 // 15 seconds + + private var pendingRetryTasks: [String: Task] = [:] + + private func applyRoutesForDomain(_ domain: String, gateway: String, source: String? = nil, persistCache: Bool = true) async -> [ActiveRoute]? { guard let ips = await resolveIPs(for: domain) else { - failedDomains.append(domain) + failedDomains.insert(domain) return nil } + if let firstIP = ips.first { + dnsCache[domain] = firstIP + } + dnsDiskCache[domain] = ips + if persistCache { + saveDNSCache() + } + var routes: [ActiveRoute] = [] for ip in ips { @@ -1959,47 +2007,51 @@ final class RouteManager: ObservableObject { return await Self.resolveIPsParallel(for: domain, userDNS: userDNS, fallbackDNS: fallbackDNS) } - /// Nonisolated DNS resolution - runs truly in parallel without MainActor serialization - /// Includes retry logic and system resolver fallback for robustness + /// Nonisolated DNS resolution - races dig and DoH in parallel with trust hierarchy. + /// Dig-based resolvers fire immediately (trusted); DoH fires after a 200ms grace period + /// so it only wins when VPN blocks UDP DNS. Resolves in ~2s on VPN instead of 8+. private nonisolated static func resolveIPsParallel(for domain: String, userDNS: String?, fallbackDNS: [String]) async -> [String]? { - // Try up to 2 attempts with all DNS servers + let dohGraceNs: UInt64 = 200_000_000 // 200ms head start for trusted dig resolvers + let hardcodedDoH = ["https://cloudflare-dns.com/dns-query", "https://dns.google/dns-query"] + for attempt in 1...2 { - // 1. Try detected non-VPN DNS first (user's original DNS before VPN) - if let userDNS = userDNS { - if let ips = await resolveWithDNSParallel(domain, dns: userDNS) { - return ips + let result: [String]? = await withTaskGroup(of: [String]?.self) { group in + // Tier 1: dig-based resolvers fire immediately (trusted, local/fast) + if let userDNS = userDNS { + group.addTask { await resolveWithDNSParallel(domain, dns: userDNS) } + } + for dns in fallbackDNS { + group.addTask { await resolveWithDNSParallel(domain, dns: dns) } } + + // Tier 2: DoH fires after grace period — only wins when dig is blocked by VPN + for doh in hardcodedDoH where !fallbackDNS.contains(doh) { + group.addTask { + do { try await Task.sleep(nanoseconds: dohGraceNs) } catch { return nil } + return await resolveWithDoHParallel(domain, dohURL: doh) + } + } + + for await result in group { + if let ips = result, !ips.isEmpty { + group.cancelAll() + return ips + } + } + return nil } - // 2. Fall back to configured DNS servers - for dns in fallbackDNS { - if let ips = await resolveWithDNSParallel(domain, dns: dns) { - return ips - } + if let result = result { + return result } - // Wait before retry (only if not last attempt) if attempt < 2 { - try? await Task.sleep(nanoseconds: 300_000_000) // 300ms - } - } - - // 3. Try DoH (DNS over HTTPS) - bypasses VPN DNS hijacking since it uses HTTPS - // This is more reliable than getaddrinfo when VPN intercepts DNS - let dohServers = ["https://cloudflare-dns.com/dns-query", "https://dns.google/dns-query"] - for doh in dohServers { - if let ips = await resolveWithDoHParallel(domain, dohURL: doh) { - return ips + do { try await Task.sleep(nanoseconds: 500_000_000) } catch { return nil } } } - // 4. Last resort: use system resolver (getaddrinfo) with timeout - // Note: This uses VPN's DNS when connected, so may not bypass VPN restrictions - if let ips = await resolveWithSystemResolver(domain, timeout: 3.0) { - return ips - } - - return nil + // System resolver as absolute last resort (uses VPN's DNS, may not bypass) + return await resolveWithSystemResolver(domain, timeout: 3.0) } /// Resolve using system's getaddrinfo - uses OS-level DNS which may work when dig fails diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index 78a7ffa..e383101 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -1263,7 +1263,7 @@ struct GeneralTab: View { HStack { VStack(alignment: .leading, spacing: 2) { BrandedAppName(fontSize: 13) - Text("Version 1.7.1") + Text("Version 1.8.0") .font(.system(size: 11)) .foregroundColor(Color(hex: "6B7280")) } @@ -1721,7 +1721,7 @@ struct InfoTab: View { // App name with branded colors BrandedAppName(fontSize: 24) - Text("v1.7.1") + Text("v1.8.0") .font(.system(size: 12, design: .monospaced)) .foregroundColor(Color(hex: "6B7280")) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 92b520f..769fe2a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to VPN Bypass will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.8.0] - 2026-02-25 + +### Added +- **Parallel DNS Resolution** - Dig and DoH now race simultaneously instead of running sequentially. When VPN blocks UDP DNS, DoH wins in ~2s instead of waiting 8+ seconds for dig timeouts first +- **Auto-Retry on DNS Failure** - When adding a domain fails DNS resolution, a 15-second auto-retry is scheduled with cancellation support +- **Immediate Hosts File Update** - Adding or toggling a domain now updates `/etc/hosts` immediately instead of waiting for the periodic refresh + +### Fixed +- **Domain Addition Not Bypassing VPN** - Adding a custom domain while connected to VPN now works instantly: DNS cache, disk cache, and hosts file are all populated immediately on success +- **Stale Gateway in Retries** - DNS retry now reads the current gateway instead of using a potentially stale captured value +- **Bulk Enable Disk Thrashing** - "Enable All" no longer writes the DNS cache to disk once per domain; saves once at the end + +### Improved +- **DNS Trust Hierarchy** - Trusted dig-based resolvers get a 200ms head start over DoH, preserving CDN locality when local DNS works while still falling back fast on VPN +- **Tracked Retry Tasks** - Retry tasks are now tracked and cancelled on domain removal, VPN disconnect, or route cleanup +- **Consistent State Management** - Removed redundant `MainActor.run` wrappers inside already-MainActor tasks; `isApplyingRoutes` properly set during retries + ## [1.7.1] - 2026-02-24 ### Fixed