fix(darwin): handle mDNSResponder on port 53 to avoid bind conflicts

When mDNSResponder is using port 53 on macOS, adjust listener config to
use 0.0.0.0:53, stop mDNSResponder before binding, and run cleanup on
install and uninstall so the DNS server can start reliably.
This commit is contained in:
Cuong Manh Le
2026-02-09 17:27:24 +07:00
committed by Cuong Manh Le
parent 40c68a13a1
commit a4f0418811
5 changed files with 224 additions and 0 deletions

View File

@@ -901,6 +901,9 @@ func selfCheckStatus(ctx context.Context, s service.Service, sockDir string) (bo
lc := cfg.FirstListener()
addr := net.JoinHostPort(lc.IP, strconv.Itoa(lc.Port))
if needMdnsResponderHack {
addr = "127.0.0.1:53"
}
mainLog.Load().Debug().Msgf("performing listener test, sending queries to %s", addr)
@@ -1113,6 +1116,10 @@ func uninstall(p *prog, s service.Service) {
// Stop already did router.Cleanup and report any error if happens,
// ignoring error here to prevent false positive.
_ = p.router.Cleanup()
// Run mDNS responder cleanup if necessary
doMdnsResponderCleanup()
mainLog.Load().Notice().Msg("Service uninstalled")
return
}
@@ -1230,6 +1237,8 @@ func tryUpdateListenerConfig(cfg *ctrld.Config, infoLogger *zerolog.Logger, noti
nextdnsMode := nextdns != ""
// For Windows server with local Dns server running, we can only try on random local IP.
hasLocalDnsServer := hasLocalDnsServerRunning()
// For Macos with mDNSResponder running on port 53, we must use 0.0.0.0 to prevent conflicting.
needMdnsResponderHack := needMdnsResponderHack
notRouter := router.Name() == ""
isDesktop := ctrld.IsDesktopPlatform()
for n, listener := range cfg.Listener {
@@ -1263,6 +1272,12 @@ func tryUpdateListenerConfig(cfg *ctrld.Config, infoLogger *zerolog.Logger, noti
lcc[n].Port = false
}
}
if needMdnsResponderHack {
listener.IP = "0.0.0.0"
listener.Port = 53
lcc[n].IP = false
lcc[n].Port = false
}
updated = updated || lcc[n].IP || lcc[n].Port
}
@@ -1295,6 +1310,9 @@ func tryUpdateListenerConfig(cfg *ctrld.Config, infoLogger *zerolog.Logger, noti
// Created listeners will be kept in listeners slice above, and close
// before function finished.
tryListen := func(addr string) error {
if needMdnsResponderHack {
killMdnsResponder()
}
udpLn, udpErr := net.ListenPacket("udp", addr)
if udpLn != nil {
closers = append(closers, udpLn)
@@ -1358,6 +1376,9 @@ func tryUpdateListenerConfig(cfg *ctrld.Config, infoLogger *zerolog.Logger, noti
}
attempts := 0
maxAttempts := 10
if needMdnsResponderHack {
maxAttempts = 1
}
for {
if attempts == maxAttempts {
notifyFunc()

View File

@@ -359,6 +359,10 @@ NOTE: running "ctrld start" without any arguments will start already installed c
initInteractiveLogging()
tasks := []task{
{func() error {
doMdnsResponderCleanup()
return nil
}, false, "Cleanup service before installation"},
{func() error {
// Save current DNS so we can restore later.
withEachPhysicalInterfaces("", "saveCurrentStaticDNS", func(i *net.Interface) error {
@@ -374,6 +378,10 @@ NOTE: running "ctrld start" without any arguments will start already installed c
}, false, "Configure service failure actions"},
{s.Start, true, "Start"},
{noticeWritingControlDConfig, false, "Notice writing ControlD config"},
{func() error {
doMdnsResponderHackPostInstall()
return nil
}, false, "Configure service post installation"},
}
mainLog.Load().Notice().Msg("Starting existing ctrld service")
if doTasks(tasks) {
@@ -437,6 +445,10 @@ NOTE: running "ctrld start" without any arguments will start already installed c
}
tasks := []task{
{func() error {
doMdnsResponderCleanup()
return nil
}, false, "Cleanup service before installation"},
{s.Stop, false, "Stop"},
{func() error { return doGenerateNextDNSConfig(nextdns) }, true, "Checking config"},
{func() error { return ensureUninstall(s) }, false, "Ensure uninstall"},
@@ -459,6 +471,10 @@ NOTE: running "ctrld start" without any arguments will start already installed c
// Note that startCmd do not actually write ControlD config, but the config file was
// generated after s.Start, so we notice users here for consistent with nextdns mode.
{noticeWritingControlDConfig, false, "Notice writing ControlD config"},
{func() error {
doMdnsResponderHackPostInstall()
return nil
}, false, "Configure service post installation"},
}
mainLog.Load().Notice().Msg("Starting service")
if doTasks(tasks) {

View File

@@ -101,6 +101,15 @@ func (p *prog) serveDNS(listenerNum string) error {
_ = w.WriteMsg(answer)
return
}
// When mDNSResponder hack has been done, ctrld was listening on 0.0.0.0:53, but only requests
// to 127.0.0.1:53 are accepted. Since binding to 0.0.0.0 will make the IP info of the local address
// hidden (appeared as [::]), we checked for requests originated from 127.0.0.1 instead.
if needMdnsResponderHack && !strings.HasPrefix(w.RemoteAddr().String(), "127.0.0.1:") {
answer := new(dns.Msg)
answer.SetRcode(m, dns.RcodeRefused)
_ = w.WriteMsg(answer)
return
}
listenerConfig := p.cfg.Listener[listenerNum]
reqId := requestID()
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, reqId)
@@ -854,6 +863,9 @@ func runDNSServer(addr, network string, handler dns.Handler) (*dns.Server, <-cha
errCh := make(chan error)
go func() {
defer close(errCh)
if needMdnsResponderHack {
killMdnsResponder()
}
if err := s.ListenAndServe(); err != nil {
s.NotifyStartedFunc()
mainLog.Load().Error().Err(err).Msgf("could not listen and serve on: %s", s.Addr)

View File

@@ -0,0 +1,154 @@
package cli
import (
"bufio"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"tailscale.com/net/netmon"
)
// On macOS, the system daemon mDNSResponder (used for proxy/mDNS/Bonjour discovery)
// listens on UDP and TCP port 53. That conflicts with ctrld when it needs to
// run a DNS proxy on port 53. The kernel does not allow two processes to bind
// the same address/port, so ctrld would fail with "address already in use" if we
// did nothing.
//
// If ctrld started before mDNSResponder and listened only on 127.0.0.1, mDNSResponder
// would bind port 53 on other interfaces, so system processes would use it as the
// DNS resolver instead of ctrld, leading to inconsistent behavior.
//
// This file implements a Darwin-only workaround:
//
// - We detect at startup whether mDNSResponder is using port 53 (or a
// persisted marker file exists from a previous run).
// - When the workaround is active, we force the listener to 0.0.0.0:53 and,
// before binding, run killall mDNSResponder so that ctrld can bind to port 53.
// - We use SO_REUSEPORT (see listener setup) so that the socket can be bound
// even when the port was recently used.
// - On install we create a marker file in the user's home directory so that
// the workaround is applied on subsequent starts; on uninstall we remove
// that file and bounce the en0 interface to restore normal mDNSResponder
// behavior.
//
// Without this, users on macOS would be unable to run ctrld as the system DNS
// on port 53 when mDNSResponder is active.
var (
// needMdnsResponderHack determines if a system-specific workaround for mDNSResponder is necessary at runtime.
needMdnsResponderHack = mDNSResponderHack()
mDNSResponderHackFilename = ".mdnsResponderHack"
)
// mDNSResponderHack checks if the mDNSResponder process and its environments meet specific criteria for operation.
func mDNSResponderHack() bool {
if st, err := os.Stat(mDNSResponderFile()); err == nil && st.Mode().IsRegular() {
return true
}
out, err := lsofCheckPort53()
if err != nil {
return false
}
if !isMdnsResponderListeningPort53(strings.NewReader(out)) {
return false
}
return true
}
// mDNSResponderFile constructs and returns the absolute path to the mDNSResponder hack file in the user's home directory.
func mDNSResponderFile() string {
if d, err := userHomeDir(); err == nil && d != "" {
return filepath.Join(d, mDNSResponderHackFilename)
}
return ""
}
// doMdnsResponderCleanup performs cleanup tasks for the mDNSResponder hack file and resets the network interface "en0".
func doMdnsResponderCleanup() {
fn := mDNSResponderFile()
if fn == "" {
return
}
if st, err := os.Stat(fn); err != nil || !st.Mode().IsRegular() {
return
}
if err := os.Remove(fn); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to remove mDNSResponder hack file")
}
ifName := "en0"
if din, err := netmon.DefaultRouteInterface(); err == nil {
ifName = din
}
if err := exec.Command("ifconfig", ifName, "down").Run(); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to disable en0")
}
if err := exec.Command("ifconfig", ifName, "up").Run(); err != nil {
mainLog.Load().Error().Err(err).Msg("failed to enable en0")
}
}
// doMdnsResponderHackPostInstall creates a hack file for mDNSResponder if required and logs debug or error messages.
func doMdnsResponderHackPostInstall() {
if !needMdnsResponderHack {
return
}
fn := mDNSResponderFile()
if fn == "" {
return
}
if f, err := os.OpenFile(fn, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0400); err != nil {
mainLog.Load().Warn().Err(err).Msgf("Could not create %s", fn)
} else {
if err := f.Close(); err != nil {
mainLog.Load().Warn().Err(err).Msgf("Could not close %s", fn)
} else {
mainLog.Load().Debug().Msgf("Created %s", fn)
}
}
}
// killMdnsResponder attempts to terminate the mDNSResponder process by running the "killall" command multiple times.
// Logs any accumulated errors if the attempts to terminate the process fail.
func killMdnsResponder() {
numAttempts := 10
errs := make([]error, 0, numAttempts)
for range numAttempts {
if err := exec.Command("killall", "mDNSResponder").Run(); err != nil {
// Exit code 1 means the process not found, do not log it.
if !strings.Contains(err.Error(), "exit status 1") {
errs = append(errs, err)
}
}
}
if len(errs) > 0 {
mainLog.Load().Debug().Err(errors.Join(errs...)).Msg("failed to kill mDNSResponder")
}
}
// lsofCheckPort53 executes the lsof command to check if any process is listening on port 53 and returns the output.
func lsofCheckPort53() (string, error) {
cmd := exec.Command("lsof", "+c0", "-i:53", "-n", "-P")
out, err := cmd.CombinedOutput()
if err != nil {
return "", err
}
return string(out), nil
}
// isMdnsResponderListeningPort53 checks if the output provided by the reader contains an mDNSResponder process.
func isMdnsResponderListeningPort53(r io.Reader) bool {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) > 0 && strings.EqualFold(fields[0], "mDNSResponder") {
return true
}
}
return false
}

View File

@@ -0,0 +1,21 @@
//go:build !darwin
package cli
// needMdnsResponderHack determines if a system-specific workaround for mDNSResponder is necessary at runtime.
var needMdnsResponderHack = mDNSResponderHack()
// mDNSResponderHack checks if the mDNSResponder process and its environments meet specific criteria for operation.
func mDNSResponderHack() bool {
return false
}
// killMdnsResponder attempts to terminate the mDNSResponder process by running the "killall" command multiple times.
// Logs any accumulated errors if the attempts to terminate the process fail.
func killMdnsResponder() {}
// doMdnsResponderCleanup performs cleanup tasks for the mDNSResponder hack file and resets the network interface "en0".
func doMdnsResponderCleanup() {}
// doMdnsResponderHackPostInstall creates a hack file for mDNSResponder if required and logs debug or error messages.
func doMdnsResponderHackPostInstall() {}