Improve support for DNS-SD and fall back to cached data for non-ICANN queries

This commit is contained in:
Daniel
2022-05-24 11:21:11 +02:00
parent 49e79fe3fd
commit 9a89f65027
6 changed files with 211 additions and 28 deletions

View File

@@ -482,10 +482,7 @@ func checkDomainHeuristics(ctx context.Context, conn *network.Connection, p *pro
trimmedDomain := strings.TrimRight(conn.Entity.Domain, ".") trimmedDomain := strings.TrimRight(conn.Entity.Domain, ".")
etld1, err := publicsuffix.EffectiveTLDPlusOne(trimmedDomain) etld1, err := publicsuffix.EffectiveTLDPlusOne(trimmedDomain)
if err != nil { if err != nil {
// we don't apply any checks here and let the request through // Don't run the check if the domain is a TLD.
// because a malformed domain-name will likely be dropped by
// checks better suited for that.
log.Tracer(ctx).Warningf("filter: failed to get eTLD+1: %s", err)
return false return false
} }

View File

@@ -106,6 +106,9 @@ func handleRequest(ctx context.Context, w dns.ResponseWriter, request *dns.Msg)
return reply(nsutil.Refused("invalid domain")) return reply(nsutil.Refused("invalid domain"))
} }
// Get public suffix after validation.
q.InitPublicSuffixData()
// Check if query is failing. // Check if query is failing.
// Some software retries failing queries excessively. This might not be a // Some software retries failing queries excessively. This might not be a
// problem normally, but handling a request is pretty expensive for the // problem normally, but handling a request is pretty expensive for the
@@ -252,10 +255,13 @@ func handleRequest(ctx context.Context, w dns.ResponseWriter, request *dns.Msg)
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, resolver.ErrNotFound): case errors.Is(err, resolver.ErrNotFound):
tracer.Tracef("nameserver: %s", err) // Try alternatives domain names for unofficial domain spaces.
conn.Failed("domain does not exist", "") rrCache = checkAlternativeCaches(ctx, q)
return reply(nsutil.NxDomain("nxdomain: " + err.Error())) if rrCache == nil {
tracer.Tracef("nameserver: %s", err)
conn.Failed("domain does not exist", "")
return reply(nsutil.NxDomain("nxdomain: " + err.Error()))
}
case errors.Is(err, resolver.ErrBlocked): case errors.Is(err, resolver.ErrBlocked):
tracer.Tracef("nameserver: %s", err) tracer.Tracef("nameserver: %s", err)
conn.Block(err.Error(), "") conn.Block(err.Error(), "")
@@ -268,7 +274,7 @@ func handleRequest(ctx context.Context, w dns.ResponseWriter, request *dns.Msg)
case errors.Is(err, resolver.ErrOffline): case errors.Is(err, resolver.ErrOffline):
if rrCache == nil { if rrCache == nil {
log.Tracer(ctx).Debugf("nameserver: not resolving %s, device is offline", q.ID()) tracer.Debugf("nameserver: not resolving %s, device is offline", q.ID())
conn.Failed("not resolving, device is offline", "") conn.Failed("not resolving, device is offline", "")
return reply(nsutil.ServerFailure(err.Error())) return reply(nsutil.ServerFailure(err.Error()))
} }
@@ -290,8 +296,12 @@ func handleRequest(ctx context.Context, w dns.ResponseWriter, request *dns.Msg)
addFailingQuery(q, errors.New("emptry reply from resolver")) addFailingQuery(q, errors.New("emptry reply from resolver"))
return reply(nsutil.ServerFailure("internal error: empty reply")) return reply(nsutil.ServerFailure("internal error: empty reply"))
case rrCache.RCode == dns.RcodeNameError: case rrCache.RCode == dns.RcodeNameError:
// Return now if NXDomain. // Try alternatives domain names for unofficial domain spaces.
return reply(nsutil.NxDomain("no answer found (NXDomain)")) rrCache = checkAlternativeCaches(ctx, q)
if rrCache == nil {
// Return now if NXDomain.
return reply(nsutil.NxDomain("no answer found (NXDomain)"))
}
} }
// Check with firewall again after resolving. // Check with firewall again after resolving.
@@ -336,3 +346,52 @@ func handleRequest(ctx context.Context, w dns.ResponseWriter, request *dns.Msg)
) )
return reply(rrCache, conn, rrCache) return reply(rrCache, conn, rrCache)
} }
func checkAlternativeCaches(ctx context.Context, q *resolver.Query) *resolver.RRCache {
// Do not try alternatives when the query is in a public suffix.
// This also includes arpa. and local.
if q.ICANNSpace {
return nil
}
// Check if the env resolver has something.
pmEnvQ := &resolver.Query{
FQDN: q.FQDN + "local." + resolver.InternalSpecialUseDomain,
QType: q.QType,
}
rrCache, err := resolver.QueryPortmasterEnv(ctx, pmEnvQ)
if err == nil && rrCache != nil && rrCache.RCode == dns.RcodeSuccess {
makeAlternativeRecord(ctx, q, rrCache, pmEnvQ.FQDN)
return rrCache
}
// Check if we have anything in cache
localFQDN := q.FQDN + "local."
rrCache, err = resolver.GetRRCache(localFQDN, q.QType)
if err == nil && rrCache != nil && rrCache.RCode == dns.RcodeSuccess {
makeAlternativeRecord(ctx, q, rrCache, localFQDN)
return rrCache
}
return nil
}
func makeAlternativeRecord(ctx context.Context, q *resolver.Query, rrCache *resolver.RRCache, altName string) {
log.Tracer(ctx).Debugf("using %s to answer query", altName)
// Duplicate answers so they match the query.
copied := make([]dns.RR, 0, len(rrCache.Answer))
for _, answer := range rrCache.Answer {
if strings.ToLower(answer.Header().Name) == altName {
copiedAnswer := dns.Copy(answer)
copiedAnswer.Header().Name = q.FQDN
copied = append(copied, copiedAnswer)
}
}
if len(copied) > 0 {
rrCache.Answer = append(rrCache.Answer, copied...)
}
// Update the question.
rrCache.Domain = q.FQDN
}

