diff --git a/docs/config.yml b/docs/config.yml index 74c174cc3..29ae4ab30 100644 --- a/docs/config.yml +++ b/docs/config.yml @@ -19,7 +19,7 @@ upstreams: laptop*: - 123.123.123.123 # optional: Determines what strategy blocky uses to choose the upstream servers. - # accepted: parallel_best, strict + # accepted: parallel_best, strict, random # default: parallel_best strategy: parallel_best # optional: timeout to query the upstream resolver. Default: 2s diff --git a/docs/configuration.md b/docs/configuration.md index 08b39315d..d04f690bf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -139,10 +139,15 @@ Blocky supports different upstream strategies (default `parallel_best`) that det Currently available strategies: -- `parallel_best`: blocky picks 2 random (weighted) resolvers from the upstream group for each query and returns the answer from the fastest one. +- `parallel_best`: blocky picks 2 random (weighted) resolvers from the upstream group for each query and returns the answer from the fastest one. If an upstream failed to answer within the last hour, it is less likely to be chosen for the race. - This improves your network speed and increases your privacy - your DNS traffic will be distributed over multiple providers + This improves your network speed and increases your privacy - your DNS traffic will be distributed over multiple providers. (When using 10 upstream servers, each upstream will get on average 20% of the DNS requests) +- `random`: blocky picks one random (weighted) resolver from the upstream group for each query and if successful, returns its response. + If the selected resolver fails to respond, a second one is picked to which the query is sent. + The weighting is identical to the `parallel_best` strategy. + Although the `random` strategy might be slower than the `parallel_best` strategy, it offers a larger increase of privacy. + (When using 10 upstream servers, each upstream will get on average 10% of the DNS requests) - `strict`: blocky forwards the request in a strict order. If the first upstream does not respond, the second is asked, and so on. !!! example diff --git a/resolver/random_resolver.go b/resolver/random_resolver.go new file mode 100644 index 000000000..e5688937e --- /dev/null +++ b/resolver/random_resolver.go @@ -0,0 +1,140 @@ +package resolver + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/0xERR0R/blocky/config" + "github.com/0xERR0R/blocky/log" + "github.com/0xERR0R/blocky/model" + "github.com/0xERR0R/blocky/util" + "github.com/sirupsen/logrus" +) + +const ( + randomResolverType = "random" +) + +// RandomResolver delegates the DNS message to one random upstream resolver +// if it can't provide the answer in time a different resolver is chosen randomly +// resolvers who fail to response get a penalty and are less likely to be chosen for the next request +type RandomResolver struct { + configurable[*config.UpstreamGroupConfig] + typed + + groupName string + resolvers []*upstreamResolverStatus +} + +// NewRandomResolver creates a new random resolver instance +func NewRandomResolver( + cfg config.UpstreamGroupConfig, bootstrap *Bootstrap, shoudVerifyUpstreams bool, +) (*RandomResolver, error) { + logger := log.PrefixedLog(randomResolverType) + + resolvers, err := createResolvers(logger, cfg, bootstrap, shoudVerifyUpstreams) + if err != nil { + return nil, err + } + + return newRandomResolver(cfg, resolvers), nil +} + +func newRandomResolver( + cfg config.UpstreamGroupConfig, resolvers []Resolver, +) *RandomResolver { + resolverStatuses := make([]*upstreamResolverStatus, 0, len(resolvers)) + + for _, r := range resolvers { + resolverStatuses = append(resolverStatuses, newUpstreamResolverStatus(r)) + } + + r := RandomResolver{ + configurable: withConfig(&cfg), + typed: withType(randomResolverType), + + groupName: cfg.Name, + resolvers: resolverStatuses, + } + + return &r +} + +func (r *RandomResolver) Name() string { + return r.String() +} + +func (r *RandomResolver) String() string { + result := make([]string, len(r.resolvers)) + for i, s := range r.resolvers { + result[i] = fmt.Sprintf("%s", s.resolver) + } + + return fmt.Sprintf("%s upstreams '%s (%s)'", randomResolverType, r.groupName, strings.Join(result, ",")) +} + +// Resolve sends the query request to a random upstream resolver +func (r *RandomResolver) Resolve(request *model.Request) (*model.Response, error) { + logger := log.WithPrefix(request.Log, randomResolverType) + + if len(r.resolvers) == 1 { + logger.WithField("resolver", r.resolvers[0].resolver).Debug("delegating to resolver") + + return r.resolvers[0].resolver.Resolve(request) + } + + timeout := config.GetConfig().Upstreams.Timeout.ToDuration() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + // first try + r1 := weightedRandom(r.resolvers, nil) + logger.Debugf("using %s as resolver", r1.resolver) + + ch := make(chan requestResponse, 1) + + go r1.resolve(request, ch) + + select { + case <-ctx.Done(): + logger.WithField("resolver", r1.resolver).Debug("upstream exceeded timeout, trying other upstream") + r1.lastErrorTime.Store(time.Now()) + case result := <-ch: + if result.err != nil { + logger.Debug("resolution failed from resolver, cause: ", result.err) + } else { + logger.WithFields(logrus.Fields{ + "resolver": *result.resolver, + "answer": util.AnswerToString(result.response.Res.Answer), + }).Debug("using response from resolver") + + return result.response, nil + } + } + + // second try + r2 := weightedRandom(r.resolvers, r1.resolver) + logger.Debugf("using %s as second resolver", r2.resolver) + + ch = make(chan requestResponse, 1) + + r2.resolve(request, ch) + + result := <-ch + if result.err != nil { + logger.Debug("resolution failed from resolver, cause: ", result.err) + + return nil, errors.New("resolution was not successful, no resolver returned answer in time") + } + + logger.WithFields(logrus.Fields{ + "resolver": *result.resolver, + "answer": util.AnswerToString(result.response.Res.Answer), + }).Debug("using response from resolver") + + return result.response, nil +} diff --git a/resolver/random_resolver_test.go b/resolver/random_resolver_test.go new file mode 100644 index 000000000..98b7c5b36 --- /dev/null +++ b/resolver/random_resolver_test.go @@ -0,0 +1,338 @@ +package resolver + +import ( + "strings" + "time" + + "github.com/0xERR0R/blocky/config" + . "github.com/0xERR0R/blocky/helpertest" + "github.com/0xERR0R/blocky/log" + . "github.com/0xERR0R/blocky/model" + "github.com/0xERR0R/blocky/util" + "github.com/miekg/dns" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("RandomResolver", Label("randomResolver"), func() { + const ( + verifyUpstreams = true + noVerifyUpstreams = false + ) + + var ( + sut *RandomResolver + upstreams []config.Upstream + sutVerify bool + + err error + + bootstrap *Bootstrap + ) + + Describe("Type", func() { + It("follows conventions", func() { + expectValidResolverType(sut) + }) + }) + + BeforeEach(func() { + upstreams = []config.Upstream{ + {Host: "wrong"}, + {Host: "127.0.0.2"}, + } + + sutVerify = noVerifyUpstreams + + bootstrap = systemResolverBootstrap + }) + + JustBeforeEach(func() { + sutConfig := config.UpstreamGroupConfig{ + Name: upstreamDefaultCfgName, + Upstreams: upstreams, + } + sut, err = NewRandomResolver(sutConfig, bootstrap, sutVerify) + }) + + config.GetConfig().Upstreams.Timeout = config.Duration(1000 * time.Millisecond) + + Describe("IsEnabled", func() { + It("is true", func() { + Expect(sut.IsEnabled()).Should(BeTrue()) + }) + }) + + Describe("LogConfig", func() { + It("should log something", func() { + logger, hook := log.NewMockEntry() + + sut.LogConfig(logger) + + Expect(hook.Calls).ShouldNot(BeEmpty()) + }) + }) + + Describe("Name", func() { + It("should contain correct resolver", func() { + Expect(sut.Name()).ShouldNot(BeEmpty()) + Expect(sut.Name()).Should(ContainSubstring(randomResolverType)) + }) + }) + + When("some default upstream resolvers cannot be reached", func() { + It("should start normally", func() { + mockUpstream := NewMockUDPUpstreamServer().WithAnswerFn(func(request *dns.Msg) (response *dns.Msg) { + response, _ = util.NewMsgWithAnswer(request.Question[0].Name, 123, A, "123.124.122.122") + + return + }) + defer mockUpstream.Close() + + upstreams := []config.Upstream{ + {Host: "wrong"}, + mockUpstream.Start(), + } + + _, err := NewRandomResolver(config.UpstreamGroupConfig{ + Name: upstreamDefaultCfgName, + Upstreams: upstreams, + }, + systemResolverBootstrap, verifyUpstreams) + Expect(err).Should(Not(HaveOccurred())) + }) + }) + + When("no upstream resolvers can be reached", func() { + BeforeEach(func() { + upstreams = []config.Upstream{ + {Host: "wrong"}, + {Host: "127.0.0.2"}, + } + }) + + When("strict checking is enabled", func() { + BeforeEach(func() { + sutVerify = verifyUpstreams + }) + It("should fail to start", func() { + Expect(err).Should(HaveOccurred()) + }) + }) + + When("strict checking is disabled", func() { + BeforeEach(func() { + sutVerify = noVerifyUpstreams + }) + It("should start", func() { + Expect(err).Should(Not(HaveOccurred())) + }) + }) + }) + + Describe("Resolving request in random order", func() { + When("Multiple upstream resolvers are defined", func() { + When("Both are responding", func() { + When("Both respond in time", func() { + BeforeEach(func() { + testUpstream1 := NewMockUDPUpstreamServer().WithAnswerRR("example.com 123 IN A 123.124.122.122") + DeferCleanup(testUpstream1.Close) + + testUpstream2 := NewMockUDPUpstreamServer().WithAnswerRR("example.com 123 IN A 123.124.122.123") + DeferCleanup(testUpstream2.Close) + + upstreams = []config.Upstream{testUpstream1.Start(), testUpstream2.Start()} + }) + It("Should return result from either one", func() { + request := newRequest("example.com.", A) + Expect(sut.Resolve(request)). + Should(SatisfyAll( + HaveTTL(BeNumerically("==", 123)), + HaveResponseType(ResponseTypeRESOLVED), + HaveReturnCode(dns.RcodeSuccess), + Or( + BeDNSRecord("example.com.", A, "123.124.122.122"), + BeDNSRecord("example.com.", A, "123.124.122.123"), + ), + )) + }) + }) + When("one upstream exceeds timeout", func() { + BeforeEach(func() { + testUpstream1 := NewMockUDPUpstreamServer().WithAnswerFn(func(request *dns.Msg) (response *dns.Msg) { + response, err := util.NewMsgWithAnswer("example.com", 123, A, "123.124.122.1") + time.Sleep(time.Duration(config.GetConfig().Upstreams.Timeout) + 2*time.Second) + + Expect(err).To(Succeed()) + + return response + }) + DeferCleanup(testUpstream1.Close) + + testUpstream2 := NewMockUDPUpstreamServer().WithAnswerRR("example.com 123 IN A 123.124.122.2") + DeferCleanup(testUpstream2.Close) + + upstreams = []config.Upstream{testUpstream1.Start(), testUpstream2.Start()} + }) + It("should ask a other random upstream and return its response", func() { + request := newRequest("example.com", A) + Expect(sut.Resolve(request)).Should( + SatisfyAll( + BeDNSRecord("example.com.", A, "123.124.122.2"), + HaveTTL(BeNumerically("==", 123)), + HaveResponseType(ResponseTypeRESOLVED), + HaveReturnCode(dns.RcodeSuccess), + )) + }) + }) + When("two upstreams exceed timeout", func() { + BeforeEach(func() { + testUpstream1 := NewMockUDPUpstreamServer().WithAnswerFn(func(request *dns.Msg) (response *dns.Msg) { + response, err := util.NewMsgWithAnswer("example.com", 123, A, "123.124.122.1") + time.Sleep(config.GetConfig().Upstreams.Timeout.ToDuration() + 2*time.Second) + + Expect(err).To(Succeed()) + + return response + }) + DeferCleanup(testUpstream1.Close) + + testUpstream2 := NewMockUDPUpstreamServer().WithAnswerFn(func(request *dns.Msg) (response *dns.Msg) { + response, err := util.NewMsgWithAnswer("example.com", 123, A, "123.124.122.2") + time.Sleep(config.GetConfig().Upstreams.Timeout.ToDuration() + 2*time.Second) + + Expect(err).To(Succeed()) + + return response + }) + DeferCleanup(testUpstream2.Close) + + testUpstream3 := NewMockUDPUpstreamServer().WithAnswerRR("example.com 123 IN A 123.124.122.3") + DeferCleanup(testUpstream3.Close) + + upstreams = []config.Upstream{testUpstream1.Start(), testUpstream2.Start(), testUpstream3.Start()} + }) + // These two tests are flaky -_- (maybe recreate the RandomResolver ) + It("should not return error (due to random selection the request could to through)", func() { + Eventually(func() error { + request := newRequest("example.com", A) + _, err := sut.Resolve(request) + + return err + }).WithTimeout(30 * time.Second). + Should(Not(HaveOccurred())) + }) + It("should return error (because it can be possible that the two broken upstreams are chosen)", func() { + Eventually(func() error { + sutConfig := config.UpstreamGroupConfig{ + Name: upstreamDefaultCfgName, + Upstreams: upstreams, + } + sut, err = NewRandomResolver(sutConfig, bootstrap, sutVerify) + + request := newRequest("example.com", A) + _, err := sut.Resolve(request) + + return err + }).WithTimeout(30 * time.Second). + Should(HaveOccurred()) + }) + }) + }) + When("None are working", func() { + BeforeEach(func() { + testUpstream1 := config.Upstream{Host: "wrong"} + testUpstream2 := config.Upstream{Host: "wrong"} + + upstreams = []config.Upstream{testUpstream1, testUpstream2} + Expect(err).Should(Succeed()) + }) + It("Should return error", func() { + request := newRequest("example.com.", A) + _, err := sut.Resolve(request) + Expect(err).Should(HaveOccurred()) + }) + }) + }) + When("only 1 upstream resolvers is defined", func() { + BeforeEach(func() { + mockUpstream := NewMockUDPUpstreamServer().WithAnswerRR("example.com 123 IN A 123.124.122.122") + DeferCleanup(mockUpstream.Close) + + upstreams = []config.Upstream{mockUpstream.Start()} + }) + It("Should use result from defined resolver", func() { + request := newRequest("example.com.", A) + + Expect(sut.Resolve(request)). + Should( + SatisfyAll( + BeDNSRecord("example.com.", A, "123.124.122.122"), + HaveTTL(BeNumerically("==", 123)), + HaveResponseType(ResponseTypeRESOLVED), + HaveReturnCode(dns.RcodeSuccess), + )) + }) + }) + }) + + Describe("Weighted random on resolver selection", func() { + When("4 upstream resolvers are defined", func() { + It("should use 2 random peeked resolvers, weighted with last error timestamp", func() { + withError1 := config.Upstream{Host: "wrong1"} + withError2 := config.Upstream{Host: "wrong2"} + + mockUpstream1 := NewMockUDPUpstreamServer().WithAnswerRR("example.com 123 IN A 123.124.122.122") + DeferCleanup(mockUpstream1.Close) + + mockUpstream2 := NewMockUDPUpstreamServer().WithAnswerRR("example.com 123 IN A 123.124.122.122") + DeferCleanup(mockUpstream2.Close) + + sut, _ = NewRandomResolver(config.UpstreamGroupConfig{ + Name: upstreamDefaultCfgName, + Upstreams: []config.Upstream{withError1, mockUpstream1.Start(), mockUpstream2.Start(), withError2}, + }, + systemResolverBootstrap, noVerifyUpstreams) + + By("all resolvers have same weight for random -> equal distribution", func() { + resolverCount := make(map[Resolver]int) + + for i := 0; i < 2000; i++ { + r := weightedRandom(sut.resolvers, nil) + resolverCount[r.resolver]++ + } + for _, v := range resolverCount { + // should be 500 ± 100 + Expect(v).Should(BeNumerically("~", 500, 100)) + } + }) + By("perform 200 request, error upstream's weight will be reduced", func() { + for i := 0; i < 200; i++ { + request := newRequest("example.com.", A) + _, _ = sut.Resolve(request) + } + }) + + By("Resolvers without errors should be selected often", func() { + resolverCount := make(map[*UpstreamResolver]int) + + for i := 0; i < 200; i++ { + r := weightedRandom(sut.resolvers, nil) + res := r.resolver.(*UpstreamResolver) + + resolverCount[res]++ + } + for k, v := range resolverCount { + if strings.Contains(k.String(), "wrong") { + // error resolvers: should be 0 - 10 + Expect(v).Should(BeNumerically("~", 0, 10)) + } else { + // should be 100 ± 20 + Expect(v).Should(BeNumerically("~", 100, 20)) + } + } + }) + }) + }) + }) +}) diff --git a/server/server.go b/server/server.go index 7f265a6a5..a91cbadb1 100644 --- a/server/server.go +++ b/server/server.go @@ -457,6 +457,8 @@ func createUpstreamBranches( upstream, err = resolver.NewParallelBestResolver(groupConfig, bootstrap, cfg.StartVerifyUpstream) case config.UpstreamStrategyStrict: upstream, err = resolver.NewStrictResolver(groupConfig, bootstrap, cfg.StartVerifyUpstream) + case config.UpstreamStrategyRandom: + upstream, err = resolver.NewRandomResolver(groupConfig, bootstrap, cfg.StartVerifyUpstream) } upstreamBranches[group] = upstream diff --git a/server/server_test.go b/server/server_test.go index a346655cc..20c172658 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -679,8 +679,7 @@ var _ = Describe("Running DNS server", func() { "default": {{Host: "0.0.0.0"}}, }, }, - }, - nil) + }, nil) Expect(err).ToNot(HaveOccurred()) Expect(branches).ToNot(BeNil()) @@ -689,6 +688,24 @@ var _ = Describe("Running DNS server", func() { }) }) + Describe("NewServer with random upstream strategy", func() { + It("successfully returns upstream branches", func() { + branches, err := createUpstreamBranches(&config.Config{ + Upstreams: config.UpstreamsConfig{ + Strategy: config.UpstreamStrategyRandom, + Groups: config.UpstreamGroups{ + "default": {{Host: "0.0.0.0"}}, + }, + }, + }, nil) + + Expect(err).ToNot(HaveOccurred()) + Expect(branches).ToNot(BeNil()) + Expect(branches).To(HaveLen(1)) + _ = branches["default"].(*resolver.RandomResolver) + }) + }) + Describe("create query resolver", func() { When("some upstream returns error", func() { It("create query resolver should return error", func() {