Files
Brad Fitzpatrick 023255e8a2 net/dnscache, feature/dnsresolvecache: only persist DNS resolutions after TLS verification
This is a follow-up to #21029 (aa2681ac5f) to make it a bit stricter
and not cache DNS results until they've passed TLS cert validation,
to weed out DNS servers that are lying (like captive portals).

Because this is done via dnscache.TLSDialer we only catch the control
connection, but that's fine. That's all we need to come back alive
if DNS was down because real system DNS is itself over Tailscale.
The DERP connections should come via IPv4/IPv6 fields in the DERPMap.
And the logging connection isn't important; it'll buffer and catch up
later as needed when DNS is back up.

Updates #21028

Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
Change-Id: I75f176a0222f04ed52c9de1247deeed9b911f92c
2026-09-10 12:49:19 -07:00

252 lines
8.0 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package dnsresolvecache persists successful DNS resolutions from
// net/dnscache to disk, one JSON file per hostname, so that a later
// tailscaled boot with misconfigured DNS can still find
// last-known-good IPs for critical hostnames such as the control
// plane. It is intended to eventually replace the DERP-based
// bootstrap DNS in net/dnsfallback.
//
// To keep bogus answers (say, from a captive portal's DNS server) off
// disk, a resolution is not written when it happens. It is instead
// held in memory as pending until a TLS connection to one of the
// resolution's IPs presents a certificate chain that is valid for
// that hostname; only then is the record flushed to disk. The chain
// is checked independently of the connection's own TLS configuration,
// which for the control plane's Noise connection deliberately
// tolerates interception. A captive portal cannot present a valid
// certificate for a hostname it is impersonating, so its answers are
// never persisted. That is also the
// invalidation contract: a hostname's file is only ever written or
// replaced by a newer resolution that was itself verified this way;
// there is no expiry. Verification currently comes from
// [dnscache.TLSDialer] (used by the control plane connection), so
// other hostnames resolve normally but are not persisted.
//
// A file is rewritten only when its contents change, so its
// modification time records when the answer last changed, not when
// it was last confirmed.
//
// This package is linked into tailscaled by default and omitted from
// tsnet. Nothing here is automatic for tsnet-based apps: to use it,
// they must both blank-import this package and configure the cache
// directory themselves by invoking [dnscache.HookSetCacheDir], which
// is otherwise only called by tailscaled at startup.
//
// This package's state is process-global. In tsnet-based apps running
// multiple tsnet.Server instances in one process, only the cache
// directory from the first [dnscache.HookSetCacheDir] call is used;
// later calls are ignored and all servers share the first cache.
package dnsresolvecache
import (
"bytes"
"crypto/sha256"
"encoding/json"
"net/netip"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"tailscale.com/atomicfile"
"tailscale.com/feature"
"tailscale.com/net/dnscache"
"tailscale.com/types/logger"
"tailscale.com/util/mak"
"tailscale.com/util/set"
)
func init() {
feature.Register("dnsresolvecache")
dnscache.HookSetCacheDir.Set(setCacheDir)
dnscache.HookPersistResolution.Set(persist)
dnscache.HookLookupDiskCache.Set(lookup)
dnscache.HookHostVerified.Set(hostVerified)
}
var (
mu sync.Mutex // guards the variables below
cacheDir string // empty until the first successful setCacheDir call
logf logger.Logf // non-nil once cacheDir is set
lastWritten map[string]string // hostname => digest of last JSON written
pending map[string]pendingRec // hostname => resolution awaiting TLS verification
)
// pendingRec is a marshaled resolution awaiting TLS verification of
// one of its IPs before being written to disk.
type pendingRec struct {
j []byte // marshaled record
ips set.Set[netip.Addr]
}
// record is the JSON structure of each persisted per-hostname file.
type record struct {
Resolver string // resolver source of the answer: "forward", "cloud", or "fallback"
A []netip.Addr `json:",omitempty"` // IPv4 addresses, sorted
AAAA []netip.Addr `json:",omitempty"` // IPv6 addresses, sorted
}
// setCacheDir creates dir if needed and enables persistence to it.
// It implements [dnscache.HookSetCacheDir].
//
// Only the first successful call has any effect; later calls are
// no-ops that keep using the first directory. See the package doc
// for what that means for multi-server tsnet apps.
func setCacheDir(dir string, lf logger.Logf) {
if lf == nil {
lf = logger.Discard
}
mu.Lock()
defer mu.Unlock()
if cacheDir != "" {
if dir != cacheDir {
lf("dnsresolvecache: cache dir already set to %q; ignoring %q", cacheDir, dir)
}
return
}
if err := os.MkdirAll(dir, 0700); err != nil {
lf("dnsresolvecache: %v", err)
return
}
cacheDir = dir
logf = lf
}
// filePath returns the path of the cache file for host.
// The caller must have validated host and hold mu (for cacheDir).
func filePath(host string) string {
return filepath.Join(cacheDir, "dns-"+host+".json")
}
// persist records the resolution of host as pending, to be written to
// disk by hostVerified once one of its IPs passes TLS certificate
// verification for host. It implements [dnscache.HookPersistResolution].
func persist(host, resolver string, ips []netip.Addr) {
host = strings.ToLower(host)
if !validHostname(host) || len(ips) == 0 {
return
}
rec := record{Resolver: resolver}
ipSet := make(set.Set[netip.Addr])
ips = slices.Clone(ips)
slices.SortFunc(ips, netip.Addr.Compare)
for _, ip := range slices.Compact(ips) {
ipSet.Add(ip)
if ip.Is4() {
rec.A = append(rec.A, ip)
} else {
rec.AAAA = append(rec.AAAA, ip)
}
}
j, err := json.Marshal(rec)
if err != nil {
return
}
mu.Lock()
defer mu.Unlock()
if cacheDir == "" {
return
}
mak.Set(&pending, host, pendingRec{j: j, ips: ipSet})
}
// hostVerified flushes the pending resolution of host to disk if ip
// is one of its addresses. It implements [dnscache.HookHostVerified].
func hostVerified(host string, ip netip.Addr) {
host = strings.ToLower(host)
if !validHostname(host) {
return
}
mu.Lock()
defer mu.Unlock()
p, ok := pending[host]
if !ok || !p.ips.Contains(ip.Unmap()) {
// Nothing pending, or the verified connection didn't use an
// IP from the pending resolution (e.g. an older resolution's
// IP, or a bogus record whose IPs never verify). Keep any
// pending record for a later handshake on a member IP.
return
}
delete(pending, host)
writeLocked(host, p.j)
}
// writeLocked writes j to host's cache file, unless the contents
// would be unchanged. The caller must hold mu, and cacheDir must be
// non-empty.
func writeLocked(host string, j []byte) {
digest := sha256.Sum256(j)
path := filePath(host)
if last, ok := lastWritten[host]; ok {
if last == string(digest[:]) {
return
}
} else if old, err := os.ReadFile(path); err == nil && bytes.Equal(old, j) {
// First write since process start and the file already
// matches; skip the write to preserve its modtime.
mak.Set(&lastWritten, host, string(digest[:]))
return
}
if err := atomicfile.WriteFile(path, j, 0600); err != nil {
logf("dnsresolvecache: writing %v: %v", path, err)
return
}
mak.Set(&lastWritten, host, string(digest[:]))
}
// lookup returns the persisted last-known-good IPs for host, if any.
// It implements [dnscache.HookLookupDiskCache].
func lookup(host string) ([]netip.Addr, bool) {
host = strings.ToLower(host)
if !validHostname(host) {
return nil, false
}
mu.Lock()
defer mu.Unlock()
if cacheDir == "" {
return nil, false
}
j, err := os.ReadFile(filePath(host))
if err != nil {
return nil, false
}
var rec record
if err := json.Unmarshal(j, &rec); err != nil {
logf("dnsresolvecache: parsing cache for %q: %v", host, err)
return nil, false
}
ips := append(rec.A, rec.AAAA...)
if len(ips) == 0 {
return nil, false
}
return ips, true
}
// validHostname reports whether host is a DNS hostname that is safe
// to embed in a filename: dot-separated non-empty labels of
// lowercase letters, digits, hyphens, and underscores. It is
// intentionally stricter than DNS itself (which permits nearly
// arbitrary bytes in labels) to keep hostile names out of paths.
func validHostname(host string) bool {
if len(host) == 0 || len(host) > 253 {
return false
}
for label := range strings.SplitSeq(host, ".") {
if len(label) == 0 || len(label) > 63 {
return false
}
for i := range len(label) {
b := label[i]
if b >= 'a' && b <= 'z' || b >= '0' && b <= '9' || b == '-' || b == '_' {
continue
}
return false
}
}
return true
}