View File

@@ -4,21 +4,38 @@ import (
"fmt" "fmt"
"net" "net"
"regexp" "regexp"
"strings"
"github.com/miekg/dns" "github.com/miekg/dns"
) )
var cleanDomainRegex = regexp.MustCompile( var (
`^` + // match beginning cleanDomainRegex = regexp.MustCompile(
`(` + // start subdomain group `^` + // match beginning
`(xn--)?` + // idn prefix `(` + // start subdomain group
`[a-z0-9_-]{1,63}` + // main chunk `(xn--)?` + // idn prefix
`\.` + // ending with a dot `[a-z0-9_-]{1,63}` + // main chunk
`)*` + // end subdomain group, allow any number of subdomains `\.` + // ending with a dot
`(xn--)?` + // TLD idn prefix `)*` + // end subdomain group, allow any number of subdomains
`[a-z0-9_-]{2,63}` + // TLD main chunk with at least two characters `(xn--)?` + // TLD idn prefix
`\.` + // ending with a dot `[a-z0-9_-]{2,63}` + // TLD main chunk with at least two characters
`$`, // match end `\.` + // ending with a dot
`$`, // match end
)
// dnsSDDomainRegex is a lot more lax to better suit the allowed characters in DNS-SD.
// Not all characters have been allowed - some special characters were
// removed to reduce the general attack surface.
dnsSDDomainRegex = regexp.MustCompile(
// Start of charset selection.
`^[` +
// Printable ASCII (character code 32-127), excluding some special characters.
` !#$%&()*+,\-\./0-9:;=?@A-Z[\\\]^_\a-z{|}~` +
// Only latin characters from extended ASCII (character code 128-255).
`ŠŒŽšœžŸ¡¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ` +
// End of charset selection.
`]*$`,
)
) )
// IsValidFqdn returns whether the given string is a valid fqdn. // IsValidFqdn returns whether the given string is a valid fqdn.
@@ -33,15 +50,18 @@ func IsValidFqdn(fqdn string) bool {
return false return false
} }
// check with regex // IsFqdn checks if a domain name is fully qualified.
if !cleanDomainRegex.MatchString(fqdn) { if !dns.IsFqdn(fqdn) {
return false return false
} }
// check with miegk/dns // Use special check for .local domains to support DNS-SD.
if strings.HasSuffix(fqdn, ".local.") {
return dnsSDDomainRegex.MatchString(fqdn)
}
// IsFqdn checks if a domain name is fully qualified. // check with regex
if !dns.IsFqdn(fqdn) { if !cleanDomainRegex.MatchString(fqdn) {
return false return false
} }

