From 7547d3a42294f3e2ab1093055d65f47854c94af6 Mon Sep 17 00:00:00 2001
From: Ainar Garipov <a.garipov@adguard.com>
Date: Thu, 17 Jun 2021 14:09:16 +0300
Subject: [PATCH] Pull request: all: sup many ips in host rules

Closes #1381.

Squashed commit of the following:

commit 44965f74d8becb27173cafc533e56e1cde484b59
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Thu Jun 17 13:54:44 2021 +0300

    dnsforward: imp code, docs

commit 23736cf9b407b668faade19b61739536caafff03
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Wed Jun 16 20:33:59 2021 +0300

    dnsforward: rm todo

commit ff086756160d72f3cdbe662862dd1fb447ac5ff3
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Wed Jun 16 20:32:01 2021 +0300

    all: sup many ips in host rules
---
 CHANGELOG.md                           |   3 +
 internal/dnsforward/config.go          |  34 ++++++-
 internal/dnsforward/dnsforward_test.go |   7 +-
 internal/dnsforward/http.go            |  60 +++++-------
 internal/dnsforward/msg.go             | 122 ++++++++++++++++---------
 internal/filtering/filtering.go        | 101 +++++++++++---------
 internal/filtering/filtering_test.go   | 104 ++++++++++++++-------
 7 files changed, 273 insertions(+), 158 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b1ad28e..66f3b1f7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,6 +34,8 @@ and this project adheres to
 
 ### Changed
 
