mirror of
https://github.com/0xERR0R/blocky.git
synced 2026-10-11 21:59:54 +00:00
feat(blocking): log the matched rule in the block reason (#2091)
This commit is contained in:
+17
-12
@@ -1,10 +1,5 @@
|
||||
package stringcache
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type ChainedGroupedCache struct {
|
||||
caches []GroupedStringCache
|
||||
}
|
||||
@@ -24,18 +19,28 @@ func (c *ChainedGroupedCache) ElementCount(group string) int {
|
||||
return sum
|
||||
}
|
||||
|
||||
func (c *ChainedGroupedCache) Contains(searchString string, groups []string) []string {
|
||||
groupMatchedMap := make(map[string]struct{}, len(groups))
|
||||
func (c *ChainedGroupedCache) Contains(searchString string, groups []string) map[string]string {
|
||||
// result is allocated lazily so the common no-match case stays allocation-free.
|
||||
// Ordering of matched groups is not defined here; callers that render the
|
||||
// result sort it (see resolver.formatBlockReason).
|
||||
//
|
||||
// If a group matches in more than one chained cache (e.g. an exact entry and
|
||||
// a wildcard), we keep a single rule per group: the last chained cache wins.
|
||||
// One representative rule per group is enough for the block reason, and the
|
||||
// chain order is fixed, so the choice is stable across requests.
|
||||
var result map[string]string
|
||||
|
||||
for _, cache := range c.caches {
|
||||
for _, group := range cache.Contains(searchString, groups) {
|
||||
groupMatchedMap[group] = struct{}{}
|
||||
for group, rule := range cache.Contains(searchString, groups) {
|
||||
if result == nil {
|
||||
result = make(map[string]string, len(groups))
|
||||
}
|
||||
|
||||
result[group] = rule
|
||||
}
|
||||
}
|
||||
|
||||
matchedGroups := slices.Sorted(maps.Keys(groupMatchedMap))
|
||||
|
||||
return matchedGroups
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *ChainedGroupedCache) Refresh(group string) GroupFactory {
|
||||
|
||||
+17
-9
@@ -56,10 +56,12 @@ var _ = Describe("Chained grouped cache", func() {
|
||||
Expect(cache.ElementCount("group1")).Should(BeNumerically("==", 2))
|
||||
})
|
||||
|
||||
It("should find strings", func() {
|
||||
It("should find strings and return the matched rule per group", func() {
|
||||
factory.Finish()
|
||||
Expect(cache.Contains("string1", []string{"group1"})).Should(ConsistOf("group1"))
|
||||
Expect(cache.Contains("string2", []string{"group1", "someOtherGroup"})).Should(ConsistOf("group1"))
|
||||
Expect(cache.Contains("string1", []string{"group1"})).
|
||||
Should(Equal(map[string]string{"group1": "string1"}))
|
||||
Expect(cache.Contains("string2", []string{"group1", "someOtherGroup"})).
|
||||
Should(Equal(map[string]string{"group1": "string2"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -86,9 +88,12 @@ var _ = Describe("Chained grouped cache", func() {
|
||||
It("should contain 4 elements in 2 groups", func() {
|
||||
Expect(cache.ElementCount("group1")).Should(BeNumerically("==", 2))
|
||||
Expect(cache.ElementCount("group2")).Should(BeNumerically("==", 2))
|
||||
Expect(cache.Contains("g1", []string{"group1", "group2"})).Should(ConsistOf("group1"))
|
||||
Expect(cache.Contains("g2", []string{"group1", "group2"})).Should(ConsistOf("group2"))
|
||||
Expect(cache.Contains("both", []string{"group1", "group2"})).Should(ConsistOf("group1", "group2"))
|
||||
Expect(cache.Contains("g1", []string{"group1", "group2"})).
|
||||
Should(Equal(map[string]string{"group1": "g1"}))
|
||||
Expect(cache.Contains("g2", []string{"group1", "group2"})).
|
||||
Should(Equal(map[string]string{"group2": "g2"}))
|
||||
Expect(cache.Contains("both", []string{"group1", "group2"})).
|
||||
Should(Equal(map[string]string{"group1": "both", "group2": "both"}))
|
||||
})
|
||||
|
||||
It("should replace group content on refresh", func() {
|
||||
@@ -99,9 +104,12 @@ var _ = Describe("Chained grouped cache", func() {
|
||||
Expect(cache.ElementCount("group1")).Should(BeNumerically("==", 1))
|
||||
Expect(cache.ElementCount("group2")).Should(BeNumerically("==", 2))
|
||||
Expect(cache.Contains("g1", []string{"group1", "group2"})).Should(BeEmpty())
|
||||
Expect(cache.Contains("newString", []string{"group1", "group2"})).Should(ConsistOf("group1"))
|
||||
Expect(cache.Contains("g2", []string{"group1", "group2"})).Should(ConsistOf("group2"))
|
||||
Expect(cache.Contains("both", []string{"group1", "group2"})).Should(ConsistOf("group2"))
|
||||
Expect(cache.Contains("newString", []string{"group1", "group2"})).
|
||||
Should(Equal(map[string]string{"group1": "newstring"})) // rule is normalized to lower-case
|
||||
Expect(cache.Contains("g2", []string{"group1", "group2"})).
|
||||
Should(Equal(map[string]string{"group2": "g2"}))
|
||||
Expect(cache.Contains("both", []string{"group1", "group2"})).
|
||||
Should(Equal(map[string]string{"group2": "both"}))
|
||||
})
|
||||
|
||||
It("should replace empty groups on refresh", func() {
|
||||
|
||||
+3
-2
@@ -2,8 +2,9 @@ package stringcache
|
||||
|
||||
type GroupedStringCache interface {
|
||||
// Contains checks if one or more groups in the cache contains the search string.
|
||||
// Returns group(s) containing the string or empty slice if string was not found
|
||||
Contains(searchString string, groups []string) []string
|
||||
// Returns a map of matching group -> the rule that matched, or nil/empty when
|
||||
// the string was not found.
|
||||
Contains(searchString string, groups []string) map[string]string
|
||||
|
||||
// Refresh creates new factory for the group to be refreshed.
|
||||
// Calling Finish on the factory will perform the group refresh.
|
||||
|
||||
+11
-4
@@ -43,16 +43,23 @@ func (c *InMemoryGroupedCache) ElementCount(group string) int {
|
||||
return cache.elementCount()
|
||||
}
|
||||
|
||||
func (c *InMemoryGroupedCache) Contains(searchString string, groups []string) []string {
|
||||
var result []string
|
||||
func (c *InMemoryGroupedCache) Contains(searchString string, groups []string) map[string]string {
|
||||
// result is allocated lazily so the common no-match case stays allocation-free.
|
||||
var result map[string]string
|
||||
|
||||
for _, group := range groups {
|
||||
c.lock.RLock()
|
||||
cache, found := c.caches[group]
|
||||
c.lock.RUnlock()
|
||||
|
||||
if found && cache.contains(searchString) {
|
||||
result = append(result, group)
|
||||
if found {
|
||||
if rule, ok := cache.findMatch(searchString); ok {
|
||||
if result == nil {
|
||||
result = make(map[string]string, len(groups))
|
||||
}
|
||||
|
||||
result[group] = rule
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-15
@@ -63,10 +63,12 @@ var _ = Describe("In-Memory grouped cache", func() {
|
||||
Expect(cache.ElementCount("group1")).Should(BeNumerically("==", 2))
|
||||
})
|
||||
|
||||
It("should find strings", func() {
|
||||
It("should find strings and return the matched rule per group", func() {
|
||||
factory.Finish()
|
||||
Expect(cache.Contains("string1", []string{"group1"})).Should(ConsistOf("group1"))
|
||||
Expect(cache.Contains("string2", []string{"group1", "someOtherGroup"})).Should(ConsistOf("group1"))
|
||||
Expect(cache.Contains("string1", []string{"group1"})).
|
||||
Should(Equal(map[string]string{"group1": "string1"}))
|
||||
Expect(cache.Contains("string2", []string{"group1", "someOtherGroup"})).
|
||||
Should(Equal(map[string]string{"group1": "string2"}))
|
||||
})
|
||||
})
|
||||
When("Regex grouped cache is used", func() {
|
||||
@@ -79,11 +81,13 @@ var _ = Describe("In-Memory grouped cache", func() {
|
||||
factory.Finish()
|
||||
})
|
||||
|
||||
It("should ignore non-regex", func() {
|
||||
It("should ignore non-regex and return the matching pattern", func() {
|
||||
Expect(cache.ElementCount("group1")).Should(BeNumerically("==", 1))
|
||||
Expect(cache.Contains("string1", []string{"group1"})).Should(BeEmpty())
|
||||
Expect(cache.Contains("string2", []string{"group1"})).Should(ConsistOf("group1"))
|
||||
Expect(cache.Contains("shouldalsomatchstring2", []string{"group1"})).Should(ConsistOf("group1"))
|
||||
Expect(cache.Contains("string2", []string{"group1"})).
|
||||
Should(Equal(map[string]string{"group1": "/string2/"}))
|
||||
Expect(cache.Contains("shouldalsomatchstring2", []string{"group1"})).
|
||||
Should(Equal(map[string]string{"group1": "/string2/"}))
|
||||
})
|
||||
})
|
||||
When("Wildcard grouped cache is used", func() {
|
||||
@@ -97,12 +101,14 @@ var _ = Describe("In-Memory grouped cache", func() {
|
||||
factory.Finish()
|
||||
})
|
||||
|
||||
It("should ignore non-wildcard", func() {
|
||||
It("should ignore non-wildcard and return the wildcard rule", func() {
|
||||
Expect(cache.ElementCount("group1")).Should(BeNumerically("==", 1))
|
||||
Expect(cache.Contains("string1", []string{"group1"})).Should(BeEmpty())
|
||||
Expect(cache.Contains("string2", []string{"group1"})).Should(BeEmpty())
|
||||
Expect(cache.Contains("string3", []string{"group1"})).Should(ConsistOf("group1"))
|
||||
Expect(cache.Contains("shouldalsomatch.string3", []string{"group1"})).Should(ConsistOf("group1"))
|
||||
Expect(cache.Contains("string3", []string{"group1"})).
|
||||
Should(Equal(map[string]string{"group1": "*.string3"}))
|
||||
Expect(cache.Contains("shouldalsomatch.string3", []string{"group1"})).
|
||||
Should(Equal(map[string]string{"group1": "*.string3"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -126,9 +132,12 @@ var _ = Describe("In-Memory grouped cache", func() {
|
||||
It("should contain 4 elements in 2 groups", func() {
|
||||
Expect(cache.ElementCount("group1")).Should(BeNumerically("==", 2))
|
||||
Expect(cache.ElementCount("group2")).Should(BeNumerically("==", 2))
|
||||
Expect(cache.Contains("g1", []string{"group1", "group2"})).Should(ConsistOf("group1"))
|
||||
Expect(cache.Contains("g2", []string{"group1", "group2"})).Should(ConsistOf("group2"))
|
||||
Expect(cache.Contains("both", []string{"group1", "group2"})).Should(ConsistOf("group1", "group2"))
|
||||
Expect(cache.Contains("g1", []string{"group1", "group2"})).
|
||||
Should(Equal(map[string]string{"group1": "g1"}))
|
||||
Expect(cache.Contains("g2", []string{"group1", "group2"})).
|
||||
Should(Equal(map[string]string{"group2": "g2"}))
|
||||
Expect(cache.Contains("both", []string{"group1", "group2"})).
|
||||
Should(Equal(map[string]string{"group1": "both", "group2": "both"}))
|
||||
})
|
||||
|
||||
It("Should replace group content on refresh", func() {
|
||||
@@ -139,9 +148,12 @@ var _ = Describe("In-Memory grouped cache", func() {
|
||||
Expect(cache.ElementCount("group1")).Should(BeNumerically("==", 1))
|
||||
Expect(cache.ElementCount("group2")).Should(BeNumerically("==", 2))
|
||||
Expect(cache.Contains("g1", []string{"group1", "group2"})).Should(BeEmpty())
|
||||
Expect(cache.Contains("newString", []string{"group1", "group2"})).Should(ConsistOf("group1"))
|
||||
Expect(cache.Contains("g2", []string{"group1", "group2"})).Should(ConsistOf("group2"))
|
||||
Expect(cache.Contains("both", []string{"group1", "group2"})).Should(ConsistOf("group2"))
|
||||
Expect(cache.Contains("newString", []string{"group1", "group2"})).
|
||||
Should(Equal(map[string]string{"group1": "newstring"})) // rule is normalized to lower-case
|
||||
Expect(cache.Contains("g2", []string{"group1", "group2"})).
|
||||
Should(Equal(map[string]string{"group2": "g2"}))
|
||||
Expect(cache.Contains("both", []string{"group1", "group2"})).
|
||||
Should(Equal(map[string]string{"group2": "both"}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Vendored
+28
-10
@@ -12,7 +12,9 @@ import (
|
||||
|
||||
type stringCache interface {
|
||||
elementCount() int
|
||||
contains(searchString string) bool
|
||||
// findMatch reports whether the cache matches searchString and, if so,
|
||||
// returns the rule that matched. rule is empty when ok is false.
|
||||
findMatch(searchString string) (rule string, ok bool)
|
||||
}
|
||||
|
||||
type cacheFactory interface {
|
||||
@@ -37,12 +39,12 @@ func (cache stringMap) elementCount() int {
|
||||
return count
|
||||
}
|
||||
|
||||
func (cache stringMap) contains(searchString string) bool {
|
||||
func (cache stringMap) findMatch(searchString string) (string, bool) {
|
||||
normalized := normalizeEntry(searchString)
|
||||
searchLen := len(normalized)
|
||||
|
||||
if searchLen == 0 {
|
||||
return false
|
||||
return "", false
|
||||
}
|
||||
|
||||
searchBucketLen := len(cache[searchLen]) / searchLen
|
||||
@@ -55,11 +57,11 @@ func (cache stringMap) contains(searchString string) bool {
|
||||
if blockRule == normalized {
|
||||
log.PrefixedLog("string_map").Debugf("block rule '%s' matched with '%s'", blockRule, searchString)
|
||||
|
||||
return true
|
||||
return blockRule, true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return "", false
|
||||
}
|
||||
|
||||
type stringCacheFactory struct {
|
||||
@@ -126,16 +128,18 @@ func (cache regexCache) elementCount() int {
|
||||
return len(cache)
|
||||
}
|
||||
|
||||
func (cache regexCache) contains(searchString string) bool {
|
||||
func (cache regexCache) findMatch(searchString string) (string, bool) {
|
||||
for _, regex := range cache {
|
||||
if regex.MatchString(searchString) {
|
||||
log.PrefixedLog("regex_cache").Debugf("regex '%s' matched with '%s'", regex, searchString)
|
||||
|
||||
return true
|
||||
// re-wrap in the '/.../' delimiters that addEntry strips on insertion
|
||||
// so the reported rule matches the entry as configured by the user.
|
||||
return "/" + regex.String() + "/", true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return "", false
|
||||
}
|
||||
|
||||
type regexCacheFactory struct {
|
||||
@@ -189,8 +193,22 @@ func (cache wildcardCache) elementCount() int {
|
||||
return cache.cnt
|
||||
}
|
||||
|
||||
func (cache wildcardCache) contains(domain string) bool {
|
||||
return cache.trie.HasParentOf(domain)
|
||||
func (cache wildcardCache) findMatch(domain string) (string, bool) {
|
||||
labels, ok := cache.trie.HasParentOf(domain)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// labels reconstruct the stored wildcard base (normalized, with the "*."
|
||||
// prefix stripped on insertion); re-prepend "*." so the reported rule
|
||||
// matches the entry as configured by the user. trie.JoinTLD pairs with the
|
||||
// trie.SplitTLD this cache is built with, so the separator stays the trie's
|
||||
// concern rather than being hard-coded here.
|
||||
rule := "*." + trie.JoinTLD(labels)
|
||||
|
||||
log.PrefixedLog("wildcard_cache").Debugf("wildcard block rule '%s' matched with '%s'", rule, domain)
|
||||
|
||||
return rule, true
|
||||
}
|
||||
|
||||
type wildcardCacheFactory struct {
|
||||
|
||||
+1
-1
@@ -224,7 +224,7 @@ func benchmarkCache(b *testing.B, data []string, newFactory func() cacheFactory)
|
||||
// - wildcards and regexes need a plain string query
|
||||
// - all benchmarks will do the same number of queries
|
||||
for _, s := range stringTestData {
|
||||
if !cache.contains(s) {
|
||||
if _, ok := cache.findMatch(s); !ok {
|
||||
b.Fatalf("cache is missing value from stringTestData: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
+98
-39
@@ -38,17 +38,33 @@ var _ = Describe("Caches", func() {
|
||||
})
|
||||
|
||||
It("should match if StringCache contains exact string", func() {
|
||||
Expect(cache.contains("apple.com")).Should(BeTrue())
|
||||
Expect(cache.contains("google.com")).Should(BeTrue())
|
||||
Expect(cache.contains("www.google.com")).Should(BeFalse())
|
||||
Expect(cache.contains("")).Should(BeFalse())
|
||||
rule, ok := cache.findMatch("apple.com")
|
||||
Expect(ok).Should(BeTrue())
|
||||
Expect(rule).Should(Equal("apple.com"))
|
||||
|
||||
rule, ok = cache.findMatch("google.com")
|
||||
Expect(ok).Should(BeTrue())
|
||||
Expect(rule).Should(Equal("google.com"))
|
||||
|
||||
_, ok = cache.findMatch("www.google.com")
|
||||
Expect(ok).Should(BeFalse())
|
||||
_, ok = cache.findMatch("")
|
||||
Expect(ok).Should(BeFalse())
|
||||
})
|
||||
|
||||
It("should match case-insensitive", func() {
|
||||
Expect(cache.contains("aPPle.com")).Should(BeTrue())
|
||||
Expect(cache.contains("google.COM")).Should(BeTrue())
|
||||
Expect(cache.contains("www.google.com")).Should(BeFalse())
|
||||
Expect(cache.contains("")).Should(BeFalse())
|
||||
It("should match case-insensitive and return the normalized rule", func() {
|
||||
rule, ok := cache.findMatch("aPPle.com")
|
||||
Expect(ok).Should(BeTrue())
|
||||
Expect(rule).Should(Equal("apple.com"))
|
||||
|
||||
rule, ok = cache.findMatch("google.COM")
|
||||
Expect(ok).Should(BeTrue())
|
||||
Expect(rule).Should(Equal("google.com"))
|
||||
|
||||
_, ok = cache.findMatch("www.google.com")
|
||||
Expect(ok).Should(BeFalse())
|
||||
_, ok = cache.findMatch("")
|
||||
Expect(ok).Should(BeFalse())
|
||||
})
|
||||
|
||||
It("should return correct element count", func() {
|
||||
@@ -77,7 +93,9 @@ var _ = Describe("Caches", func() {
|
||||
|
||||
It("finds every entry regardless of insertion order", func() {
|
||||
for _, e := range entries {
|
||||
Expect(cache.contains(e)).Should(BeTrue(), e)
|
||||
rule, ok := cache.findMatch(e)
|
||||
Expect(ok).Should(BeTrue(), e)
|
||||
Expect(rule).Should(Equal(e), e)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -117,19 +135,39 @@ var _ = Describe("Caches", func() {
|
||||
cache = factory.create()
|
||||
})
|
||||
|
||||
It("should match if one regex in StringCache matches string", func() {
|
||||
Expect(cache.contains("google.com")).Should(BeTrue())
|
||||
Expect(cache.contains("google.coma")).Should(BeTrue())
|
||||
Expect(cache.contains("agoogle.com")).Should(BeTrue())
|
||||
Expect(cache.contains("www.google.com")).Should(BeTrue())
|
||||
Expect(cache.contains("apple.com")).Should(BeTrue())
|
||||
Expect(cache.contains("apple.de")).Should(BeTrue())
|
||||
Expect(cache.contains("apple.it")).Should(BeFalse())
|
||||
Expect(cache.contains("www.apple.com")).Should(BeFalse())
|
||||
Expect(cache.contains("applecom")).Should(BeFalse())
|
||||
Expect(cache.contains("www.amazon.com")).Should(BeTrue())
|
||||
Expect(cache.contains("amazon.com")).Should(BeTrue())
|
||||
Expect(cache.contains("myamazon.com")).Should(BeTrue())
|
||||
It("should match if one regex in StringCache matches string and return the pattern", func() {
|
||||
rule, ok := cache.findMatch("google.com")
|
||||
Expect(ok).Should(BeTrue())
|
||||
Expect(rule).Should(Equal("/.*google.com/"))
|
||||
|
||||
_, ok = cache.findMatch("google.coma")
|
||||
Expect(ok).Should(BeTrue())
|
||||
_, ok = cache.findMatch("agoogle.com")
|
||||
Expect(ok).Should(BeTrue())
|
||||
_, ok = cache.findMatch("www.google.com")
|
||||
Expect(ok).Should(BeTrue())
|
||||
|
||||
rule, ok = cache.findMatch("apple.com")
|
||||
Expect(ok).Should(BeTrue())
|
||||
Expect(rule).Should(Equal("/^apple\\.(de|com)$/"))
|
||||
|
||||
_, ok = cache.findMatch("apple.de")
|
||||
Expect(ok).Should(BeTrue())
|
||||
_, ok = cache.findMatch("apple.it")
|
||||
Expect(ok).Should(BeFalse())
|
||||
_, ok = cache.findMatch("www.apple.com")
|
||||
Expect(ok).Should(BeFalse())
|
||||
_, ok = cache.findMatch("applecom")
|
||||
Expect(ok).Should(BeFalse())
|
||||
|
||||
rule, ok = cache.findMatch("www.amazon.com")
|
||||
Expect(ok).Should(BeTrue())
|
||||
Expect(rule).Should(Equal("/amazon/"))
|
||||
|
||||
_, ok = cache.findMatch("amazon.com")
|
||||
Expect(ok).Should(BeTrue())
|
||||
_, ok = cache.findMatch("myamazon.com")
|
||||
Expect(ok).Should(BeTrue())
|
||||
})
|
||||
|
||||
It("should return correct element count", func() {
|
||||
@@ -168,30 +206,51 @@ var _ = Describe("Caches", func() {
|
||||
cache = factory.create()
|
||||
})
|
||||
|
||||
It("should match if one regex in StringCache matches string", func() {
|
||||
It("should match and return the wildcard rule including the '*.' prefix", func() {
|
||||
// first entry
|
||||
Expect(cache.contains("example.com")).Should(BeTrue())
|
||||
Expect(cache.contains("www.example.com")).Should(BeTrue())
|
||||
rule, ok := cache.findMatch("example.com")
|
||||
Expect(ok).Should(BeTrue())
|
||||
Expect(rule).Should(Equal("*.example.com"))
|
||||
|
||||
rule, ok = cache.findMatch("www.example.com")
|
||||
Expect(ok).Should(BeTrue())
|
||||
Expect(rule).Should(Equal("*.example.com"))
|
||||
|
||||
// look alikes
|
||||
Expect(cache.contains("com")).Should(BeFalse())
|
||||
Expect(cache.contains("example.coma")).Should(BeFalse())
|
||||
Expect(cache.contains("an-example.com")).Should(BeFalse())
|
||||
Expect(cache.contains("examplecom")).Should(BeFalse())
|
||||
_, ok = cache.findMatch("com")
|
||||
Expect(ok).Should(BeFalse())
|
||||
_, ok = cache.findMatch("example.coma")
|
||||
Expect(ok).Should(BeFalse())
|
||||
_, ok = cache.findMatch("an-example.com")
|
||||
Expect(ok).Should(BeFalse())
|
||||
_, ok = cache.findMatch("examplecom")
|
||||
Expect(ok).Should(BeFalse())
|
||||
|
||||
// other entry
|
||||
Expect(cache.contains("example.org")).Should(BeTrue())
|
||||
Expect(cache.contains("www.example.org")).Should(BeTrue())
|
||||
rule, ok = cache.findMatch("example.org")
|
||||
Expect(ok).Should(BeTrue())
|
||||
Expect(rule).Should(Equal("*.example.org"))
|
||||
|
||||
_, ok = cache.findMatch("www.example.org")
|
||||
Expect(ok).Should(BeTrue())
|
||||
|
||||
// unrelated
|
||||
Expect(cache.contains("example.net")).Should(BeFalse())
|
||||
Expect(cache.contains("www.example.net")).Should(BeFalse())
|
||||
_, ok = cache.findMatch("example.net")
|
||||
Expect(ok).Should(BeFalse())
|
||||
_, ok = cache.findMatch("www.example.net")
|
||||
Expect(ok).Should(BeFalse())
|
||||
|
||||
// third entry
|
||||
Expect(cache.contains("blocked")).Should(BeTrue())
|
||||
Expect(cache.contains("sub.blocked")).Should(BeTrue())
|
||||
Expect(cache.contains("sub.sub.blocked")).Should(BeTrue())
|
||||
Expect(cache.contains("example.blocked")).Should(BeTrue())
|
||||
// third entry (single label)
|
||||
rule, ok = cache.findMatch("blocked")
|
||||
Expect(ok).Should(BeTrue())
|
||||
Expect(rule).Should(Equal("*.blocked"))
|
||||
|
||||
_, ok = cache.findMatch("sub.blocked")
|
||||
Expect(ok).Should(BeTrue())
|
||||
_, ok = cache.findMatch("sub.sub.blocked")
|
||||
Expect(ok).Should(BeTrue())
|
||||
_, ok = cache.findMatch("example.blocked")
|
||||
Expect(ok).Should(BeTrue())
|
||||
})
|
||||
|
||||
It("should return correct element count", func() {
|
||||
|
||||
@@ -1015,7 +1015,9 @@ You can choose which information from processed DNS request and response should
|
||||
|
||||
- `clientIP`: origin IP address from the request
|
||||
- `clientName`: resolved client name(s) from the origins request
|
||||
- `responseReason`: reason for the response (e.g. from which upstream resolver), response type and code
|
||||
- `responseReason`: reason for the response (e.g. from which upstream resolver), response type and code. For blocked
|
||||
queries the reason also names the matched rule per group, e.g. `BLOCKED (ads: *.docler.com)`, so you can tell which
|
||||
denylist entry caused the block
|
||||
- `responseAnswer`: returned DNS answer
|
||||
- `question`: DNS question from the request
|
||||
- `duration`: request processing time in milliseconds
|
||||
@@ -1128,6 +1130,10 @@ Configuration parameters:
|
||||
|
||||
DNS responses can be extended with EDE codes according to [RFC8914](https://datatracker.ietf.org/doc/rfc8914/).
|
||||
|
||||
For blocked queries the EDE extra text carries the same reason as the query log, including the matched group and rule
|
||||
(e.g. `BLOCKED CNAME (ads: *.docler.com)`), so a client that requests EDE information learns which denylist entry caused
|
||||
the block.
|
||||
|
||||
Configuration parameters:
|
||||
|
||||
| Parameter | Type | Mandatory | Default value | Description |
|
||||
|
||||
+8
-4
@@ -36,8 +36,10 @@ type ListCacheType int
|
||||
|
||||
// Matcher checks if a domain is in a list
|
||||
type Matcher interface {
|
||||
// Match matches passed domain name against cached list entries
|
||||
Match(domain string, groupsToCheck []string) (groups []string)
|
||||
// Match matches passed domain name against cached list entries.
|
||||
// Returns a map of matching group -> the rule that matched, or nil/empty
|
||||
// when the domain is not in any of the groups.
|
||||
Match(domain string, groupsToCheck []string) (matches map[string]string)
|
||||
}
|
||||
|
||||
// ListCache generic cache of strings divided in groups
|
||||
@@ -112,8 +114,10 @@ func logger() *logrus.Entry {
|
||||
return log.PrefixedLog("list_cache")
|
||||
}
|
||||
|
||||
// Match matches passed domain name against cached list entries
|
||||
func (b *ListCache) Match(domain string, groupsToCheck []string) (groups []string) {
|
||||
// Match matches passed domain name against cached list entries.
|
||||
// Returns a map of matching group -> the rule that matched, or nil/empty when
|
||||
// the domain is not in any of the groups.
|
||||
func (b *ListCache) Match(domain string, groupsToCheck []string) (matches map[string]string) {
|
||||
return b.groupedCache.Contains(domain, groupsToCheck)
|
||||
}
|
||||
|
||||
|
||||
+30
-30
@@ -125,7 +125,7 @@ var _ = Describe("ListCache", func() {
|
||||
|
||||
It("should delete existing elements from group cache", func(ctx context.Context) {
|
||||
group := sut.Match("blocked1.com", []string{"gr1"})
|
||||
Expect(group).Should(ContainElement("gr1"))
|
||||
Expect(group).Should(HaveKey("gr1"))
|
||||
|
||||
err := sut.refresh(ctx)
|
||||
Expect(err).Should(Succeed())
|
||||
@@ -149,10 +149,10 @@ var _ = Describe("ListCache", func() {
|
||||
|
||||
It("should still other domains", func() {
|
||||
group := sut.Match("inlinedomain1.com", []string{"gr1"})
|
||||
Expect(group).Should(ContainElement("gr1"))
|
||||
Expect(group).Should(HaveKey("gr1"))
|
||||
|
||||
group = sut.Match("inlinedomain2.com", []string{"gr1"})
|
||||
Expect(group).Should(ContainElement("gr1"))
|
||||
Expect(group).Should(HaveKey("gr1"))
|
||||
})
|
||||
})
|
||||
When("a temporary/transient err occurs on download", func() {
|
||||
@@ -178,7 +178,7 @@ var _ = Describe("ListCache", func() {
|
||||
By("Lists loaded without timeout", func() {
|
||||
Eventually(func(g Gomega) {
|
||||
group := sut.Match("blocked1.com", []string{"gr1"})
|
||||
g.Expect(group).Should(ContainElement("gr1"))
|
||||
g.Expect(group).Should(HaveKey("gr1"))
|
||||
}, "1s").Should(Succeed())
|
||||
})
|
||||
|
||||
@@ -186,14 +186,14 @@ var _ = Describe("ListCache", func() {
|
||||
|
||||
By("List couldn't be loaded due to timeout", func() {
|
||||
group := sut.Match("blocked1.com", []string{"gr1"})
|
||||
Expect(group).Should(ContainElement("gr1"))
|
||||
Expect(group).Should(HaveKey("gr1"))
|
||||
})
|
||||
|
||||
_ = sut.Refresh(ctx)
|
||||
|
||||
By("List couldn't be loaded due to timeout", func() {
|
||||
group := sut.Match("blocked1.com", []string{"gr1"})
|
||||
Expect(group).Should(ContainElement("gr1"))
|
||||
Expect(group).Should(HaveKey("gr1"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -216,14 +216,14 @@ var _ = Describe("ListCache", func() {
|
||||
It("should keep existing elements from group cache", func(ctx context.Context) {
|
||||
By("Lists loaded without err", func() {
|
||||
group := sut.Match("blocked1.com", []string{"gr1"})
|
||||
Expect(group).Should(ContainElement("gr1"))
|
||||
Expect(group).Should(HaveKey("gr1"))
|
||||
})
|
||||
|
||||
Expect(sut.refresh(ctx)).Should(HaveOccurred())
|
||||
|
||||
By("Lists from first load is kept", func() {
|
||||
group := sut.Match("blocked1.com", []string{"gr1"})
|
||||
Expect(group).Should(ContainElement("gr1"))
|
||||
Expect(group).Should(HaveKey("gr1"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -237,13 +237,13 @@ var _ = Describe("ListCache", func() {
|
||||
|
||||
It("should download the list and match against", func() {
|
||||
group := sut.Match("blocked1.com", []string{"gr1", "gr2"})
|
||||
Expect(group).Should(ContainElement("gr1"))
|
||||
Expect(group).Should(HaveKey("gr1"))
|
||||
|
||||
group = sut.Match("blocked1a.com", []string{"gr1", "gr2"})
|
||||
Expect(group).Should(ContainElement("gr1"))
|
||||
Expect(group).Should(HaveKey("gr1"))
|
||||
|
||||
group = sut.Match("blocked1a.com", []string{"gr2"})
|
||||
Expect(group).Should(ContainElement("gr2"))
|
||||
Expect(group).Should(HaveKey("gr2"))
|
||||
})
|
||||
})
|
||||
When("Configuration has some faulty urls", func() {
|
||||
@@ -256,13 +256,13 @@ var _ = Describe("ListCache", func() {
|
||||
|
||||
It("should download the list and match against", func() {
|
||||
group := sut.Match("blocked1.com", []string{"gr1", "gr2"})
|
||||
Expect(group).Should(ContainElement("gr1"))
|
||||
Expect(group).Should(HaveKey("gr1"))
|
||||
|
||||
group = sut.Match("blocked1a.com", []string{"gr1", "gr2"})
|
||||
Expect(group).Should(ContainElements("gr1", "gr2"))
|
||||
Expect(group).Should(SatisfyAll(HaveKey("gr1"), HaveKey("gr2")))
|
||||
|
||||
group = sut.Match("blocked1a.com", []string{"gr2"})
|
||||
Expect(group).Should(ContainElement("gr2"))
|
||||
Expect(group).Should(HaveKey("gr2"))
|
||||
})
|
||||
})
|
||||
When("List will be updated", func() {
|
||||
@@ -297,13 +297,13 @@ var _ = Describe("ListCache", func() {
|
||||
Expect(sut.groupedCache.ElementCount("gr2")).Should(Equal(2))
|
||||
|
||||
group := sut.Match("blocked1.com", []string{"gr1", "gr2"})
|
||||
Expect(group).Should(ContainElement("gr1"))
|
||||
Expect(group).Should(HaveKey("gr1"))
|
||||
|
||||
group = sut.Match("blocked1a.com", []string{"gr1", "gr2"})
|
||||
Expect(group).Should(ContainElement("gr1"))
|
||||
Expect(group).Should(HaveKey("gr1"))
|
||||
|
||||
group = sut.Match("blocked1a.com", []string{"gr2"})
|
||||
Expect(group).Should(ContainElement("gr2"))
|
||||
Expect(group).Should(HaveKey("gr2"))
|
||||
})
|
||||
})
|
||||
When("group with bigger files", func() {
|
||||
@@ -340,10 +340,10 @@ var _ = Describe("ListCache", func() {
|
||||
It("should match", func() {
|
||||
Expect(sut.groupedCache.ElementCount("gr1")).Should(Equal(2))
|
||||
group := sut.Match("inlinedomain1.com", []string{"gr1"})
|
||||
Expect(group).Should(ContainElement("gr1"))
|
||||
Expect(group).Should(HaveKey("gr1"))
|
||||
|
||||
group = sut.Match("inlinedomain2.com", []string{"gr1"})
|
||||
Expect(group).Should(ContainElement("gr1"))
|
||||
Expect(group).Should(HaveKey("gr1"))
|
||||
})
|
||||
})
|
||||
When("Text file can't be parsed", func() {
|
||||
@@ -360,7 +360,7 @@ var _ = Describe("ListCache", func() {
|
||||
|
||||
It("should still match already imported strings", func() {
|
||||
group := sut.Match("inlinedomain1.com", []string{"gr1"})
|
||||
Expect(group).Should(ContainElement("gr1"))
|
||||
Expect(group).Should(HaveKey("gr1"))
|
||||
})
|
||||
})
|
||||
When("Text file has too many errors", func() {
|
||||
@@ -387,7 +387,7 @@ var _ = Describe("ListCache", func() {
|
||||
|
||||
It("should still parse the domain", func() {
|
||||
group := sut.Match("inlinedomain1.com", []string{"gr1"})
|
||||
Expect(group).Should(ContainElement("gr1"))
|
||||
Expect(group).Should(HaveKey("gr1"))
|
||||
})
|
||||
})
|
||||
When("inline regex content is defined", func() {
|
||||
@@ -399,10 +399,10 @@ var _ = Describe("ListCache", func() {
|
||||
|
||||
It("should match", func() {
|
||||
group := sut.Match("apple.com", []string{"gr1"})
|
||||
Expect(group).Should(ContainElement("gr1"))
|
||||
Expect(group).Should(HaveKey("gr1"))
|
||||
|
||||
group = sut.Match("apple.de", []string{"gr1"})
|
||||
Expect(group).Should(ContainElement("gr1"))
|
||||
Expect(group).Should(HaveKey("gr1"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -483,10 +483,10 @@ var _ = Describe("ListCache", func() {
|
||||
|
||||
// Verify the successful source's entries are available
|
||||
group := sut.Match("blocked1.com", []string{"gr1"})
|
||||
Expect(group).Should(ContainElement("gr1"), "entries from successful source should be available")
|
||||
Expect(group).Should(HaveKey("gr1"), "entries from successful source should be available")
|
||||
|
||||
group = sut.Match("blocked2.com", []string{"gr1"})
|
||||
Expect(group).Should(ContainElement("gr1"), "entries from successful source should be available")
|
||||
Expect(group).Should(HaveKey("gr1"), "entries from successful source should be available")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -515,7 +515,7 @@ var _ = Describe("ListCache", func() {
|
||||
Expect(sut).ShouldNot(BeNil())
|
||||
|
||||
group := sut.Match("blocked1.com", []string{"gr1"})
|
||||
Expect(group).Should(ContainElement("gr1"))
|
||||
Expect(group).Should(HaveKey("gr1"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -545,7 +545,7 @@ var _ = Describe("ListCache.seedFromDisk", func() {
|
||||
|
||||
sut.seedFromDisk(ctx)
|
||||
|
||||
Expect(sut.Match("seeded.com", []string{"ads"})).Should(ContainElement("ads"))
|
||||
Expect(sut.Match("seeded.com", []string{"ads"})).Should(HaveKey("ads"))
|
||||
})
|
||||
|
||||
It("also seeds inline and file sources that share a group with an HTTP source", func(ctx context.Context) {
|
||||
@@ -578,9 +578,9 @@ var _ = Describe("ListCache.seedFromDisk", func() {
|
||||
|
||||
sut.seedFromDisk(ctx)
|
||||
|
||||
Expect(sut.Match("http-seeded.com", []string{"ads"})).Should(ContainElement("ads"))
|
||||
Expect(sut.Match("file-seeded.com", []string{"ads"})).Should(ContainElement("ads"))
|
||||
Expect(sut.Match("inline-seeded.com", []string{"ads"})).Should(ContainElement("ads"))
|
||||
Expect(sut.Match("http-seeded.com", []string{"ads"})).Should(HaveKey("ads"))
|
||||
Expect(sut.Match("file-seeded.com", []string{"ads"})).Should(HaveKey("ads"))
|
||||
Expect(sut.Match("inline-seeded.com", []string{"ads"})).Should(HaveKey("ads"))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -392,8 +392,8 @@ func (r *BlockingResolver) handleDenylist(ctx context.Context, groupsToCheck []s
|
||||
domain := util.ExtractDomain(question)
|
||||
logger := logger.WithField(logFieldDomain, domain)
|
||||
|
||||
if groups := r.matches(groupsToCheck, r.allowlistMatcher, domain); len(groups) > 0 {
|
||||
logger.WithField("groups", groups).Debugf("domain is allowlisted")
|
||||
if matches := r.matches(groupsToCheck, r.allowlistMatcher, domain); len(matches) > 0 {
|
||||
logger.WithField("matches", matches).Debug("domain is allowlisted")
|
||||
|
||||
resp, err := r.next.Resolve(ctx, request)
|
||||
if err != nil {
|
||||
@@ -412,8 +412,8 @@ func (r *BlockingResolver) handleDenylist(ctx context.Context, groupsToCheck []s
|
||||
return true, resp, err
|
||||
}
|
||||
|
||||
if groups := r.matches(groupsToCheck, r.denylistMatcher, domain); len(groups) > 0 {
|
||||
resp, err := r.handleBlocked(logger, request, question, fmt.Sprintf("BLOCKED (%s)", strings.Join(groups, ",")))
|
||||
if matches := r.matches(groupsToCheck, r.denylistMatcher, domain); len(matches) > 0 {
|
||||
resp, err := r.handleBlocked(logger, request, question, formatBlockReason(matches, ""))
|
||||
|
||||
return true, resp, err
|
||||
}
|
||||
@@ -442,11 +442,10 @@ func (r *BlockingResolver) Resolve(ctx context.Context, request *model.Request)
|
||||
if len(entryToCheck) > 0 {
|
||||
logger := logger.WithField("response_entry", entryToCheck)
|
||||
|
||||
if groups := r.matches(groupsToCheck, r.allowlistMatcher, entryToCheck); len(groups) > 0 {
|
||||
logger.WithField("groups", groups).Debugf("%s is allowlisted", tName)
|
||||
} else if groups := r.matches(groupsToCheck, r.denylistMatcher, entryToCheck); len(groups) > 0 {
|
||||
return r.handleBlocked(logger, request, request.Req.Question[0], fmt.Sprintf("BLOCKED %s (%s)", tName,
|
||||
strings.Join(groups, ",")))
|
||||
if matches := r.matches(groupsToCheck, r.allowlistMatcher, entryToCheck); len(matches) > 0 {
|
||||
logger.WithField("matches", matches).Debugf("%s is allowlisted", tName)
|
||||
} else if matches := r.matches(groupsToCheck, r.denylistMatcher, entryToCheck); len(matches) > 0 {
|
||||
return r.handleBlocked(logger, request, request.Req.Question[0], formatBlockReason(matches, tName))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -564,10 +563,31 @@ func (r *BlockingResolver) collectGroupsForClient(request *model.Request) []sche
|
||||
|
||||
func (r *BlockingResolver) matches(groupsToCheck []string, m lists.Matcher,
|
||||
domain string,
|
||||
) []string {
|
||||
) map[string]string {
|
||||
return m.Match(domain, groupsToCheck)
|
||||
}
|
||||
|
||||
// formatBlockReason renders a block reason of the form
|
||||
// "BLOCKED[ TYPE] (group: rule[, group: rule...])". Matched groups are sorted
|
||||
// so the rendered reason is deterministic when more than one group matches.
|
||||
func formatBlockReason(matches map[string]string, typeName string) string {
|
||||
reason := "BLOCKED"
|
||||
if typeName != "" {
|
||||
reason += " " + typeName
|
||||
}
|
||||
|
||||
if len(matches) == 0 {
|
||||
return reason
|
||||
}
|
||||
|
||||
entries := make([]string, 0, len(matches))
|
||||
for _, group := range slices.Sorted(maps.Keys(matches)) {
|
||||
entries = append(entries, fmt.Sprintf("%s: %s", group, matches[group]))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s (%s)", reason, strings.Join(entries, ", "))
|
||||
}
|
||||
|
||||
type blockHandler interface {
|
||||
handleBlock(question dns.Question, response *dns.Msg)
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
BeDNSRecord("domain1.com.", A, "0.0.0.0"),
|
||||
HaveTTL(BeNumerically("==", 21600)),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (gr1)"),
|
||||
HaveReason("BLOCKED (gr1: domain1.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
})
|
||||
@@ -231,7 +231,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
BeDNSRecord("domain1.com.", A, "0.0.0.0"),
|
||||
HaveTTL(BeNumerically("==", 21600)),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (gr1)"),
|
||||
HaveReason("BLOCKED (gr1: domain1.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
})
|
||||
@@ -242,7 +242,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
BeDNSRecord("domain1.com.", A, "0.0.0.0"),
|
||||
HaveTTL(BeNumerically("==", 21600)),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (gr1)"),
|
||||
HaveReason("BLOCKED (gr1: domain1.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
})
|
||||
@@ -253,7 +253,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
BeDNSRecord("blocked2.com.", A, "0.0.0.0"),
|
||||
HaveTTL(BeNumerically("==", 21600)),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (gr2)"),
|
||||
HaveReason("BLOCKED (gr2: blocked2.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
})
|
||||
@@ -264,7 +264,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
BeDNSRecord("domain1.com.", AAAA, "::"),
|
||||
HaveTTL(BeNumerically("==", 21600)),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (gr1)"),
|
||||
HaveReason("BLOCKED (gr1: domain1.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
})
|
||||
@@ -286,7 +286,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
BeDNSRecord("domain1.com.", A, "0.0.0.0"),
|
||||
HaveTTL(BeNumerically("==", 21600)),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (gr1)"),
|
||||
HaveReason("BLOCKED (gr1: domain1.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
})
|
||||
@@ -326,7 +326,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
BeDNSRecord("domain1.com.", A, "0.0.0.0"),
|
||||
HaveTTL(BeNumerically("==", 21600)),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (gr1)"),
|
||||
HaveReason("BLOCKED (gr1: domain1.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
})
|
||||
@@ -337,7 +337,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
BeDNSRecord("domain1.com.", A, "0.0.0.0"),
|
||||
HaveTTL(BeNumerically("==", 21600)),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (gr1)"),
|
||||
HaveReason("BLOCKED (gr1: domain1.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
})
|
||||
@@ -351,7 +351,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
BeDNSRecord("domain1.com.", A, "0.0.0.0"),
|
||||
HaveTTL(BeNumerically("==", 21600)),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (gr1)"),
|
||||
HaveReason("BLOCKED (gr1: domain1.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
})
|
||||
@@ -362,7 +362,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
BeDNSRecord("blocked2.com.", A, "0.0.0.0"),
|
||||
HaveTTL(BeNumerically("==", 21600)),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (gr2)"),
|
||||
HaveReason("BLOCKED (gr2: blocked2.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
})
|
||||
@@ -375,7 +375,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
BeDNSRecord("domain1.com.", A, "0.0.0.0"),
|
||||
HaveTTL(BeNumerically("==", 21600)),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (gr1)"),
|
||||
HaveReason("BLOCKED (gr1: domain1.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
})
|
||||
@@ -389,7 +389,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
BeDNSRecord("blocked3.com.", A, "0.0.0.0"),
|
||||
HaveTTL(BeNumerically("==", 21600)),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (defaultGroup)"),
|
||||
HaveReason("BLOCKED (defaultGroup: blocked3.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
})
|
||||
@@ -416,7 +416,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
HaveNoAnswer(),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReturnCode(dns.RcodeNameError),
|
||||
HaveReason("BLOCKED (defaultGroup)"),
|
||||
HaveReason("BLOCKED (defaultGroup: blocked3.com)"),
|
||||
HaveAuthority(),
|
||||
HaveSOARecord(60, 60), // 1 minute = 60 seconds
|
||||
))
|
||||
@@ -444,7 +444,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
HaveNoAnswer(),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReturnCode(dns.RcodeNameError),
|
||||
HaveReason("BLOCKED (defaultGroup)"),
|
||||
HaveReason("BLOCKED (defaultGroup: blocked3.com)"),
|
||||
HaveAuthority(),
|
||||
HaveSOARecord(7200, 7200), // 2 hours in seconds
|
||||
))
|
||||
@@ -473,7 +473,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
HaveTTL(BeNumerically("==", 1234)),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
HaveReason("BLOCKED (defaultGroup)"),
|
||||
HaveReason("BLOCKED (defaultGroup: blocked3.com)"),
|
||||
))
|
||||
})
|
||||
|
||||
@@ -490,7 +490,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
HaveTTL(BeNumerically("==", 1234)),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
HaveReason("BLOCKED (defaultGroup)"),
|
||||
HaveReason("BLOCKED (defaultGroup: blocked3.com)"),
|
||||
))
|
||||
})
|
||||
})
|
||||
@@ -518,7 +518,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
HaveTTL(BeNumerically("==", 21600)),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
HaveReason("BLOCKED (defaultGroup)"),
|
||||
HaveReason("BLOCKED (defaultGroup: blocked3.com)"),
|
||||
))
|
||||
})
|
||||
|
||||
@@ -530,7 +530,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
HaveTTL(BeNumerically("==", 21600)),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
HaveReason("BLOCKED (defaultGroup)"),
|
||||
HaveReason("BLOCKED (defaultGroup: blocked3.com)"),
|
||||
))
|
||||
})
|
||||
})
|
||||
@@ -557,7 +557,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
HaveTTL(BeNumerically("==", 21600)),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
HaveReason("BLOCKED (defaultGroup)"),
|
||||
HaveReason("BLOCKED (defaultGroup: blocked3.com)"),
|
||||
))
|
||||
})
|
||||
})
|
||||
@@ -576,7 +576,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
HaveTTL(BeNumerically("==", 21600)),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
HaveReason("BLOCKED IP (defaultGroup)"),
|
||||
HaveReason("BLOCKED IP (defaultGroup: 123.145.123.145)"),
|
||||
))
|
||||
})
|
||||
})
|
||||
@@ -596,7 +596,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
HaveTTL(BeNumerically("==", 21600)),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
HaveReason("BLOCKED IP (defaultGroup)"),
|
||||
HaveReason("BLOCKED IP (defaultGroup: 2001:db8:85a3:8d3::370:7344)"),
|
||||
))
|
||||
})
|
||||
})
|
||||
@@ -619,7 +619,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
HaveTTL(BeNumerically("==", 21600)),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
HaveReason("BLOCKED CNAME (defaultGroup)"),
|
||||
HaveReason("BLOCKED CNAME (defaultGroup: badcnamedomain.com)"),
|
||||
))
|
||||
})
|
||||
})
|
||||
@@ -1114,7 +1114,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
SatisfyAll(
|
||||
BeDNSRecord("blocked3.com.", A, "0.0.0.0"),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (defaultGroup)"),
|
||||
HaveReason("BLOCKED (defaultGroup: blocked3.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
})
|
||||
@@ -1125,7 +1125,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
SatisfyAll(
|
||||
BeDNSRecord("domain1.com.", A, "0.0.0.0"),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (group1)"),
|
||||
HaveReason("BLOCKED (group1: domain1.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
})
|
||||
@@ -1188,7 +1188,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
SatisfyAll(
|
||||
BeDNSRecord("domain1.com.", A, "0.0.0.0"),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (group1)"),
|
||||
HaveReason("BLOCKED (group1: domain1.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
})
|
||||
@@ -1203,7 +1203,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
SatisfyAll(
|
||||
BeDNSRecord("blocked3.com.", A, "0.0.0.0"),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (defaultGroup)"),
|
||||
HaveReason("BLOCKED (defaultGroup: blocked3.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
})
|
||||
@@ -1213,7 +1213,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
SatisfyAll(
|
||||
BeDNSRecord("domain1.com.", A, "0.0.0.0"),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (group1)"),
|
||||
HaveReason("BLOCKED (group1: domain1.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
})
|
||||
@@ -1269,7 +1269,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
SatisfyAll(
|
||||
BeDNSRecord("blocked3.com.", A, "0.0.0.0"),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (defaultGroup)"),
|
||||
HaveReason("BLOCKED (defaultGroup: blocked3.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
|
||||
@@ -1278,7 +1278,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
SatisfyAll(
|
||||
BeDNSRecord("domain1.com.", A, "0.0.0.0"),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (group1)"),
|
||||
HaveReason("BLOCKED (group1: domain1.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
})
|
||||
@@ -1293,7 +1293,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
SatisfyAll(
|
||||
BeDNSRecord("blocked3.com.", A, "0.0.0.0"),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (defaultGroup)"),
|
||||
HaveReason("BLOCKED (defaultGroup: blocked3.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
})
|
||||
@@ -1303,7 +1303,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
SatisfyAll(
|
||||
BeDNSRecord("domain1.com.", A, "0.0.0.0"),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (group1)"),
|
||||
HaveReason("BLOCKED (group1: domain1.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
})
|
||||
@@ -1326,7 +1326,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
SatisfyAll(
|
||||
BeDNSRecord("blocked3.com.", A, "0.0.0.0"),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (defaultGroup)"),
|
||||
HaveReason("BLOCKED (defaultGroup: blocked3.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
})
|
||||
@@ -1357,7 +1357,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
SatisfyAll(
|
||||
BeDNSRecord("blocked3.com.", A, "0.0.0.0"),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (defaultGroup)"),
|
||||
HaveReason("BLOCKED (defaultGroup: blocked3.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
|
||||
@@ -1366,7 +1366,7 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
SatisfyAll(
|
||||
BeDNSRecord("domain1.com.", A, "0.0.0.0"),
|
||||
HaveResponseType(ResponseTypeBLOCKED),
|
||||
HaveReason("BLOCKED (group1)"),
|
||||
HaveReason("BLOCKED (group1: domain1.com)"),
|
||||
HaveReturnCode(dns.RcodeSuccess),
|
||||
))
|
||||
})
|
||||
@@ -1459,3 +1459,25 @@ var _ = Describe("BlockingResolver", Label("blockingResolver"), func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("formatBlockReason", func() {
|
||||
It("renders the matched group and rule", func() {
|
||||
Expect(formatBlockReason(map[string]string{"gr1": "domain1.com"}, "")).
|
||||
Should(Equal("BLOCKED (gr1: domain1.com)"))
|
||||
})
|
||||
|
||||
It("includes the entry type when given (e.g. CNAME/IP)", func() {
|
||||
Expect(formatBlockReason(map[string]string{"ads": "*.docler.com"}, "CNAME")).
|
||||
Should(Equal("BLOCKED CNAME (ads: *.docler.com)"))
|
||||
})
|
||||
|
||||
It("renders multiple matched groups sorted by group for deterministic output", func() {
|
||||
matches := map[string]string{"zeta": "z.com", "alpha": "a.com", "mid": "m.com"}
|
||||
|
||||
// repeat to guard against Go's randomized map iteration order
|
||||
for range 20 {
|
||||
Expect(formatBlockReason(matches, "")).
|
||||
Should(Equal("BLOCKED (alpha: a.com, mid: m.com, zeta: z.com)"))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ package resolver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/0xERR0R/blocky/config"
|
||||
"github.com/0xERR0R/blocky/model"
|
||||
@@ -9,6 +10,14 @@ import (
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// maxEDETextLength bounds the EDE extra text. The reason now embeds the matched
|
||||
// denylist rule, which is unbounded for regex entries. dns.Msg.Truncate keeps
|
||||
// the OPT record and subtracts its full length from the size budget, so an
|
||||
// oversized extra text would shrink (or eliminate) the room left for the actual
|
||||
// answer on a size-limited (typically UDP) response. Bounding it here keeps the
|
||||
// OPT small; the full reason is still recorded in the query log.
|
||||
const maxEDETextLength = 200
|
||||
|
||||
// A EDEResolver is responsible for adding the reason for the response as EDNS0 option
|
||||
type EDEResolver struct {
|
||||
configurable[*config.EDE]
|
||||
@@ -53,7 +62,22 @@ func (r *EDEResolver) addExtraReasoning(res *model.Response) {
|
||||
|
||||
edeOption := new(dns.EDNS0_EDE)
|
||||
edeOption.InfoCode = infocode
|
||||
edeOption.ExtraText = res.Reason
|
||||
edeOption.ExtraText = truncateText(res.Reason, maxEDETextLength)
|
||||
|
||||
util.SetEdns0Option(res.Res, edeOption)
|
||||
}
|
||||
|
||||
// truncateText limits s to at most maxLen bytes, cutting on a UTF-8 rune
|
||||
// boundary so it never emits an invalid (partial) rune.
|
||||
func truncateText(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
|
||||
cut := maxLen
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
|
||||
return s[:cut]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/0xERR0R/blocky/config"
|
||||
. "github.com/0xERR0R/blocky/helpertest"
|
||||
@@ -111,6 +112,54 @@ var _ = Describe("EdeResolver", func() {
|
||||
))
|
||||
})
|
||||
|
||||
When("resolver returns a blocked response carrying the matched rule", func() {
|
||||
BeforeEach(func() {
|
||||
m = &mockResolver{}
|
||||
m.On("Resolve", mock.Anything).Return(&Response{
|
||||
Res: mockAnswer,
|
||||
RType: ResponseTypeBLOCKED,
|
||||
Reason: "BLOCKED CNAME (ads: *.docler.com)",
|
||||
}, nil)
|
||||
})
|
||||
|
||||
It("returns the matched rule to the client in the EDE extra text", func() {
|
||||
Expect(sut.Resolve(ctx, newRequest("example.com.", A))).
|
||||
Should(
|
||||
SatisfyAll(
|
||||
HaveEdnsOption(dns.EDNS0EDE),
|
||||
WithTransform(extractEdeOption,
|
||||
SatisfyAll(
|
||||
HaveField("InfoCode", Equal(dns.ExtendedErrorCodeBlocked)),
|
||||
HaveField("ExtraText", Equal("BLOCKED CNAME (ads: *.docler.com)")),
|
||||
)),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
When("resolver returns a blocked response with an oversized reason", func() {
|
||||
longReason := "BLOCKED (ads: /" + strings.Repeat("a", 500) + "/)"
|
||||
|
||||
BeforeEach(func() {
|
||||
m = &mockResolver{}
|
||||
m.On("Resolve", mock.Anything).Return(&Response{
|
||||
Res: mockAnswer,
|
||||
RType: ResponseTypeBLOCKED,
|
||||
Reason: longReason,
|
||||
}, nil)
|
||||
})
|
||||
|
||||
It("caps the EDE extra text so it can't bloat the OPT record", func() {
|
||||
Expect(sut.Resolve(ctx, newRequest("example.com.", A))).
|
||||
Should(
|
||||
SatisfyAll(
|
||||
HaveEdnsOption(dns.EDNS0EDE),
|
||||
WithTransform(extractEdeOption,
|
||||
WithTransform(func(o dns.EDNS0_EDE) int { return len(o.ExtraText) },
|
||||
BeNumerically("<=", maxEDETextLength))),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
When("resolver returns other", func() {
|
||||
BeforeEach(func() {
|
||||
m = &mockResolver{}
|
||||
|
||||
@@ -18,3 +18,10 @@ func SplitTLD(domain string) (label, rest string) {
|
||||
|
||||
return label, rest
|
||||
}
|
||||
|
||||
// JoinTLD is the inverse of SplitTLD: it reconstructs an entry from the labels
|
||||
// returned by HasParentOf on a trie built with SplitTLD.
|
||||
// ["example", "com"] -> "example.com"
|
||||
func JoinTLD(labels []string) string {
|
||||
return strings.Join(labels, ".")
|
||||
}
|
||||
|
||||
@@ -44,3 +44,22 @@ var _ = Describe("SpltTLD", func() {
|
||||
Expect(rest).Should(Equal("www"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("JoinTLD", func() {
|
||||
It("reconstructs an entry from labels in entry order", func() {
|
||||
Expect(JoinTLD([]string{"example", "com"})).Should(Equal("example.com"))
|
||||
})
|
||||
|
||||
It("handles a single label", func() {
|
||||
Expect(JoinTLD([]string{"blocked"})).Should(Equal("blocked"))
|
||||
})
|
||||
|
||||
It("reproduces the labels returned by HasParentOf", func() {
|
||||
sut := NewTrie(SplitTLD)
|
||||
sut.Insert("sub.example.com")
|
||||
|
||||
labels, ok := sut.HasParentOf("a.sub.example.com")
|
||||
Expect(ok).Should(BeTrue())
|
||||
Expect(JoinTLD(labels)).Should(Equal("sub.example.com"))
|
||||
})
|
||||
})
|
||||
|
||||
+50
-52
@@ -1,11 +1,5 @@
|
||||
package trie
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/0xERR0R/blocky/log"
|
||||
)
|
||||
|
||||
// Trie stores a set of strings and can quickly check
|
||||
// if it contains an element, or one of its parents.
|
||||
//
|
||||
@@ -37,12 +31,19 @@ func (t *Trie) Insert(key string) {
|
||||
t.root.insert(key, t.split)
|
||||
}
|
||||
|
||||
func (t *Trie) HasParentOf(key string) bool {
|
||||
// HasParentOf reports whether the trie contains key or one of its parents.
|
||||
// On a match it also returns the labels of the matched entry; joining them
|
||||
// with the separator used by the trie's SplitFunc reproduces the entry.
|
||||
// The labels are nil when there is no match.
|
||||
func (t *Trie) HasParentOf(key string) ([]string, bool) {
|
||||
// labels are built only on the matching path (during recursion unwind),
|
||||
// so a miss does not allocate. They come back in entry order, ready to be
|
||||
// joined by the caller with the separator matching its SplitFunc.
|
||||
return t.root.hasParentOf(key, t.split)
|
||||
}
|
||||
|
||||
type node interface {
|
||||
hasParentOf(key string, split SplitFunc) bool
|
||||
hasParentOf(key string, split SplitFunc) (labels []string, ok bool)
|
||||
}
|
||||
|
||||
// We save memory by not keeping track of children of
|
||||
@@ -97,7 +98,7 @@ func (n *parent) insert(key string, split SplitFunc) {
|
||||
continue
|
||||
|
||||
case terminal:
|
||||
if child.hasParentOf(rest, split) {
|
||||
if _, ok := child.hasParentOf(rest, split); ok {
|
||||
// Found a parent/"prefix" in the set
|
||||
return
|
||||
}
|
||||
@@ -113,45 +114,30 @@ func (n *parent) insert(key string, split SplitFunc) {
|
||||
}
|
||||
}
|
||||
|
||||
func (n *parent) hasParentOf(key string, split SplitFunc) bool {
|
||||
searchString := key
|
||||
rule := ""
|
||||
func (n *parent) hasParentOf(key string, split SplitFunc) ([]string, bool) {
|
||||
label, rest := split(key)
|
||||
|
||||
for {
|
||||
label, rest := split(key)
|
||||
rule = strings.Join([]string{label, rule}, ".")
|
||||
|
||||
child, ok := n.children[label]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
switch child := child.(type) {
|
||||
case *parent:
|
||||
if len(rest) == 0 {
|
||||
// The trie only contains children/"suffixes" of the
|
||||
// key we're searching for
|
||||
return false
|
||||
}
|
||||
|
||||
// Continue down the trie
|
||||
key = rest
|
||||
n = child
|
||||
|
||||
continue
|
||||
|
||||
case terminal:
|
||||
// Continue down the trie
|
||||
matched := child.hasParentOf(rest, split)
|
||||
if matched {
|
||||
rule = strings.Join([]string{child.String(), rule}, ".")
|
||||
rule = strings.Trim(rule, ".")
|
||||
log.PrefixedLog("trie").Debugf("wildcard block rule '%s' matched with '%s'", rule, searchString)
|
||||
}
|
||||
|
||||
return matched
|
||||
}
|
||||
child, ok := n.children[label]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// A *parent child means the trie only stores longer entries/"suffixes" below
|
||||
// this node; if the search key has no more labels, none of them can be a
|
||||
// parent of it.
|
||||
if _, isParent := child.(*parent); isParent && len(rest) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
labels, ok := child.hasParentOf(rest, split)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// On the matching path only: prepend-by-append this node's label. The deeper
|
||||
// labels were collected first, so appending the current (more significant)
|
||||
// label keeps the entry's natural order.
|
||||
return append(labels, label), true
|
||||
}
|
||||
|
||||
type terminal string
|
||||
@@ -160,29 +146,41 @@ func (t terminal) String() string {
|
||||
return string(t)
|
||||
}
|
||||
|
||||
func (t terminal) hasParentOf(searchKey string, split SplitFunc) bool {
|
||||
func (t terminal) hasParentOf(searchKey string, split SplitFunc) ([]string, bool) {
|
||||
tKey := t.String()
|
||||
if tKey == "" {
|
||||
return true
|
||||
return nil, true
|
||||
}
|
||||
|
||||
// labels of this terminal, collected only while it keeps matching so a
|
||||
// mismatch stays allocation-free.
|
||||
var labels []string
|
||||
|
||||
for {
|
||||
tLabel, tRest := split(tKey)
|
||||
|
||||
searchLabel, searchRest := split(searchKey)
|
||||
if searchLabel != tLabel {
|
||||
return false
|
||||
return nil, false
|
||||
}
|
||||
|
||||
labels = append(labels, tLabel)
|
||||
|
||||
if len(tRest) == 0 {
|
||||
// Found a parent/"prefix" in the set
|
||||
return true
|
||||
// Found a parent/"prefix" in the set. labels are in peel order
|
||||
// (most significant last); reverse them into entry order so the
|
||||
// caller's parent nodes can append their labels after these.
|
||||
for i, j := 0, len(labels)-1; i < j; i, j = i+1, j-1 {
|
||||
labels[i], labels[j] = labels[j], labels[i]
|
||||
}
|
||||
|
||||
return labels, true
|
||||
}
|
||||
|
||||
if len(searchRest) == 0 {
|
||||
// The trie only contains children/"suffixes" of the
|
||||
// key we're searching for
|
||||
return false
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Continue down the trie
|
||||
|
||||
+89
-19
@@ -1,6 +1,8 @@
|
||||
package trie
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -19,12 +21,14 @@ var _ = Describe("Trie", func() {
|
||||
})
|
||||
|
||||
It("should not find domains", func() {
|
||||
Expect(sut.HasParentOf("example.com")).Should(BeFalse())
|
||||
_, ok := sut.HasParentOf("example.com")
|
||||
Expect(ok).Should(BeFalse())
|
||||
})
|
||||
|
||||
It("should not insert the empty string", func() {
|
||||
sut.Insert("")
|
||||
Expect(sut.HasParentOf("")).Should(BeFalse())
|
||||
_, ok := sut.HasParentOf("")
|
||||
Expect(ok).Should(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,13 +41,16 @@ var _ = Describe("Trie", func() {
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
Expect(sut.HasParentOf(domainOk)).Should(BeFalse())
|
||||
_, ok := sut.HasParentOf(domainOk)
|
||||
Expect(ok).Should(BeFalse())
|
||||
sut.Insert(domainOk)
|
||||
Expect(sut.HasParentOf(domainOk)).Should(BeTrue())
|
||||
_, ok = sut.HasParentOf(domainOk)
|
||||
Expect(ok).Should(BeTrue())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
Expect(sut.HasParentOf(domainOk)).Should(BeTrue())
|
||||
_, ok := sut.HasParentOf(domainOk)
|
||||
Expect(ok).Should(BeTrue())
|
||||
})
|
||||
|
||||
It("should be found", func() {})
|
||||
@@ -51,41 +58,49 @@ var _ = Describe("Trie", func() {
|
||||
It("should contain subdomains", func() {
|
||||
subdomain := "www." + domainOk
|
||||
|
||||
Expect(sut.HasParentOf(subdomain)).Should(BeTrue())
|
||||
_, ok := sut.HasParentOf(subdomain)
|
||||
Expect(ok).Should(BeTrue())
|
||||
})
|
||||
|
||||
It("should support inserting subdomains", func() {
|
||||
subdomain := "www." + domainOk
|
||||
|
||||
Expect(sut.HasParentOf(subdomain)).Should(BeTrue())
|
||||
_, ok := sut.HasParentOf(subdomain)
|
||||
Expect(ok).Should(BeTrue())
|
||||
sut.Insert(subdomain)
|
||||
Expect(sut.HasParentOf(subdomain)).Should(BeTrue())
|
||||
_, ok = sut.HasParentOf(subdomain)
|
||||
Expect(ok).Should(BeTrue())
|
||||
})
|
||||
|
||||
It("should not find unrelated", func() {
|
||||
Expect(sut.HasParentOf(domainKo)).Should(BeFalse())
|
||||
_, ok := sut.HasParentOf(domainKo)
|
||||
Expect(ok).Should(BeFalse())
|
||||
})
|
||||
|
||||
It("should not find uninserted parent", func() {
|
||||
Expect(sut.HasParentOf(domainOkTLD)).Should(BeFalse())
|
||||
_, ok := sut.HasParentOf(domainOkTLD)
|
||||
Expect(ok).Should(BeFalse())
|
||||
})
|
||||
|
||||
It("should not find deep uninserted parent", func() {
|
||||
sut.Insert("sub.sub.sub.test")
|
||||
|
||||
Expect(sut.HasParentOf("sub.sub.test")).Should(BeFalse())
|
||||
_, ok := sut.HasParentOf("sub.sub.test")
|
||||
Expect(ok).Should(BeFalse())
|
||||
})
|
||||
|
||||
It("should find inserted parent", func() {
|
||||
sut.Insert(domainOkTLD)
|
||||
Expect(sut.HasParentOf(domainOkTLD)).Should(BeTrue())
|
||||
_, ok := sut.HasParentOf(domainOkTLD)
|
||||
Expect(ok).Should(BeTrue())
|
||||
})
|
||||
|
||||
It("should insert sibling", func() {
|
||||
sibling := "other." + domainOkTLD
|
||||
|
||||
sut.Insert(sibling)
|
||||
Expect(sut.HasParentOf(sibling)).Should(BeTrue())
|
||||
_, ok := sut.HasParentOf(sibling)
|
||||
Expect(ok).Should(BeTrue())
|
||||
})
|
||||
|
||||
It("should insert grand-children siblings", func() {
|
||||
@@ -94,15 +109,70 @@ var _ = Describe("Trie", func() {
|
||||
xyzSub := "xyz." + base
|
||||
|
||||
sut.Insert(abcSub)
|
||||
Expect(sut.HasParentOf(abcSub)).Should(BeTrue())
|
||||
Expect(sut.HasParentOf(xyzSub)).Should(BeFalse())
|
||||
Expect(sut.HasParentOf(base)).Should(BeFalse())
|
||||
_, ok := sut.HasParentOf(abcSub)
|
||||
Expect(ok).Should(BeTrue())
|
||||
_, ok = sut.HasParentOf(xyzSub)
|
||||
Expect(ok).Should(BeFalse())
|
||||
_, ok = sut.HasParentOf(base)
|
||||
Expect(ok).Should(BeFalse())
|
||||
|
||||
sut.Insert(xyzSub)
|
||||
Expect(sut.HasParentOf(xyzSub)).Should(BeTrue())
|
||||
Expect(sut.HasParentOf(abcSub)).Should(BeTrue())
|
||||
Expect(sut.HasParentOf(base)).Should(BeFalse())
|
||||
_, ok = sut.HasParentOf(xyzSub)
|
||||
Expect(ok).Should(BeTrue())
|
||||
_, ok = sut.HasParentOf(abcSub)
|
||||
Expect(ok).Should(BeTrue())
|
||||
_, ok = sut.HasParentOf(base)
|
||||
Expect(ok).Should(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Matched entry reconstruction", func() {
|
||||
// HasParentOf returns the labels of the matched stored entry; joining
|
||||
// them with the domain separator must reproduce the original entry.
|
||||
reconstruct := func(labels []string) string {
|
||||
return strings.Join(labels, ".")
|
||||
}
|
||||
|
||||
It("reconstructs a single-label-prefix entry from a subdomain match", func() {
|
||||
sut.Insert("example.com")
|
||||
|
||||
labels, ok := sut.HasParentOf("www.example.com")
|
||||
Expect(ok).Should(BeTrue())
|
||||
Expect(reconstruct(labels)).Should(Equal("example.com"))
|
||||
})
|
||||
|
||||
It("reconstructs the entry on an exact match", func() {
|
||||
sut.Insert("example.com")
|
||||
|
||||
labels, ok := sut.HasParentOf("example.com")
|
||||
Expect(ok).Should(BeTrue())
|
||||
Expect(reconstruct(labels)).Should(Equal("example.com"))
|
||||
})
|
||||
|
||||
It("reconstructs a multi-label entry", func() {
|
||||
sut.Insert("sub.example.com")
|
||||
|
||||
labels, ok := sut.HasParentOf("a.sub.example.com")
|
||||
Expect(ok).Should(BeTrue())
|
||||
Expect(reconstruct(labels)).Should(Equal("sub.example.com"))
|
||||
})
|
||||
|
||||
It("reconstructs the matched sibling under a shared parent node", func() {
|
||||
sut.Insert("a.com")
|
||||
sut.Insert("b.com")
|
||||
|
||||
labels, ok := sut.HasParentOf("x.a.com")
|
||||
Expect(ok).Should(BeTrue())
|
||||
Expect(reconstruct(labels)).Should(Equal("a.com"))
|
||||
})
|
||||
|
||||
It("returns nil labels when there is no match", func() {
|
||||
sut.Insert("example.com")
|
||||
|
||||
labels, ok := sut.HasParentOf("example.org")
|
||||
Expect(ok).Should(BeFalse())
|
||||
Expect(labels).Should(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user