View File

@@ -5,10 +5,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"net" "net"
"strings"
"sync" "sync"
"time" "time"
"github.com/miekg/dns" "github.com/miekg/dns"
"golang.org/x/net/publicsuffix"
"github.com/safing/portbase/database" "github.com/safing/portbase/database"
"github.com/safing/portbase/log" "github.com/safing/portbase/log"
@@ -90,6 +92,11 @@ type Query struct {
IgnoreFailing bool IgnoreFailing bool
LocalResolversOnly bool LocalResolversOnly bool
// ICANNSpace signifies if the domain is within ICANN managed domain space.
ICANNSpace bool
// Domain root is the effective TLD +1.
DomainRoot string
// internal // internal
dotPrefixedFQDN string dotPrefixedFQDN string
} }
@@ -99,6 +106,41 @@ func (q *Query) ID() string {
return q.FQDN + q.QType.String() return q.FQDN + q.QType.String()
} }
// InitPublicSuffixData initializes the public suffix data.
func (q *Query) InitPublicSuffixData() {
// Get public suffix and derive if domain is in ICANN space.
suffix, icann := publicsuffix.PublicSuffix(strings.TrimSuffix(q.FQDN, "."))
if icann || strings.Contains(suffix, ".") {
q.ICANNSpace = true
}
// Override some cases.
switch suffix {
case "example":
q.ICANNSpace = true // Defined by ICANN.
case "invalid":
q.ICANNSpace = true // Defined by ICANN.
case "local":
q.ICANNSpace = true // Defined by ICANN.
case "localhost":
q.ICANNSpace = true // Defined by ICANN.
case "onion":
q.ICANNSpace = false // Defined by ICANN, but special.
case "test":
q.ICANNSpace = true // Defined by ICANN.
}
// Add suffix to adhere to FQDN format.
suffix += "."
switch {
case len(q.FQDN) == len(suffix):
// We are at or below the domain root, reset.
q.DomainRoot = ""
case len(q.FQDN) > len(suffix):
domainRootStart := strings.LastIndex(q.FQDN[:len(q.FQDN)-len(suffix)-1], ".") + 1
q.DomainRoot = q.FQDN[domainRootStart:]
}
}
// check runs sanity checks and does some initialization. Returns whether the query passed the basic checks. // check runs sanity checks and does some initialization. Returns whether the query passed the basic checks.
func (q *Query) check() (ok bool) { func (q *Query) check() (ok bool) {
if q.FQDN == "" { if q.FQDN == "" {

View File

@@ -128,7 +128,7 @@ func (er *envResolverConn) makeRRCache(q *Query, answers []dns.RR) *RRCache {
// Disable caching, as the env always has the raw data available. // Disable caching, as the env always has the raw data available.
q.NoCaching = true q.NoCaching = true
return &RRCache{ rrCache := &RRCache{
Domain: q.FQDN, Domain: q.FQDN,
Question: q.QType, Question: q.QType,
RCode: dns.RcodeSuccess, RCode: dns.RcodeSuccess,
@@ -136,6 +136,10 @@ func (er *envResolverConn) makeRRCache(q *Query, answers []dns.RR) *RRCache {
Extra: []dns.RR{internalSpecialUseComment}, // Always add comment about this TLD. Extra: []dns.RR{internalSpecialUseComment}, // Always add comment about this TLD.
Resolver: envResolver.Info.Copy(), Resolver: envResolver.Info.Copy(),
} }
if len(rrCache.Answer) == 0 {
rrCache.RCode = dns.RcodeNameError
}
return rrCache
} }
func (er *envResolverConn) ReportFailure() {} func (er *envResolverConn) ReportFailure() {}
@@ -145,3 +149,8 @@ func (er *envResolverConn) IsFailing() bool {
} }
func (er *envResolverConn) ResetFailure() {} func (er *envResolverConn) ResetFailure() {}
// QueryPortmasterEnv queries the environment resolver directly.
func QueryPortmasterEnv(ctx context.Context, q *Query) (*RRCache, error) {
return envResolver.Conn.Query(ctx, q)
}

View File

@@ -7,6 +7,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/miekg/dns" "github.com/miekg/dns"
"github.com/safing/portbase/log" "github.com/safing/portbase/log"
@@ -103,3 +105,57 @@ func TestBulkResolving(t *testing.T) {
t.Logf("total time taken: %s", time.Since(started)) t.Logf("total time taken: %s", time.Since(started))
} }
func TestPublicSuffix(t *testing.T) {
t.Parallel()
testSuffix(t, "co.uk.", "", true)
testSuffix(t, "amazon.co.uk.", "amazon.co.uk.", true)
testSuffix(t, "books.amazon.co.uk.", "amazon.co.uk.", true)
testSuffix(t, "www.books.amazon.co.uk.", "amazon.co.uk.", true)
testSuffix(t, "com.", "", true)
testSuffix(t, "amazon.com.", "amazon.com.", true)
testSuffix(t, "example0.debian.net.", "example0.debian.net.", true)
testSuffix(t, "example1.debian.org.", "debian.org.", true)
testSuffix(t, "golang.dev.", "golang.dev.", true)
testSuffix(t, "golang.net.", "golang.net.", true)
testSuffix(t, "play.golang.org.", "golang.org.", true)
testSuffix(t, "gophers.in.space.museum.", "in.space.museum.", true)
testSuffix(t, "0emm.com.", "0emm.com.", true)
testSuffix(t, "a.0emm.com.", "", true)
testSuffix(t, "b.c.d.0emm.com.", "c.d.0emm.com.", true)
testSuffix(t, "org.", "", true)
testSuffix(t, "foo.org.", "foo.org.", true)
testSuffix(t, "foo.co.uk.", "foo.co.uk.", true)
testSuffix(t, "foo.dyndns.org.", "foo.dyndns.org.", true)
testSuffix(t, "foo.blogspot.co.uk.", "foo.blogspot.co.uk.", true)
testSuffix(t, "there.is.no.such-tld.", "no.such-tld.", false)
testSuffix(t, "www.some.bit.", "some.bit.", false)
testSuffix(t, "cromulent.", "", false)
testSuffix(t, "arpa.", "", true)
testSuffix(t, "in-addr.arpa.", "", true)
testSuffix(t, "1.in-addr.arpa.", "1.in-addr.arpa.", true)
testSuffix(t, "ip6.arpa.", "", true)
testSuffix(t, "1.ip6.arpa.", "1.ip6.arpa.", true)
testSuffix(t, "www.some.arpa.", "some.arpa.", true)
testSuffix(t, "www.some.home.arpa.", "home.arpa.", true)
testSuffix(t, ".", "", false)
testSuffix(t, "", "", false)
// Test edge case domains.
testSuffix(t, "www.some.example.", "some.example.", true)
testSuffix(t, "www.some.invalid.", "some.invalid.", true)
testSuffix(t, "www.some.local.", "some.local.", true)
testSuffix(t, "www.some.localhost.", "some.localhost.", true)
testSuffix(t, "www.some.onion.", "some.onion.", false)
testSuffix(t, "www.some.test.", "some.test.", true)
}
func testSuffix(t *testing.T, fqdn, domainRoot string, icannSpace bool) {
t.Helper()
q := &Query{FQDN: fqdn}
q.InitPublicSuffixData()
assert.Equal(t, domainRoot, q.DomainRoot)
assert.Equal(t, icannSpace, q.ICANNSpace)
}