mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-24 14:08:27 +02:00
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:
1 parent
1dd94eb02e
commit
4792b0eeab
9 files changed
+1157
-5
No files matched your search
@@ -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()
|
||||
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user