Skip to content

dns_watchdog fails to restore DNS after VPN disconnect on macOS #290

@Safibulae

Description

@Safibulae

Description

When a VPN client (Windscribe) disconnects on macOS, it wipes all DNS settings from the Wi-Fi interface. networksetup -getdnsservers Wi-Fi returns "There aren't any DNS Servers set on Wi-Fi." ctrld's built-in dns_watchdog does not restore DNS in this scenario, even with dns_watchdog_enabled = true and dns_watchdog_interval = "2s".

Environment

  • ctrld version: 1.4.9
  • OS: macOS 15.3 Sequoia (also reproducible on macOS 26.x Tahoe)
  • VPN client: Windscribe (AmneziaWG protocol)
  • ctrld mode: --cd (API-managed)
  • Listener: 0.0.0.0:53

Steps to Reproduce

  1. Install ctrld with sudo ctrld start --cd <resolver_id>
  2. Verify DNS is set to 127.0.0.1: networksetup -getdnsservers Wi-Fi
  3. Connect Windscribe VPN (any protocol)
  4. Disconnect Windscribe VPN
  5. Check DNS: networksetup -getdnsservers Wi-Fi

Expected: DNS should be 127.0.0.1 (restored by watchdog)
Actual: DNS is empty ("There aren't any DNS Servers set on Wi-Fi")

Running sudo ctrld restart manually restores DNS, but the watchdog does not recover automatically.

Analysis

Looking at the source code, the dnsChanged() comparison logic in prog.go is correct - an empty slice vs ["127.0.0.1"] would return true. The issue appears to be in the watchdog's ticker loop:

case <-ticker.C:
    if p.recoveryRunning.Load() {
        return   // This kills the watchdog goroutine permanently
    }

When the VPN disconnects, it triggers a network change event that sets recoveryRunning = true. The watchdog's next tick sees this flag and executes return, which permanently terminates the watchdog goroutine. After the recovery process completes, the watchdog is dead and DNS changes are never detected again.

The return should likely be continue (to skip one tick and retry on the next interval) rather than terminating the goroutine entirely.

TOML Config

[service]
  cache_enable = true
  cache_serve_stale = true
  dns_watchdog_enabled = true
  dns_watchdog_interval = "2s"

[listener]
  [listener.0]
    ip = '0.0.0.0'
    port = 53

[listener.0.policy]
  name = 'Failover'
  failover_rcodes = ['SERVFAIL']
  networks = [
    {'network.0' = ['upstream.0', 'upstream.1']}
  ]

[upstream]
  [upstream.0]
    name = 'ControlD'
    type = 'doh'
    endpoint = 'https://dns.controld.com/<resolver_id>'
    bootstrap_ip = '76.76.2.22'
    timeout = 5000

  [upstream.1]
    name = 'Cloudflare'
    type = 'doh'
    endpoint = 'https://dns.cloudflare.com/dns-query'
    bootstrap_ip = '1.1.1.1'
    timeout = 5000

Workaround

Custom LaunchAgent that polls networksetup -getdnsservers Wi-Fi every 5 seconds and restores 127.0.0.1 if DNS is empty or changed. This works reliably but shouldn't be necessary given the built-in watchdog feature.

Related Issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions