Files
AdGuardHome/internal/filtering/filtering.go
Stanislav Chzhen 86de4e75f0 Pull request 2442: AGDNS-3061-config-modifier
Merge in DNS/adguard-home from AGDNS-3061-config-modifier to master

Squashed commit of the following:

commit a0068547bd0209d12e8dbf98ddd5e4ed2545cdd0
Merge: 97b798af6 451255675
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Aug 5 17:39:31 2025 +0300

    Merge branch 'master' into AGDNS-3061-config-modifier

commit 97b798af6a50ee27ee5ed2bcf1c4c3670f5afc5d
Merge: 96d21efc9 b8043e4f0
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Wed Jul 30 20:27:41 2025 +0300

    Merge branch 'master' into AGDNS-3061-config-modifier

commit 96d21efc984073adef5026de8a03b5bf94542648
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Wed Jul 30 20:18:43 2025 +0300

    all: imp code

commit 67c5608b4be3bd712a0ab5980f25ecea1ed21d65
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Tue Jul 29 20:31:19 2025 +0300

    all: imp code

commit 52f45a9f70f57d8e7f7fc0e9e8291ff0dde74343
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Mon Jul 28 20:32:13 2025 +0300

    all: use config modifier

commit d389ffd286460d8ff1964bd2ee8dabdafb832b9b
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Fri Jul 25 14:20:31 2025 +0300

    bamboo-specs: fix ci

commit 3f303ac9131a07e4af5783696b237436fd93d65c
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Fri Jul 25 14:18:42 2025 +0300

    home: config modifier
2025-08-05 18:16:39 +03:00

