mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-17 20:19:55 +00:00
517 lines
15 KiB
Go
517 lines
15 KiB
Go
package dnsfwd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"net"
|
|
"net/netip"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hashicorp/go-multierror"
|
|
"github.com/miekg/dns"
|
|
log "github.com/sirupsen/logrus"
|
|
"golang.zx2c4.com/wireguard/tun/netstack"
|
|
|
|
nberrors "github.com/netbirdio/netbird/client/errors"
|
|
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
|
"github.com/netbirdio/netbird/client/internal/dns/resutil"
|
|
"github.com/netbirdio/netbird/client/internal/peer"
|
|
"github.com/netbirdio/netbird/route"
|
|
)
|
|
|
|
const errResolveFailed = "failed to resolve query for domain=%s: %v"
|
|
const upstreamTimeout = 15 * time.Second
|
|
|
|
// EDE info codes the forwarder emits on upstream failures so the querying
|
|
// client can see the reason without inspecting this peer's logs. They live in
|
|
// the RFC 8914 Private Use range (49152-65535); the Go resolver never exposes a
|
|
// real upstream EDE here, so these cannot collide with a genuine code.
|
|
const (
|
|
edeNetbirdUpstreamTimeout uint16 = 49152
|
|
edeNetbirdUpstreamFailure uint16 = 49153
|
|
)
|
|
|
|
type resolver interface {
|
|
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
|
|
LookupMX(ctx context.Context, name string) ([]*net.MX, error)
|
|
LookupTXT(ctx context.Context, name string) ([]string, error)
|
|
LookupNS(ctx context.Context, name string) ([]*net.NS, error)
|
|
LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error)
|
|
LookupCNAME(ctx context.Context, host string) (string, error)
|
|
LookupAddr(ctx context.Context, addr string) ([]string, error)
|
|
}
|
|
|
|
type firewaller interface {
|
|
UpdateSet(set firewall.Set, prefixes []netip.Prefix) error
|
|
}
|
|
|
|
type DNSForwarder struct {
|
|
listenAddress netip.AddrPort
|
|
ttl uint32
|
|
statusRecorder *peer.Status
|
|
|
|
dnsServer *dns.Server
|
|
mux *dns.ServeMux
|
|
tcpServer *dns.Server
|
|
tcpMux *dns.ServeMux
|
|
|
|
mutex sync.RWMutex
|
|
fwdEntries []*ForwarderEntry
|
|
firewall firewaller
|
|
resolver resolver
|
|
cache *cache
|
|
|
|
wgIface wgIface
|
|
}
|
|
|
|
func NewDNSForwarder(listenAddress netip.AddrPort, ttl uint32, firewall firewaller, statusRecorder *peer.Status, wgIface wgIface) *DNSForwarder {
|
|
log.Debugf("creating DNS forwarder with listen_address=%s ttl=%d", listenAddress, ttl)
|
|
return &DNSForwarder{
|
|
listenAddress: listenAddress,
|
|
ttl: ttl,
|
|
firewall: firewall,
|
|
statusRecorder: statusRecorder,
|
|
resolver: net.DefaultResolver,
|
|
cache: newCache(),
|
|
wgIface: wgIface,
|
|
}
|
|
}
|
|
|
|
func (f *DNSForwarder) Listen(entries []*ForwarderEntry) error {
|
|
var netstackNet *netstack.Net
|
|
if f.wgIface != nil {
|
|
netstackNet = f.wgIface.GetNet()
|
|
}
|
|
|
|
addrDesc := f.listenAddress.String()
|
|
if netstackNet != nil {
|
|
addrDesc = fmt.Sprintf("netstack %s", f.listenAddress)
|
|
}
|
|
log.Infof("starting DNS forwarder on address=%s", addrDesc)
|
|
|
|
udpLn, err := f.createUDPListener(netstackNet)
|
|
if err != nil {
|
|
return fmt.Errorf("create UDP listener: %w", err)
|
|
}
|
|
|
|
tcpLn, err := f.createTCPListener(netstackNet)
|
|
if err != nil {
|
|
return fmt.Errorf("create TCP listener: %w", err)
|
|
}
|
|
|
|
mux := dns.NewServeMux()
|
|
f.mux = mux
|
|
mux.HandleFunc(".", f.handleDNSQueryUDP)
|
|
f.dnsServer = &dns.Server{
|
|
PacketConn: udpLn,
|
|
Handler: mux,
|
|
}
|
|
|
|
tcpMux := dns.NewServeMux()
|
|
f.tcpMux = tcpMux
|
|
tcpMux.HandleFunc(".", f.handleDNSQueryTCP)
|
|
f.tcpServer = &dns.Server{
|
|
Listener: tcpLn,
|
|
Handler: tcpMux,
|
|
}
|
|
|
|
f.UpdateDomains(entries)
|
|
|
|
errCh := make(chan error, 2)
|
|
|
|
go func() {
|
|
log.Infof("DNS UDP listener running on %s", addrDesc)
|
|
errCh <- f.dnsServer.ActivateAndServe()
|
|
}()
|
|
go func() {
|
|
log.Infof("DNS TCP listener running on %s", addrDesc)
|
|
errCh <- f.tcpServer.ActivateAndServe()
|
|
}()
|
|
|
|
return <-errCh
|
|
}
|
|
|
|
func (f *DNSForwarder) createUDPListener(netstackNet *netstack.Net) (net.PacketConn, error) {
|
|
if netstackNet != nil {
|
|
return netstackNet.ListenUDPAddrPort(f.listenAddress)
|
|
}
|
|
|
|
return net.ListenUDP("udp", net.UDPAddrFromAddrPort(f.listenAddress))
|
|
}
|
|
|
|
func (f *DNSForwarder) createTCPListener(netstackNet *netstack.Net) (net.Listener, error) {
|
|
if netstackNet != nil {
|
|
return netstackNet.ListenTCPAddrPort(f.listenAddress)
|
|
}
|
|
|
|
return net.ListenTCP("tcp", net.TCPAddrFromAddrPort(f.listenAddress))
|
|
}
|
|
|
|
func (f *DNSForwarder) UpdateDomains(entries []*ForwarderEntry) {
|
|
f.mutex.Lock()
|
|
defer f.mutex.Unlock()
|
|
|
|
// remove cache entries for domains that no longer appear
|
|
f.removeStaleCacheEntries(f.fwdEntries, entries)
|
|
|
|
f.fwdEntries = entries
|
|
log.Debugf("Updated DNS forwarder with %d domains", len(entries))
|
|
}
|
|
|
|
// removeStaleCacheEntries unsets cache items for domains that were present
|
|
// in the old list but not present in the new list.
|
|
func (f *DNSForwarder) removeStaleCacheEntries(oldEntries, newEntries []*ForwarderEntry) {
|
|
if f.cache == nil {
|
|
return
|
|
}
|
|
|
|
newSet := make(map[string]struct{}, len(newEntries))
|
|
for _, e := range newEntries {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
newSet[e.Domain.PunycodeString()] = struct{}{}
|
|
}
|
|
|
|
for _, e := range oldEntries {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
pattern := e.Domain.PunycodeString()
|
|
if _, ok := newSet[pattern]; !ok {
|
|
f.cache.unset(pattern)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (f *DNSForwarder) Close(ctx context.Context) error {
|
|
var result *multierror.Error
|
|
|
|
if f.dnsServer != nil {
|
|
if err := f.dnsServer.ShutdownContext(ctx); err != nil {
|
|
result = multierror.Append(result, fmt.Errorf("UDP shutdown: %w", err))
|
|
}
|
|
}
|
|
if f.tcpServer != nil {
|
|
if err := f.tcpServer.ShutdownContext(ctx); err != nil {
|
|
result = multierror.Append(result, fmt.Errorf("TCP shutdown: %w", err))
|
|
}
|
|
}
|
|
|
|
return nberrors.FormatErrorOrNil(result)
|
|
}
|
|
|
|
func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, query *dns.Msg, startTime time.Time) {
|
|
if len(query.Question) == 0 {
|
|
return
|
|
}
|
|
question := query.Question[0]
|
|
qname := strings.ToLower(question.Name)
|
|
|
|
logger.Tracef("question: domain=%s type=%s class=%s",
|
|
qname, dns.TypeToString[question.Qtype], dns.ClassToString[question.Qclass])
|
|
|
|
resp := query.SetReply(query)
|
|
|
|
mostSpecificResId, matchingEntries := f.getMatchingEntries(strings.TrimSuffix(qname, "."))
|
|
if mostSpecificResId == "" {
|
|
resp.Rcode = dns.RcodeRefused
|
|
f.writeResponse(logger, w, resp, qname, startTime)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), upstreamTimeout)
|
|
defer cancel()
|
|
|
|
reqHasEdns := query.IsEdns0() != nil
|
|
|
|
switch question.Qtype {
|
|
case dns.TypeA, dns.TypeAAAA:
|
|
f.handleAddressQuery(ctx, logger, w, resp, mostSpecificResId, matchingEntries, reqHasEdns, startTime)
|
|
case dns.TypeMX, dns.TypeTXT, dns.TypeNS, dns.TypeSRV, dns.TypeCNAME, dns.TypePTR:
|
|
f.handleRecordQuery(ctx, logger, w, resp, startTime)
|
|
default:
|
|
// The domain is routed here, so any other type is answered NODATA
|
|
// (NOERROR, empty answer) rather than falling back to a resolver that
|
|
// would poison the name with NXDOMAIN. The Extended DNS Error lets a
|
|
// client tell this capability-driven NODATA apart from an
|
|
// authoritative one. The OPT pseudo-record must not appear unless the
|
|
// query advertised EDNS0.
|
|
if reqHasEdns {
|
|
attachEDE(resp, dns.ExtendedErrorCodeNotSupported, "netbird forwarder: unsupported query type")
|
|
}
|
|
f.writeResponse(logger, w, resp, qname, startTime)
|
|
}
|
|
}
|
|
|
|
// handleAddressQuery resolves A/AAAA queries, programs the firewall sets and
|
|
// resolved-IP state, and caches the answer for resilience on upstream failure.
|
|
func (f *DNSForwarder) handleAddressQuery(
|
|
ctx context.Context,
|
|
logger *log.Entry,
|
|
w dns.ResponseWriter,
|
|
resp *dns.Msg,
|
|
mostSpecificResId route.ResID,
|
|
matchingEntries []*ForwarderEntry,
|
|
reqHasEdns bool,
|
|
startTime time.Time,
|
|
) {
|
|
question := resp.Question[0]
|
|
qname := strings.ToLower(question.Name)
|
|
|
|
network := resutil.NetworkForQtype(question.Qtype)
|
|
result := resutil.LookupIP(ctx, f.resolver, network, qname, question.Qtype)
|
|
if result.Err != nil {
|
|
f.handleDNSError(ctx, logger, w, question, resp, qname, result, reqHasEdns, startTime)
|
|
return
|
|
}
|
|
|
|
f.updateInternalState(result.IPs, mostSpecificResId, matchingEntries)
|
|
resp.Answer = append(resp.Answer, resutil.IPsToRRs(qname, result.IPs, f.ttl)...)
|
|
f.cache.set(qname, question.Qtype, result.IPs)
|
|
|
|
f.writeResponse(logger, w, resp, qname, startTime)
|
|
}
|
|
|
|
// handleRecordQuery resolves non-address record types (MX, TXT, NS, SRV,
|
|
// CNAME, PTR) through the host resolver. Missing records are answered NODATA so
|
|
// the routed name is never poisoned with NXDOMAIN.
|
|
func (f *DNSForwarder) handleRecordQuery(
|
|
ctx context.Context,
|
|
logger *log.Entry,
|
|
w dns.ResponseWriter,
|
|
resp *dns.Msg,
|
|
startTime time.Time,
|
|
) {
|
|
question := resp.Question[0]
|
|
qname := strings.ToLower(question.Name)
|
|
|
|
records, rcode := resutil.LookupRecords(ctx, f.resolver, qname, question.Qtype, f.ttl)
|
|
resp.Rcode = rcode
|
|
resp.Answer = append(resp.Answer, records...)
|
|
f.writeResponse(logger, w, resp, qname, startTime)
|
|
}
|
|
|
|
func (f *DNSForwarder) writeResponse(logger *log.Entry, w dns.ResponseWriter, resp *dns.Msg, qname string, startTime time.Time) {
|
|
if err := w.WriteMsg(resp); err != nil {
|
|
logger.Errorf("failed to write DNS response: %v", err)
|
|
return
|
|
}
|
|
|
|
logger.Tracef("response: domain=%s rcode=%s answers=%s size=%dB took=%s",
|
|
qname, dns.RcodeToString[resp.Rcode], resutil.FormatAnswers(resp.Answer), resp.Len(), time.Since(startTime))
|
|
}
|
|
|
|
// udpResponseWriter wraps a dns.ResponseWriter to handle UDP-specific truncation.
|
|
type udpResponseWriter struct {
|
|
dns.ResponseWriter
|
|
query *dns.Msg
|
|
}
|
|
|
|
func (u *udpResponseWriter) WriteMsg(resp *dns.Msg) error {
|
|
opt := u.query.IsEdns0()
|
|
maxSize := dns.MinMsgSize
|
|
if opt != nil {
|
|
maxSize = int(opt.UDPSize())
|
|
}
|
|
|
|
if resp.Len() > maxSize {
|
|
resp.Truncate(maxSize)
|
|
}
|
|
|
|
return u.ResponseWriter.WriteMsg(resp)
|
|
}
|
|
|
|
func (f *DNSForwarder) handleDNSQueryUDP(w dns.ResponseWriter, query *dns.Msg) {
|
|
startTime := time.Now()
|
|
fields := log.Fields{
|
|
"request_id": resutil.GenerateRequestID(),
|
|
"dns_id": fmt.Sprintf("%04x", query.Id),
|
|
}
|
|
if addr := w.RemoteAddr(); addr != nil {
|
|
fields["client"] = addr.String()
|
|
}
|
|
logger := log.WithFields(fields)
|
|
|
|
f.handleDNSQuery(logger, &udpResponseWriter{ResponseWriter: w, query: query}, query, startTime)
|
|
}
|
|
|
|
func (f *DNSForwarder) handleDNSQueryTCP(w dns.ResponseWriter, query *dns.Msg) {
|
|
startTime := time.Now()
|
|
fields := log.Fields{
|
|
"request_id": resutil.GenerateRequestID(),
|
|
"dns_id": fmt.Sprintf("%04x", query.Id),
|
|
}
|
|
if addr := w.RemoteAddr(); addr != nil {
|
|
fields["client"] = addr.String()
|
|
}
|
|
logger := log.WithFields(fields)
|
|
|
|
f.handleDNSQuery(logger, w, query, startTime)
|
|
}
|
|
|
|
func (f *DNSForwarder) updateInternalState(ips []netip.Addr, mostSpecificResId route.ResID, matchingEntries []*ForwarderEntry) {
|
|
var prefixes []netip.Prefix
|
|
if mostSpecificResId != "" {
|
|
for _, ip := range ips {
|
|
var prefix netip.Prefix
|
|
if ip.Is4() {
|
|
prefix = netip.PrefixFrom(ip, 32)
|
|
} else {
|
|
prefix = netip.PrefixFrom(ip, 128)
|
|
}
|
|
prefixes = append(prefixes, prefix)
|
|
f.statusRecorder.AddResolvedIPLookupEntry(prefix, mostSpecificResId)
|
|
}
|
|
}
|
|
|
|
if f.firewall != nil {
|
|
f.updateFirewall(matchingEntries, prefixes)
|
|
}
|
|
}
|
|
|
|
func (f *DNSForwarder) updateFirewall(matchingEntries []*ForwarderEntry, prefixes []netip.Prefix) {
|
|
var merr *multierror.Error
|
|
for _, entry := range matchingEntries {
|
|
if err := f.firewall.UpdateSet(entry.Set, prefixes); err != nil {
|
|
merr = multierror.Append(merr, fmt.Errorf("update set for domain=%s: %w", entry.Domain, err))
|
|
}
|
|
}
|
|
if merr != nil {
|
|
log.Errorf("failed to update firewall sets (%d/%d): %v",
|
|
len(merr.Errors),
|
|
len(matchingEntries),
|
|
nberrors.FormatErrorOrNil(merr))
|
|
}
|
|
}
|
|
|
|
// handleDNSError processes DNS lookup errors and sends an appropriate error response.
|
|
func (f *DNSForwarder) handleDNSError(
|
|
ctx context.Context,
|
|
logger *log.Entry,
|
|
w dns.ResponseWriter,
|
|
question dns.Question,
|
|
resp *dns.Msg,
|
|
domain string,
|
|
result resutil.LookupResult,
|
|
reqHasEdns bool,
|
|
startTime time.Time,
|
|
) {
|
|
qType := question.Qtype
|
|
qTypeName := dns.TypeToString[qType]
|
|
|
|
resp.Rcode = result.Rcode
|
|
|
|
// NotFound: cache negative result and respond
|
|
if result.Rcode == dns.RcodeNameError || result.Rcode == dns.RcodeSuccess {
|
|
f.cache.set(domain, question.Qtype, nil)
|
|
f.writeResponse(logger, w, resp, domain, startTime)
|
|
return
|
|
}
|
|
|
|
// Upstream failed but we might have a cached answer—serve it if present.
|
|
if ips, ok := f.cache.get(domain, qType); ok {
|
|
if len(ips) > 0 {
|
|
logger.Debugf("serving cached DNS response after upstream failure: domain=%s type=%s", domain, qTypeName)
|
|
resp.Answer = append(resp.Answer, resutil.IPsToRRs(domain, ips, f.ttl)...)
|
|
resp.Rcode = dns.RcodeSuccess
|
|
f.writeResponse(logger, w, resp, domain, startTime)
|
|
return
|
|
}
|
|
|
|
// Cached negative result - re-verify NXDOMAIN vs NODATA
|
|
verifyResult := resutil.LookupIP(ctx, f.resolver, resutil.NetworkForQtype(qType), domain, qType)
|
|
if verifyResult.Rcode == dns.RcodeNameError || verifyResult.Rcode == dns.RcodeSuccess {
|
|
resp.Rcode = verifyResult.Rcode
|
|
f.writeResponse(logger, w, resp, domain, startTime)
|
|
return
|
|
}
|
|
}
|
|
|
|
// No cache or verification failed. Log with or without the server field for more context.
|
|
var dnsErr *net.DNSError
|
|
if errors.As(result.Err, &dnsErr) && dnsErr.Server != "" {
|
|
logger.Warnf("upstream failure: type=%s domain=%s server=%s: %v", qTypeName, domain, dnsErr.Server, result.Err)
|
|
} else {
|
|
logger.Warnf(errResolveFailed, domain, result.Err)
|
|
}
|
|
|
|
if reqHasEdns {
|
|
attachEDE(resp, edeCodeFor(dnsErr), edeText(dnsErr))
|
|
}
|
|
|
|
f.writeResponse(logger, w, resp, domain, startTime)
|
|
}
|
|
|
|
// getMatchingEntries retrieves the resource IDs for a given domain.
|
|
// It returns the most specific match and all matching resource IDs.
|
|
func (f *DNSForwarder) getMatchingEntries(domain string) (route.ResID, []*ForwarderEntry) {
|
|
var selectedResId route.ResID
|
|
var bestScore int
|
|
var matches []*ForwarderEntry
|
|
|
|
f.mutex.RLock()
|
|
defer f.mutex.RUnlock()
|
|
|
|
for _, entry := range f.fwdEntries {
|
|
var score int
|
|
pattern := entry.Domain.PunycodeString()
|
|
|
|
switch {
|
|
case strings.HasPrefix(pattern, "*."):
|
|
baseDomain := strings.TrimPrefix(pattern, "*.")
|
|
|
|
if strings.EqualFold(domain, baseDomain) || strings.HasSuffix(domain, "."+baseDomain) {
|
|
score = len(baseDomain)
|
|
matches = append(matches, entry)
|
|
}
|
|
case domain == pattern:
|
|
score = math.MaxInt
|
|
matches = append(matches, entry)
|
|
default:
|
|
continue
|
|
}
|
|
|
|
if score > bestScore {
|
|
bestScore = score
|
|
selectedResId = entry.ResID
|
|
}
|
|
}
|
|
|
|
return selectedResId, matches
|
|
}
|
|
|
|
// edeCodeFor maps an upstream lookup error to the NetBird EDE info code.
|
|
func edeCodeFor(dnsErr *net.DNSError) uint16 {
|
|
if dnsErr != nil && dnsErr.IsTimeout {
|
|
return edeNetbirdUpstreamTimeout
|
|
}
|
|
return edeNetbirdUpstreamFailure
|
|
}
|
|
|
|
// edeText builds the EDE extra-text describing the class of upstream failure.
|
|
// It deliberately omits the upstream server address, which may be an internal
|
|
// resolver and is exposed to any client permitted to use the route; the full
|
|
// detail stays in the forwarder's local log.
|
|
func edeText(dnsErr *net.DNSError) string {
|
|
if dnsErr != nil && dnsErr.IsTimeout {
|
|
return "netbird forwarder: upstream timeout"
|
|
}
|
|
return "netbird forwarder: upstream failure"
|
|
}
|
|
|
|
// attachEDE adds an Extended DNS Error (RFC 8914) option to the response,
|
|
// creating the OPT pseudo-record if the response does not already carry one.
|
|
func attachEDE(resp *dns.Msg, code uint16, text string) {
|
|
opt := resp.IsEdns0()
|
|
if opt == nil {
|
|
resp.SetEdns0(dns.DefaultMsgSize, false)
|
|
opt = resp.IsEdns0()
|
|
}
|
|
opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: code, ExtraText: text})
|
|
}
|