feat(cli): route Tailscale Split DNS and App Connector domains in macOS intercept mode

When a discovered VPN resolver is Tailscale's quad-100, read the Split
DNS and App Connector routes from Tailscale's LocalAPI and route those
suffixes to quad-100 instead of Control D.

Master adaptation: newVPNDNSManager keeps master's logger argument and
Refresh keeps its context argument. tailscaleRoutesFor takes the
refresh context, and a retry builds its context from the manager's
logger (backgroundCtx). setDNS closes the previous manager and wires
the gated retry the same way as on v1.0.
This commit is contained in:
Dev Scribe authored and Cuong Manh Le committed 2026-10-12 14:41:12 +07:00
1 parent 1dd94eb02e
commit 4792b0eeab
9 files changed
+1157 -5

No files matched your search

+21
View File
@@ -439,6 +439,24 @@ func (p *prog) beginNetworkActivity() bool {
return true
}
// retryVPNDNSRefresh returns the refresh that a Tailscale route retry of m
// runs. Like other background refreshes it is admitted through the network
// activity gate, and it does nothing once m is no longer the live manager.
func (p *prog) retryVPNDNSRefresh(m *vpnDNSManager) func() {
return func() {
if !p.beginNetworkActivity() {
return
}
defer p.netMonitorWG.Done()
p.vpnDNSJournalMu.Lock()
live := p.vpnDNS == m
p.vpnDNSJournalMu.Unlock()
if live {
m.Refresh(m.backgroundCtx(), true)
}
}
}
func (p *prog) networkActivityClosed() bool {
p.netMonitorMu.Lock()
defer p.netMonitorMu.Unlock()
@@ -1670,7 +1688,9 @@ func (p *prog) setDNS(systemNameservers []string) {
// matching queries to the DNS server on that interface.
// Skipped in --intercept-mode hard where all DNS goes through ctrld.
p.vpnDNSJournalMu.Lock()
p.vpnDNS.Close()
p.vpnDNS = newVPNDNSManager(&p.logger, p.exemptVPNDNSServers)
p.vpnDNS.retryRefresh = p.retryVPNDNSRefresh(p.vpnDNS)
p.vpnDNS.Refresh(ctrld.LoggerCtx(context.Background(), p.logger.Load()))
p.vpnDNSJournalMu.Unlock()
}
@@ -1922,6 +1942,7 @@ func (p *prog) resetDNS(isStart bool, restoreStatic bool) {
// Clean up VPN DNS manager
p.vpnDNSJournalMu.Lock()
p.vpnDNS.Close()
p.vpnDNS = nil
p.vpnDNSJournalMu.Unlock()
+155
View File
@@ -0,0 +1,155 @@
package cli
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"regexp"
"strconv"
"time"
)
// tailscaleLocalAPITimeout bounds one LocalAPI read. Discovery runs on network
// change paths, so it must never stall them.
const tailscaleLocalAPITimeout = 2 * time.Second
// tailscaleLocalAPIHost is the Host header Tailscale's LocalAPI requires.
const tailscaleLocalAPIHost = "local-tailscaled.sock"
// tailscaleDNSConfigPath returns the network's DNS config, including Split DNS
// and App Connector routes. Tailscale added it in v1.98.0.
const tailscaleDNSConfigPath = "/localapi/v0/dns-config"
// maxTailscaleDNSConfigBytes bounds the response body ctrld reads.
const maxTailscaleDNSConfigBytes = 4 << 20
var errTailscaleLocalAPIUnavailable = errors.New("tailscale LocalAPI not found")
// tailscaleLocalAPIEndpoint is one way to reach a running Tailscale LocalAPI.
type tailscaleLocalAPIEndpoint struct {
name string
dial func(ctx context.Context) (net.Conn, error)
token string // HTTP Basic-Auth password; empty for the tailscaled socket
}
// tailscaleAppStoreProc is the exact command name of the Mac App Store
// Tailscale network extension.
const tailscaleAppStoreProc = "IPNExtension"
// sameUserProofPath matches the sameuserproof file of the Mac App Store
// Tailscale extension: <home>/Library/Group Containers/<group>.tailscale.ipn.macos/
// sameuserproof-<port>-<token>. Paths anywhere else are not accepted, because
// any local user can create a file with that name under /tmp.
var sameUserProofPath = regexp.MustCompile(`^/Users/[^/]+/Library/Group Containers/[^/]+\.tailscale\.ipn\.macos/sameuserproof-([0-9]+)-([^/]+)$`)
// macOSSameUserProof is one App Store LocalAPI candidate found by lsof.
type macOSSameUserProof struct {
pid int
port int
token string
}
// parseMacOSSameUserProofs extracts App Store LocalAPI candidates from
// `lsof -F pcn` output. A candidate needs a process whose command is exactly
// IPNExtension (lsof -c matches a prefix only) holding open a sameuserproof
// file in a user's Tailscale group container. The caller still has to verify
// the process code signature, because a command name is not proof.
func parseMacOSSameUserProofs(out []byte) []macOSSameUserProof {
var proofs []macOSSameUserProof
pid, command := 0, ""
sc := bufio.NewScanner(bytes.NewReader(out))
for sc.Scan() {
line := sc.Text()
if line == "" {
continue
}
value := line[1:]
switch line[0] {
case 'p':
pid, command = 0, ""
if p, err := strconv.Atoi(value); err == nil && p > 0 {
pid = p
}
case 'c':
command = value
case 'n':
if pid == 0 || command != tailscaleAppStoreProc {
continue
}
m := sameUserProofPath.FindStringSubmatch(value)
if m == nil {
continue
}
port, err := strconv.Atoi(m[1])
if err != nil || port <= 0 || port > 65535 {
continue
}
proofs = append(proofs, macOSSameUserProof{pid: pid, port: port, token: m[2]})
}
}
return proofs
}
// fetchTailscaleDNSConfig reads the dns-config body from one endpoint.
func fetchTailscaleDNSConfig(ctx context.Context, ep tailscaleLocalAPIEndpoint) ([]byte, error) {
tr := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return ep.dial(ctx) },
DisableKeepAlives: true,
Proxy: nil,
}
defer tr.CloseIdleConnections()
client := &http.Client{Transport: tr}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+tailscaleLocalAPIHost+tailscaleDNSConfigPath, nil)
if err != nil {
return nil, err
}
if ep.token != "" {
req.SetBasicAuth("", ep.token)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("%s: %w", ep.name, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxTailscaleDNSConfigBytes+1))
if err != nil {
return nil, fmt.Errorf("%s: %w", ep.name, err)
}
if resp.StatusCode != http.StatusOK {
// Older Tailscale has no dns-config endpoint and answers 404.
return nil, fmt.Errorf("%s: dns-config returned HTTP %d", ep.name, resp.StatusCode)
}
if len(body) > maxTailscaleDNSConfigBytes {
return nil, fmt.Errorf("%s: dns-config response exceeds %d bytes", ep.name, maxTailscaleDNSConfigBytes)
}
return body, nil
}
// discoverTailscaleRoutesFrom tries each endpoint in order and returns the
// routes of the first that answers. The error never contains the token.
func discoverTailscaleRoutesFrom(ctx context.Context, endpoints []tailscaleLocalAPIEndpoint) ([]tailscaleRoute, error) {
if len(endpoints) == 0 {
return nil, errTailscaleLocalAPIUnavailable
}
var errs []error
for _, ep := range endpoints {
body, err := fetchTailscaleDNSConfig(ctx, ep)
if err != nil {
errs = append(errs, err)
continue
}
routes, err := parseTailscaleRoutes(body)
if err != nil {
errs = append(errs, fmt.Errorf("%s: decode dns-config: %w", ep.name, err))
continue
}
return routes, nil
}
return nil, errors.Join(errs...)
}
+115
View File
@@ -0,0 +1,115 @@
//go:build darwin
package cli
import (
"context"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
// Locations used by the three macOS Tailscale variants. These match
// tailscale.com/safesocket and tailscale.com/paths.
const (
tailscaleMacsysDir = "/Library/Tailscale"
tailscaledUnixSocket = "/var/run/tailscaled.socket"
)
// tailscaleCodeRequirement accepts only the Mac App Store Tailscale network
// extension. Apple re-signs App Store code with its own leaf certificate
// (marker OID 1.2.840.113635.100.6.1.9) and signs a bundle identifier only for
// the developer that owns it; other builds carry Tailscale's team identifier
// W5364U7YZB, which Tailscale publishes for its macOS extension policy.
const tailscaleCodeRequirement = `anchor apple generic and identifier "io.tailscale.ipn.macos.network-extension" and ` +
`(certificate leaf[field.1.2.840.113635.100.6.1.9] or certificate leaf[subject.OU] = "W5364U7YZB")`
// discoverTailscaleRoutesFn reads Tailscale DNS routes; tests replace it.
//
// discoverTailscaleRoutes reads Tailscale's DNS routes through its LocalAPI.
// ctrld runs as root, so it can read the credentials of each variant. The
// root-owned credentials come first: the Standalone system extension
// (/Library/Tailscale) and open source tailscaled (unix socket). The Mac App
// Store extension (sameuserproof file found with lsof) comes last, and only
// after its process passes a Tailscale code signature check, because any
// local user can start a process with that name.
var discoverTailscaleRoutesFn = discoverTailscaleRoutes
// listAppStoreProofsFn lists the open files of IPNExtension processes; tests
// replace it. "+c 0" prints the full command name, so the parser can require
// an exact match; -c alone matches any command that starts with the name.
var listAppStoreProofsFn = func(ctx context.Context) ([]byte, error) {
return exec.CommandContext(ctx, "lsof", "-n", "-a", "+c", "0", "-c", tailscaleAppStoreProc, "-F", "pcn").Output()
}
// verifyTailscaleProcessFn checks the code signature of a running process;
// tests replace it.
var verifyTailscaleProcessFn = verifyTailscaleProcess
func discoverTailscaleRoutes(ctx context.Context) ([]tailscaleRoute, error) {
return discoverTailscaleRoutesFrom(ctx, tailscaleLocalAPIEndpoints(ctx))
}
func tailscaleLocalAPIEndpoints(ctx context.Context) []tailscaleLocalAPIEndpoint {
var eps []tailscaleLocalAPIEndpoint
if portStr, err := os.Readlink(filepath.Join(tailscaleMacsysDir, "ipnport")); err == nil {
if port, err := strconv.Atoi(portStr); err == nil && port > 0 && port <= 65535 {
if b, err := os.ReadFile(filepath.Join(tailscaleMacsysDir, "sameuserproof-"+portStr)); err == nil {
if token := strings.TrimSpace(string(b)); token != "" {
eps = append(eps, tailscaleLocalAPIEndpoint{name: "standalone", dial: tcpLocalAPIDialer(port), token: token})
}
}
}
}
if fi, err := os.Stat(tailscaledUnixSocket); err == nil && fi.Mode()&os.ModeSocket != 0 {
eps = append(eps, tailscaleLocalAPIEndpoint{
name: "tailscaled",
dial: func(ctx context.Context) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", tailscaledUnixSocket)
},
})
}
return append(eps, appStoreLocalAPIEndpoints(ctx)...)
}
// appStoreLocalAPIEndpoints returns the App Store LocalAPI endpoint of the
// first IPNExtension process that is signed by Tailscale.
func appStoreLocalAPIEndpoints(ctx context.Context) []tailscaleLocalAPIEndpoint {
out, err := listAppStoreProofsFn(ctx)
if err != nil {
return nil
}
verified := make(map[int]bool)
for _, proof := range parseMacOSSameUserProofs(out) {
ok, seen := verified[proof.pid]
if !seen {
ok = verifyTailscaleProcessFn(ctx, proof.pid)
verified[proof.pid] = ok
}
if ok {
return []tailscaleLocalAPIEndpoint{{name: "app-store", dial: tcpLocalAPIDialer(proof.port), token: proof.token}}
}
}
return nil
}
// verifyTailscaleProcess reports whether the running process pid is signed by
// Tailscale. codesign validates a running process dynamically when given a
// pid instead of a path.
func verifyTailscaleProcess(ctx context.Context, pid int) bool {
return exec.CommandContext(ctx, "codesign", "--verify", "-R="+tailscaleCodeRequirement, strconv.Itoa(pid)).Run() == nil
}
func tcpLocalAPIDialer(port int) func(ctx context.Context) (net.Conn, error) {
return func(ctx context.Context) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
}
}
+37
View File
@@ -0,0 +1,37 @@
//go:build darwin
package cli
import (
"context"
"testing"
)
func TestAppStoreLocalAPIEndpointsRequireTailscaleSignature(t *testing.T) {
oldList, oldVerify := listAppStoreProofsFn, verifyTailscaleProcessFn
t.Cleanup(func() { listAppStoreProofsFn, verifyTailscaleProcessFn = oldList, oldVerify })
const proof = "/Users/u/Library/Group Containers/W5364U7YZB.io.tailscale.ipn.macos/sameuserproof-"
listAppStoreProofsFn = func(context.Context) ([]byte, error) {
return []byte("p100\ncIPNExtension\nn" + proof + "8080-spoof\n" +
"p200\ncIPNExtension\nn" + proof + "49152-real\n"), nil
}
var checked []int
verifyTailscaleProcessFn = func(_ context.Context, pid int) bool {
checked = append(checked, pid)
return pid == 200
}
eps := appStoreLocalAPIEndpoints(context.Background())
if len(eps) != 1 || eps[0].name != "app-store" || eps[0].token != "real" {
t.Fatalf("endpoints = %+v, want only the signed process", eps)
}
if len(checked) != 2 || checked[0] != 100 || checked[1] != 200 {
t.Fatalf("verified pids = %v, want [100 200]", checked)
}
verifyTailscaleProcessFn = func(context.Context, int) bool { return false }
if eps := appStoreLocalAPIEndpoints(context.Background()); len(eps) != 0 {
t.Fatalf("endpoints = %+v for unsigned processes, want none", eps)
}
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !darwin
package cli
import "context"
// discoverTailscaleRoutesFn is nil off macOS: other platforms publish
// Tailscale split domains through the OS (NRPT, systemd-resolved).
var discoverTailscaleRoutesFn func(context.Context) ([]tailscaleRoute, error)
+336
View File
@@ -0,0 +1,336 @@
package cli
import (
"context"
"encoding/json"
"net"
"net/netip"
"net/url"
"slices"
"strings"
"sync"
"time"
"github.com/Control-D-Inc/ctrld"
"tailscale.com/net/tsaddr"
)
// maxTailscaleRoutes bounds the routes taken from one Tailscale DNS config so
// a very large App Connector set cannot grow the route table without limit.
const maxTailscaleRoutes = 10000
// tailscaleRouteRetryDelays are the waits before each retry after a failed
// LocalAPI read while quad-100 is still discovered. Retries stop after the
// last one, so a steady network costs nothing.
var tailscaleRouteRetryDelays = []time.Duration{5 * time.Second, 15 * time.Second, 60 * time.Second}
// tailscaleDNSConfig is the subset of Tailscale's LocalAPI dns-config response
// (tailcfg.DNSConfig) that ctrld reads. Routes maps a DNS suffix to the
// resolvers Tailscale uses for it: Split DNS entries and App Connector domains.
type tailscaleDNSConfig struct {
Routes map[string][]struct {
Addr string `json:"Addr"`
} `json:"Routes"`
}
// tailscaleRoute is one suffix that quad-100 resolves.
type tailscaleRoute struct {
Domain string
// NeedsPassthrough is set when quad-100 forwards the suffix as plain DNS
// on port 53 to a Tailscale IPv4 address. pf passes that traffic only
// through the VPN DNS interface passthrough, which exists in non-exit mode
// only. Everywhere else it would be redirected back to ctrld.
NeedsPassthrough bool
}
// tailscaleResolverPath tells how quad-100 reaches one route resolver, as seen
// by ctrld's pf rules.
type tailscaleResolverPath int
const (
// tailscaleResolverLoopFree: not plain DNS on port 53 (DoH over the
// tailnet, peerAPI), or no upstream at all. pf never redirects it.
tailscaleResolverLoopFree tailscaleResolverPath = iota
// tailscaleResolverViaPassthrough: plain DNS on port 53 to a Tailscale
// IPv4 address, reached on the tunnel interface.
tailscaleResolverViaPassthrough
// tailscaleResolverLoops: plain DNS on port 53 to an address the
// passthrough does not cover (public, LAN, IPv6), or an unknown form.
tailscaleResolverLoops
)
// classifyTailscaleResolver classifies one dnstype.Resolver Addr: a plain IP,
// an IP:port, or an http(s) URL. Unknown forms are treated as looping, so the
// suffix keeps going to Control D as it did before Tailscale routes existed.
func classifyTailscaleResolver(addr string) tailscaleResolverPath {
addr = strings.TrimSpace(addr)
if strings.HasPrefix(addr, "http://") || strings.HasPrefix(addr, "https://") {
u, err := url.Parse(addr)
if err != nil || u.Hostname() == "" {
return tailscaleResolverLoops
}
if u.Port() != "53" {
return tailscaleResolverLoopFree
}
ip, err := netip.ParseAddr(u.Hostname())
if err != nil {
return tailscaleResolverLoops
}
return classifyTailscalePort53(ip)
}
if ip, err := netip.ParseAddr(addr); err == nil {
return classifyTailscalePort53(ip)
}
if ipp, err := netip.ParseAddrPort(addr); err == nil {
if ipp.Port() != 53 {
return tailscaleResolverLoopFree
}
return classifyTailscalePort53(ipp.Addr())
}
return tailscaleResolverLoops
}
// classifyTailscalePort53 classifies a plain port-53 resolver. The pf
// passthrough is IPv4-only and covers only the tunnel interface, which carries
// the Tailscale CGNAT range in non-exit mode.
func classifyTailscalePort53(ip netip.Addr) tailscaleResolverPath {
ip = ip.Unmap()
if ip.Is4() && tsaddr.CGNATRange().Contains(ip) {
return tailscaleResolverViaPassthrough
}
return tailscaleResolverLoops
}
// parseTailscaleRoutes returns the sorted, normalized routes of a dns-config
// response. It drops the root, IP literals and names that cannot be DNS
// suffixes, and suffixes with a resolver that quad-100 can reach only through
// pf's port-53 redirect. Those keep going to Control D rather than loop.
func parseTailscaleRoutes(body []byte) ([]tailscaleRoute, error) {
var cfg tailscaleDNSConfig
if err := json.Unmarshal(body, &cfg); err != nil {
return nil, err
}
byDomain := make(map[string]tailscaleRoute, len(cfg.Routes))
looping := 0
for suffix, resolvers := range cfg.Routes {
domain := normalizeVPNDomain(strings.TrimPrefix(strings.TrimSpace(suffix), "*."))
if !validTailscaleRouteDomain(domain) {
continue
}
route := tailscaleRoute{Domain: domain}
loops := false
for _, resolver := range resolvers {
switch classifyTailscaleResolver(resolver.Addr) {
case tailscaleResolverViaPassthrough:
route.NeedsPassthrough = true
case tailscaleResolverLoops:
loops = true
}
}
if loops {
looping++
continue
}
if prev, ok := byDomain[domain]; ok {
route.NeedsPassthrough = route.NeedsPassthrough || prev.NeedsPassthrough
}
byDomain[domain] = route
}
routes := make([]tailscaleRoute, 0, len(byDomain))
for _, route := range byDomain {
routes = append(routes, route)
}
slices.SortFunc(routes, func(a, b tailscaleRoute) int { return strings.Compare(a.Domain, b.Domain) })
if looping > 0 {
mainLog.Load().Debug().Msgf("Tailscale DNS config: skipped %d routes whose port-53 resolvers pf would redirect back to ctrld", looping)
}
if len(routes) > maxTailscaleRoutes {
mainLog.Load().Warn().Msgf("Tailscale DNS config has %d routes; keeping the first %d", len(routes), maxTailscaleRoutes)
routes = routes[:maxTailscaleRoutes]
}
return routes, nil
}
func validTailscaleRouteDomain(domain string) bool {
if domain == "" || net.ParseIP(domain) != nil {
return false
}
return !strings.ContainsAny(domain, " /:*\t\r\n")
}
// isTailscaleServiceIP reports whether server is Tailscale's DNS service
// address (quad-100) in either address family.
func isTailscaleServiceIP(server string) bool {
ip := net.ParseIP(server)
if ip == nil {
return false
}
return ip.Equal(net.IP(tsaddr.TailscaleServiceIP().AsSlice())) ||
ip.Equal(net.IP(tsaddr.TailscaleServiceIPv6().AsSlice()))
}
func hasTailscaleResolver(configs []ctrld.VPNDNSConfig) bool {
for _, config := range configs {
if slices.ContainsFunc(config.Servers, isTailscaleServiceIP) {
return true
}
}
return false
}
// withTailscaleRouteDomains returns configs with the route domains appended to
// every config whose servers include Tailscale's service IP. Other configs are
// returned unchanged, so non-Tailscale resolvers never gain these routes.
// Routes that need the pf passthrough are added only to a config that gets
// one: a named interface in non-exit mode. It also returns the set of domains
// that were added.
func withTailscaleRouteDomains(configs []ctrld.VPNDNSConfig, routes []tailscaleRoute) ([]ctrld.VPNDNSConfig, map[string]bool) {
if len(routes) == 0 {
return configs, nil
}
added := make(map[string]bool)
out := make([]ctrld.VPNDNSConfig, len(configs))
for i, config := range configs {
out[i] = config
if !slices.ContainsFunc(config.Servers, isTailscaleServiceIP) {
continue
}
hasPassthrough := config.InterfaceName != "" && !config.IsExitMode
merged := append([]string{}, config.Domains...)
for _, route := range routes {
if route.NeedsPassthrough && !hasPassthrough {
continue
}
if !slices.Contains(merged, route.Domain) {
merged = append(merged, route.Domain)
added[route.Domain] = true
}
}
out[i].Domains = merged
}
return out, added
}
// tailscaleRouteState keeps the last good Tailscale route set and the bounded
// retry after a failed read.
type tailscaleRouteState struct {
// last is the last successful read. It is guarded by the manager's
// discoveryMu, which every refresh holds.
last []tailscaleRoute
mu sync.Mutex
timer *time.Timer
next int // index into tailscaleRouteRetryDelays of the next retry
retrying bool // a retry timer fired and its refresh has not read yet
closed bool
}
// takeRetry reports whether the refresh now reading was started by a retry.
func (s *tailscaleRouteState) takeRetry() bool {
s.mu.Lock()
defer s.mu.Unlock()
retrying := s.retrying
s.retrying = false
return retrying
}
// scheduleRetry arms the next retry after a failed read. A failure outside a
// retry restarts the sequence unless a retry is already pending.
func (s *tailscaleRouteState) scheduleRetry(fromRetry bool, refresh func()) {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return
}
if !fromRetry {
if s.timer != nil {
return
}
s.next = 0
}
if s.next >= len(tailscaleRouteRetryDelays) {
return
}
delay := tailscaleRouteRetryDelays[s.next]
s.next++
s.timer = time.AfterFunc(delay, func() {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.timer = nil
s.retrying = true
s.mu.Unlock()
refresh()
})
}
// stopRetry cancels a pending retry and resets the sequence.
func (s *tailscaleRouteState) stopRetry() {
s.mu.Lock()
defer s.mu.Unlock()
if s.timer != nil {
s.timer.Stop()
s.timer = nil
}
s.next = 0
s.retrying = false
}
// close stops retries for good.
func (s *tailscaleRouteState) close() {
s.mu.Lock()
defer s.mu.Unlock()
s.closed = true
if s.timer != nil {
s.timer.Stop()
s.timer = nil
}
}
// tailscaleRoutesFor reads the Tailscale DNS routes when a discovered resolver
// is Tailscale's. After a failed read it keeps the last good set and schedules
// a bounded retry; without a Tailscale resolver it forgets the set.
// The caller must hold m.discoveryMu.
func (m *vpnDNSManager) tailscaleRoutesFor(ctx context.Context, configs []ctrld.VPNDNSConfig) []tailscaleRoute {
fromRetry := m.tailscale.takeRetry()
if m.discoverTailscaleRoutes == nil || !hasTailscaleResolver(configs) {
m.tailscale.last = nil
m.tailscale.stopRetry()
return nil
}
readCtx, cancel := context.WithTimeout(ctx, tailscaleLocalAPITimeout)
defer cancel()
routes, err := m.discoverTailscaleRoutes(readCtx)
if err != nil {
ctrld.Log(ctx, ctrld.LoggerFromCtx(ctx).Debug().Err(err), "Tailscale DNS routes unavailable; keeping %d last known routes and retrying", len(m.tailscale.last))
retry := m.retryRefresh
if retry == nil {
retry = func() { m.Refresh(m.backgroundCtx(), true) }
}
m.tailscale.scheduleRetry(fromRetry, retry)
return m.tailscale.last
}
m.tailscale.last = routes
m.tailscale.stopRetry()
return routes
}
// backgroundCtx returns a context carrying the manager's logger for refreshes
// that start outside a caller's context, such as a Tailscale route retry.
func (m *vpnDNSManager) backgroundCtx() context.Context {
logger := mainLog.Load()
if m.logger != nil && m.logger.Load() != nil {
logger = m.logger.Load()
}
return ctrld.LoggerCtx(context.Background(), logger)
}
// Close stops background work of the manager. It is safe on a nil manager.
func (m *vpnDNSManager) Close() {
if m == nil {
return
}
m.tailscale.close()
}
+451
View File
@@ -0,0 +1,451 @@
package cli
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Control-D-Inc/ctrld"
)
// tailscaleDNSConfigFixture is a reduced LocalAPI dns-config response with
// Split DNS zones, App Connector routes, a connector IP literal and the root.
// tail.example is a Split DNS zone on a tailnet resolver, which pf passes only
// in non-exit mode. lan.example and pub.example use resolvers off the tunnel,
// and mixed.example has one of each; pf would redirect those back to ctrld.
const tailscaleDNSConfigFixture = `{
"Resolvers": [],
"Routes": {
"corp.example.": [],
"tail.example": [{"Addr": "100.64.0.2"}],
"lan.example": [{"Addr": "192.168.1.2"}],
"pub.example": [{"Addr": "8.8.8.8"}],
"v6.example": [{"Addr": "fd7a:115c:a1e0::2"}],
"mixed.example": [{"Addr": "100.64.0.2"}, {"Addr": "10.0.0.2"}],
"doh.example": [{"Addr": "https://dns.example/dns-query"}],
"backend.example.de": [{"Addr": "http://100.95.61.111:34024/dns-query"}],
"cd.backend.example.co.jp": [{"Addr": "http://100.94.53.33:34880/dns-query"}],
"Queue.RMQ4.Example.com": [{"Addr": "http://100.94.53.33:34880/dns-query"}],
"*.wild.example": [{"Addr": "http://100.1.2.3:1/dns-query"}],
"46.30.138.82": [{"Addr": "http://100.123.219.98:49667/dns-query"}],
"2001:db8::1": [{"Addr": "http://100.123.219.98:49667/dns-query"}],
".": [{"Addr": "1.1.1.1"}],
"": []
},
"Domains": ["tailnet.ts.net"]
}`
func TestParseTailscaleRoutes(t *testing.T) {
got, err := parseTailscaleRoutes([]byte(tailscaleDNSConfigFixture))
if err != nil {
t.Fatal(err)
}
want := []tailscaleRoute{
{Domain: "backend.example.de"},
{Domain: "cd.backend.example.co.jp"},
{Domain: "corp.example"},
{Domain: "doh.example"},
{Domain: "queue.rmq4.example.com"},
{Domain: "tail.example", NeedsPassthrough: true},
{Domain: "wild.example"},
}
if !slices.Equal(got, want) {
t.Fatalf("parseTailscaleRoutes() = %v, want %v", got, want)
}
if _, err := parseTailscaleRoutes([]byte("not json")); err == nil {
t.Fatal("expected an error for an undecodable body")
}
}
func TestClassifyTailscaleResolver(t *testing.T) {
for addr, want := range map[string]tailscaleResolverPath{
"http://100.95.61.111:34024/dns-query": tailscaleResolverLoopFree,
"https://dns.example/dns-query": tailscaleResolverLoopFree,
"100.64.0.2:5353": tailscaleResolverLoopFree,
"100.64.0.2": tailscaleResolverViaPassthrough,
"100.64.0.2:53": tailscaleResolverViaPassthrough,
"http://100.64.0.2:53/dns-query": tailscaleResolverViaPassthrough,
"10.0.0.2": tailscaleResolverLoops,
"8.8.8.8": tailscaleResolverLoops,
"fd7a:115c:a1e0::2": tailscaleResolverLoops,
"[2001:db8::1]:53": tailscaleResolverLoops,
"http://dns.example:53/": tailscaleResolverLoops,
"tls://dns.example": tailscaleResolverLoops,
"": tailscaleResolverLoops,
} {
if got := classifyTailscaleResolver(addr); got != want {
t.Errorf("classifyTailscaleResolver(%q) = %d, want %d", addr, got, want)
}
}
}
func TestParseTailscaleRoutesCapsAndLogsTruncation(t *testing.T) {
var b strings.Builder
b.WriteString(`{"Routes":{`)
for i := 0; i < maxTailscaleRoutes+5; i++ {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `"r%05d.example":[]`, i)
}
b.WriteString(`}}`)
logs := captureMainLog(t)
got, err := parseTailscaleRoutes([]byte(b.String()))
if err != nil {
t.Fatal(err)
}
if len(got) != maxTailscaleRoutes {
t.Fatalf("kept %d routes, want %d", len(got), maxTailscaleRoutes)
}
if want := fmt.Sprintf("has %d routes; keeping the first %d", maxTailscaleRoutes+5, maxTailscaleRoutes); !strings.Contains(logs.String(), want) {
t.Fatalf("log does not report the truncation %q: %s", want, logs.String())
}
}
func TestWithTailscaleRouteDomainsOnlyExtendsTailscaleResolver(t *testing.T) {
configs := []ctrld.VPNDNSConfig{
{InterfaceName: "utun4", Servers: []string{"100.100.100.100", "fd7a:115c:a1e0::53"}, Domains: []string{"tailnet.ts.net", "corp.example"}},
{InterfaceName: "en0", Servers: []string{"192.168.1.1"}, Domains: []string{"lan"}},
}
got, added := withTailscaleRouteDomains(configs, []tailscaleRoute{{Domain: "corp.example"}, {Domain: "backend.example.de"}})
if !added["backend.example.de"] || added["corp.example"] {
t.Fatalf("added = %v, want only backend.example.de", added)
}
if want := []string{"tailnet.ts.net", "corp.example", "backend.example.de"}; !slices.Equal(got[0].Domains, want) {
t.Fatalf("tailscale domains = %v, want %v", got[0].Domains, want)
}
if want := []string{"lan"}; !slices.Equal(got[1].Domains, want) {
t.Fatalf("non-tailscale domains changed: %v", got[1].Domains)
}
if want := []string{"tailnet.ts.net", "corp.example"}; !slices.Equal(configs[0].Domains, want) {
t.Fatalf("input configs were mutated: %v", configs[0].Domains)
}
}
func TestVPNDNSRefreshRoutesTailscaleRoutesToQuad100(t *testing.T) {
m := newVPNDNSManager(&mainLog, nil)
m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
return []ctrld.VPNDNSConfig{
{InterfaceName: "utun4", Servers: []string{"100.100.100.100"}, Domains: []string{"tailnet.ts.net", "lan"}},
{InterfaceName: "en0", Servers: []string{"192.168.1.248"}, Domains: []string{"lan"}},
}
}
m.discoverTailscaleRoutes = func(context.Context) ([]tailscaleRoute, error) {
return parseTailscaleRoutes([]byte(tailscaleDNSConfigFixture))
}
m.Refresh(context.Background(), true)
for _, name := range []string{"host.corp.example.", "backend.example.de.", "x.cd.backend.example.co.jp.", "host.tail.example."} {
if got := m.UpstreamForDomain(name); !slices.Equal(got, []string{"100.100.100.100"}) {
t.Errorf("UpstreamForDomain(%q) = %v, want quad-100", name, got)
}
}
for _, name := range []string{"example.de.", "other.example.com.", "46.30.138.82.", "host.lan.example.", "host.pub.example.", "host.v6.example.", "host.mixed.example."} {
if got := m.UpstreamForDomain(name); got != nil {
t.Errorf("UpstreamForDomain(%q) = %v, want no route", name, got)
}
}
for _, ex := range m.CurrentExemptions() {
if ex.Server != "100.100.100.100" && ex.Server != "192.168.1.248" {
t.Errorf("unexpected exemption %+v", ex)
}
}
}
func TestVPNDNSRefreshSkipsTailscaleLookupWithoutQuad100(t *testing.T) {
m := newVPNDNSManager(&mainLog, nil)
m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
return []ctrld.VPNDNSConfig{{InterfaceName: "utun5", Servers: []string{"10.8.0.1"}, Domains: []string{"corp.example"}}}
}
var calls atomic.Int32
m.discoverTailscaleRoutes = func(context.Context) ([]tailscaleRoute, error) {
calls.Add(1)
return []tailscaleRoute{{Domain: "should.not.appear"}}, nil
}
m.Refresh(context.Background(), true)
if calls.Load() != 0 {
t.Fatalf("Tailscale LocalAPI queried %d times without a Tailscale resolver", calls.Load())
}
if got := m.UpstreamForDomain("x.should.not.appear."); got != nil {
t.Fatalf("unexpected route %v", got)
}
}
func TestVPNDNSRefreshKeepsOSRoutesWhenTailscaleUnavailable(t *testing.T) {
m := newVPNDNSManager(&mainLog, nil)
m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
return []ctrld.VPNDNSConfig{{InterfaceName: "utun4", Servers: []string{"100.100.100.100"}, Domains: []string{"tailnet.ts.net"}}}
}
m.discoverTailscaleRoutes = func(context.Context) ([]tailscaleRoute, error) {
return nil, errTailscaleLocalAPIUnavailable
}
stubTailscaleRetryDelays(t, time.Hour)
defer m.Close()
m.Refresh(context.Background(), true)
if got := m.Routes(); len(got) != 1 || !slices.Equal(got["tailnet.ts.net"], []string{"100.100.100.100"}) {
t.Fatalf("routes = %v, want only the OS-discovered tailnet route", got)
}
}
func TestVPNDNSRefreshPicksUpTailscaleRouteEdits(t *testing.T) {
m := newVPNDNSManager(&mainLog, nil)
m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
return []ctrld.VPNDNSConfig{{InterfaceName: "utun4", Servers: []string{"100.100.100.100"}, Domains: []string{"tailnet.ts.net"}}}
}
routes := []tailscaleRoute{{Domain: "corp.example"}}
m.discoverTailscaleRoutes = func(context.Context) ([]tailscaleRoute, error) {
return slices.Clone(routes), nil
}
m.Refresh(context.Background(), true)
// A Tailscale reconnect is a tunnel change, which runs a refresh.
routes = []tailscaleRoute{{Domain: "new.connector.example"}}
m.Refresh(context.Background(), true)
if got := m.UpstreamForDomain("api.new.connector.example."); !slices.Equal(got, []string{"100.100.100.100"}) {
t.Fatalf("new connector route = %v, want quad-100", got)
}
if got := m.UpstreamForDomain("host.corp.example."); got != nil {
t.Fatalf("removed route still present: %v", got)
}
}
func TestParseMacOSSameUserProofs(t *testing.T) {
const good = "/Users/u/Library/Group Containers/W5364U7YZB.io.tailscale.ipn.macos/sameuserproof-49152-abcdef0123"
out := []byte("p123\ncIPNExtension\nn/private/var/folders/x\nn" + good + "\n" +
// A process whose name only starts with IPNExtension: lsof -c matches it.
"p200\ncIPNExtensionEvil\nn" + good + "\n" +
// The right name, but the proof file is outside a group container.
"p300\ncIPNExtension\nn/tmp/x.tailscale.ipn.macos/sameuserproof-8080-spoof\n" +
"n/Users/u/Library/Group Containers/io.tailscale.ipn.macos/sameuserproof-notaport-tok\n" +
"n/Users/u/Library/Group Containers/io.tailscale.ipn.macos/sameuserproof-49152-\n" +
"n/Users/u/Library/Group Containers/io.tailscale.ipn.macos/nested/sameuserproof-1-tok\n")
got := parseMacOSSameUserProofs(out)
want := []macOSSameUserProof{{pid: 123, port: 49152, token: "abcdef0123"}}
if !slices.Equal(got, want) {
t.Fatalf("parseMacOSSameUserProofs() = %+v, want %+v", got, want)
}
}
func TestVPNDNSRefreshSkipsPassthroughRoutesInExitMode(t *testing.T) {
for _, tc := range []struct {
name string
exitMode bool
wantTail bool
}{
{name: "split", exitMode: false, wantTail: true},
{name: "exit node", exitMode: true, wantTail: false},
} {
t.Run(tc.name, func(t *testing.T) {
m := newVPNDNSManager(&mainLog, nil)
m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
return []ctrld.VPNDNSConfig{{InterfaceName: "utun4", Servers: []string{"100.100.100.100"}, Domains: []string{"tailnet.ts.net"}, IsExitMode: tc.exitMode}}
}
m.discoverTailscaleRoutes = func(context.Context) ([]tailscaleRoute, error) {
return parseTailscaleRoutes([]byte(tailscaleDNSConfigFixture))
}
m.Refresh(context.Background(), true)
// A peerAPI DoH connector never crosses pf's port-53 redirect.
if got := m.UpstreamForDomain("api.backend.example.de."); !slices.Equal(got, []string{"100.100.100.100"}) {
t.Fatalf("connector route = %v, want quad-100", got)
}
got := m.UpstreamForDomain("host.tail.example.")
if tc.wantTail && !slices.Equal(got, []string{"100.100.100.100"}) {
t.Fatalf("tailnet Split DNS route = %v, want quad-100", got)
}
if !tc.wantTail && got != nil {
t.Fatalf("tailnet Split DNS route = %v in exit mode, want none (pf has no passthrough)", got)
}
})
}
}
func TestVPNDNSRefreshKeepsLastTailscaleRoutesAndRetries(t *testing.T) {
stubTailscaleRetryDelays(t, 10*time.Millisecond, 10*time.Millisecond)
m := newVPNDNSManager(&mainLog, nil)
defer m.Close()
m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
return []ctrld.VPNDNSConfig{{InterfaceName: "utun4", Servers: []string{"100.100.100.100"}, Domains: []string{"tailnet.ts.net"}}}
}
var mu sync.Mutex
results := []error{nil, errors.New("503 no netmap"), nil}
served := []tailscaleRoute{{Domain: "old.example"}}
var calls atomic.Int32
m.discoverTailscaleRoutes = func(context.Context) ([]tailscaleRoute, error) {
mu.Lock()
defer mu.Unlock()
i := int(calls.Add(1)) - 1
if i < len(results) && results[i] != nil {
return nil, results[i]
}
return slices.Clone(served), nil
}
m.Refresh(context.Background(), true)
mu.Lock()
served = []tailscaleRoute{{Domain: "new.example"}}
mu.Unlock()
// This refresh fails: the last good routes stay.
m.Refresh(context.Background(), true)
if got := m.UpstreamForDomain("host.old.example."); !slices.Equal(got, []string{"100.100.100.100"}) {
t.Fatalf("last good route = %v after a failed read, want quad-100", got)
}
// The retry reads again and replaces the set.
deadline := time.Now().Add(5 * time.Second)
for m.UpstreamForDomain("host.new.example.") == nil {
if time.Now().After(deadline) {
t.Fatalf("retry did not refresh the routes; %d LocalAPI calls", calls.Load())
}
time.Sleep(5 * time.Millisecond)
}
if got := m.UpstreamForDomain("host.old.example."); got != nil {
t.Fatalf("replaced route still present: %v", got)
}
// A successful read stops the retries.
time.Sleep(50 * time.Millisecond)
if got := calls.Load(); got != 3 {
t.Fatalf("LocalAPI calls = %d, want 3 (no retry after success)", got)
}
}
func TestVPNDNSTailscaleRetriesAreBounded(t *testing.T) {
stubTailscaleRetryDelays(t, time.Millisecond, time.Millisecond, time.Millisecond)
m := newVPNDNSManager(&mainLog, nil)
defer m.Close()
m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
return []ctrld.VPNDNSConfig{{InterfaceName: "utun4", Servers: []string{"100.100.100.100"}}}
}
var calls atomic.Int32
m.discoverTailscaleRoutes = func(context.Context) ([]tailscaleRoute, error) {
calls.Add(1)
return nil, errTailscaleLocalAPIUnavailable
}
m.Refresh(context.Background(), true)
time.Sleep(200 * time.Millisecond)
// One read on the refresh, then one per retry delay, then nothing.
if got := calls.Load(); got != 4 {
t.Fatalf("LocalAPI calls = %d, want 4", got)
}
}
func TestVPNDNSTailscaleRoutesDroppedWithoutQuad100(t *testing.T) {
m := newVPNDNSManager(&mainLog, nil)
withTS := true
m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
if withTS {
return []ctrld.VPNDNSConfig{{InterfaceName: "utun4", Servers: []string{"100.100.100.100"}}}
}
return nil
}
m.discoverTailscaleRoutes = func(context.Context) ([]tailscaleRoute, error) {
return []tailscaleRoute{{Domain: "old.example"}}, nil
}
m.Refresh(context.Background(), true)
withTS = false
m.Refresh(context.Background(), false)
if m.tailscale.last != nil {
t.Fatalf("last Tailscale routes kept without quad-100: %v", m.tailscale.last)
}
}
func TestRetryVPNDNSRefreshUsesNetworkActivityGate(t *testing.T) {
m := newVPNDNSManager(&mainLog, nil)
var calls atomic.Int32
m.discoverVPNDNS = func(context.Context) []ctrld.VPNDNSConfig {
calls.Add(1)
return nil
}
p := &prog{vpnDNS: m}
retry := p.retryVPNDNSRefresh(m)
retry()
if got := calls.Load(); got != 1 {
t.Fatalf("refreshes = %d for the live manager, want 1", got)
}
p.vpnDNS = newVPNDNSManager(&mainLog, nil)
retry()
if got := calls.Load(); got != 1 {
t.Fatalf("retry of a replaced manager refreshed it (%d refreshes)", got)
}
p.vpnDNS = m
p.closeNetMonitor()
retry()
if got := calls.Load(); got != 1 {
t.Fatalf("retry refreshed after shutdown (%d refreshes)", got)
}
}
func stubTailscaleRetryDelays(t *testing.T, delays ...time.Duration) {
t.Helper()
old := tailscaleRouteRetryDelays
tailscaleRouteRetryDelays = delays
t.Cleanup(func() { tailscaleRouteRetryDelays = old })
}
func TestDiscoverTailscaleRoutesFromLocalAPI(t *testing.T) {
const token = "s3cret-token"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != tailscaleDNSConfigPath || r.Host != tailscaleLocalAPIHost {
http.NotFound(w, r)
return
}
if _, pass, ok := r.BasicAuth(); !ok || pass != token {
http.Error(w, "auth required", http.StatusUnauthorized)
return
}
_, _ = w.Write([]byte(tailscaleDNSConfigFixture))
}))
defer srv.Close()
dial := func(ctx context.Context) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "tcp", srv.Listener.Addr().String())
}
got, err := discoverTailscaleRoutesFrom(context.Background(), []tailscaleLocalAPIEndpoint{
{name: "stale", dial: func(context.Context) (net.Conn, error) { return nil, errors.New("connection refused") }},
{name: "wrong-token", dial: dial, token: "nope"},
{name: "good", dial: dial, token: token},
})
if err != nil {
t.Fatal(err)
}
if !slices.Contains(got, tailscaleRoute{Domain: "corp.example"}) || !slices.Contains(got, tailscaleRoute{Domain: "backend.example.de"}) {
t.Fatalf("routes = %v", got)
}
_, err = discoverTailscaleRoutesFrom(context.Background(), []tailscaleLocalAPIEndpoint{{name: "wrong-token", dial: dial, token: "nope"}})
if err == nil {
t.Fatal("expected an error for a rejected token")
}
if strings.Contains(err.Error(), "nope") || strings.Contains(err.Error(), token) {
t.Fatalf("error leaks the token: %v", err)
}
if _, err := discoverTailscaleRoutesFrom(context.Background(), nil); !errors.Is(err, errTailscaleLocalAPIUnavailable) {
t.Fatalf("err = %v, want errTailscaleLocalAPIUnavailable", err)
}
}
+28 -5
View File
@@ -59,6 +59,17 @@ type vpnDNSManager struct {
// discoverVPNDNS is injected for tests so Refresh does not depend on the
// runner host's real VPN/virtual adapter state.
discoverVPNDNS func(context.Context) []ctrld.VPNDNSConfig
// discoverTailscaleRoutes reads the Split DNS and App Connector suffixes
// that Tailscale resolves internally and does not publish to the OS. Nil
// where the OS already exposes them.
discoverTailscaleRoutes func(context.Context) ([]tailscaleRoute, error)
// tailscale keeps the last good Tailscale route set and the bounded retry
// after a failed LocalAPI read.
tailscale tailscaleRouteState
// retryRefresh runs the refresh of a Tailscale route retry. The program
// sets it to admit the refresh through its network activity gate, so a
// retry never runs during shutdown. Nil means m.Refresh(true).
retryRefresh func()
// refreshStateMu keeps noisy network-change storms from running overlapping
// full VPN DNS refreshes and retains one trailing refresh when an event arrives
// during discovery so the newest OS state is not lost.
@@ -81,10 +92,11 @@ const vpnDNSRefreshKey = "vpn_dns_refresh"
// the OS-level intercept rules to permit ctrld's outbound queries to those IPs.
func newVPNDNSManager(logger *atomic.Pointer[ctrld.Logger], exemptFunc vpnDNSExemptFunc) *vpnDNSManager {
return &vpnDNSManager{
routes: make(map[string][]string),
logger: logger,
discoverVPNDNS: ctrld.DiscoverVPNDNS,
onServersChanged: exemptFunc,
routes: make(map[string][]string),
logger: logger,
discoverVPNDNS: ctrld.DiscoverVPNDNS,
discoverTailscaleRoutes: discoverTailscaleRoutesFn,
onServersChanged: exemptFunc,
}
}
@@ -146,6 +158,12 @@ func (m *vpnDNSManager) refreshOnce(ctx context.Context, guardAgainstNoNameserve
}
}
}
// Tailscale routes go in after exit mode is known: routes that need the
// pf passthrough are skipped in exit mode.
configs, tailscaleDomains := withTailscaleRouteDomains(configs, m.tailscaleRoutesFor(ctx, configs))
if len(tailscaleDomains) > 0 {
ctrld.Log(ctx, logger.Debug(), "Added %d Tailscale DNS routes -> Tailscale resolver", len(tailscaleDomains))
}
m.mu.Lock()
defer m.mu.Unlock()
@@ -188,7 +206,11 @@ func (m *vpnDNSManager) refreshOnce(ctx context.Context, guardAgainstNoNameserve
if domain != "" {
m.routes[domain] = append([]string{}, config.Servers...)
ctrld.Log(ctx, logger.Debug(), "Added VPN DNS route: %s -> %v", domain, config.Servers)
// Tailscale routes can number in the thousands; one summary
// line above covers them.
if !tailscaleDomains[domain] {
ctrld.Log(ctx, logger.Debug(), "Added VPN DNS route: %s -> %v", domain, config.Servers)
}
}
}
}
@@ -288,6 +310,7 @@ func (m *vpnDNSManager) RefreshRoutesOnly() (routes, domainlessServers, exemptio
}
}
}
configs, _ = withTailscaleRouteDomains(configs, m.tailscaleRoutesFor(ctx, configs))
m.mu.Lock()
defer m.mu.Unlock()
+5
View File
@@ -292,6 +292,11 @@ dig @127.0.0.1 example.com
- **Split DNS for VPN internal domains**: In `--intercept-mode dns` mode, VPN search domains are auto-detected from virtual network adapters and forwarded to the VPN's DNS servers automatically. In `--intercept-mode hard` mode, VPN internal domains (e.g., `*.corp.local`) will NOT resolve unless configured as explicit upstream rules in ctrld's configuration.
- **Tailscale Split DNS and App Connectors (macOS)**: The sandboxed Tailscale app keeps Split DNS routes with custom resolvers and App Connector domains inside its own resolver (`100.100.100.100`); they do not appear in `scutil --dns`. When a discovered resolver is Tailscale's, ctrld also reads Tailscale's route list from its LocalAPI (`/localapi/v0/dns-config`, Tailscale v1.98.0 or later) and routes those suffixes to `100.100.100.100`. The list is read on each VPN DNS refresh; after changing Split DNS or App Connectors, reconnect Tailscale to apply them. Names routed this way are resolved by the tailnet and not filtered by Control D, as in standard mode. If the LocalAPI is unavailable, only `scutil --dns` domains are used.
- **Loop guard**: a route is taken only when quad-100 can reach its resolvers without crossing pf's port-53 redirect. DoH and App Connector (peerAPI) resolvers always qualify. A plain port-53 resolver qualifies only on a Tailscale IPv4 address and only in non-exit mode, where the VPN DNS interface passthrough exists (section 9). Routes with any other resolver (public, LAN, IPv6, or any port-53 resolver in exit-node mode) keep going to Control D; routing them to quad-100 would loop back to ctrld.
- **Endpoint trust**: root-owned credentials are tried first (Standalone `/Library/Tailscale`, then `/var/run/tailscaled.socket`). The App Store extension is used last, and only when the process holding the `sameuserproof` file is named exactly `IPNExtension`, the file sits in a user's `Library/Group Containers/*.tailscale.ipn.macos/`, and the running process passes a Tailscale code-signature check.
- **Failed reads**: a failed LocalAPI read (for example a 503 while Tailscale starts) keeps the last good route set while quad-100 is still discovered, and retries after 5 s, 15 s and 60 s, then stops until the next network change.
- **macOS mDNSResponder interaction**: On macOS, ctrld uses a workaround ("mDNSResponder hack") that binds to `0.0.0.0:53` instead of `127.0.0.1:53` and refuses queries from non-localhost sources. In dns-intercept mode, pf's `rdr` rewrites the destination IP to `127.0.0.1:53` but preserves the original source IP (e.g., `192.168.2.73`). The mDNSResponder source-IP check is automatically bypassed in dns-intercept mode because the pf/WFP rules already ensure only legitimate intercepted DNS traffic reaches ctrld's listener.
- **Other WFP/pf users**: If other software (VPN, firewall, endpoint security) also uses WFP or pf for DNS interception, there may be priority conflicts. ctrld uses maximum sublayer weight on Windows and a named anchor on macOS to minimize this risk. See "VPN App Coexistence" below for macOS-specific defenses.