@ -5,12 +5,18 @@ package dns
import (
"bytes"
"context"
"fmt"
"net"
"net/netip"
"os"
"sync"
"go4.org/mem"
"tailscale.com/control/controlknobs"
"tailscale.com/health"
"tailscale.com/net/dns/resolvconffile"
"tailscale.com/net/dns/resolver"
"tailscale.com/net/tsaddr"
"tailscale.com/types/logger"
"tailscale.com/util/eventbus"
@ -28,12 +34,44 @@ func NewOSConfigurator(logf logger.Logf, _ *health.Tracker, _ *eventbus.Bus, _ p
// darwinConfigurator is the tailscaled-on-macOS DNS OS configurator that
// maintains the Split DNS nameserver entries pointing MagicDNS DNS suffixes
// to 100.100.100.100 using the macOS /etc/resolver/$SUFFIX files.
//
// On macOS CLI (tailscaled without Network Extension), packets to 100.100.100.100
// don't reach the TUN device because mDNSResponder mediates all DNS. To work around
// this, we run a local DNS listener on 127.0.0.1 and point the /etc/resolver files
// to that address instead. We use a non-standard port (preferring 5533) because
// macOS intercepts port 53 at a low level before it reaches userspace listeners.
type darwinConfigurator struct {
logf logger . Logf
ifName string
mu sync . Mutex
resolver * resolver . Resolver // set by SetResolver; used to handle local DNS queries
listener * net . UDPConn // local DNS listener on 127.0.0.1
listenerPort int // actual port the listener is bound to
ctx context . Context // for listener goroutine
cancel context . CancelFunc // cancels listener goroutine
}
// SetResolver sets the DNS resolver to use for handling local DNS queries.
// This must be called before SetDNS for the local DNS listener to work.
func ( c * darwinConfigurator ) SetResolver ( r * resolver . Resolver ) {
c . mu . Lock ( )
defer c . mu . Unlock ( )
c . resolver = r
}
func ( c * darwinConfigurator ) Close ( ) error {
c . mu . Lock ( )
if c . cancel != nil {
c . cancel ( )
c . cancel = nil
}
if c . listener != nil {
c . listener . Close ( )
c . listener = nil
}
c . mu . Unlock ( )
c . removeResolverFiles ( func ( domain string ) bool { return true } )
return nil
}
@ -43,13 +81,54 @@ func (c *darwinConfigurator) SupportsSplitDNS() bool {
}
func ( c * darwinConfigurator ) SetDNS ( cfg OSConfig ) error {
// Check if we need to start a local DNS listener.
// On macOS CLI, packets to 100.100.100.100 don't reach the TUN device
// because mDNSResponder mediates all DNS. We work around this by running
// a local DNS listener on 127.0.0.1:53.
needsLocalListener := false
for _ , ip := range cfg . Nameservers {
if ip == tsaddr . TailscaleServiceIP ( ) || ip == tsaddr . TailscaleServiceIPv6 ( ) {
needsLocalListener = true
break
}
}
c . mu . Lock ( )
hasResolver := c . resolver != nil
c . mu . Unlock ( )
// Determine which nameserver to use in /etc/resolver files
var resolverNameservers [ ] netip . Addr
var listenerPort int
if needsLocalListener && hasResolver {
// Start local DNS listener and use 127.0.0.1 in resolver files
port , err := c . ensureLocalListener ( )
if err != nil {
c . logf ( "failed to start local DNS listener: %v; falling back to 100.100.100.100" , err )
resolverNameservers = cfg . Nameservers
} else {
// Use 127.0.0.1:<port> instead of 100.100.100.100
resolverNameservers = [ ] netip . Addr { netip . MustParseAddr ( "127.0.0.1" ) }
listenerPort = port
c . logf ( "local DNS listener running on 127.0.0.1:%d" , port )
}
} else {
resolverNameservers = cfg . Nameservers
// Stop any existing listener if we don't need it
c . stopLocalListener ( )
}
var buf bytes . Buffer
buf . WriteString ( macResolverFileHeader )
for _ , ip := range cfg . Nameservers {
for _ , ip := range resolver Nameservers {
buf . WriteString ( "nameserver " )
buf . WriteString ( ip . String ( ) )
buf . WriteString ( "\n" )
}
// Add port directive if using local listener on non-standard port
if listenerPort != 0 {
fmt . Fprintf ( & buf , "port %d\n" , listenerPort )
}
if err := os . MkdirAll ( "/etc/resolver" , 0755 ) ; err != nil {
return err
@ -87,6 +166,116 @@ func (c *darwinConfigurator) SetDNS(cfg OSConfig) error {
return c . removeResolverFiles ( func ( domain string ) bool { return ! keep [ domain ] } )
}
// tailscaleDNSPort is the preferred port for the local DNS listener.
// We avoid port 53 because macOS intercepts it at a low level.
// This port is registered with IANA for Tailscale DNS (pending registration,
// using 5533 as it visually resembles "53" with padding).
const tailscaleDNSPort = 5533
// ensureLocalListener starts a local DNS listener on 127.0.0.1 if not already running.
// Returns the port the listener is bound to.
func ( c * darwinConfigurator ) ensureLocalListener ( ) ( int , error ) {
c . mu . Lock ( )
defer c . mu . Unlock ( )
if c . listener != nil {
// Already running
return c . listenerPort , nil
}
if c . resolver == nil {
return 0 , nil // No resolver set, can't handle queries
}
// Try the preferred Tailscale DNS port first.
// If that fails (e.g., another process using it), let the OS assign a port.
// We avoid port 53 because macOS intercepts it at a low level before
// it reaches userspace listeners.
var conn * net . UDPConn
var err error
for _ , port := range [ ] int { tailscaleDNSPort , 0 } {
addr := & net . UDPAddr { IP : net . ParseIP ( "127.0.0.1" ) , Port : port }
conn , err = net . ListenUDP ( "udp" , addr )
if err == nil {
break
}
if port == tailscaleDNSPort {
c . logf ( "preferred DNS port %d unavailable, using ephemeral port: %v" , port , err )
}
}
if err != nil {
return 0 , err
}
// Get the actual port (important when OS assigned it)
actualPort := conn . LocalAddr ( ) . ( * net . UDPAddr ) . Port
c . listener = conn
c . listenerPort = actualPort
c . ctx , c . cancel = context . WithCancel ( context . Background ( ) )
go c . runLocalListener ( c . ctx , conn , c . resolver )
return actualPort , nil
}
// stopLocalListener stops the local DNS listener if running.
func ( c * darwinConfigurator ) stopLocalListener ( ) {
c . mu . Lock ( )
defer c . mu . Unlock ( )
if c . cancel != nil {
c . cancel ( )
c . cancel = nil
}
if c . listener != nil {
c . listener . Close ( )
c . listener = nil
}
}
// runLocalListener handles incoming DNS queries on the local listener.
func ( c * darwinConfigurator ) runLocalListener ( ctx context . Context , conn * net . UDPConn , res * resolver . Resolver ) {
buf := make ( [ ] byte , 65535 )
for {
select {
case <- ctx . Done ( ) :
return
default :
}
n , addr , err := conn . ReadFromUDP ( buf )
if err != nil {
select {
case <- ctx . Done ( ) :
return
default :
c . logf ( "local DNS listener read error: %v" , err )
continue
}
}
// Handle the query in a goroutine
query := make ( [ ] byte , n )
copy ( query , buf [ : n ] )
go c . handleLocalQuery ( ctx , conn , addr , query , res )
}
}
// handleLocalQuery processes a single DNS query and sends the response.
func ( c * darwinConfigurator ) handleLocalQuery ( ctx context . Context , conn * net . UDPConn , addr * net . UDPAddr , query [ ] byte , res * resolver . Resolver ) {
from := netip . AddrPortFrom ( netip . AddrFrom4 ( [ 4 ] byte { 127 , 0 , 0 , 1 } ) , uint16 ( addr . Port ) )
resp , err := res . Query ( ctx , query , "udp" , from )
if err != nil {
c . logf ( "local DNS query error: %v" , err )
return
}
_ , err = conn . WriteToUDP ( resp , addr )
if err != nil {
c . logf ( "local DNS response write error: %v" , err )
}
}
// GetBaseConfig returns the current OS DNS configuration, extracting it from /etc/resolv.conf.
// We should really be using the SystemConfiguration framework to get this information, as this
// is not a stable public API, and is provided mostly as a compatibility effort with Unix