Skip to content

feat(tun): auto set system DNS to TUN gateway#58

Open
iTsingchen wants to merge 2 commits intoSitoi:devfrom
iTsingchen:feat/tun-system-dns
Open

feat(tun): auto set system DNS to TUN gateway#58
iTsingchen wants to merge 2 commits intoSitoi:devfrom
iTsingchen:feat/tun-system-dns

Conversation

@iTsingchen
Copy link
Copy Markdown

Summary

Closes #55

When TUN mode is enabled alongside other VPN (e.g. Viscosity OpenVPN), macOS scoped routing causes system DNS queries to bypass the TUN interface and get polluted by GFW, making sites like YouTube unreachable.

This PR adds automatic system DNS management to ClashBar's TUN lifecycle:

  • TUN enable: Set system DNS to TUN gateway (198.18.0.1) via privileged XPC helper
  • TUN disable: Restore original DNS settings
  • Crash safety: DNS is also restored on app cleanup/termination path

Changes

File Change
SystemProxyRepository.swift Add setDNS / restoreDNS to protocol
DefaultSystemProxyRepository.swift Implement DNS via XPC helper
SystemProxyService.swift Refactor to support DNS operations
ProxyHelperProtocol.swift Add DNS commands to XPC protocol
ProxyHelper/Daemon/main.swift Handle setDNS / restoreDNS via networksetup
AppSession+Tun.swift DNS setup/teardown in TUN lifecycle
AppSession+Lifecycle.swift DNS restore on session teardown
AppSession+Settings.swift DNS management in settings flow
AppSession+ControllerValidation.swift Simplify validation logic
YAMLScalarUtilities.swift New utility for YAML config parsing

Root Cause Analysis

macOS creates a scoped default route for each network service with DNS configured. When multiple interfaces have default routes, mDNSResponder binds DNS queries to the physical interface (en0), bypassing TUN route rules and getting polluted by GFW.

mDNSResponder -> 223.5.5.5 bound to en0 -> ISP -> GFW -> polluted response (69.171.235.22)

Setting system DNS directly to the TUN gateway ensures all DNS queries flow through Clash, regardless of scoped routing behavior.

Testing

  • TUN enable sets system DNS to 198.18.0.1
  • TUN disable restores original DNS
  • YouTube accessible with Viscosity VPN running simultaneously
  • App crash/force-quit restores DNS via cleanup path

🤖 Generated with Claude Code

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 5, 2026

@iTsingchen is attempting to deploy a commit to the Sitoi Team on Vercel.

A member of the Team first needs to authorize it.

@Sitoi Sitoi requested a review from Copilot April 7, 2026 08:54
@Sitoi Sitoi deleted the branch Sitoi:dev April 7, 2026 08:59
@Sitoi Sitoi closed this Apr 7, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds automatic system DNS management to ClashBar’s TUN lifecycle on macOS, aiming to prevent system DNS queries from bypassing the TUN interface (notably when another VPN is also active) and getting polluted.

Changes:

  • Add privileged XPC helper commands to set/restore system DNS and wire them through the app’s SystemProxy layer.
  • Integrate DNS set/restore into TUN enable/disable, startup reconciliation, and termination/cleanup paths.
  • Add YAML scalar helpers to reliably parse DNS-related values from config content.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
Sources/ProxyHelperShared/ProxyHelperProtocol.swift Extends XPC protocol with DNS set/restore commands.
Sources/ProxyHelper/Daemon/main.swift Implements DNS backup/set/restore via networksetup in privileged helper.
Sources/ClashBar/Infrastructure/System/SystemProxy/SystemProxyService.swift Adds async + blocking DNS operations via XPC helper and refactors blocking helper invocation.
Sources/ClashBar/Infrastructure/System/SystemProxy/DefaultSystemProxyRepository.swift Exposes DNS methods through the repository implementation.
Sources/ClashBar/Domain/Repositories/SystemProxyRepository.swift Expands repository protocol to include DNS operations.
Sources/ClashBar/App/Session/Extensions/AppSession+Tun.swift Sets DNS on TUN enable and restores on disable/failure paths; adds config-based DNS resolution.
Sources/ClashBar/App/Session/Coordinators/AppSession+Lifecycle.swift Restores DNS on termination and core transitions/recovery.
Sources/ClashBar/App/Session/Extensions/AppSession+Settings.swift Refactors settings flow to apply TUN changes via runtime change helper (enabling DNS hooks).
Sources/ClashBar/App/Session/Extensions/AppSession+ControllerValidation.swift Reuses new YAML scalar utilities and exposes whitespace helper used by TUN parsing.
Sources/ClashBar/App/Session/Extensions/AppSession+Config.swift Uses refactored applyTunRuntimeChange path.
Sources/ClashBar/Shared/Utilities/YAMLScalarUtilities.swift New shared helpers for stripping inline comments and normalizing YAML scalar values.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread Sources/ProxyHelper/Daemon/main.swift Outdated
"\(state.port)|\(state.servers)"
}.joined(separator: "\n")
let dir = (backupFile as NSString).deletingLastPathComponent
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createDirectory(atPath:withIntermediateDirectories:) is missing the required attributes: parameter, which will not compile with Foundation’s FileManager API. Pass attributes: nil (or explicit permissions) to fix the build.

Suggested change
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true, attributes: nil)

Copilot uses AI. Check for mistakes.
Comment on lines +339 to +379
private static let backupFilePath = "/var/db/clashbar/original-dns.txt"

private struct HardwarePortResult {
let port: String
let interface: String
}

private struct DNSState {
let port: String
let servers: String
}

func setDNSServers(dnsServer: String) throws {
guard Self.isValidIPv4(dnsServer) else {
throw ProxyHelperError.systemConfigurationFailure(
action: "Set DNS servers",
code: 0,
detail: "Invalid DNS server address: \(dnsServer)")
}

let ports = try detectPhysicalHardwarePorts()
guard !ports.isEmpty else {
throw ProxyHelperError.systemConfigurationFailure(
action: "Set DNS servers",
code: 0,
detail: "No physical network service found")
}

let currentStates = try ports.map { port in
DNSState(port: port.port, servers: try readCurrentDNS(port: port.port))
}

let backupFile = Self.backupFilePath
if !FileManager.default.fileExists(atPath: backupFile) {
let backup = currentStates.map { state in
"\(state.port)|\(state.servers)"
}.joined(separator: "\n")
let dir = (backupFile as NSString).deletingLastPathComponent
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
try backup.write(toFile: backupFile, atomically: true, encoding: .utf8)
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DNS backup file is written under /var/db/clashbar without explicitly setting restrictive permissions/ownership. If that directory or file ends up writable by non-root, an unprivileged user could tamper with original-dns.txt and influence what the privileged helper restores later. Create the directory/file with explicit root-only permissions (e.g., 0700/0600) and consider validating file ownership/mode before trusting its contents.

Copilot uses AI. Check for mistakes.
let listResult = runProcessSynchronously(
executable: "/usr/sbin/networksetup",
arguments: ["-listnetworkserviceorder"])

Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

detectPhysicalHardwarePorts() uses networksetup -listnetworkserviceorder output but doesn’t check exitCode. If networksetup fails, this will silently produce an empty ports list and throw the misleading "No physical network service found" error. Consider failing fast when exitCode != 0 and including combinedOutput in the error detail.

Suggested change
guard listResult.exitCode == 0 else {
let detail = listResult.combinedOutput.trimmingCharacters(in: .whitespacesAndNewlines)
throw ProxyHelperError.systemConfigurationFailure(
action: "List network service order",
code: listResult.exitCode,
detail: detail.isEmpty
? "networksetup -listnetworkserviceorder failed with no output"
: detail)
}

Copilot uses AI. Check for mistakes.
Comment on lines +445 to +455
private func readCurrentDNS(port: String) throws -> String {
let result = runProcessSynchronously(
executable: "/usr/sbin/networksetup",
arguments: ["-getdnsservers", port])

let output = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
if output.hasPrefix("There aren't any DNS Servers set on") {
return "empty"
}
guard !output.isEmpty else { return "empty" }
return output
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

readCurrentDNS() ignores exitCode/stderr from networksetup -getdnsservers. On failure it can return empty (or partial stdout) and corrupt the backup/restore behavior. Treat non-zero exit codes as errors (using combinedOutput) so backup/restore doesn’t proceed with invalid data.

Copilot uses AI. Check for mistakes.
Comment on lines +237 to +259

DispatchQueue.global(qos: .userInitiated).async {
let connection = NSXPCConnection(
machServiceName: ProxyHelperConstants.machServiceName,
options: .privileged)
connection.remoteObjectInterface = NSXPCInterface(with: ProxyHelperProtocol.self)
connection.activate()

guard let helper = connection.remoteObjectProxyWithErrorHandler({ _ in
semaphore.signal()
}) as? ProxyHelperProtocol else {
connection.invalidate()
semaphore.signal()
return
}

operation(helper) { _, _ in
connection.invalidate()
semaphore.signal()
}
}

_ = semaphore.wait(timeout: .now() + timeout)
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

invokeBlockingHelper waits with a timeout but does not invalidate the NSXPCConnection when the wait times out. That can leave a live privileged XPC connection running past shutdown/termination and leak resources. Consider invalidating/cancelling the connection on timeout (e.g., by capturing it and calling invalidate() after wait returns .timedOut).

Suggested change
DispatchQueue.global(qos: .userInitiated).async {
let connection = NSXPCConnection(
machServiceName: ProxyHelperConstants.machServiceName,
options: .privileged)
connection.remoteObjectInterface = NSXPCInterface(with: ProxyHelperProtocol.self)
connection.activate()
guard let helper = connection.remoteObjectProxyWithErrorHandler({ _ in
semaphore.signal()
}) as? ProxyHelperProtocol else {
connection.invalidate()
semaphore.signal()
return
}
operation(helper) { _, _ in
connection.invalidate()
semaphore.signal()
}
}
_ = semaphore.wait(timeout: .now() + timeout)
let connectionLock = NSLock()
var connection: NSXPCConnection?
DispatchQueue.global(qos: .userInitiated).async {
let localConnection = NSXPCConnection(
machServiceName: ProxyHelperConstants.machServiceName,
options: .privileged)
connectionLock.lock()
connection = localConnection
connectionLock.unlock()
localConnection.remoteObjectInterface = NSXPCInterface(with: ProxyHelperProtocol.self)
localConnection.activate()
guard let helper = localConnection.remoteObjectProxyWithErrorHandler({ _ in
localConnection.invalidate()
semaphore.signal()
}) as? ProxyHelperProtocol else {
localConnection.invalidate()
semaphore.signal()
return
}
operation(helper) { _, _ in
localConnection.invalidate()
semaphore.signal()
}
}
if semaphore.wait(timeout: .now() + timeout) == .timedOut {
connectionLock.lock()
let timedOutConnection = connection
connectionLock.unlock()
timedOutConnection?.invalidate()
}

Copilot uses AI. Check for mistakes.
Comment on lines +230 to +231
// Restore DNS AFTER disabling TUN, once the default route is back on the physical interface.
try await self.restoreTunDNS()
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DNS is restored immediately after issuing the disable PATCH, before verifying the runtime TUN state. If the PATCH returns but TUN remains enabled (or verification fails), this will restore system DNS while TUN is still active, reintroducing the exact DNS-bypass problem this PR is trying to solve. Restore DNS only after verifyTunRuntimeState(expectedEnabled: false) succeeds (or re-set DNS if disable verification fails).

Suggested change
// Restore DNS AFTER disabling TUN, once the default route is back on the physical interface.
try await self.restoreTunDNS()
// Restore DNS only AFTER the runtime confirms TUN is disabled, so we do not
// reintroduce DNS bypass if the PATCH returns before the TUN state changes.
do {
try await self.verifyTunRuntimeState(expectedEnabled: false)
try await self.restoreTunDNS()
} catch {
do {
try await self.setTunDNSForEnable()
} catch {
appendLog(
level: "error",
message: "Failed to re-apply TUN DNS after disable verification failure: \(error.localizedDescription)"
)
}
throw error
}

Copilot uses AI. Check for mistakes.
Comment on lines +203 to +204
// the physical interface. This also covers the case where no en* service
// is found by falling back to the default route approach.
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment claims a fallback "default route approach" when no en* service is found, but the helper currently throws when no physical network service is detected and there is no default-route-based fallback shown here. Please either implement the fallback or adjust the comment so it accurately describes the behavior.

Suggested change
// the physical interface. This also covers the case where no en* service
// is found by falling back to the default route approach.
// the physical interface while DNS is being prepared for TUN enablement.
// This path relies on setTunDNSForEnable() succeeding; there is no
// default-route fallback implemented here when no physical service is found.

Copilot uses AI. Check for mistakes.
Comment on lines +503 to +504
} else if self.isTunEnabled {
// TUN already active — ensure DNS is set since patchTunConfig was skipped
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

During core feature recovery, if runtimeConfig.tunEnabled is already true but self.isTunEnabled is false (which can happen because prepareCoreFeatureRecoveryBeforeCoreTransition sets it to false), this branch will skip tunRestored = true and won’t set DNS. That leaves the app thinking TUN isn’t restored even though it is. Consider treating runtimeConfig.tunEnabled == true as restored regardless of self.isTunEnabled, and ensure DNS is set in that case.

Suggested change
} else if self.isTunEnabled {
// TUN already active — ensure DNS is set since patchTunConfig was skipped
} else {
// TUN already active in runtime — ensure DNS is set since patchTunConfig was skipped,
// even if the local isTunEnabled flag was reset during core transition preparation.

Copilot uses AI. Check for mistakes.
Comment on lines +224 to +227
// Restore DNS before stopping core (TUN mode may have set system DNS)
if isTunEnabled {
self.systemProxyRepository.restoreDNSServersBlocking(timeout: 2.0)
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shutdown restores DNS only when isTunEnabled is true. If the app previously set DNS but the in-memory flag is stale/false (or recovery logic hasn’t run yet), DNS may not be restored on termination even though the helper backup file exists. Since restoreDNSServersBlocking is effectively a no-op when there’s no backup, consider calling it unconditionally during shutdown.

Suggested change
// Restore DNS before stopping core (TUN mode may have set system DNS)
if isTunEnabled {
self.systemProxyRepository.restoreDNSServersBlocking(timeout: 2.0)
}
// Restore DNS before stopping core; this is safe to attempt even when no DNS backup exists.
self.systemProxyRepository.restoreDNSServersBlocking(timeout: 2.0)

Copilot uses AI. Check for mistakes.
@Sitoi Sitoi reopened this Apr 7, 2026
@Sitoi Sitoi changed the base branch from beta to dev April 7, 2026 09:27
iTsingchen added a commit to iTsingchen/clash-bar that referenced this pull request Apr 7, 2026
- Add attributes parameter to createDirectory and set restrictive permissions (0700/0600)
- Check exitCode in detectPhysicalHardwarePorts() and readCurrentDNS()
- Invalidate XPC connection on timeout in invokeBlockingHelper
- Restore DNS only after TUN disable verification succeeds
- Fix misleading comment about fallback in patchTunConfig
- Remove stale isTunEnabled guard in core feature recovery
- Call DNS restore unconditionally during shutdown
- Remove duplicate verification in applyTunRuntimeChange

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@iTsingchen
Copy link
Copy Markdown
Author

@copilot review

iTsingchen added a commit to iTsingchen/clash-bar that referenced this pull request Apr 7, 2026
- Add attributes parameter to createDirectory and set restrictive permissions (0700/0600)
- Check exitCode in detectPhysicalHardwarePorts() and readCurrentDNS()
- Invalidate XPC connection on timeout in invokeBlockingHelper
- Restore DNS only after TUN disable verification succeeds
- Fix misleading comment about fallback in patchTunConfig
- Remove stale isTunEnabled guard in core feature recovery
- Call DNS restore unconditionally during shutdown
- Remove duplicate verification in applyTunRuntimeChange

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@iTsingchen iTsingchen force-pushed the feat/tun-system-dns branch from 0a03dcf to cee1767 Compare April 7, 2026 11:37
iTsingchen and others added 2 commits April 8, 2026 10:22
…sable

When TUN mode is enabled, ClashBar now automatically sets the system DNS
to the TUN gateway address (198.18.0.1) via the privileged XPC helper.
This prevents macOS scoped routing from bypassing TUN when multiple
network interfaces (e.g. VPN) are active, which caused DNS pollution
by GFW (Sitoi#55).

- Add setDNS/restoreDNS to SystemProxyRepository and XPC helper protocol
- Handle DNS setup in TUN lifecycle (enable/disable/cleanup)
- Extract YAML scalar utility for config parsing
- Simplify controller validation logic

Closes Sitoi#55

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add attributes parameter to createDirectory and set restrictive permissions (0700/0600)
- Check exitCode in detectPhysicalHardwarePorts() and readCurrentDNS()
- Invalidate XPC connection on timeout in invokeBlockingHelper
- Restore DNS only after TUN disable verification succeeds
- Fix misleading comment about fallback in patchTunConfig
- Remove stale isTunEnabled guard in core feature recovery
- Call DNS restore unconditionally during shutdown
- Remove duplicate verification in applyTunRuntimeChange

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@iTsingchen iTsingchen force-pushed the feat/tun-system-dns branch from cee1767 to cfe35b6 Compare April 8, 2026 02:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants