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()