mirror of https://github.com/tailscale/tailscale/
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
312 lines
8.0 KiB
Go
312 lines
8.0 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package dnscache
|
|
|
|
import (
|
|
"cmp"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/golang/groupcache/lru"
|
|
"golang.org/x/net/dns/dnsmessage"
|
|
)
|
|
|
|
// MessageCache is a cache that works at the DNS message layer,
|
|
// with its cache keyed on a DNS wire-level question, and capable
|
|
// of replying to DNS messages.
|
|
//
|
|
// Its zero value is ready for use with a default cache size.
|
|
// Use SetMaxCacheSize to specify the cache size.
|
|
//
|
|
// It's safe for concurrent use.
|
|
type MessageCache struct {
|
|
// Clock is a clock, for testing.
|
|
// If nil, time.Now is used.
|
|
Clock func() time.Time
|
|
|
|
mu sync.Mutex
|
|
cacheSizeSet int // 0 means default
|
|
cache lru.Cache // msgQ => *msgCacheValue
|
|
}
|
|
|
|
func (c *MessageCache) now() time.Time {
|
|
if c.Clock != nil {
|
|
return c.Clock()
|
|
}
|
|
return time.Now()
|
|
}
|
|
|
|
// SetMaxCacheSize sets the maximum number of DNS cache entries that
|
|
// can be stored.
|
|
func (c *MessageCache) SetMaxCacheSize(n int) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.cacheSizeSet = n
|
|
c.pruneLocked()
|
|
}
|
|
|
|
// Flush clears the cache.
|
|
func (c *MessageCache) Flush() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.cache.Clear()
|
|
}
|
|
|
|
// pruneLocked prunes down the cache size to the configured (or
|
|
// default) max size.
|
|
func (c *MessageCache) pruneLocked() {
|
|
max := cmp.Or(c.cacheSizeSet, 500)
|
|
for c.cache.Len() > max {
|
|
c.cache.RemoveOldest()
|
|
}
|
|
}
|
|
|
|
// msgQ is the MessageCache cache key.
|
|
//
|
|
// It's basically a golang.org/x/net/dns/dnsmessage#Question but the
|
|
// Class is omitted (we only cache ClassINET) and we store a Go string
|
|
// instead of a 256 byte dnsmessage.Name array.
|
|
type msgQ struct {
|
|
Name string
|
|
Type dnsmessage.Type // A, AAAA, MX, etc
|
|
}
|
|
|
|
// A *msgCacheValue is the cached value for a msgQ (question) key.
|
|
//
|
|
// Despite using pointers for storage and methods, the value is
|
|
// immutable once placed in the cache.
|
|
type msgCacheValue struct {
|
|
Expires time.Time
|
|
|
|
// Answers are the minimum data to reconstruct a DNS response
|
|
// message. TTLs are added later when converting to a
|
|
// dnsmessage.Resource.
|
|
Answers []msgResource
|
|
}
|
|
|
|
type msgResource struct {
|
|
Name string
|
|
Type dnsmessage.Type // dnsmessage.UnknownResource.Type
|
|
Data []byte // dnsmessage.UnknownResource.Data
|
|
}
|
|
|
|
// ErrCacheMiss is a sentinel error returned by MessageCache.ReplyFromCache
|
|
// when the request can not be satisfied from cache.
|
|
var ErrCacheMiss = errors.New("cache miss")
|
|
|
|
var parserPool = &sync.Pool{
|
|
New: func() any { return new(dnsmessage.Parser) },
|
|
}
|
|
|
|
// ReplyFromCache writes a DNS reply to w for the provided DNS query message,
|
|
// which must begin with the two ID bytes of a DNS message.
|
|
//
|
|
// If there's a cache miss, the message is invalid or unexpected,
|
|
// ErrCacheMiss is returned. On cache hit, either nil or an error from
|
|
// a w.Write call is returned.
|
|
func (c *MessageCache) ReplyFromCache(w io.Writer, dnsQueryMessage []byte) error {
|
|
cacheKey, txID, ok := getDNSQueryCacheKey(dnsQueryMessage)
|
|
if !ok {
|
|
return ErrCacheMiss
|
|
}
|
|
now := c.now()
|
|
|
|
c.mu.Lock()
|
|
cacheEntI, _ := c.cache.Get(cacheKey)
|
|
v, ok := cacheEntI.(*msgCacheValue)
|
|
if ok && now.After(v.Expires) {
|
|
c.cache.Remove(cacheKey)
|
|
ok = false
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
if !ok {
|
|
return ErrCacheMiss
|
|
}
|
|
|
|
ttl := uint32(v.Expires.Sub(now).Seconds())
|
|
|
|
packedRes, err := packDNSResponse(cacheKey, txID, ttl, v.Answers)
|
|
if err != nil {
|
|
return ErrCacheMiss
|
|
}
|
|
_, err = w.Write(packedRes)
|
|
return err
|
|
}
|
|
|
|
var (
|
|
errNotCacheable = errors.New("question not cacheable")
|
|
)
|
|
|
|
// AddCacheEntry adds a cache entry to the cache.
|
|
// It returns an error if the entry could not be cached.
|
|
func (c *MessageCache) AddCacheEntry(qPacket, res []byte) error {
|
|
cacheKey, qID, ok := getDNSQueryCacheKey(qPacket)
|
|
if !ok {
|
|
return errNotCacheable
|
|
}
|
|
now := c.now()
|
|
v := &msgCacheValue{}
|
|
|
|
p := parserPool.Get().(*dnsmessage.Parser)
|
|
defer parserPool.Put(p)
|
|
|
|
resh, err := p.Start(res)
|
|
if err != nil {
|
|
return fmt.Errorf("reading header in response: %w", err)
|
|
}
|
|
if resh.ID != qID {
|
|
return fmt.Errorf("response ID doesn't match query ID")
|
|
}
|
|
q, err := p.Question()
|
|
if err != nil {
|
|
return fmt.Errorf("reading 1st question in response: %w", err)
|
|
}
|
|
if _, err := p.Question(); err != dnsmessage.ErrSectionDone {
|
|
if err == nil {
|
|
return errors.New("unexpected 2nd question in response")
|
|
}
|
|
return fmt.Errorf("after reading 1st question in response: %w", err)
|
|
}
|
|
if resName := asciiLowerName(q.Name).String(); resName != cacheKey.Name {
|
|
return fmt.Errorf("response question name %q != question name %q", resName, cacheKey.Name)
|
|
}
|
|
for {
|
|
rh, err := p.AnswerHeader()
|
|
if err == dnsmessage.ErrSectionDone {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("reading answer: %w", err)
|
|
}
|
|
res, err := p.UnknownResource()
|
|
if err != nil {
|
|
return fmt.Errorf("reading resource: %w", err)
|
|
}
|
|
if rh.Class != dnsmessage.ClassINET {
|
|
continue
|
|
}
|
|
|
|
// Set the cache entry's expiration to the soonest
|
|
// we've seen. (They should all be the same, though)
|
|
expires := now.Add(time.Duration(rh.TTL) * time.Second)
|
|
if v.Expires.IsZero() || expires.Before(v.Expires) {
|
|
v.Expires = expires
|
|
}
|
|
v.Answers = append(v.Answers, msgResource{
|
|
Name: rh.Name.String(),
|
|
Type: rh.Type,
|
|
Data: res.Data, // doesn't alias; a copy from dnsmessage.unpackUnknownResource
|
|
})
|
|
}
|
|
c.addCacheValue(cacheKey, v)
|
|
return nil
|
|
}
|
|
|
|
func (c *MessageCache) addCacheValue(cacheKey msgQ, v *msgCacheValue) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.cache.Add(cacheKey, v)
|
|
c.pruneLocked()
|
|
}
|
|
|
|
func getDNSQueryCacheKey(msg []byte) (cacheKey msgQ, txID uint16, ok bool) {
|
|
p := parserPool.Get().(*dnsmessage.Parser)
|
|
defer parserPool.Put(p)
|
|
h, err := p.Start(msg)
|
|
const dnsHeaderSize = 12
|
|
if err != nil || h.OpCode != 0 || h.Response || h.Truncated ||
|
|
len(msg) < dnsHeaderSize { // p.Start checks this anyway, but to be explicit for slicing below
|
|
return cacheKey, 0, false
|
|
}
|
|
var (
|
|
numQ = binary.BigEndian.Uint16(msg[4:6])
|
|
numAns = binary.BigEndian.Uint16(msg[6:8])
|
|
numAuth = binary.BigEndian.Uint16(msg[8:10])
|
|
numAddn = binary.BigEndian.Uint16(msg[10:12])
|
|
)
|
|
_ = numAddn // ignore this for now; do client OSes send EDNS additional? assume so, ignore.
|
|
if !(numQ == 1 && numAns == 0 && numAuth == 0) {
|
|
// Something weird. We don't want to deal with it.
|
|
return cacheKey, 0, false
|
|
}
|
|
q, err := p.Question()
|
|
if err != nil {
|
|
// Already verified numQ == 1 so shouldn't happen, but:
|
|
return cacheKey, 0, false
|
|
}
|
|
if q.Class != dnsmessage.ClassINET {
|
|
// We only cache the Internet class.
|
|
return cacheKey, 0, false
|
|
}
|
|
return msgQ{Name: asciiLowerName(q.Name).String(), Type: q.Type}, h.ID, true
|
|
}
|
|
|
|
func asciiLowerName(n dnsmessage.Name) dnsmessage.Name {
|
|
nb := n.Data[:]
|
|
if int(n.Length) < len(n.Data) {
|
|
nb = nb[:n.Length]
|
|
}
|
|
for i, b := range nb {
|
|
if 'A' <= b && b <= 'Z' {
|
|
n.Data[i] += 0x20
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// packDNSResponse builds a DNS response for the given question and
|
|
// transaction ID. The response resource records will have the
|
|
// same provided TTL.
|
|
func packDNSResponse(q msgQ, txID uint16, ttl uint32, answers []msgResource) ([]byte, error) {
|
|
var baseMem []byte // TODO: guess a max size based on looping over answers?
|
|
b := dnsmessage.NewBuilder(baseMem, dnsmessage.Header{
|
|
ID: txID,
|
|
Response: true,
|
|
OpCode: 0,
|
|
Authoritative: false,
|
|
Truncated: false,
|
|
RCode: dnsmessage.RCodeSuccess,
|
|
})
|
|
name, err := dnsmessage.NewName(q.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := b.StartQuestions(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := b.Question(dnsmessage.Question{
|
|
Name: name,
|
|
Type: q.Type,
|
|
Class: dnsmessage.ClassINET,
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := b.StartAnswers(); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, r := range answers {
|
|
name, err := dnsmessage.NewName(r.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := b.UnknownResource(dnsmessage.ResourceHeader{
|
|
Name: name,
|
|
Type: r.Type,
|
|
Class: dnsmessage.ClassINET,
|
|
TTL: ttl,
|
|
}, dnsmessage.UnknownResource{
|
|
Type: r.Type,
|
|
Data: r.Data,
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return b.Finish()
|
|
}
|