131 lines
3.4 KiB
Go
131 lines
3.4 KiB
Go
// Package dns provides a bounded, cached DNS enricher that follows CNAME chains
|
|||
|
|
// with loop detection.
|
||
|
|
package dns
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"net"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"tea.karlcom.de/karl/bucketeer/internal/metrics"
|
||
|
|
)
|
||
|
|
|
||
|
|
// ErrCNAMELoop is returned when a CNAME chain revisits a host.
|
||
|
|
var ErrCNAMELoop = errors.New("cname loop detected")
|
||
|
|
|
||
|
|
// Resolver is the subset of DNS operations the enricher needs.
|
||
|
|
type Resolver interface {
|
||
|
|
LookupCNAME(ctx context.Context, host string) (string, error)
|
||
|
|
LookupIP(ctx context.Context, host string) ([]net.IP, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Result is a resolved hostname: its CNAME chain (chain[0] is the queried host)
|
||
|
|
// and any A/AAAA addresses.
|
||
|
|
type Result struct {
|
||
|
|
Chain []string
|
||
|
|
IPs []net.IP
|
||
|
|
}
|
||
|
|
|
||
|
|
// GoResolver uses the standard library resolver.
|
||
|
|
//
|
||
|
|
// ponytail: net.Resolver.LookupCNAME returns the final canonical name, so the
|
||
|
|
// chain has at most two entries and intermediate CNAME hops are not observed.
|
||
|
|
// Swap in miekg/dns if the full chain is ever needed.
|
||
|
|
type GoResolver struct {
|
||
|
|
R *net.Resolver
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewGoResolver() *GoResolver {
|
||
|
|
return &GoResolver{R: net.DefaultResolver}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (g *GoResolver) LookupCNAME(ctx context.Context, host string) (string, error) {
|
||
|
|
return g.R.LookupCNAME(ctx, host)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (g *GoResolver) LookupIP(ctx context.Context, host string) ([]net.IP, error) {
|
||
|
|
return g.R.LookupIP(ctx, "ip", host)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Follow walks a CNAME chain up to maxDepth hops, detecting loops.
|
||
|
|
func Follow(ctx context.Context, r Resolver, host string, maxDepth int) ([]string, error) {
|
||
|
|
if maxDepth < 1 {
|
||
|
|
maxDepth = 1
|
||
|
|
}
|
||
|
|
chain := []string{host}
|
||
|
|
cur := host
|
||
|
|
for i := 0; i < maxDepth; i++ {
|
||
|
|
next, err := r.LookupCNAME(ctx, cur)
|
||
|
|
if err != nil {
|
||
|
|
return chain, err
|
||
|
|
}
|
||
|
|
next = strings.TrimSuffix(next, ".")
|
||
|
|
if next == "" || next == cur {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
for _, seen := range chain {
|
||
|
|
if seen == next {
|
||
|
|
return chain, ErrCNAMELoop
|
||
|
|
}
|
||
|
|
}
|
||
|
|
chain = append(chain, next)
|
||
|
|
cur = next
|
||
|
|
}
|
||
|
|
return chain, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Enricher resolves hostnames with caching and bounded CNAME depth. It is safe
|
||
|
|
// for concurrent use.
|
||
|
|
type Enricher struct {
|
||
|
|
Resolver Resolver
|
||
|
|
Cache *TTLCache[Result]
|
||
|
|
PositiveTTL time.Duration
|
||
|
|
NegativeTTL time.Duration
|
||
|
|
Timeout time.Duration
|
||
|
|
MaxDepth int
|
||
|
|
Metrics *metrics.Metrics
|
||
|
|
}
|
||
|
|
|
||
|
|
// Resolve returns the result for host. ok is false when the lookup failed;
|
||
|
|
// failures (including "no such host") are negatively cached for NegativeTTL.
|
||
|
|
func (e *Enricher) Resolve(ctx context.Context, host string) (Result, bool) {
|
||
|
|
if e.Timeout > 0 {
|
||
|
|
var cancel context.CancelFunc
|
||
|
|
ctx, cancel = context.WithTimeout(ctx, e.Timeout)
|
||
|
|
defer cancel()
|
||
|
|
}
|
||
|
|
if e.Cache != nil {
|
||
|
|
if res, ok := e.Cache.Get(host); ok {
|
||
|
|
if e.Metrics != nil {
|
||
|
|
e.Metrics.DNSCacheHits.Add(1)
|
||
|
|
}
|
||
|
|
return res, len(res.Chain) > 0 || len(res.IPs) > 0
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if e.Metrics != nil {
|
||
|
|
e.Metrics.DNSQueries.Add(1)
|
||
|
|
}
|
||
|
|
|
||
|
|
chain, cnameErr := Follow(ctx, e.Resolver, host, e.MaxDepth)
|
||
|
|
if cnameErr != nil && len(chain) == 1 {
|
||
|
|
// A definitive NXDOMAIN has no A/AAAA to fetch either, so skip the
|
||
|
|
// second lookup. NXDOMAIN is a valid answer, not an infrastructure error.
|
||
|
|
if e.Metrics != nil && !definitive(cnameErr) {
|
||
|
|
e.Metrics.DNSErrors.Add(1)
|
||
|
|
}
|
||
|
|
if e.Cache != nil {
|
||
|
|
e.Cache.Set(host, Result{}, e.NegativeTTL)
|
||
|
|
}
|
||
|
|
return Result{}, false
|
||
|
|
}
|
||
|
|
res := Result{Chain: chain}
|
||
|
|
if ips, err := e.Resolver.LookupIP(ctx, host); err == nil {
|
||
|
|
res.IPs = ips
|
||
|
|
}
|
||
|
|
if e.Cache != nil {
|
||
|
|
e.Cache.Set(host, res, e.PositiveTTL)
|
||
|
|
}
|
||
|
|
return res, true
|
||
|
|
}
|