+- When /etc/hosts-type rules have several IPs for one host, all IPs are now
+  returned instead of only the first one ([#1381]).
 - The setting `rlimit_nofile` is now in the `os` block of the configuration
   file, together with the new `group` and `user` settings ([#2763]).
 - Permissions on filter files are now `0o644` instead of `0o600` ([#3198]).
@@ -63,6 +65,7 @@ released by then.
 
 - Go 1.15 support.
 
+[#1381]: https://github.com/AdguardTeam/AdGuardHome/issues/1381
 [#1691]: https://github.com/AdguardTeam/AdGuardHome/issues/1691
 [#2280]: https://github.com/AdguardTeam/AdGuardHome/issues/2280
 [#2439]: https://github.com/AdguardTeam/AdGuardHome/issues/2439
diff --git a/internal/dnsforward/config.go b/internal/dnsforward/config.go
index 905e5cec..3b28c236 100644
--- a/internal/dnsforward/config.go
+++ b/internal/dnsforward/config.go
@@ -21,6 +21,30 @@ import (
 	"github.com/ameshkov/dnscrypt/v2"
 )
 
+// BlockingMode is an enum of all allowed blocking modes.
+type BlockingMode string
+
+// Allowed blocking modes.
+const (
+	// BlockingModeCustomIP means respond with a custom IP address.
+	BlockingModeCustomIP BlockingMode = "custom_ip"
+
+	// BlockingModeDefault is the same as BlockingModeNullIP for
+	// Adblock-style rules, but responds with the IP address specified in
+	// the rule when blocked by an `/etc/hosts`-style rule.
+	BlockingModeDefault BlockingMode = "default"
+
+	// BlockingModeNullIP means respond with a zero IP address: "0.0.0.0"
+	// for A requests and "::" for AAAA ones.
+	BlockingModeNullIP BlockingMode = "null_ip"
+
+	// BlockingModeNXDOMAIN means respond with the NXDOMAIN code.
+	BlockingModeNXDOMAIN BlockingMode = "nxdomain"
+
+	// BlockingModeREFUSED means respond with the REFUSED code.
+	BlockingModeREFUSED BlockingMode = "refused"
+)
+
 // FilteringConfig represents the DNS filtering configuration of AdGuard Home
 // The zero FilteringConfig is empty and ready for use.
 type FilteringConfig struct {
@@ -38,11 +62,11 @@ type FilteringConfig struct {
 	// Protection configuration
 	// --
 
-	ProtectionEnabled  bool   `yaml:"protection_enabled"`   // whether or not use any of filtering features
-	BlockingMode       string `yaml:"blocking_mode"`        // mode how to answer filtered requests
-	BlockingIPv4       net.IP `yaml:"blocking_ipv4"`        // IP address to be returned for a blocked A request
-	BlockingIPv6       net.IP `yaml:"blocking_ipv6"`        // IP address to be returned for a blocked AAAA request
-	BlockedResponseTTL uint32 `yaml:"blocked_response_ttl"` // if 0, then default is used (3600)
+	ProtectionEnabled  bool         `yaml:"protection_enabled"`   // whether or not use any of filtering features
+	BlockingMode       BlockingMode `yaml:"blocking_mode"`        // mode how to answer filtered requests
+	BlockingIPv4       net.IP       `yaml:"blocking_ipv4"`        // IP address to be returned for a blocked A request
+	BlockingIPv6       net.IP       `yaml:"blocking_ipv6"`        // IP address to be returned for a blocked AAAA request
+	BlockedResponseTTL uint32       `yaml:"blocked_response_ttl"` // if 0, then default is used (3600)
 
 	// IP (or domain name) which is used to respond to DNS requests blocked by parental control or safe-browsing
 	ParentalBlockHost     string `yaml:"parental_block_host"`
diff --git a/internal/dnsforward/dnsforward_test.go b/internal/dnsforward/dnsforward_test.go
index 9bc4f53e..a7ed4fc4 100644
--- a/internal/dnsforward/dnsforward_test.go
+++ b/internal/dnsforward/dnsforward_test.go
@@ -520,6 +520,7 @@ func TestBlockedRequest(t *testing.T) {
 		TCPListenAddrs: []*net.TCPAddr{{}},
 		FilteringConfig: FilteringConfig{
 			ProtectionEnabled: true,
+			BlockingMode:      BlockingModeDefault,
 		},
 	}
 	s := createTestServer(t, &filtering.Config{}, forwardConf, nil)
@@ -622,6 +623,7 @@ func TestBlockCNAME(t *testing.T) {
 		TCPListenAddrs: []*net.TCPAddr{{}},
 		FilteringConfig: FilteringConfig{
 			ProtectionEnabled: true,
+			BlockingMode:      BlockingModeDefault,
 		},
 	}
 	s := createTestServer(t, &filtering.Config{}, forwardConf, nil)
@@ -724,7 +726,7 @@ func TestNullBlockedRequest(t *testing.T) {
 		TCPListenAddrs: []*net.TCPAddr{{}},
 		FilteringConfig: FilteringConfig{
 			ProtectionEnabled: true,
-			BlockingMode:      "null_ip",
+			BlockingMode:      BlockingModeNullIP,
 		},
 	}
 	s := createTestServer(t, &filtering.Config{}, forwardConf, nil)
@@ -777,7 +779,7 @@ func TestBlockedCustomIP(t *testing.T) {
 		TCPListenAddrs: []*net.TCPAddr{{}},
 		FilteringConfig: FilteringConfig{
 			ProtectionEnabled: true,
-			BlockingMode:      "custom_ip",
+			BlockingMode:      BlockingModeCustomIP,
 			BlockingIPv4:      nil,
 			UpstreamDNS:       []string{"8.8.8.8:53", "8.8.4.4:53"},
 		},
@@ -827,6 +829,7 @@ func TestBlockedByHosts(t *testing.T) {
 		TCPListenAddrs: []*net.TCPAddr{{}},
 		FilteringConfig: FilteringConfig{
 			ProtectionEnabled: true,
+			BlockingMode:      BlockingModeDefault,
 		},
 	}
 
diff --git a/internal/dnsforward/http.go b/internal/dnsforward/http.go
index 06baa9a9..3e18f1ad 100644
--- a/internal/dnsforward/http.go
+++ b/internal/dnsforward/http.go
@@ -29,21 +29,21 @@ type dnsConfig struct {
 	UpstreamsFile *string   `json:"upstream_dns_file"`
 	Bootstraps    *[]string `json:"bootstrap_dns"`
 
-	ProtectionEnabled *bool     `json:"protection_enabled"`
-	RateLimit         *uint32   `json:"ratelimit"`
-	BlockingMode      *string   `json:"blocking_mode"`
-	BlockingIPv4      net.IP    `json:"blocking_ipv4"`
-	BlockingIPv6      net.IP    `json:"blocking_ipv6"`
-	EDNSCSEnabled     *bool     `json:"edns_cs_enabled"`
-	DNSSECEnabled     *bool     `json:"dnssec_enabled"`
-	DisableIPv6       *bool     `json:"disable_ipv6"`
-	UpstreamMode      *string   `json:"upstream_mode"`
-	CacheSize         *uint32   `json:"cache_size"`
-	CacheMinTTL       *uint32   `json:"cache_ttl_min"`
-	CacheMaxTTL       *uint32   `json:"cache_ttl_max"`
-	ResolveClients    *bool     `json:"resolve_clients"`
-	UsePrivateRDNS    *bool     `json:"use_private_ptr_resolvers"`
-	LocalPTRUpstreams *[]string `json:"local_ptr_upstreams"`
+	ProtectionEnabled *bool         `json:"protection_enabled"`
+	RateLimit         *uint32       `json:"ratelimit"`
+	BlockingMode      *BlockingMode `json:"blocking_mode"`
+	BlockingIPv4      net.IP        `json:"blocking_ipv4"`
+	BlockingIPv6      net.IP        `json:"blocking_ipv6"`
+	EDNSCSEnabled     *bool         `json:"edns_cs_enabled"`
+	DNSSECEnabled     *bool         `json:"dnssec_enabled"`
+	DisableIPv6       *bool         `json:"disable_ipv6"`
+	UpstreamMode      *string       `json:"upstream_mode"`
+	CacheSize         *uint32       `json:"cache_size"`
+	CacheMinTTL       *uint32       `json:"cache_ttl_min"`
+	CacheMaxTTL       *uint32       `json:"cache_ttl_max"`
+	ResolveClients    *bool         `json:"resolve_clients"`
+	UsePrivateRDNS    *bool         `json:"use_private_ptr_resolvers"`
+	LocalPTRUpstreams *[]string     `json:"local_ptr_upstreams"`
 }
 
 func (s *Server) getDNSConfig() dnsConfig {
@@ -126,27 +126,17 @@ func (req *dnsConfig) checkBlockingMode() bool {
 		return true
 	}
 
-	bm := *req.BlockingMode
-	if bm == "custom_ip" {
-		if req.BlockingIPv4.To4() == nil {
-			return false
-		}
-
-		return req.BlockingIPv6 != nil
+	switch bm := *req.BlockingMode; bm {
+	case BlockingModeDefault,
+		BlockingModeREFUSED,
+		BlockingModeNXDOMAIN,
+		BlockingModeNullIP:
+		return true
+	case BlockingModeCustomIP:
+		return req.BlockingIPv4.To4() != nil && req.BlockingIPv6 != nil
+	default:
+		return false
 	}
-
-	for _, valid := range []string{
-		"default",
-		"refused",
-		"nxdomain",
-		"null_ip",
-	} {
-		if bm == valid {
-			return true
-		}
-	}
-
-	return false
 }
 
 func (req *dnsConfig) checkUpstreamsMode() bool {
diff --git a/internal/dnsforward/msg.go b/internal/dnsforward/msg.go
index 617604f3..42307619 100644
--- a/internal/dnsforward/msg.go
+++ b/internal/dnsforward/msg.go
@@ -26,17 +26,29 @@ func (s *Server) makeResponse(req *dns.Msg) (resp *dns.Msg) {
 	return resp
 }
 
+// ipsFromRules extracts non-IP addresses from the filtering result rules.
+func ipsFromRules(resRules []*filtering.ResultRule) (ips []net.IP) {
+	for _, r := range resRules {
+		if r.IP != nil {
+			ips = append(ips, r.IP)
+		}
+	}
+
+	return ips
+}
+
 // genDNSFilterMessage generates a DNS message corresponding to the filtering result
 func (s *Server) genDNSFilterMessage(d *proxy.DNSContext, result *filtering.Result) *dns.Msg {
 	m := d.Req
 
 	if m.Question[0].Qtype != dns.TypeA && m.Question[0].Qtype != dns.TypeAAAA {
-		if s.conf.BlockingMode == "null_ip" {
+		if s.conf.BlockingMode == BlockingModeNullIP {
 			return s.makeResponse(m)
 		}
 		return s.genNXDomain(m)
 	}
 
+	ips := ipsFromRules(result.Rules)
 	switch result.Reason {
 	case filtering.FilteredSafeBrowsing:
 		return s.genBlockedHost(m, s.conf.SafeBrowsingBlockHost, d)
@@ -46,42 +58,45 @@ func (s *Server) genDNSFilterMessage(d *proxy.DNSContext, result *filtering.Resu
 		// If the query was filtered by "Safe search", filtering also must return
 		// the IP address that must be used in response.
 		// In this case regardless of the filtering method, we should return it
-		if result.Reason == filtering.FilteredSafeSearch &&
-			len(result.Rules) > 0 &&
-			result.Rules[0].IP != nil {
-			return s.genResponseWithIP(m, result.Rules[0].IP)
+		if result.Reason == filtering.FilteredSafeSearch && len(ips) > 0 {
+			return s.genResponseWithIPs(m, ips)
 		}
 
-		if s.conf.BlockingMode == "null_ip" {
-			// it means that we should return 0.0.0.0 or :: for any blocked request
-			return s.makeResponseNullIP(m)
-		} else if s.conf.BlockingMode == "custom_ip" {
-			// means that we should return custom IP for any blocked request
-
+		switch s.conf.BlockingMode {
+		case BlockingModeCustomIP:
 			switch m.Question[0].Qtype {
 			case dns.TypeA:
 				return s.genARecord(m, s.conf.BlockingIPv4)
 			case dns.TypeAAAA:
 				return s.genAAAARecord(m, s.conf.BlockingIPv6)
+			default:
+				// Generally shouldn't happen, since the types
+				// are checked above.
+				log.Error(
+					"dns: invalid msg type %d for blocking mode %s",
+					m.Question[0].Qtype,
+					s.conf.BlockingMode,
+				)
+
+				return s.makeResponse(m)
+			}
+		case BlockingModeDefault:
+			if len(ips) > 0 {
+				return s.genResponseWithIPs(m, ips)
 			}
-		} else if s.conf.BlockingMode == "nxdomain" {
-			// means that we should return NXDOMAIN for any blocked request
 
+			return s.makeResponseNullIP(m)
+		case BlockingModeNullIP:
+			return s.makeResponseNullIP(m)
+		case BlockingModeNXDOMAIN:
 			return s.genNXDomain(m)
-		} else if s.conf.BlockingMode == "refused" {
-			// means that we should return NXDOMAIN for any blocked request
-
+		case BlockingModeREFUSED:
 			return s.makeResponseREFUSED(m)
-		}
+		default:
+			log.Error("dns: invalid blocking mode %q", s.conf.BlockingMode)
 
-		// Default blocking mode
-		// If there's an IP specified in the rule, return it
-		// For host-type rules, return null IP
-		if len(result.Rules) > 0 && result.Rules[0].IP != nil {
-			return s.genResponseWithIP(m, result.Rules[0].IP)
+			return s.makeResponse(m)
 		}
-
-		return s.makeResponseNullIP(m)
 	}
 }
 
@@ -166,35 +181,60 @@ func (s *Server) genAnswerTXT(req *dns.Msg, strs []string) (ans *dns.TXT) {
 	}
 }
 
-// generate DNS response message with an IP address
-func (s *Server) genResponseWithIP(req *dns.Msg, ip net.IP) *dns.Msg {
-	if req.Question[0].Qtype == dns.TypeA && ip.To4() != nil {
-		return s.genARecord(req, ip.To4())
-	} else if req.Question[0].Qtype == dns.TypeAAAA &&
-		len(ip) == net.IPv6len && ip.To4() == nil {
-		return s.genAAAARecord(req, ip)
+// genResponseWithIPs generates a DNS response message with the provided IP
+// addresses and an appropriate resource record type.  If any of the IPs cannot
+// be converted to the correct protocol, genResponseWithIPs returns an empty
+// response.
+func (s *Server) genResponseWithIPs(req *dns.Msg, ips []net.IP) (resp *dns.Msg) {
+	var ans []dns.RR
+	switch req.Question[0].Qtype {
+	case dns.TypeA:
+		for _, ip := range ips {
+			if ip4 := ip.To4(); ip4 == nil {
+				ans = nil
+
+				break
+			}
+
+			ans = append(ans, s.genAnswerA(req, ip))
+		}
+	case dns.TypeAAAA:
+		for _, ip := range ips {
+			ans = append(ans, s.genAnswerAAAA(req, ip.To16()))
+		}
+	default:
+		// Go on and return an empty response.
 	}
 
-	// empty response
-	resp := s.makeResponse(req)
+	resp = s.makeResponse(req)
+	resp.Answer = ans
+
 	return resp
 }
 
-// Respond with 0.0.0.0 for A, :: for AAAA, empty response for other types
-func (s *Server) makeResponseNullIP(req *dns.Msg) *dns.Msg {
-	if req.Question[0].Qtype == dns.TypeA {
-		return s.genARecord(req, []byte{0, 0, 0, 0})
-	} else if req.Question[0].Qtype == dns.TypeAAAA {
-		return s.genAAAARecord(req, net.IPv6zero)
+// makeResponseNullIP creates a response with 0.0.0.0 for A requests, :: for
+// AAAA requests, and an empty response for other types.
+func (s *Server) makeResponseNullIP(req *dns.Msg) (resp *dns.Msg) {
+	// Respond with the corresponding zero IP type as opposed to simply
+	// using one or the other in both cases, because the IPv4 zero IP is
+	// converted to a IPV6-mapped IPv4 address, while the IPv6 zero IP is
+	// converted into an empty slice instead of the zero IPv4.
+	switch req.Question[0].Qtype {
+	case dns.TypeA:
+		resp = s.genResponseWithIPs(req, []net.IP{{0, 0, 0, 0}})
+	case dns.TypeAAAA:
+		resp = s.genResponseWithIPs(req, []net.IP{net.IPv6zero})
+	default:
+		resp = s.makeResponse(req)
 	}
 
-	return s.makeResponse(req)
+	return resp
 }
 
 func (s *Server) genBlockedHost(request *dns.Msg, newAddr string, d *proxy.DNSContext) *dns.Msg {
 	ip := net.ParseIP(newAddr)
 	if ip != nil {
-		return s.genResponseWithIP(request, ip)
+		return s.genResponseWithIPs(request, []net.IP{ip})
 	}
 
 	// look up the hostname, TODO: cache
diff --git a/internal/filtering/filtering.go b/internal/filtering/filtering.go
index 14df5ef5..d1ce6f94 100644
--- a/internal/filtering/filtering.go
+++ b/internal/filtering/filtering.go
@@ -344,13 +344,13 @@ var gctx dnsFilterContext
 
 // ResultRule contains information about applied rules.
 type ResultRule struct {
-	// FilterListID is the ID of the rule's filter list.
-	FilterListID int64 `json:",omitempty"`
 	// Text is the text of the rule.
 	Text string `json:",omitempty"`
 	// IP is the host IP.  It is nil unless the rule uses the
 	// /etc/hosts syntax or the reason is FilteredSafeSearch.
 	IP net.IP `json:",omitempty"`
+	// FilterListID is the ID of the rule's filter list.
+	FilterListID int64 `json:",omitempty"`
 }
 
 // Result contains the result of a request check.
@@ -657,26 +657,43 @@ func (d *DNSFilter) initFiltering(allowFilters, blockFilters []Filter) error {
 	return nil
 }
 
-// matchHostProcessAllowList processes the allowlist logic of host
-// matching.
-func (d *DNSFilter) matchHostProcessAllowList(host string, dnsres urlfilter.DNSResult) (res Result, err error) {
-	var rule rules.Rule
-	if dnsres.NetworkRule != nil {
-		rule = dnsres.NetworkRule
-	} else if len(dnsres.HostRulesV4) > 0 {
-		rule = dnsres.HostRulesV4[0]
-	} else if len(dnsres.HostRulesV6) > 0 {
-		rule = dnsres.HostRulesV6[0]
+// hostRules is a helper that converts a slice of host rules into a slice of the
+// rules.Rule interface values.
+func hostRulesToRules(netRules []*rules.HostRule) (res []rules.Rule) {
+	if netRules == nil {
+		return nil
 	}
 
-	if rule == nil {
+	res = make([]rules.Rule, len(netRules))
+	for i, nr := range netRules {
+		res[i] = nr
+	}
+
+	return res
+}
+
+// matchHostProcessAllowList processes the allowlist logic of host
+// matching.
+func (d *DNSFilter) matchHostProcessAllowList(
+	host string,
+	dnsres urlfilter.DNSResult,
+) (res Result, err error) {
+	var matchedRules []rules.Rule
+	if dnsres.NetworkRule != nil {
+		matchedRules = []rules.Rule{dnsres.NetworkRule}
+	} else if len(dnsres.HostRulesV4) > 0 {
+		matchedRules = hostRulesToRules(dnsres.HostRulesV4)
+	} else if len(dnsres.HostRulesV6) > 0 {
+		matchedRules = hostRulesToRules(dnsres.HostRulesV6)
+	}
+
+	if len(matchedRules) == 0 {
 		return Result{}, fmt.Errorf("invalid dns result: rules are empty")
 	}
 
-	log.Debug("Filtering: found allowlist rule for host %q: %q  list_id: %d",
-		host, rule.Text(), rule.GetFilterListID())
+	log.Debug("filtering: allowlist rules for host %q: %+v", host, matchedRules)
 
-	return makeResult(rule, NotFilteredAllowList), nil
+	return makeResult(matchedRules, NotFilteredAllowList), nil
 }
 
 // matchHostProcessDNSResult processes the matched DNS filtering result.
@@ -690,21 +707,23 @@ func (d *DNSFilter) matchHostProcessDNSResult(
 			reason = NotFilteredAllowList
 		}
 
-		return makeResult(dnsres.NetworkRule, reason)
+		return makeResult([]rules.Rule{dnsres.NetworkRule}, reason)
 	}
 
 	if qtype == dns.TypeA && dnsres.HostRulesV4 != nil {
-		rule := dnsres.HostRulesV4[0]
-		res = makeResult(rule, FilteredBlockList)
-		res.Rules[0].IP = rule.IP.To4()
+		res = makeResult(hostRulesToRules(dnsres.HostRulesV4), FilteredBlockList)
+		for i, hr := range dnsres.HostRulesV4 {
+			res.Rules[i].IP = hr.IP.To4()
+		}
 
 		return res
 	}
 
 	if qtype == dns.TypeAAAA && dnsres.HostRulesV6 != nil {
-		rule := dnsres.HostRulesV6[0]
-		res = makeResult(rule, FilteredBlockList)
-		res.Rules[0].IP = rule.IP.To16()
+		res = makeResult(hostRulesToRules(dnsres.HostRulesV6), FilteredBlockList)
+		for i, hr := range dnsres.HostRulesV6 {
+			res.Rules[i].IP = hr.IP.To16()
+		}
 
 		return res
 	}
@@ -712,17 +731,14 @@ func (d *DNSFilter) matchHostProcessDNSResult(
 	if dnsres.HostRulesV4 != nil || dnsres.HostRulesV6 != nil {
 		// Question type doesn't match the host rules.  Return the first
 		// matched host rule, but without an IP address.
-		var rule rules.Rule
+		var matchedRules []rules.Rule
 		if dnsres.HostRulesV4 != nil {
-			rule = dnsres.HostRulesV4[0]
+			matchedRules = []rules.Rule{dnsres.HostRulesV4[0]}
 		} else if dnsres.HostRulesV6 != nil {
-			rule = dnsres.HostRulesV6[0]
+			matchedRules = []rules.Rule{dnsres.HostRulesV6[0]}
 		}
 
-		res = makeResult(rule, FilteredBlockList)
-		res.Rules[0].IP = net.IP{}
-
-		return res
+		return makeResult(matchedRules, FilteredBlockList)
 	}
 
 	return Result{}
@@ -780,8 +796,7 @@ func (d *DNSFilter) matchHost(
 	}
 
 	res = d.matchHostProcessDNSResult(qtype, dnsres)
-	if len(res.Rules) > 0 {
-		r := res.Rules[0]
+	for _, r := range res.Rules {
 		log.Debug(
 			"filtering: found rule %q for host %q, filter list id: %d",
 			r.Text,
@@ -794,20 +809,20 @@ func (d *DNSFilter) matchHost(
 }
 
 // makeResult returns a properly constructed Result.
-func makeResult(rule rules.Rule, reason Reason) Result {
-	res := Result{
-		Reason: reason,
-		Rules: []*ResultRule{{
-			FilterListID: int64(rule.GetFilterListID()),
-			Text:         rule.Text(),
-		}},
+func makeResult(matchedRules []rules.Rule, reason Reason) (res Result) {
+	resRules := make([]*ResultRule, len(matchedRules))
+	for i, mr := range matchedRules {
+		resRules[i] = &ResultRule{
+			FilterListID: int64(mr.GetFilterListID()),
+			Text:         mr.Text(),
+		}
 	}
 
-	if reason == FilteredBlockList {
-		res.IsFiltered = true
+	return Result{
+		IsFiltered: reason == FilteredBlockList,
+		Reason:     reason,
+		Rules:      resRules,
 	}
-
-	return res
 }
 
 // InitModule manually initializes blocked services map.
diff --git a/internal/filtering/filtering_test.go b/internal/filtering/filtering_test.go
index b63e1055..746e9ed0 100644
--- a/internal/filtering/filtering_test.go
+++ b/internal/filtering/filtering_test.go
@@ -60,29 +60,33 @@ func (d *DNSFilter) checkMatch(t *testing.T, hostname string) {
 	t.Helper()
 
 	res, err := d.CheckHost(hostname, dns.TypeA, &setts)
-	require.Nilf(t, err, "Error while matching host %s: %s", hostname, err)
-	assert.Truef(t, res.IsFiltered, "Expected hostname %s to match", hostname)
+	require.NoErrorf(t, err, "host %q", hostname)
+
+	assert.Truef(t, res.IsFiltered, "host %q", hostname)
 }
 
 func (d *DNSFilter) checkMatchIP(t *testing.T, hostname, ip string, qtype uint16) {
 	t.Helper()
 
 	res, err := d.CheckHost(hostname, qtype, &setts)
-	require.Nilf(t, err, "Error while matching host %s: %s", hostname, err)
-	assert.Truef(t, res.IsFiltered, "Expected hostname %s to match", hostname)
+	require.NoErrorf(t, err, "host %q", hostname, err)
+	require.NotEmpty(t, res.Rules, "host %q", hostname)
+
+	assert.Truef(t, res.IsFiltered, "host %q", hostname)
 
-	require.NotEmpty(t, res.Rules, "Expected result to have rules")
 	r := res.Rules[0]
 	require.NotNilf(t, r.IP, "Expected ip %s to match, actual: %v", ip, r.IP)
-	assert.Equalf(t, ip, r.IP.String(), "Expected ip %s to match, actual: %v", ip, r.IP)
+
+	assert.Equalf(t, ip, r.IP.String(), "host %q", hostname)
 }
 
 func (d *DNSFilter) checkMatchEmpty(t *testing.T, hostname string) {
 	t.Helper()
 
 	res, err := d.CheckHost(hostname, dns.TypeA, &setts)
-	require.Nilf(t, err, "Error while matching host %s: %s", hostname, err)
-	assert.Falsef(t, res.IsFiltered, "Expected hostname %s to not match", hostname)
+	require.NoErrorf(t, err, "host %q", hostname)
+
+	assert.Falsef(t, res.IsFiltered, "host %q", hostname)
 }
 
 func TestEtcHostsMatching(t *testing.T) {
@@ -112,10 +116,12 @@ func TestEtcHostsMatching(t *testing.T) {
 
 	// Empty IPv6.
 	res, err := d.CheckHost("block.com", dns.TypeAAAA, &setts)
-	require.Nil(t, err)
+	require.NoError(t, err)
+
 	assert.True(t, res.IsFiltered)
 
 	require.Len(t, res.Rules, 1)
+
 	assert.Equal(t, "0.0.0.0 block.com", res.Rules[0].Text)
 	assert.Empty(t, res.Rules[0].IP)
 
@@ -124,27 +130,34 @@ func TestEtcHostsMatching(t *testing.T) {
 
 	// Empty IPv4.
 	res, err = d.CheckHost("ipv6.com", dns.TypeA, &setts)
-	require.Nil(t, err)
+	require.NoError(t, err)
+
 	assert.True(t, res.IsFiltered)
 
 	require.Len(t, res.Rules, 1)
+
 	assert.Equal(t, "::1  ipv6.com", res.Rules[0].Text)
 	assert.Empty(t, res.Rules[0].IP)
 
-	// Two IPv4, the first one returned.
+	// Two IPv4, both must be returned.
 	res, err = d.CheckHost("host2", dns.TypeA, &setts)
-	require.Nil(t, err)
+	require.NoError(t, err)
+
 	assert.True(t, res.IsFiltered)
 
-	require.Len(t, res.Rules, 1)
+	require.Len(t, res.Rules, 2)
+
 	assert.Equal(t, res.Rules[0].IP, net.IP{0, 0, 0, 1})
+	assert.Equal(t, res.Rules[1].IP, net.IP{0, 0, 0, 2})
 
 	// One IPv6 address.
 	res, err = d.CheckHost("host2", dns.TypeAAAA, &setts)
-	require.Nil(t, err)
+	require.NoError(t, err)
+
 	assert.True(t, res.IsFiltered)
 
 	require.Len(t, res.Rules, 1)
+
 	assert.Equal(t, res.Rules[0].IP, net.IPv6loopback)
 }
 
@@ -205,8 +218,9 @@ func TestSafeSearch(t *testing.T) {
 	d := newForTest(&Config{SafeSearchEnabled: true}, nil)
 	t.Cleanup(d.Close)
 	val, ok := d.SafeSearchDomain("www.google.com")
-	require.True(t, ok, "Expected safesearch to find result for www.google.com")
-	assert.Equal(t, "forcesafesearch.google.com", val, "Expected safesearch for google.com to be forcesafesearch.google.com")
+	require.True(t, ok)
+
+	assert.Equal(t, "forcesafesearch.google.com", val)
 }
 
 func TestCheckHostSafeSearchYandex(t *testing.T) {
@@ -226,10 +240,12 @@ func TestCheckHostSafeSearchYandex(t *testing.T) {
 	} {
 		t.Run(strings.ToLower(host), func(t *testing.T) {
 			res, err := d.CheckHost(host, dns.TypeA, &setts)
-			require.Nil(t, err)
+			require.NoError(t, err)
+
 			assert.True(t, res.IsFiltered)
 
 			require.Len(t, res.Rules, 1)
+
 			assert.Equal(t, yandexIP, res.Rules[0].IP)
 		})
 	}
@@ -257,9 +273,12 @@ func TestCheckHostSafeSearchGoogle(t *testing.T) {
 	} {
 		t.Run(host, func(t *testing.T) {
 			res, err := d.CheckHost(host, dns.TypeA, &setts)
-			require.Nil(t, err)
+			require.NoError(t, err)
+
 			assert.True(t, res.IsFiltered)
+
 			require.Len(t, res.Rules, 1)
+
 			assert.Equal(t, ip, res.Rules[0].IP)
 		})
 	}
@@ -272,8 +291,10 @@ func TestSafeSearchCacheYandex(t *testing.T) {
 
 	// Check host with disabled safesearch.
 	res, err := d.CheckHost(domain, dns.TypeA, &setts)
-	require.Nil(t, err)
+	require.NoError(t, err)
+
 	assert.False(t, res.IsFiltered)
+
 	require.Empty(t, res.Rules)
 
 	yandexIP := net.IPv4(213, 180, 193, 56)
@@ -282,7 +303,7 @@ func TestSafeSearchCacheYandex(t *testing.T) {
 	t.Cleanup(d.Close)
 
 	res, err = d.CheckHost(domain, dns.TypeA, &setts)
-	require.Nilf(t, err, "CheckHost for safesearh domain %s failed cause %s", domain, err)
+	require.NoError(t, err)
 
 	// For yandex we already know valid IP.
 	require.Len(t, res.Rules, 1)
@@ -292,6 +313,7 @@ func TestSafeSearchCacheYandex(t *testing.T) {
 	cachedValue, isFound := getCachedResult(gctx.safeSearchCache, domain)
 	require.True(t, isFound)
 	require.Len(t, cachedValue.Rules, 1)
+
 	assert.Equal(t, cachedValue.Rules[0].IP, yandexIP)
 }
 
@@ -304,8 +326,10 @@ func TestSafeSearchCacheGoogle(t *testing.T) {
 
 	const domain = "www.google.ru"
 	res, err := d.CheckHost(domain, dns.TypeA, &setts)
-	require.Nil(t, err)
+	require.NoError(t, err)
+
 	assert.False(t, res.IsFiltered)
+
 	require.Empty(t, res.Rules)
 
 	d = newForTest(&Config{SafeSearchEnabled: true}, nil)
@@ -314,10 +338,10 @@ func TestSafeSearchCacheGoogle(t *testing.T) {
 
 	// Lookup for safesearch domain.
 	safeDomain, ok := d.SafeSearchDomain(domain)
-	require.Truef(t, ok, "Failed to get safesearch domain for %s", domain)
+	require.True(t, ok)
 
 	ips, err := resolver.LookupIP(context.Background(), "ip", safeDomain)
-	require.Nilf(t, err, "Failed to lookup for %s", safeDomain)
+	require.NoError(t, err)
 
 	var ip net.IP
 	for _, foundIP := range ips {
@@ -329,14 +353,16 @@ func TestSafeSearchCacheGoogle(t *testing.T) {
 	}
 
 	res, err = d.CheckHost(domain, dns.TypeA, &setts)
-	require.Nil(t, err)
+	require.NoError(t, err)
 	require.Len(t, res.Rules, 1)
+
 	assert.True(t, res.Rules[0].IP.Equal(ip))
 
 	// Check cache.
 	cachedValue, isFound := getCachedResult(gctx.safeSearchCache, domain)
 	require.True(t, isFound)
 	require.Len(t, cachedValue.Rules, 1)
+
 	assert.True(t, cachedValue.Rules[0].IP.Equal(ip))
 }
 
@@ -357,6 +383,7 @@ func TestParentalControl(t *testing.T) {
 
 	d.checkMatch(t, matching)
 	require.Contains(t, logOutput.String(), "Parental lookup for "+matching)
+
 	d.checkMatch(t, "www."+matching)
 	d.checkMatchEmpty(t, "www.yandex.ru")
 	d.checkMatchEmpty(t, "yandex.ru")
@@ -654,7 +681,8 @@ func TestMatching(t *testing.T) {
 			t.Cleanup(d.Close)
 
 			res, err := d.CheckHost(tc.host, tc.wantDNSType, &setts)
-			require.Nilf(t, err, "Error while matching host %s: %s", tc.host, err)
+			require.NoError(t, err)
+
 			assert.Equalf(t, tc.wantIsFiltered, res.IsFiltered, "Hostname %s has wrong result (%v must be %v)", tc.host, res.IsFiltered, tc.wantIsFiltered)
 			assert.Equalf(t, tc.wantReason, res.Reason, "Hostname %s has wrong reason (%v must be %v)", tc.host, res.Reason, tc.wantReason)
 		})
@@ -677,23 +705,31 @@ func TestWhitelist(t *testing.T) {
 	}}
 	d := newForTest(nil, filters)
 
-	require.Nil(t, d.SetFilters(filters, whiteFilters, false))
+	err := d.SetFilters(filters, whiteFilters, false)
+	require.NoError(t, err)
+
 	t.Cleanup(d.Close)
 
 	// Matched by white filter.
 	res, err := d.CheckHost("host1", dns.TypeA, &setts)
-	require.Nil(t, err)
+	require.NoError(t, err)
+
 	assert.False(t, res.IsFiltered)
 	assert.Equal(t, res.Reason, NotFilteredAllowList)
+
 	require.Len(t, res.Rules, 1)
+
 	assert.Equal(t, "||host1^", res.Rules[0].Text)
 
 	// Not matched by white filter, but matched by block filter.
 	res, err = d.CheckHost("host2", dns.TypeA, &setts)
-	require.Nil(t, err)
+	require.NoError(t, err)
+
 	assert.True(t, res.IsFiltered)
 	assert.Equal(t, res.Reason, FilteredBlockList)
+
 	require.Len(t, res.Rules, 1)
+
 	assert.Equal(t, "||host2^", res.Rules[0].Text)
 }
 
@@ -796,7 +832,8 @@ func BenchmarkSafeBrowsing(b *testing.B) {
 	})
 	for n := 0; n < b.N; n++ {
 		res, err := d.CheckHost(blocked, dns.TypeA, &setts)
-		require.Nilf(b, err, "Error while matching host %s: %s", blocked, err)
+		require.NoError(b, err)
+
 		assert.True(b, res.IsFiltered, "Expected hostname %s to match", blocked)
 	}
 }
@@ -812,7 +849,8 @@ func BenchmarkSafeBrowsingParallel(b *testing.B) {
 	b.RunParallel(func(pb *testing.PB) {
 		for pb.Next() {
 			res, err := d.CheckHost(blocked, dns.TypeA, &setts)
-			require.Nilf(b, err, "Error while matching host %s: %s", blocked, err)
+			require.NoError(b, err)
+
 			assert.True(b, res.IsFiltered, "Expected hostname %s to match", blocked)
 		}
 	})
@@ -823,7 +861,8 @@ func BenchmarkSafeSearch(b *testing.B) {
 	b.Cleanup(d.Close)
 	for n := 0; n < b.N; n++ {
 		val, ok := d.SafeSearchDomain("www.google.com")
-		require.True(b, ok, "Expected safesearch to find result for www.google.com")
+		require.True(b, ok)
+
 		assert.Equal(b, "forcesafesearch.google.com", val, "Expected safesearch for google.com to be forcesafesearch.google.com")
 	}
 }
@@ -834,7 +873,8 @@ func BenchmarkSafeSearchParallel(b *testing.B) {
 	b.RunParallel(func(pb *testing.PB) {
 		for pb.Next() {
 			val, ok := d.SafeSearchDomain("www.google.com")
-			require.True(b, ok, "Expected safesearch to find result for www.google.com")
+			require.True(b, ok)
+
 			assert.Equal(b, "forcesafesearch.google.com", val, "Expected safesearch for google.com to be forcesafesearch.google.com")
 		}
 	})