net/dns: fix MagicDNS for macOS CLI/Homebrew version

MagicDNS was not working on macOS when using the CLI version
installed via Homebrew. Two issues were fixed:

1. MatchDomains was only set for iOS, not darwin, so /etc/resolver
   files were never created.

2. macOS intercepts port 53 before packets reach userspace, so a
   local DNS listener on port 5533 is now used instead.

The listener binds to 127.0.0.1 only and writes resolver files with
the appropriate port directive.

Signed-off-by: Technophobe01 <pkjarvis01@gmail.com>
pull/18272/head
Technophobe01 3 weeks ago
parent d451cd54a7
commit 4fdf7c9daa

@ -114,6 +114,13 @@ func NewManager(logf logger.Logf, oscfg OSConfigurator, health *health.Tracker,
m.ctx, m.ctxCancel = context.WithCancel(context.Background())
m.logf("using %T", m.os)
// If the OS configurator supports receiving the resolver (e.g., for local DNS
// listening on macOS CLI), provide it.
if rs, ok := oscfg.(interface{ SetResolver(*resolver.Resolver) }); ok {
rs.SetResolver(m.resolver)
}
return m
}
@ -334,6 +341,15 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig
rcfg.Routes = routes
rcfg.Routes["."] = cfg.DefaultResolvers
ocfg.Nameservers = cfg.serviceIPs(m.knobs)
// On macOS CLI (tailscaled), set MatchDomains for MagicDNS domains
// so that /etc/resolver/<domain> files are created.
if m.goos == "darwin" && m.os.SupportsSplitDNS() {
for _, domain := range rcfg.LocalDomains {
if !strings.HasSuffix(string(domain), ".arpa.") {
ocfg.MatchDomains = append(ocfg.MatchDomains, domain)
}
}
}
return rcfg, ocfg, nil
}
@ -418,6 +434,20 @@ func (m *Manager) compileConfig(cfg Config) (rcfg resolver.Config, ocfg OSConfig
m.logf("iOS split DNS is disabled by nodeattr")
}
}
// On macOS CLI (tailscaled), we must set MatchDomains for MagicDNS
// domains so that /etc/resolver/<domain> files are created. Without
// these files, macOS won't route MagicDNS queries to 100.100.100.100.
// MagicDNS domains (routes with no resolvers) are in LocalDomains.
// We skip .arpa domains (reverse DNS) as they don't need resolver files.
// Only do this when the OS configurator supports split DNS (the real
// darwinConfigurator does, but tests may use a fake that doesn't).
if m.goos == "darwin" && m.os.SupportsSplitDNS() {
for _, domain := range rcfg.LocalDomains {
if !strings.HasSuffix(string(domain), ".arpa.") {
ocfg.MatchDomains = append(ocfg.MatchDomains, domain)
}
}
}
var defaultRoutes []*dnstype.Resolver
for _, ip := range baseCfg.Nameservers {
defaultRoutes = append(defaultRoutes, &dnstype.Resolver{Addr: ip.String()})

@ -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 resolverNameservers {
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

@ -655,11 +655,9 @@ func TestManager(t *testing.T) {
goos: "linux",
},
{
// The `routes-magic-split-linux` test case above on Darwin should NOT result in a
// split DNS configuration.
// Check that MatchDomains is empty. Due to Apple limitations, we cannot set MatchDomains
// without those domains also being SearchDomains.
name: "routes-magic-does-not-split-on-darwin",
// On macOS CLI (tailscaled), MagicDNS domains should be in MatchDomains
// so /etc/resolver/<domain> files are created for split DNS.
name: "routes-magic-split-on-darwin",
in: Config{
Routes: upstreams(
"corp.com", "2.2.2.2",
@ -673,6 +671,7 @@ func TestManager(t *testing.T) {
os: OSConfig{
Nameservers: mustIPs("100.100.100.100"),
SearchDomains: fqdns("tailscale.com", "universe.tf"),
MatchDomains: fqdns("ts.com"),
},
rs: resolver.Config{
Routes: upstreams(
@ -823,9 +822,9 @@ func TestManager(t *testing.T) {
goos: "ios",
},
{
// on darwin, verify that with the same config as in ios-use-split-dns-when-no-custom-resolvers,
// MatchDomains are NOT set.
name: "darwin-dont-use-split-dns-when-no-custom-resolvers",
// On macOS CLI (tailscaled), MagicDNS domains should be in MatchDomains
// so /etc/resolver/<domain> files are created for split DNS.
name: "darwin-use-split-dns-for-magicdns",
in: Config{
Routes: upstreams("ts.net", "199.247.155.52", "optimistic-display.ts.net", ""),
SearchDomains: fqdns("optimistic-display.ts.net"),
@ -834,6 +833,7 @@ func TestManager(t *testing.T) {
os: OSConfig{
Nameservers: mustIPs("100.100.100.100"),
SearchDomains: fqdns("optimistic-display.ts.net"),
MatchDomains: fqdns("optimistic-display.ts.net"),
},
rs: resolver.Config{
Routes: upstreams(

Loading…
Cancel
Save