diff --git a/blocklist.go b/blocklist.go index 230bf54..685d334 100644 --- a/blocklist.go +++ b/blocklist.go @@ -14,33 +14,68 @@ import ( // Blocklist is a resolver that returns NXDOMAIN or a spoofed IP for every query that // matches. Everything else is passed through to another resolver. type Blocklist struct { + BlocklistOptions resolver Resolver - db BlocklistDB - loader BlocklistLoader - mu sync.Mutex + mu sync.RWMutex } var _ Resolver = &Blocklist{} -// NewBlocklist returns a new instance of a blocklist resolver. If a non-nil loader is provided -// the rules are loaded from it immediately into the DB. If refresh is >0, the rules are reloaded -// periodically. -func NewBlocklist(resolver Resolver, db BlocklistDB, l BlocklistLoader, refresh time.Duration) (*Blocklist, error) { - if l != nil { - rules, err := l.Load() +type BlocklistOptions struct { + BlocklistDB BlocklistDB + + // Loader for the blacklist during initialization and refresh. Disabled + // if nil. + BlocklistLoader BlocklistLoader + + // Refresh period for the blocklist. Disabled if 0. + BlocklistRefresh time.Duration + + // Rules that override the blocklist rules, effecively negate them. + AllowlistDB BlocklistDB + + // Loader for the allowlist during initialization and refresh. Disabled + // if nil. + AllowlistLoader BlocklistLoader + + // Refresh period for the allowlist. Disabled if 0. + AllowlistRefresh time.Duration +} + +// NewBlocklist returns a new instance of a blocklist resolver. +func NewBlocklist(resolver Resolver, opt BlocklistOptions) (*Blocklist, error) { + blocklist := &Blocklist{resolver: resolver, BlocklistOptions: opt} + + // Load the blocklist immediately if a loader was given + if blocklist.BlocklistLoader != nil { + rules, err := blocklist.BlocklistLoader.Load() if err != nil { return nil, fmt.Errorf("failed to retrieve rules: %w", err) } - db, err = db.New(rules) + blocklist.BlocklistDB, err = blocklist.BlocklistDB.New(rules) if err != nil { return nil, fmt.Errorf("failed to load rules: %w", err) } } - blocklist := &Blocklist{resolver: resolver, db: db, loader: l} - // Start the refresh goroutine if we have a loader and a refresh period was given - if l != nil && refresh > 0 { - go blocklist.refreshLoop(refresh) + // Load the allowlist immediately if a loader was given + if blocklist.AllowlistLoader != nil { + rules, err := blocklist.AllowlistLoader.Load() + if err != nil { + return nil, fmt.Errorf("failed to retrieve rules: %w", err) + } + blocklist.AllowlistDB, err = blocklist.AllowlistDB.New(rules) + if err != nil { + return nil, fmt.Errorf("failed to load rules: %w", err) + } + } + + // Start the refresh goroutines if we have a loader and a refresh period was given + if blocklist.BlocklistLoader != nil && blocklist.BlocklistRefresh > 0 { + go blocklist.refreshLoopBlocklist(blocklist.BlocklistRefresh) + } + if blocklist.AllowlistLoader != nil && blocklist.AllowlistRefresh > 0 { + go blocklist.refreshLoopAllowlist(blocklist.AllowlistRefresh) } return blocklist, nil } @@ -53,7 +88,21 @@ func (r *Blocklist) Resolve(q *dns.Msg, ci ClientInfo) (*dns.Msg, error) { } question := q.Question[0] log := Log.WithFields(logrus.Fields{"client": ci.SourceIP, "qname": question.Name}) - ip, rule, ok := r.db.Match(question) + + r.mu.RLock() + blocklistDB := r.BlocklistDB + allowlistDB := r.AllowlistDB + r.mu.RUnlock() + + // Forward to upstream immediately if there's a match in the allowlist + if allowlistDB != nil { + if _, rule, ok := allowlistDB.Match(question); ok { + log.WithField("rule", rule).WithField("resolver", r.resolver.String()).Trace("matched allowlist, forwarding") + return r.resolver.Resolve(q, ci) + } + } + + ip, rule, ok := blocklistDB.Match(question) if !ok { // Didn't match anything, pass it on to the next resolver log.WithField("resolver", r.resolver.String()).Trace("forwarding unmodified query to resolver") @@ -102,26 +151,49 @@ func (r *Blocklist) Resolve(q *dns.Msg, ci ClientInfo) (*dns.Msg, error) { } func (r *Blocklist) String() string { - return fmt.Sprintf("Blocklist(%s)", r.db) + r.mu.RLock() + blocklistDB := r.BlocklistDB + r.mu.RUnlock() + return fmt.Sprintf("Blocklist(%s)", blocklistDB) } -func (r *Blocklist) refreshLoop(refresh time.Duration) { +func (r *Blocklist) refreshLoopBlocklist(refresh time.Duration) { for { time.Sleep(refresh) Log.Debug("reloading blocklist") - rules, err := r.loader.Load() + rules, err := r.BlocklistLoader.Load() if err != nil { Log.WithError(err).Error("failed to retrieve rules") continue } - db, err := r.db.New(rules) + db, err := r.BlocklistDB.New(rules) if err != nil { Log.WithError(err).Error("failed to load rules") continue } r.mu.Lock() - r.db = db + r.BlocklistDB = db + r.mu.Unlock() + } +} +func (r *Blocklist) refreshLoopAllowlist(refresh time.Duration) { + for { + time.Sleep(refresh) + Log.Debug("reloading allowlist") + + rules, err := r.AllowlistLoader.Load() + if err != nil { + Log.WithError(err).Error("failed to retrieve rules") + continue + } + db, err := r.AllowlistDB.New(rules) + if err != nil { + Log.WithError(err).Error("failed to load rules") + continue + } + r.mu.Lock() + r.AllowlistDB = db r.mu.Unlock() } } diff --git a/blocklist_test.go b/blocklist_test.go index 40dfe5a..196faf7 100644 --- a/blocklist_test.go +++ b/blocklist_test.go @@ -15,7 +15,10 @@ func TestBlocklistRegexp(t *testing.T) { m, err := NewRegexpDB(`(^|\.)block\.test`, `(^|\.)evil\.test`) require.NoError(t, err) - b, err := NewBlocklist(r, m, nil, 0) + opt := BlocklistOptions{ + BlocklistDB: m, + } + b, err := NewBlocklist(r, opt) require.NoError(t, err) // First query a domain not blocked. Should be passed through to the resolver diff --git a/cmd/routedns/config.go b/cmd/routedns/config.go index 05c7c1d..fc492fe 100644 --- a/cmd/routedns/config.go +++ b/cmd/routedns/config.go @@ -47,16 +47,22 @@ type doh struct { type group struct { Resolvers []string Type string - Blocklist []string // Blocklist rules, only used by "blocklist" type - Format string // Blocklist input format: "regex", "domain", or "hosts" - Source string // Location of external blocklist, can be a local path or remote URL - Refresh int // Blocklist refresh when using an external source, in seconds Replace []rdns.ReplaceOperation // only used by "replace" type GCPeriod int `toml:"gc-period"` // Time-period (seconds) used to expire cached items in the "cache" type ECSOp string `toml:"ecs-op"` // ECS modifier operation, "add", "delete", "privacy" ECSAddress net.IP `toml:"ecs-address"` // ECS address. If empty for "add", uses the client IP. Ignored for "privacy" and "delete" ECSPrefix4 uint8 `toml:"ecs-prefix4"` // ECS IPv4 address prefix, 0-32. Used for "add" and "privacy" ECSPrefix6 uint8 `toml:"ecs-prefix6"` // ECS IPv6 address prefix, 0-128. Used for "add" and "privacy" + + // Blocklist options + Blocklist []string // Blocklist rules, only used by "blocklist" type + Format string // Blocklist input format: "regex", "domain", or "hosts" + Source string // Location of external blocklist, can be a local path or remote URL + Refresh int // Blocklist refresh when using an external source, in seconds + Allowlist []string // Rules to override the blocklist rules + AllowlistFormat string `toml:"allowlist-format"` + AllowlistSource string `toml:"allowlist-source"` + AllowlistRefresh int `toml:"allowlist-refresh"` } type router struct { diff --git a/cmd/routedns/example-config/blocklist-allow.toml b/cmd/routedns/example-config/blocklist-allow.toml new file mode 100644 index 0000000..c54dd7a --- /dev/null +++ b/cmd/routedns/example-config/blocklist-allow.toml @@ -0,0 +1,27 @@ +[resolvers.cloudflare-dot] +address = "1.1.1.1:853" +protocol = "dot" + +[groups.cloudflare-blocklist] +type = "blocklist" +resolvers = ["cloudflare-dot"] +format = "domain" +blocklist = [ + 'evil.com', + '.facebook.com', + '*.twitter.com', +] +allowlist-format = "domain" +allowlist = [ # Allowlist items override/bypass blocklist items + 'allowed.facebook.com', +] + +[listeners.local-udp] +address = ":53" +protocol = "udp" +resolver = "cloudflare-blocklist" + +[listeners.local-tcp] +address = ":53" +protocol = "tcp" +resolver = "cloudflare-blocklist" diff --git a/cmd/routedns/example-config/blocklist-remote-allow.toml b/cmd/routedns/example-config/blocklist-remote-allow.toml new file mode 100644 index 0000000..377bc67 --- /dev/null +++ b/cmd/routedns/example-config/blocklist-remote-allow.toml @@ -0,0 +1,25 @@ +# Config with a remote blocklist that is refreshed once a day and also a remote +# allowlist that overrides any blocking rules +[resolvers.cloudflare-dot] +address = "1.1.1.1:853" +protocol = "dot" + +[groups.cloudflare-blocklist] +type = "blocklist" +resolvers = ["cloudflare-dot"] +format = "regexp" +source = "https://raw.githubusercontent.com/cbuijs/accomplist/master/deugniets/routedns.regexp.list" +refresh = 86400 +allowlist-format = "domain" +allowlist-source = "https://some.other.list" +allowlist-refresh = 86400 + +[listeners.local-udp] +address = ":53" +protocol = "udp" +resolver = "cloudflare-blocklist" + +[listeners.local-tcp] +address = ":53" +protocol = "tcp" +resolver = "cloudflare-blocklist" diff --git a/cmd/routedns/example-config/blocklist-remote.toml b/cmd/routedns/example-config/blocklist-remote.toml index aecf0d5..7da866c 100644 --- a/cmd/routedns/example-config/blocklist-remote.toml +++ b/cmd/routedns/example-config/blocklist-remote.toml @@ -6,7 +6,7 @@ protocol = "dot" type = "blocklist" resolvers = ["cloudflare-dot"] # Anything that passes the filter is sent on to this resolver format = "regexp" # "domain", "hosts" or "regexp", defaults to "regexp" -source = "https://raw.githubusercontent.com/cbuijs/accomplist/master/deugniets/plain.black.regex.list" +source = "https://raw.githubusercontent.com/cbuijs/accomplist/master/deugniets/routedns.regexp.list" refresh = 86400 # Time to refresh the blocklist from the URL in seconds [listeners.local-udp] diff --git a/cmd/routedns/main.go b/cmd/routedns/main.go index 1b2c415..cbcc3dc 100644 --- a/cmd/routedns/main.go +++ b/cmd/routedns/main.go @@ -238,9 +238,12 @@ func instantiateGroup(id string, g group, resolvers map[string]rdns.Resolver) er return fmt.Errorf("type blocklist only supports one resolver in '%s'", id) } if len(g.Blocklist) > 0 && g.Source != "" { - return fmt.Errorf("type static blocklist can't be used with 'source' in '%s'", id) + return fmt.Errorf("static blocklist can't be used with 'source' in '%s'", id) } - var loader rdns.BlocklistLoader + if len(g.Allowlist) > 0 && g.AllowlistSource != "" { + return fmt.Errorf("static allowlist can't be used with 'source' in '%s'", id) + } + var blocklistLoader rdns.BlocklistLoader if g.Source != "" { loc, err := url.Parse(g.Source) if err != nil { @@ -248,34 +251,77 @@ func instantiateGroup(id string, g group, resolvers map[string]rdns.Resolver) er } switch loc.Scheme { case "http", "https": - loader = rdns.NewHTTPLoader(g.Source) + blocklistLoader = rdns.NewHTTPLoader(g.Source) case "": - loader = rdns.NewFileLoader(g.Source) + blocklistLoader = rdns.NewFileLoader(g.Source) default: return fmt.Errorf("unsupported scheme '%s' in '%s'", loc.Scheme, g.Source) } } - var db rdns.BlocklistDB + var allowlistLoader rdns.BlocklistLoader + if g.AllowlistSource != "" { + loc, err := url.Parse(g.AllowlistSource) + if err != nil { + return err + } + switch loc.Scheme { + case "http", "https": + allowlistLoader = rdns.NewHTTPLoader(g.AllowlistSource) + case "": + allowlistLoader = rdns.NewFileLoader(g.AllowlistSource) + default: + return fmt.Errorf("unsupported scheme '%s' in '%s'", loc.Scheme, g.AllowlistSource) + } + } + var blocklistDB rdns.BlocklistDB switch g.Format { case "regexp", "": - db, err = rdns.NewRegexpDB(g.Blocklist...) + blocklistDB, err = rdns.NewRegexpDB(g.Blocklist...) if err != nil { return err } case "domain": - db, err = rdns.NewDomainDB(g.Blocklist...) + blocklistDB, err = rdns.NewDomainDB(g.Blocklist...) if err != nil { return err } case "hosts": - db, err = rdns.NewHostsDB(g.Blocklist...) + blocklistDB, err = rdns.NewHostsDB(g.Blocklist...) if err != nil { return err } default: return fmt.Errorf("unsupported blocklist format '%s'", g.Format) } - resolvers[id], err = rdns.NewBlocklist(gr[0], db, loader, time.Duration(g.Refresh)*time.Second) + var allowlistDB rdns.BlocklistDB + switch g.AllowlistFormat { + case "regexp", "": + allowlistDB, err = rdns.NewRegexpDB(g.Allowlist...) + if err != nil { + return err + } + case "domain": + allowlistDB, err = rdns.NewDomainDB(g.Allowlist...) + if err != nil { + return err + } + case "hosts": + allowlistDB, err = rdns.NewHostsDB(g.Allowlist...) + if err != nil { + return err + } + default: + return fmt.Errorf("unsupported allowlist format '%s'", g.Format) + } + opt := rdns.BlocklistOptions{ + BlocklistDB: blocklistDB, + BlocklistLoader: blocklistLoader, + BlocklistRefresh: time.Duration(g.Refresh) * time.Second, + AllowlistDB: allowlistDB, + AllowlistLoader: allowlistLoader, + AllowlistRefresh: time.Duration(g.AllowlistRefresh) * time.Second, + } + resolvers[id], err = rdns.NewBlocklist(gr[0], opt) if err != nil { return err }