1302 lines
35 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package filtering implements a DNS request and response filter.
package filtering
import (
"context"
"fmt"
"io/fs"
"log/slog"
"net"
"net/http"
"net/netip"
"os"
"path/filepath"
"runtime"
"runtime/debug"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/AdguardTeam/AdGuardHome/internal/agh"
"github.com/AdguardTeam/AdGuardHome/internal/aghhttp"
"github.com/AdguardTeam/AdGuardHome/internal/aghos"
"github.com/AdguardTeam/AdGuardHome/internal/filtering/rulelist"
"github.com/AdguardTeam/golibs/container"
"github.com/AdguardTeam/golibs/errors"
"github.com/AdguardTeam/golibs/hostsfile"
"github.com/AdguardTeam/golibs/logutil/slogutil"
"github.com/AdguardTeam/golibs/mathutil"
"github.com/AdguardTeam/golibs/syncutil"
"github.com/AdguardTeam/urlfilter"
"github.com/AdguardTeam/urlfilter/filterlist"
"github.com/AdguardTeam/urlfilter/rules"
"github.com/miekg/dns"
)
// ServiceEntry - blocked service array element
type ServiceEntry struct {
Name string
Rules []*rules.NetworkRule
}
// Settings are custom filtering settings for a client.
//
// TODO(s.chzhen): Move to the client package.
type Settings struct {
ClientName string
ClientIP netip.Addr
ClientTags []string
ServicesRules []ServiceEntry
// BlockedServices is the configuration of blocked services of a client. It
// is nil if the client does not have any blocked services.
BlockedServices *BlockedServices
ProtectionEnabled bool
FilteringEnabled bool
SafeSearchEnabled bool
SafeBrowsingEnabled bool
ParentalEnabled bool
// ClientSafeSearch is a client configured safe search.
ClientSafeSearch SafeSearch
}
// Resolver is the interface for net.Resolver to simplify testing.
type Resolver interface {
LookupIP(ctx context.Context, network, host string) (ips []net.IP, err error)
}
// Config allows you to configure DNS filtering with New() or just change variables directly.
type Config struct {
// logger is used to log the operations of DNS filtering. It must not be
// nil.
Logger *slog.Logger `yaml:"-"`
// BlockingIPv4 is the IP address to be returned for a blocked A request.
BlockingIPv4 netip.Addr `yaml:"blocking_ipv4"`
// BlockingIPv6 is the IP address to be returned for a blocked AAAA request.
BlockingIPv6 netip.Addr `yaml:"blocking_ipv6"`
// SafeBrowsingChecker is the safe browsing hash-prefix checker.
SafeBrowsingChecker Checker `yaml:"-"`
// ParentControl is the parental control hash-prefix checker.
ParentalControlChecker Checker `yaml:"-"`
SafeSearch SafeSearch `yaml:"-"`
// ApplyClientFiltering retrieves persistent client information using the
// ClientID or client IP address, and applies it to the filtering settings.
// It must not be nil.
ApplyClientFiltering func(clientID string, cliAddr netip.Addr, setts *Settings) `yaml:"-"`
// BlockedServices is the configuration of blocked services.
// Per-client settings can override this configuration.
BlockedServices *BlockedServices `yaml:"blocked_services"`
// EtcHosts is a container of IP-hostname pairs taken from the operating
// system configuration files (e.g. /etc/hosts).
//
// TODO(e.burkov): Move it to dnsforward entirely.
EtcHosts hostsfile.Storage `yaml:"-"`
// ConfModifier is used to update the global configuration. It must not be
// nil.
ConfModifier agh.ConfigModifier `yaml:"-"`
// Register an HTTP handler
HTTPRegister aghhttp.RegisterFunc `yaml:"-"`
// HTTPClient is the client to use for updating the remote filters.
HTTPClient *http.Client `yaml:"-"`
// filtersMu protects filter lists.
filtersMu *sync.RWMutex
// ProtectionDisabledUntil is the timestamp until when the protection is
// disabled.
ProtectionDisabledUntil *time.Time `yaml:"protection_disabled_until"`
SafeSearchConf SafeSearchConfig `yaml:"safe_search"`
// DataDir is used to store filters' contents.
DataDir string `yaml:"-"`
// BlockingMode defines the way how blocked responses are constructed.
BlockingMode BlockingMode `yaml:"blocking_mode"`
// ParentalBlockHost is the IP (or domain name) which is used to respond to
// DNS requests blocked by parental control.
ParentalBlockHost string `yaml:"parental_block_host"`
// SafeBrowsingBlockHost is the IP (or domain name) which is used to respond
// to DNS requests blocked by safe-browsing.
SafeBrowsingBlockHost string `yaml:"safebrowsing_block_host"`
Rewrites []*LegacyRewrite `yaml:"rewrites"`
// Filters are the blocking filter lists.
Filters []FilterYAML `yaml:"-"`
// WhitelistFilters are the allowing filter lists.
WhitelistFilters []FilterYAML `yaml:"-"`
// UserRules is the global list of custom rules.
UserRules []string `yaml:"-"`
// SafeFSPatterns are the patterns for matching which local filtering-rule
// files can be added.
SafeFSPatterns []string `yaml:"safe_fs_patterns"`
SafeBrowsingCacheSize uint `yaml:"safebrowsing_cache_size"` // (in bytes)
SafeSearchCacheSize uint `yaml:"safesearch_cache_size"` // (in bytes)
ParentalCacheSize uint `yaml:"parental_cache_size"` // (in bytes)
// TODO(a.garipov): Use timeutil.Duration
CacheTime uint `yaml:"cache_time"` // Element's TTL (in minutes)
// enabled is used to be returned within Settings.
//
// It is of type uint32 to be accessed by atomic.
//
// TODO(e.burkov): Use atomic.Bool in Go 1.19.
enabled uint32
// FiltersUpdateIntervalHours is the time period to update filters
// (in hours).
FiltersUpdateIntervalHours uint32 `yaml:"filters_update_interval"`
// BlockedResponseTTL is the time-to-live value for blocked responses. If
// 0, then default value is used (3600).
BlockedResponseTTL uint32 `yaml:"blocked_response_ttl"`
// FilteringEnabled indicates whether or not use filter lists.
FilteringEnabled bool `yaml:"filtering_enabled"`
ParentalEnabled bool `yaml:"parental_enabled"`
SafeBrowsingEnabled bool `yaml:"safebrowsing_enabled"`
// ProtectionEnabled defines whether or not use any of filtering features.
ProtectionEnabled bool `yaml:"protection_enabled"`
}
// 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"
)
// LookupStats store stats collected during safebrowsing or parental checks
type LookupStats struct {
Requests uint64 // number of HTTP requests that were sent
CacheHits uint64 // number of lookups that didn't need HTTP requests
Pending int64 // number of currently pending HTTP requests
PendingMax int64 // maximum number of pending HTTP requests
}
// Stats store LookupStats for safebrowsing, parental and safesearch
type Stats struct {
Safebrowsing LookupStats
Parental LookupStats
Safesearch LookupStats
}
// Parameters to pass to filters-initializer goroutine
type filtersInitializerParams struct {
allowFilters []Filter
blockFilters []Filter
}
type hostChecker struct {
check func(host string, qtype uint16, setts *Settings) (res Result, err error)
name string
}
// Checker is used for safe browsing or parental control hash-prefix filtering.
type Checker interface {
// Check returns true if request for the host should be blocked.
Check(host string) (block bool, err error)
}
// DNSFilter matches hostnames and DNS requests against filtering rules.
type DNSFilter struct {
// logger is used for logging the filtering process.
logger *slog.Logger
// idGen is used to generate IDs for package urlfilter.
idGen *idGenerator
// bufPool is a pool of buffers used for filtering-rule list parsing.
bufPool *syncutil.Pool[[]byte]
rulesStorage *filterlist.RuleStorage
filteringEngine *urlfilter.DNSEngine
rulesStorageAllow *filterlist.RuleStorage
filteringEngineAllow *urlfilter.DNSEngine
safeSearch SafeSearch
// safeBrowsingChecker is the safe browsing hash-prefix checker.
safeBrowsingChecker Checker
// parentalControl is the parental control hash-prefix checker.
parentalControlChecker Checker
// applyClientFiltering retrieves persistent client information using the
// ClientID or client IP address, and applies it to the filtering settings.
//
// TODO(s.chzhen): Consider finding a better approach while taking an
// import cycle into account.
applyClientFiltering func(clientID string, cliAddr netip.Addr, setts *Settings)
engineLock sync.RWMutex
// confMu protects conf.
confMu *sync.RWMutex
// conf contains filtering parameters.
conf *Config
// done is the channel to signal to stop running filters updates loop.
done chan struct{}
// Channel for passing data to filters-initializer goroutine
filtersInitializerChan chan filtersInitializerParams
filtersInitializerLock sync.Mutex
refreshLock *sync.Mutex
hostCheckers []hostChecker
safeFSPatterns []string
}
// Filter represents a filter list
type Filter struct {
// FilePath is the path to a filtering rules list file.
FilePath string `yaml:"-"`
// Data is the content of the file.
Data []byte `yaml:"-"`
// ID is automatically assigned when filter is added using nextFilterID.
ID rulelist.URLFilterID `yaml:"id"`
}
// Reason holds an enum detailing why it was filtered or not filtered
type Reason int
const (
// reasons for not filtering
// NotFilteredNotFound - host was not find in any checks, default value for result
NotFilteredNotFound Reason = iota
// NotFilteredAllowList - the host is explicitly allowed
NotFilteredAllowList
// NotFilteredError is returned when there was an error during
// checking. Reserved, currently unused.
NotFilteredError
// reasons for filtering
// FilteredBlockList - the host was matched to be advertising host
FilteredBlockList
// FilteredSafeBrowsing - the host was matched to be malicious/phishing
FilteredSafeBrowsing
// FilteredParental - the host was matched to be outside of parental control settings
FilteredParental
// FilteredInvalid - the request was invalid and was not processed
FilteredInvalid
// FilteredSafeSearch - the host was replaced with safesearch variant
FilteredSafeSearch
// FilteredBlockedService - the host is blocked by "blocked services" settings
FilteredBlockedService
// Rewritten is returned when there was a rewrite by a legacy DNS rewrite
// rule.
Rewritten
// RewrittenAutoHosts is returned when there was a rewrite by autohosts
// rules (/etc/hosts and so on).
RewrittenAutoHosts
// RewrittenRule is returned when a $dnsrewrite filter rule was applied.
//
// TODO(a.garipov): Remove Rewritten and RewrittenAutoHosts by merging their
// functionality into RewrittenRule.
//
// See https://github.com/AdguardTeam/AdGuardHome/issues/2499.
RewrittenRule
)
// TODO(a.garipov): Resync with actual code names or replace completely
// in HTTP API v1.
var reasonNames = []string{
NotFilteredNotFound: "NotFilteredNotFound",
NotFilteredAllowList: "NotFilteredWhiteList",
NotFilteredError: "NotFilteredError",
FilteredBlockList: "FilteredBlackList",
FilteredSafeBrowsing: "FilteredSafeBrowsing",
FilteredParental: "FilteredParental",
FilteredInvalid: "FilteredInvalid",
FilteredSafeSearch: "FilteredSafeSearch",
FilteredBlockedService: "FilteredBlockedService",
Rewritten: "Rewrite",
RewrittenAutoHosts: "RewriteEtcHosts",
RewrittenRule: "RewriteRule",
}
func (r Reason) String() string {
if r < 0 || int(r) >= len(reasonNames) {
return ""
}
return reasonNames[r]
}
// In returns true if reasons include r.
func (r Reason) In(reasons ...Reason) (ok bool) { return slices.Contains(reasons, r) }
// SetEnabled sets the status of the *DNSFilter.
func (d *DNSFilter) SetEnabled(enabled bool) {
atomic.StoreUint32(&d.conf.enabled, mathutil.BoolToNumber[uint32](enabled))
}
// Settings returns filtering settings.
func (d *DNSFilter) Settings() (s *Settings) {
d.confMu.RLock()
defer d.confMu.RUnlock()
return &Settings{
FilteringEnabled: atomic.LoadUint32(&d.conf.enabled) != 0,
SafeSearchEnabled: d.conf.SafeSearchConf.Enabled,
SafeBrowsingEnabled: d.conf.SafeBrowsingEnabled,
ParentalEnabled: d.conf.ParentalEnabled,
}
}
// WriteDiskConfig - write configuration
func (d *DNSFilter) WriteDiskConfig(c *Config) {
func() {
d.confMu.Lock()
defer d.confMu.Unlock()
*c = *d.conf
c.Rewrites = cloneRewrites(c.Rewrites)
}()
d.conf.filtersMu.RLock()
defer d.conf.filtersMu.RUnlock()
c.Filters = slices.Clone(d.conf.Filters)
c.WhitelistFilters = slices.Clone(d.conf.WhitelistFilters)
c.UserRules = slices.Clone(d.conf.UserRules)
}
// setFilters sets new filters, synchronously or asynchronously. When filters
// are set asynchronously, the old filters continue working until the new
// filters are ready.
//
// In this case the caller must ensure that the old filter files are intact.
func (d *DNSFilter) setFilters(
ctx context.Context,
blockFilters []Filter,
allowFilters []Filter,
async bool,
) (err error) {
if async {
params := filtersInitializerParams{
allowFilters: allowFilters,
blockFilters: blockFilters,
}
d.filtersInitializerLock.Lock()
defer d.filtersInitializerLock.Unlock()
// Remove all pending tasks.
removeLoop:
for {
select {
case <-d.filtersInitializerChan:
// Continue removing.
default:
break removeLoop
}
}
d.filtersInitializerChan <- params
return nil
}
return d.initFiltering(ctx, allowFilters, blockFilters)
}
// Close - close the object
func (d *DNSFilter) Close() {
d.engineLock.Lock()
defer d.engineLock.Unlock()
if d.done != nil {
d.done <- struct{}{}
}
d.reset(context.TODO())
}
func (d *DNSFilter) reset(ctx context.Context) {
if d.rulesStorage != nil {
if err := d.rulesStorage.Close(); err != nil {
d.logger.ErrorContext(ctx, "closing rules storage", slogutil.KeyError, err)
}
}
if d.rulesStorageAllow != nil {
if err := d.rulesStorageAllow.Close(); err != nil {
d.logger.ErrorContext(ctx, "closing allow rules storage", slogutil.KeyError, err)
}
}
}
// ProtectionStatus returns the status of protection and time until it's
// disabled if so.
func (d *DNSFilter) ProtectionStatus() (status bool, disabledUntil *time.Time) {
d.confMu.RLock()
defer d.confMu.RUnlock()
return d.conf.ProtectionEnabled, d.conf.ProtectionDisabledUntil
}
// SetProtectionStatus updates the status of protection and time until it's
// disabled.
func (d *DNSFilter) SetProtectionStatus(status bool, disabledUntil *time.Time) {
d.confMu.Lock()
defer d.confMu.Unlock()
d.conf.ProtectionEnabled = status
d.conf.ProtectionDisabledUntil = disabledUntil
}
// SetProtectionEnabled updates the status of protection.
func (d *DNSFilter) SetProtectionEnabled(status bool) {
d.confMu.Lock()
defer d.confMu.Unlock()
d.conf.ProtectionEnabled = status
}
// SetBlockingMode sets blocking mode properties.
func (d *DNSFilter) SetBlockingMode(mode BlockingMode, bIPv4, bIPv6 netip.Addr) {
d.confMu.Lock()
defer d.confMu.Unlock()
d.conf.BlockingMode = mode
if mode == BlockingModeCustomIP {
d.conf.BlockingIPv4 = bIPv4
d.conf.BlockingIPv6 = bIPv6
}
}
// BlockingMode returns blocking mode properties.
func (d *DNSFilter) BlockingMode() (mode BlockingMode, bIPv4, bIPv6 netip.Addr) {
d.confMu.RLock()
defer d.confMu.RUnlock()
return d.conf.BlockingMode, d.conf.BlockingIPv4, d.conf.BlockingIPv6
}
// SetBlockedResponseTTL sets TTL for blocked responses.
func (d *DNSFilter) SetBlockedResponseTTL(ttl uint32) {
d.confMu.Lock()
defer d.confMu.Unlock()
d.conf.BlockedResponseTTL = ttl
}
// BlockedResponseTTL returns TTL for blocked responses.
func (d *DNSFilter) BlockedResponseTTL() (ttl uint32) {
d.confMu.Lock()
defer d.confMu.Unlock()
return d.conf.BlockedResponseTTL
}
// SafeBrowsingBlockHost returns a host for safe browsing blocked responses.
func (d *DNSFilter) SafeBrowsingBlockHost() (host string) {
return d.conf.SafeBrowsingBlockHost
}
// ParentalBlockHost returns a host for parental protection blocked responses.
func (d *DNSFilter) ParentalBlockHost() (host string) {
return d.conf.ParentalBlockHost
}
// ResultRule contains information about applied rules.
type ResultRule struct {
// 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 netip.Addr `json:",omitempty"`
// FilterListID is the ID of the rule's filter list.
FilterListID rulelist.URLFilterID `json:",omitempty"`
}
// Result contains the result of a request check.
//
// All fields transitively have omitempty tags so that the query log doesn't
// become too large.
//
// TODO(a.garipov): Clarify relationships between fields. Perhaps replace with
// a sum type or an interface?
type Result struct {
// DNSRewriteResult is the $dnsrewrite filter rule result.
DNSRewriteResult *DNSRewriteResult `json:",omitempty"`
// CanonName is the CNAME value from the lookup rewrite result. It is empty
// unless Reason is set to Rewritten or RewrittenRule.
CanonName string `json:",omitempty"`
// ServiceName is the name of the blocked service. It is empty unless
// Reason is set to FilteredBlockedService.
ServiceName string `json:",omitempty"`
// IPList is the lookup rewrite result. It is empty unless Reason is set to
// Rewritten.
IPList []netip.Addr `json:",omitempty"`
// Rules are applied rules. If Rules are not empty, each rule is not nil.
Rules []*ResultRule `json:",omitempty"`
// Reason is the reason for blocking or unblocking the request.
Reason Reason `json:",omitempty"`
// IsFiltered is true if the request is filtered.
//
// TODO(d.kolyshev): Get rid of this flag.
IsFiltered bool `json:",omitempty"`
}
// Matched returns true if any match at all was found regardless of
// whether it was filtered or not.
func (r Reason) Matched() bool {
return r != NotFilteredNotFound
}
// CheckHostRules tries to match the host against filtering rules only.
func (d *DNSFilter) CheckHostRules(host string, rrtype uint16, setts *Settings) (Result, error) {
return d.matchHost(strings.ToLower(host), rrtype, setts)
}
// CheckHost tries to match the host against filtering rules, then safebrowsing
// and parental control rules, if they are enabled.
func (d *DNSFilter) CheckHost(
host string,
qtype uint16,
setts *Settings,
) (res Result, err error) {
// Sometimes clients try to resolve ".", which is a request to get root
// servers.
if host == "" {
return Result{}, nil
}
host = strings.ToLower(host)
if setts.FilteringEnabled {
res = d.processRewrites(host, qtype)
if res.Reason == Rewritten {
return res, nil
}
}
for _, hc := range d.hostCheckers {
res, err = hc.check(host, qtype, setts)
if err != nil {
return Result{}, fmt.Errorf("%s: %w", hc.name, err)
}
if res.Reason.Matched() {
return res, nil
}
}
return Result{}, nil
}
// processRewrites performs filtering based on the legacy rewrite records.
//
// Firstly, it finds CNAME rewrites for host. If the CNAME is the same as host,
// this query isn't filtered. If it's different, repeat the process for the new
// CNAME, breaking loops in the process.
//
// Secondly, it finds A or AAAA rewrites for host and, if found, sets res.IPList
// accordingly. If the found rewrite has a special value of "A" or "AAAA", the
// result is an exception.
func (d *DNSFilter) processRewrites(host string, qtype uint16) (res Result) {
d.confMu.RLock()
defer d.confMu.RUnlock()
ctx := context.TODO()
rewrites, matched := findRewrites(d.conf.Rewrites, host, qtype)
if !matched {
return Result{}
}
res.Reason = Rewritten
cnames := container.NewMapSet[string]()
origHost := host
for matched && len(rewrites) > 0 && rewrites[0].Type == dns.TypeCNAME {
rw := rewrites[0]
rwPat := rw.Domain
rwAns := rw.Answer
d.logger.DebugContext(ctx, "found rewrite", "host", host, "cname", rwAns)
if origHost == rwAns || rwPat == rwAns {
// Either a request for the hostname itself or a rewrite of
// a pattern onto itself, both of which are an exception rules.
// Return a not filtered result.
return Result{}
} else if host == rwAns && isWildcard(rwPat) {
// An "*.example.com → sub.example.com" rewrite matching in a loop.
//
// See https://github.com/AdguardTeam/AdGuardHome/issues/4016.
res.CanonName = host
break
}
host = rwAns
if cnames.Has(host) {
d.logger.InfoContext(ctx, "cname loop", "host", host, "original", origHost)
return res
}
cnames.Add(host)
res.CanonName = host
rewrites, matched = findRewrites(d.conf.Rewrites, host, qtype)
}
d.setRewriteResult(ctx, &res, host, rewrites, qtype)
return res
}
// matchBlockedServicesRules checks the host against the blocked services rules
// in settings, if any. err is always nil, it is only there to make this a
// valid hostChecker function.
func (d *DNSFilter) matchBlockedServicesRules(
host string,
_ uint16,
setts *Settings,
) (res Result, err error) {
if !setts.ProtectionEnabled {
return Result{}, nil
}
svcs := setts.ServicesRules
if len(svcs) == 0 {
return Result{}, nil
}
req := rules.NewRequestForHostname(host)
for _, s := range svcs {
for _, rule := range s.Rules {
if rule.Match(req) {
res.Reason = FilteredBlockedService
res.IsFiltered = true
res.ServiceName = s.Name
ruleText := rule.Text()
res.Rules = []*ResultRule{{
FilterListID: rule.GetFilterListID(),
Text: ruleText,
}}
d.logger.DebugContext(
context.TODO(),
"blocked services matched rule",
"rule", ruleText,
"host", host,
"service", s.Name,
)
return res, nil
}
}
}
return res, nil
}
//
// Adding rule and matching against the rules
//
func newRuleStorage(filters []Filter) (rs *filterlist.RuleStorage, err error) {
lists := make([]filterlist.RuleList, 0, len(filters))
for _, f := range filters {
var rl filterlist.RuleList
var skip bool
rl, skip, err = ruleListFromFilter(f)
if skip {
continue
}
if err != nil {
// Don't wrap the error, because it's informative enough as is.
return nil, err
}
lists = append(lists, rl)
}
rs, err = filterlist.NewRuleStorage(lists)
if err != nil {
return nil, fmt.Errorf("creating rule storage: %w", err)
}
return rs, nil
}
// ruleListFromFilter returns a rule list from a Filter.
func ruleListFromFilter(f Filter) (rl filterlist.RuleList, skip bool, err error) {
id := int(f.ID)
if len(f.Data) != 0 {
return &filterlist.StringRuleList{
ID: id,
RulesText: string(f.Data),
IgnoreCosmetic: true,
}, false, nil
}
if f.FilePath == "" {
return nil, true, nil
}
if runtime.GOOS == "windows" {
// On Windows we don't pass a file to urlfilter because it's
// difficult to update this file while it's being used.
var data []byte
data, err = os.ReadFile(f.FilePath)
if errors.Is(err, fs.ErrNotExist) {
return nil, true, nil
} else if err != nil {
return nil, false, fmt.Errorf("reading filter content: %w", err)
}
return &filterlist.StringRuleList{
ID: id,
RulesText: string(data),
IgnoreCosmetic: true,
}, false, nil
}
var list *filterlist.FileRuleList
list, err = filterlist.NewFileRuleList(id, f.FilePath, true)
if errors.Is(err, fs.ErrNotExist) {
return nil, true, nil
} else if err != nil {
return nil, false, fmt.Errorf("creating file rule list with %q: %w", f.FilePath, err)
}
return list, false, nil
}
// Initialize urlfilter objects.
func (d *DNSFilter) initFiltering(ctx context.Context, allowFilters, blockFilters []Filter) (err error) {
rulesStorage, err := newRuleStorage(blockFilters)
if err != nil {
return err
}
rulesStorageAllow, err := newRuleStorage(allowFilters)
if err != nil {
return err
}
filteringEngine := urlfilter.NewDNSEngine(rulesStorage)
filteringEngineAllow := urlfilter.NewDNSEngine(rulesStorageAllow)
func() {
d.engineLock.Lock()
defer d.engineLock.Unlock()
d.reset(ctx)
d.rulesStorage = rulesStorage
d.filteringEngine = filteringEngine
d.rulesStorageAllow = rulesStorageAllow
d.filteringEngineAllow = filteringEngineAllow
}()
// Make sure that the OS reclaims memory as soon as possible.
debug.FreeOSMemory()
d.logger.DebugContext(ctx, "initialized filtering engine")
return nil
}
// 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
}
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(
ctx context.Context,
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")
}
d.logger.DebugContext(
ctx,
"allowlist rules for host",
"host", host,
"rules", matchedRules,
)
return makeResult(matchedRules, NotFilteredAllowList), nil
}
// matchHostProcessDNSResult processes the matched DNS filtering result.
func (d *DNSFilter) matchHostProcessDNSResult(
qtype uint16,
dnsres *urlfilter.DNSResult,
) (res Result) {
if dnsres.NetworkRule != nil {
reason := FilteredBlockList
if dnsres.NetworkRule.Whitelist {
reason = NotFilteredAllowList
}
return makeResult([]rules.Rule{dnsres.NetworkRule}, reason)
}
if result, ok := resultFromHostRules(qtype, dnsres); ok {
return result
}
return hostResultForOtherQType(dnsres)
}
// resultFromHostRules handles the HostRulesV4/HostRulesV6 case for
// [matchHostProcessDNSResult]. dnsres must not be nil.
func resultFromHostRules(qtype uint16, dnsres *urlfilter.DNSResult) (res Result, ok bool) {
if qtype == dns.TypeA && dnsres.HostRulesV4 != nil {
res = makeResult(hostRulesToRules(dnsres.HostRulesV4), FilteredBlockList)
for i, hr := range dnsres.HostRulesV4 {
res.Rules[i].IP = hr.IP
}
return res, true
}
if qtype == dns.TypeAAAA && dnsres.HostRulesV6 != nil {
res = makeResult(hostRulesToRules(dnsres.HostRulesV6), FilteredBlockList)
for i, hr := range dnsres.HostRulesV6 {
res.Rules[i].IP = hr.IP
}
return res, true
}
return Result{}, false
}
// hostResultForOtherQType returns a result based on the host rules in dnsres,
// if any. dnsres.HostRulesV4 take precedence over dnsres.HostRulesV6.
func hostResultForOtherQType(dnsres *urlfilter.DNSResult) (res Result) {
if len(dnsres.HostRulesV4) != 0 {
return makeResult([]rules.Rule{dnsres.HostRulesV4[0]}, FilteredBlockList)
}
if len(dnsres.HostRulesV6) != 0 {
return makeResult([]rules.Rule{dnsres.HostRulesV6[0]}, FilteredBlockList)
}
return Result{}
}
// matchHost is a low-level way to check only if host is filtered by rules,
// skipping expensive safebrowsing and parental lookups.
func (d *DNSFilter) matchHost(
host string,
rrtype uint16,
setts *Settings,
) (res Result, err error) {
if !setts.FilteringEnabled {
return Result{}, nil
}
ctx := context.TODO()
ufReq := &urlfilter.DNSRequest{
Hostname: host,
SortedClientTags: setts.ClientTags,
ClientIP: setts.ClientIP,
ClientName: setts.ClientName,
DNSType: rrtype,
}
d.engineLock.RLock()
// Keep in mind that this lock must be held no just when calling Match() but
// also while using the rules returned by it.
//
// TODO(e.burkov): Inspect if the above is true.
defer d.engineLock.RUnlock()
if setts.ProtectionEnabled && d.filteringEngineAllow != nil {
dnsres, ok := d.filteringEngineAllow.MatchRequest(ufReq)
if ok {
return d.matchHostProcessAllowList(ctx, host, dnsres)
}
}
if d.filteringEngine == nil {
return Result{}, nil
}
dnsres, matchedEngine := d.filteringEngine.MatchRequest(ufReq)
// Check DNS rewrites first, because the API there is a bit awkward.
dnsRWRes := d.processDNSResultRewrites(dnsres, host)
if dnsRWRes.Reason != NotFilteredNotFound {
return dnsRWRes, nil
} else if !matchedEngine {
return Result{}, nil
}
if !setts.ProtectionEnabled {
// Don't check non-dnsrewrite filtering results.
return Result{}, nil
}
res = d.matchHostProcessDNSResult(rrtype, dnsres)
for _, r := range res.Rules {
d.logger.DebugContext(
ctx,
"found rule for host",
"host", host,
"rule", r.Text,
"filter_list_id", r.FilterListID,
)
}
return res, nil
}
// makeResult returns a properly constructed Result.
func makeResult(matchedRules []rules.Rule, reason Reason) (res Result) {
resRules := make([]*ResultRule, len(matchedRules))
for i, mr := range matchedRules {
resRules[i] = &ResultRule{
FilterListID: mr.GetFilterListID(),
Text: mr.Text(),
}
}
return Result{
Rules: resRules,
Reason: reason,
IsFiltered: reason == FilteredBlockList,
}
}
// InitModule manually initializes blocked services map. l must not be nil.
func InitModule(ctx context.Context, l *slog.Logger) {
initBlockedServices(ctx, l)
}
// New creates properly initialized DNS Filter that is ready to be used. c must
// be non-nil.
func New(c *Config, blockFilters []Filter) (d *DNSFilter, err error) {
ctx := context.TODO()
d = &DNSFilter{
logger: c.Logger,
idGen: newIDGenerator(int32(time.Now().Unix()), c.Logger),
bufPool: syncutil.NewSlicePool[byte](rulelist.DefaultRuleBufSize),
safeSearch: c.SafeSearch,
refreshLock: &sync.Mutex{},
safeBrowsingChecker: c.SafeBrowsingChecker,
parentalControlChecker: c.ParentalControlChecker,
applyClientFiltering: c.ApplyClientFiltering,
confMu: &sync.RWMutex{},
}
err = d.validateSafeFSPatterns(c.SafeFSPatterns)
if err != nil {
// Don't wrap the error, because it's informative enough as is.
return nil, err
}
d.hostCheckers = []hostChecker{{
check: d.matchSysHosts,
name: "hosts container",
}, {
check: d.matchHost,
name: "filtering",
}, {
check: d.matchBlockedServicesRules,
name: "blocked services",
}, {
check: d.checkSafeBrowsing,
name: "safe browsing",
}, {
check: d.checkParental,
name: "parental",
}, {
check: d.checkSafeSearch,
name: "safe search",
}}
defer func() { err = errors.Annotate(err, "filtering: %w") }()
d.conf = c
d.conf.filtersMu = &sync.RWMutex{}
err = d.prepareRewrites(ctx)
if err != nil {
return nil, fmt.Errorf("rewrites: preparing: %w", err)
}
if d.conf.BlockedServices != nil {
err = d.conf.BlockedServices.Validate()
if err != nil {
return nil, fmt.Errorf("filtering: %w", err)
}
}
if blockFilters != nil {
err = d.initFiltering(ctx, nil, blockFilters)
if err != nil {
d.Close()
return nil, fmt.Errorf("initializing filtering subsystem: %w", err)
}
}
err = os.MkdirAll(filepath.Join(d.conf.DataDir, filterDir), aghos.DefaultPermDir)
if err != nil {
d.Close()
return nil, fmt.Errorf("making filtering directory: %w", err)
}
d.loadFilters(ctx, d.conf.Filters)
d.loadFilters(ctx, d.conf.WhitelistFilters)
d.conf.Filters = deduplicateFilters(d.conf.Filters)
d.conf.WhitelistFilters = deduplicateFilters(d.conf.WhitelistFilters)
d.idGen.fix(d.conf.Filters)
d.idGen.fix(d.conf.WhitelistFilters)
return d, nil
}
// validateSafeFSPatterns validates and stores patterns for local filteringrule
// files.
func (d *DNSFilter) validateSafeFSPatterns(patterns []string) (err error) {
for i, p := range patterns {
// Use Match to validate the patterns here.
_, err = filepath.Match(p, "test")
if err != nil {
return fmt.Errorf("safe_fs_patterns: at index %d: %w", i, err)
}
d.safeFSPatterns = append(d.safeFSPatterns, p)
}
return nil
}
// Start registers web handlers and starts filters updates loop.
func (d *DNSFilter) Start() {
d.filtersInitializerChan = make(chan filtersInitializerParams, 1)
d.done = make(chan struct{}, 1)
d.RegisterFilteringHandlers()
go d.updatesLoop(context.TODO())
}
// updatesLoop initializes new filters and checks for filters updates in a loop.
func (d *DNSFilter) updatesLoop(ctx context.Context) {
defer slogutil.RecoverAndLog(ctx, d.logger)
ivl := time.Second * 5
t := time.NewTimer(ivl)
for {
select {
case params := <-d.filtersInitializerChan:
err := d.initFiltering(ctx, params.allowFilters, params.blockFilters)
if err != nil {
d.logger.ErrorContext(ctx, "initializing", slogutil.KeyError, err)
continue
}
case <-t.C:
ivl = d.periodicallyRefreshFilters(ivl)
t.Reset(ivl)
case <-d.done:
t.Stop()
return
}
}
}
// periodicallyRefreshFilters checks for filters updates and returns time
// interval for the next update.
func (d *DNSFilter) periodicallyRefreshFilters(ivl time.Duration) (nextIvl time.Duration) {
const maxInterval = time.Hour
if d.conf.FiltersUpdateIntervalHours == 0 {
return ivl
}
isNetErr, ok := false, false
_, isNetErr, ok = d.tryRefreshFilters(true, true, false)
if ok && !isNetErr {
ivl = maxInterval
} else if isNetErr {
ivl *= 2
ivl = max(ivl, maxInterval)
}
return ivl
}
// Safe browsing and parental control methods.
// TODO(a.garipov): Unify with checkParental.
func (d *DNSFilter) checkSafeBrowsing(
host string,
_ uint16,
setts *Settings,
) (res Result, err error) {
if !setts.ProtectionEnabled || !setts.SafeBrowsingEnabled {
return Result{}, nil
}
ctx := context.TODO()
if d.logger.Enabled(ctx, slogutil.LevelDebug) {
startTime := time.Now()
defer func() {
elapsed := time.Since(startTime)
d.logger.DebugContext(ctx, "safebrowsing lookup", "host", host, "elapsed", elapsed)
}()
}
res = Result{
Rules: []*ResultRule{{
Text: "adguard-malware-shavar",
FilterListID: rulelist.URLFilterIDSafeBrowsing,
}},
Reason: FilteredSafeBrowsing,
IsFiltered: true,
}
block, err := d.safeBrowsingChecker.Check(host)
if !block || err != nil {
return Result{}, err
}
return res, nil
}
// TODO(a.garipov): Unify with checkSafeBrowsing.
func (d *DNSFilter) checkParental(
host string,
_ uint16,
setts *Settings,
) (res Result, err error) {
if !setts.ProtectionEnabled || !setts.ParentalEnabled {
return Result{}, nil
}
ctx := context.TODO()
if d.logger.Enabled(ctx, slogutil.LevelDebug) {
startTime := time.Now()
defer func() {
elapsed := time.Since(startTime)
d.logger.DebugContext(ctx, "parental lookup", "host", host, "elapsed", elapsed)
}()
}
res = Result{
Rules: []*ResultRule{{
Text: "parental CATEGORY_BLACKLISTED",
FilterListID: rulelist.URLFilterIDParentalControl,
}},
Reason: FilteredParental,
IsFiltered: true,
}
block, err := d.parentalControlChecker.Check(host)
if !block || err != nil {
return Result{}, err
}
return res, nil
}