diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index 49d8b67..70c2312 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -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() diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index a1074f2..dbd13bf 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -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) { diff --git a/cmd/cli/dns_proxy.go b/cmd/cli/dns_proxy.go index 3d8cc30..60dfd49 100644 --- a/cmd/cli/dns_proxy.go +++ b/cmd/cli/dns_proxy.go @@ -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) diff --git a/cmd/cli/mdnsresponder_hack_darwin.go b/cmd/cli/mdnsresponder_hack_darwin.go new file mode 100644 index 0000000..6687bc5 --- /dev/null +++ b/cmd/cli/mdnsresponder_hack_darwin.go @@ -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 +} diff --git a/cmd/cli/mdnsresponder_hack_others.go b/cmd/cli/mdnsresponder_hack_others.go new file mode 100644 index 0000000..5d6ada5 --- /dev/null +++ b/cmd/cli/mdnsresponder_hack_others.go @@ -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() {}