refactor(sessionresolver): adapt to changing network conditions (#238)
* feat(sessionresolver): try many and use what works * fix(sessionresolver): make sure we can use quic * fix: the config struct is unnecessary * fix: make kvstore optional * feat: write simple integration test * feat: start adding tests * feat: continue writing tests * fix(sessionresolver): add more unit tests * fix(sessionresolver): finish adding tests * refactor(sessionresolver): changes after code review
This commit is contained in:
		
							parent
							
								
									12e1164940
								
							
						
					
					
						commit
						034db78f94
					
				
							
								
								
									
										27
									
								
								internal/engine/internal/sessionresolver/childresolver.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								internal/engine/internal/sessionresolver/childresolver.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,27 @@ | |||||||
|  | package sessionresolver | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"time" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // childResolver is the DNS client that this package uses | ||||||
|  | // to perform individual domain name resolutions. | ||||||
|  | type childResolver interface { | ||||||
|  | 	// LookupHost performs a DNS lookup. | ||||||
|  | 	LookupHost(ctx context.Context, domain string) ([]string, error) | ||||||
|  | 
 | ||||||
|  | 	// CloseIdleConnections closes idle connections. | ||||||
|  | 	CloseIdleConnections() | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // timeLimitedLookup performs a time-limited lookup using the given re. | ||||||
|  | func (r *Resolver) timeLimitedLookup(ctx context.Context, re childResolver, hostname string) ([]string, error) { | ||||||
|  | 	// Algorithm similar to Firefox TRR2 mode. See: | ||||||
|  | 	// https://wiki.mozilla.org/Trusted_Recursive_Resolver#DNS-over-HTTPS_Prefs_in_Firefox | ||||||
|  | 	// We use a higher timeout than Firefox's timeout (1.5s) to be on the safe side | ||||||
|  | 	// and therefore see to use DoH more often. | ||||||
|  | 	ctx, cancel := context.WithTimeout(ctx, 4*time.Second) | ||||||
|  | 	defer cancel() | ||||||
|  | 	return re.LookupHost(ctx, hostname) | ||||||
|  | } | ||||||
| @ -0,0 +1,80 @@ | |||||||
|  | package sessionresolver | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"errors" | ||||||
|  | 	"io" | ||||||
|  | 	"testing" | ||||||
|  | 	"time" | ||||||
|  | 
 | ||||||
|  | 	"github.com/google/go-cmp/cmp" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | type FakeResolver struct { | ||||||
|  | 	Closed bool | ||||||
|  | 	Data   []string | ||||||
|  | 	Err    error | ||||||
|  | 	Sleep  time.Duration | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (r *FakeResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) { | ||||||
|  | 	select { | ||||||
|  | 	case <-time.After(r.Sleep): | ||||||
|  | 		return r.Data, r.Err | ||||||
|  | 	case <-ctx.Done(): | ||||||
|  | 		return nil, ctx.Err() | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (r *FakeResolver) CloseIdleConnections() { | ||||||
|  | 	r.Closed = true | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestTimeLimitedLookupSuccess(t *testing.T) { | ||||||
|  | 	reso := &Resolver{} | ||||||
|  | 	re := &FakeResolver{ | ||||||
|  | 		Data: []string{"8.8.8.8", "8.8.4.4"}, | ||||||
|  | 	} | ||||||
|  | 	ctx := context.Background() | ||||||
|  | 	out, err := reso.timeLimitedLookup(ctx, re, "dns.google") | ||||||
|  | 	if err != nil { | ||||||
|  | 		t.Fatal(err) | ||||||
|  | 	} | ||||||
|  | 	if diff := cmp.Diff(re.Data, out); diff != "" { | ||||||
|  | 		t.Fatal(diff) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestTimeLimitedLookupFailure(t *testing.T) { | ||||||
|  | 	reso := &Resolver{} | ||||||
|  | 	re := &FakeResolver{ | ||||||
|  | 		Err: io.EOF, | ||||||
|  | 	} | ||||||
|  | 	ctx := context.Background() | ||||||
|  | 	out, err := reso.timeLimitedLookup(ctx, re, "dns.google") | ||||||
|  | 	if !errors.Is(err, re.Err) { | ||||||
|  | 		t.Fatal("not the error we expected", err) | ||||||
|  | 	} | ||||||
|  | 	if out != nil { | ||||||
|  | 		t.Fatal("expected nil here") | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestTimeLimitedLookupWillTimeout(t *testing.T) { | ||||||
|  | 	if testing.Short() { | ||||||
|  | 		t.Skip("skipping test in short mode") | ||||||
|  | 	} | ||||||
|  | 	reso := &Resolver{} | ||||||
|  | 	re := &FakeResolver{ | ||||||
|  | 		Err:   io.EOF, | ||||||
|  | 		Sleep: 20 * time.Second, | ||||||
|  | 	} | ||||||
|  | 	ctx := context.Background() | ||||||
|  | 	out, err := reso.timeLimitedLookup(ctx, re, "dns.google") | ||||||
|  | 	if !errors.Is(err, context.DeadlineExceeded) { | ||||||
|  | 		t.Fatal("not the error we expected", err) | ||||||
|  | 	} | ||||||
|  | 	if out != nil { | ||||||
|  | 		t.Fatal("expected nil here") | ||||||
|  | 	} | ||||||
|  | } | ||||||
							
								
								
									
										25
									
								
								internal/engine/internal/sessionresolver/clientmaker.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								internal/engine/internal/sessionresolver/clientmaker.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,25 @@ | |||||||
|  | package sessionresolver | ||||||
|  | 
 | ||||||
|  | import "github.com/ooni/probe-cli/v3/internal/engine/netx" | ||||||
|  | 
 | ||||||
|  | // dnsclientmaker makes a new resolver. | ||||||
|  | type dnsclientmaker interface { | ||||||
|  | 	// Make makes a new resolver. | ||||||
|  | 	Make(config netx.Config, URL string) (childResolver, error) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // clientmaker returns a valid dnsclientmaker | ||||||
|  | func (r *Resolver) clientmaker() dnsclientmaker { | ||||||
|  | 	if r.dnsClientMaker != nil { | ||||||
|  | 		return r.dnsClientMaker | ||||||
|  | 	} | ||||||
|  | 	return &defaultDNSClientMaker{} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // defaultDNSClientMaker is the default dnsclientmaker | ||||||
|  | type defaultDNSClientMaker struct{} | ||||||
|  | 
 | ||||||
|  | // Make implements dnsclientmaker.Make. | ||||||
|  | func (*defaultDNSClientMaker) Make(config netx.Config, URL string) (childResolver, error) { | ||||||
|  | 	return netx.NewDNSClient(config, URL) | ||||||
|  | } | ||||||
							
								
								
									
										52
									
								
								internal/engine/internal/sessionresolver/clientmaker_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								internal/engine/internal/sessionresolver/clientmaker_test.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,52 @@ | |||||||
|  | package sessionresolver | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"errors" | ||||||
|  | 	"io" | ||||||
|  | 	"testing" | ||||||
|  | 
 | ||||||
|  | 	"github.com/ooni/probe-cli/v3/internal/engine/netx" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | type fakeDNSClientMaker struct { | ||||||
|  | 	reso        childResolver | ||||||
|  | 	err         error | ||||||
|  | 	savedConfig netx.Config | ||||||
|  | 	savedURL    string | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (c *fakeDNSClientMaker) Make(config netx.Config, URL string) (childResolver, error) { | ||||||
|  | 	c.savedConfig = config | ||||||
|  | 	c.savedURL = URL | ||||||
|  | 	return c.reso, c.err | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestClientMakerWithOverride(t *testing.T) { | ||||||
|  | 	m := &fakeDNSClientMaker{err: io.EOF} | ||||||
|  | 	reso := &Resolver{dnsClientMaker: m} | ||||||
|  | 	out, err := reso.clientmaker().Make(netx.Config{}, "https://dns.google/dns-query") | ||||||
|  | 	if !errors.Is(err, io.EOF) { | ||||||
|  | 		t.Fatal("not the error we expected", err) | ||||||
|  | 	} | ||||||
|  | 	if out != nil { | ||||||
|  | 		t.Fatal("expected nil here") | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestClientDefaultWithCancelledContext(t *testing.T) { | ||||||
|  | 	reso := &Resolver{} | ||||||
|  | 	re, err := reso.clientmaker().Make(netx.Config{}, "https://dns.google/dns-query") | ||||||
|  | 	if err != nil { | ||||||
|  | 		t.Fatal(err) | ||||||
|  | 	} | ||||||
|  | 	ctx, cancel := context.WithCancel(context.Background()) | ||||||
|  | 	cancel() // fail immediately | ||||||
|  | 	out, err := re.LookupHost(ctx, "dns.google") | ||||||
|  | 	if !errors.Is(err, context.Canceled) { | ||||||
|  | 		t.Fatal("not the error we expected", err) | ||||||
|  | 	} | ||||||
|  | 	if out != nil { | ||||||
|  | 		t.Fatal("expected nil output") | ||||||
|  | 	} | ||||||
|  | } | ||||||
							
								
								
									
										35
									
								
								internal/engine/internal/sessionresolver/codec.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								internal/engine/internal/sessionresolver/codec.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,35 @@ | |||||||
|  | package sessionresolver | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // codec is the codec we use. | ||||||
|  | type codec interface { | ||||||
|  | 	// Encode encodes v as a stream of bytes. | ||||||
|  | 	Encode(v interface{}) ([]byte, error) | ||||||
|  | 
 | ||||||
|  | 	// Decode decodes b into a stream of bytes. | ||||||
|  | 	Decode(b []byte, v interface{}) error | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // getCodec always returns a valid codec. | ||||||
|  | func (r *Resolver) getCodec() codec { | ||||||
|  | 	if r.codec != nil { | ||||||
|  | 		return r.codec | ||||||
|  | 	} | ||||||
|  | 	return &defaultCodec{} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // defaultCodec is the default codec. | ||||||
|  | type defaultCodec struct{} | ||||||
|  | 
 | ||||||
|  | // Decode decodes b into v using the default codec. | ||||||
|  | func (*defaultCodec) Decode(b []byte, v interface{}) error { | ||||||
|  | 	return json.Unmarshal(b, v) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Encode encodes v using the default codec. | ||||||
|  | func (*defaultCodec) Encode(v interface{}) ([]byte, error) { | ||||||
|  | 	return json.Marshal(v) | ||||||
|  | } | ||||||
							
								
								
									
										48
									
								
								internal/engine/internal/sessionresolver/codec_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								internal/engine/internal/sessionresolver/codec_test.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,48 @@ | |||||||
|  | package sessionresolver | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"testing" | ||||||
|  | 
 | ||||||
|  | 	"github.com/google/go-cmp/cmp" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | type FakeCodec struct { | ||||||
|  | 	EncodeData []byte | ||||||
|  | 	EncodeErr  error | ||||||
|  | 	DecodeErr  error | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (c *FakeCodec) Encode(v interface{}) ([]byte, error) { | ||||||
|  | 	return c.EncodeData, c.EncodeErr | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (c *FakeCodec) Decode(b []byte, v interface{}) error { | ||||||
|  | 	return c.DecodeErr | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestCodecCustom(t *testing.T) { | ||||||
|  | 	c := &FakeCodec{} | ||||||
|  | 	reso := &Resolver{codec: c} | ||||||
|  | 	if r := reso.getCodec(); r != c { | ||||||
|  | 		t.Fatal("not the codec we expected") | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestCodecDefault(t *testing.T) { | ||||||
|  | 	reso := &Resolver{} | ||||||
|  | 	in := resolverinfo{ | ||||||
|  | 		URL:   "https://dns.google/dns.query", | ||||||
|  | 		Score: 0.99, | ||||||
|  | 	} | ||||||
|  | 	data, err := reso.getCodec().Encode(in) | ||||||
|  | 	if err != nil { | ||||||
|  | 		t.Fatal(err) | ||||||
|  | 	} | ||||||
|  | 	var out resolverinfo | ||||||
|  | 	if err := reso.getCodec().Decode(data, &out); err != nil { | ||||||
|  | 		t.Fatal(err) | ||||||
|  | 	} | ||||||
|  | 	if diff := cmp.Diff(in, out); diff != "" { | ||||||
|  | 		t.Fatal(diff) | ||||||
|  | 	} | ||||||
|  | } | ||||||
							
								
								
									
										32
									
								
								internal/engine/internal/sessionresolver/dependencies.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								internal/engine/internal/sessionresolver/dependencies.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,32 @@ | |||||||
|  | package sessionresolver | ||||||
|  | 
 | ||||||
|  | // KVStore is a generic key-value store. We use it to store | ||||||
|  | // on disk persistent state used by this package. | ||||||
|  | type KVStore interface { | ||||||
|  | 	// Get gets the value for the given key. | ||||||
|  | 	Get(key string) ([]byte, error) | ||||||
|  | 
 | ||||||
|  | 	// Set sets the value of the given key. | ||||||
|  | 	Set(key string, value []byte) error | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Logger defines the common logger interface. | ||||||
|  | type Logger interface { | ||||||
|  | 	// Debug emits a debug message. | ||||||
|  | 	Debug(msg string) | ||||||
|  | 
 | ||||||
|  | 	// Debugf formats and emits a debug message. | ||||||
|  | 	Debugf(format string, v ...interface{}) | ||||||
|  | 
 | ||||||
|  | 	// Info emits an informational message. | ||||||
|  | 	Info(msg string) | ||||||
|  | 
 | ||||||
|  | 	// Infof format and emits an informational message. | ||||||
|  | 	Infof(format string, v ...interface{}) | ||||||
|  | 
 | ||||||
|  | 	// Warn emits a warning message. | ||||||
|  | 	Warn(msg string) | ||||||
|  | 
 | ||||||
|  | 	// Warnf formats and emits a warning message. | ||||||
|  | 	Warnf(format string, v ...interface{}) | ||||||
|  | } | ||||||
							
								
								
									
										23
									
								
								internal/engine/internal/sessionresolver/errwrapper.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								internal/engine/internal/sessionresolver/errwrapper.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | |||||||
|  | package sessionresolver | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"errors" | ||||||
|  | 	"fmt" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // errwrapper wraps an error to include the URL of the | ||||||
|  | // resolver that we're currently using. | ||||||
|  | type errwrapper struct { | ||||||
|  | 	error | ||||||
|  | 	URL string | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Error implements error.Error. | ||||||
|  | func (ew *errwrapper) Error() string { | ||||||
|  | 	return fmt.Sprintf("<%s> %s", ew.URL, ew.error.Error()) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Is allows consumers to query for the type of the underlying error. | ||||||
|  | func (ew *errwrapper) Is(target error) bool { | ||||||
|  | 	return errors.Is(ew.error, target) | ||||||
|  | } | ||||||
							
								
								
									
										24
									
								
								internal/engine/internal/sessionresolver/errwrapper_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								internal/engine/internal/sessionresolver/errwrapper_test.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,24 @@ | |||||||
|  | package sessionresolver | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"errors" | ||||||
|  | 	"io" | ||||||
|  | 	"testing" | ||||||
|  | 
 | ||||||
|  | 	"github.com/google/go-cmp/cmp" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func TestErrWrapper(t *testing.T) { | ||||||
|  | 	ew := &errwrapper{ | ||||||
|  | 		error: io.EOF, | ||||||
|  | 		URL:   "https://dns.quad9.net/dns-query", | ||||||
|  | 	} | ||||||
|  | 	o := ew.Error() | ||||||
|  | 	expect := "<https://dns.quad9.net/dns-query> EOF" | ||||||
|  | 	if diff := cmp.Diff(expect, o); diff != "" { | ||||||
|  | 		t.Fatal(diff) | ||||||
|  | 	} | ||||||
|  | 	if !errors.Is(ew, io.EOF) { | ||||||
|  | 		t.Fatal("not the sub-error we expected") | ||||||
|  | 	} | ||||||
|  | } | ||||||
							
								
								
									
										29
									
								
								internal/engine/internal/sessionresolver/integration_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								internal/engine/internal/sessionresolver/integration_test.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,29 @@ | |||||||
|  | package sessionresolver_test | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"testing" | ||||||
|  | 
 | ||||||
|  | 	"github.com/ooni/probe-cli/v3/internal/engine/internal/sessionresolver" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func TestSessionResolverGood(t *testing.T) { | ||||||
|  | 	if testing.Short() { | ||||||
|  | 		t.Skip("skipping test in short mode") | ||||||
|  | 	} | ||||||
|  | 	reso := &sessionresolver.Resolver{} | ||||||
|  | 	defer reso.CloseIdleConnections() | ||||||
|  | 	if reso.Network() != "sessionresolver" { | ||||||
|  | 		t.Fatal("unexpected Network") | ||||||
|  | 	} | ||||||
|  | 	if reso.Address() != "" { | ||||||
|  | 		t.Fatal("unexpected Address") | ||||||
|  | 	} | ||||||
|  | 	addrs, err := reso.LookupHost(context.Background(), "google.com") | ||||||
|  | 	if err != nil { | ||||||
|  | 		t.Fatal(err) | ||||||
|  | 	} | ||||||
|  | 	if len(addrs) < 1 { | ||||||
|  | 		t.Fatal("expected some addrs here") | ||||||
|  | 	} | ||||||
|  | } | ||||||
							
								
								
									
										43
									
								
								internal/engine/internal/sessionresolver/memkvstore.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								internal/engine/internal/sessionresolver/memkvstore.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,43 @@ | |||||||
|  | package sessionresolver | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"errors" | ||||||
|  | 	"fmt" | ||||||
|  | 	"sync" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func (r *Resolver) kvstore() KVStore { | ||||||
|  | 	defer r.mu.Unlock() | ||||||
|  | 	r.mu.Lock() | ||||||
|  | 	if r.KVStore == nil { | ||||||
|  | 		r.KVStore = &memkvstore{} | ||||||
|  | 	} | ||||||
|  | 	return r.KVStore | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | var errMemkvstoreNotFound = errors.New("memkvstore: not found") | ||||||
|  | 
 | ||||||
|  | type memkvstore struct { | ||||||
|  | 	m  map[string][]byte | ||||||
|  | 	mu sync.Mutex | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (kvs *memkvstore) Get(key string) ([]byte, error) { | ||||||
|  | 	defer kvs.mu.Unlock() | ||||||
|  | 	kvs.mu.Lock() | ||||||
|  | 	out, good := kvs.m[key] | ||||||
|  | 	if !good { | ||||||
|  | 		return nil, fmt.Errorf("%w: %s", errMemkvstoreNotFound, key) | ||||||
|  | 	} | ||||||
|  | 	return out, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (kvs *memkvstore) Set(key string, value []byte) error { | ||||||
|  | 	defer kvs.mu.Unlock() | ||||||
|  | 	kvs.mu.Lock() | ||||||
|  | 	if kvs.m == nil { | ||||||
|  | 		kvs.m = make(map[string][]byte) | ||||||
|  | 	} | ||||||
|  | 	kvs.m[key] = value | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
							
								
								
									
										47
									
								
								internal/engine/internal/sessionresolver/memkvstore_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								internal/engine/internal/sessionresolver/memkvstore_test.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,47 @@ | |||||||
|  | package sessionresolver | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"errors" | ||||||
|  | 	"testing" | ||||||
|  | 
 | ||||||
|  | 	"github.com/google/go-cmp/cmp" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func TestKVStoreCustom(t *testing.T) { | ||||||
|  | 	kvs := &memkvstore{} | ||||||
|  | 	reso := &Resolver{KVStore: kvs} | ||||||
|  | 	o := reso.kvstore() | ||||||
|  | 	if o != kvs { | ||||||
|  | 		t.Fatal("not the kvstore we expected") | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestMemkvstoreGetNotFound(t *testing.T) { | ||||||
|  | 	reso := &Resolver{} | ||||||
|  | 	key := "antani" | ||||||
|  | 	out, err := reso.kvstore().Get(key) | ||||||
|  | 	if !errors.Is(err, errMemkvstoreNotFound) { | ||||||
|  | 		t.Fatal("not the error we expected", err) | ||||||
|  | 	} | ||||||
|  | 	if out != nil { | ||||||
|  | 		t.Fatal("expected nil here") | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestMemkvstoreRoundTrip(t *testing.T) { | ||||||
|  | 	reso := &Resolver{} | ||||||
|  | 	key := []string{"antani", "mascetti"} | ||||||
|  | 	value := [][]byte{[]byte(`mascetti`), []byte(`antani`)} | ||||||
|  | 	for idx := 0; idx < 2; idx++ { | ||||||
|  | 		if err := reso.kvstore().Set(key[idx], value[idx]); err != nil { | ||||||
|  | 			t.Fatal(err) | ||||||
|  | 		} | ||||||
|  | 		out, err := reso.kvstore().Get(key[idx]) | ||||||
|  | 		if err != nil { | ||||||
|  | 			t.Fatal(err) | ||||||
|  | 		} | ||||||
|  | 		if diff := cmp.Diff(value[idx], out); diff != "" { | ||||||
|  | 			t.Fatal(diff) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
							
								
								
									
										121
									
								
								internal/engine/internal/sessionresolver/resolvermaker.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								internal/engine/internal/sessionresolver/resolvermaker.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,121 @@ | |||||||
|  | package sessionresolver | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"math/rand" | ||||||
|  | 	"strings" | ||||||
|  | 	"time" | ||||||
|  | 
 | ||||||
|  | 	"github.com/apex/log" | ||||||
|  | 	"github.com/ooni/probe-cli/v3/internal/engine/netx" | ||||||
|  | 	"github.com/ooni/probe-cli/v3/internal/engine/netx/bytecounter" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // resolvemaker contains rules for making a resolver. | ||||||
|  | type resolvermaker struct { | ||||||
|  | 	url   string | ||||||
|  | 	score float64 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // systemResolverURL is the URL of the system resolver. | ||||||
|  | const systemResolverURL = "system:///" | ||||||
|  | 
 | ||||||
|  | // allmakers contains all the makers in a list. We use the http3 | ||||||
|  | // prefix to indicate we wanna use http3. The code will translate | ||||||
|  | // this to https and set the proper next options. | ||||||
|  | var allmakers = []*resolvermaker{{ | ||||||
|  | 	url: "https://cloudflare-dns.com/dns-query", | ||||||
|  | }, { | ||||||
|  | 	url: "http3://cloudflare-dns.com/dns-query", | ||||||
|  | }, { | ||||||
|  | 	url: "https://dns.google/dns-query", | ||||||
|  | }, { | ||||||
|  | 	url: "http3://dns.google/dns-query", | ||||||
|  | }, { | ||||||
|  | 	url: "https://dns.quad9.net/dns-query", | ||||||
|  | }, { | ||||||
|  | 	url: "https://doh.powerdns.org/", | ||||||
|  | }, { | ||||||
|  | 	url: systemResolverURL, | ||||||
|  | }, { | ||||||
|  | 	url: "https://mozilla.cloudflare-dns.com/dns-query", | ||||||
|  | }, { | ||||||
|  | 	url: "http3://mozilla.cloudflare-dns.com/dns-query", | ||||||
|  | }} | ||||||
|  | 
 | ||||||
|  | // allbyurl contains all the resolvermakers by URL | ||||||
|  | var allbyurl map[string]*resolvermaker | ||||||
|  | 
 | ||||||
|  | // init fills allbyname and gives a nonzero initial score | ||||||
|  | // to all resolvers except for the system resolver. We set | ||||||
|  | // the system resolver score to zero, so that it's less | ||||||
|  | // likely than other resolvers in this list. | ||||||
|  | func init() { | ||||||
|  | 	allbyurl = make(map[string]*resolvermaker) | ||||||
|  | 	rng := rand.New(rand.NewSource(time.Now().UnixNano())) | ||||||
|  | 	for _, e := range allmakers { | ||||||
|  | 		allbyurl[e.url] = e | ||||||
|  | 		if e.url != systemResolverURL { | ||||||
|  | 			e.score = rng.Float64() | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // byteCounter returns the configured byteCounter or a default | ||||||
|  | func (r *Resolver) byteCounter() *bytecounter.Counter { | ||||||
|  | 	if r.ByteCounter != nil { | ||||||
|  | 		return r.ByteCounter | ||||||
|  | 	} | ||||||
|  | 	return bytecounter.New() | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // logger returns the configured logger or a default | ||||||
|  | func (r *Resolver) logger() Logger { | ||||||
|  | 	if r.Logger != nil { | ||||||
|  | 		return r.Logger | ||||||
|  | 	} | ||||||
|  | 	return log.Log | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // newresolver creates a new resolver with the given config and URL. This is | ||||||
|  | // where we expand http3 to https and set the h3 options. | ||||||
|  | func (r *Resolver) newresolver(URL string) (childResolver, error) { | ||||||
|  | 	h3 := strings.HasPrefix(URL, "http3://") | ||||||
|  | 	if h3 { | ||||||
|  | 		URL = strings.Replace(URL, "http3://", "https://", 1) | ||||||
|  | 	} | ||||||
|  | 	return r.clientmaker().Make(netx.Config{ | ||||||
|  | 		BogonIsError: true, | ||||||
|  | 		ByteCounter:  r.byteCounter(), | ||||||
|  | 		HTTP3Enabled: h3, | ||||||
|  | 		Logger:       r.logger(), | ||||||
|  | 	}, URL) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // getresolver returns a resolver with the given URL. This function caches | ||||||
|  | // already allocated resolvers so we only allocate them once. | ||||||
|  | func (r *Resolver) getresolver(URL string) (childResolver, error) { | ||||||
|  | 	defer r.mu.Unlock() | ||||||
|  | 	r.mu.Lock() | ||||||
|  | 	if re, found := r.res[URL]; found == true { | ||||||
|  | 		return re, nil // already created | ||||||
|  | 	} | ||||||
|  | 	re, err := r.newresolver(URL) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err // config err? | ||||||
|  | 	} | ||||||
|  | 	if r.res == nil { | ||||||
|  | 		r.res = make(map[string]childResolver) | ||||||
|  | 	} | ||||||
|  | 	r.res[URL] = re | ||||||
|  | 	return re, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // closeall closes the cached resolvers. | ||||||
|  | func (r *Resolver) closeall() { | ||||||
|  | 	defer r.mu.Unlock() | ||||||
|  | 	r.mu.Lock() | ||||||
|  | 	for _, re := range r.res { | ||||||
|  | 		re.CloseIdleConnections() | ||||||
|  | 	} | ||||||
|  | 	r.res = nil | ||||||
|  | } | ||||||
							
								
								
									
										124
									
								
								internal/engine/internal/sessionresolver/resolvermaker_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								internal/engine/internal/sessionresolver/resolvermaker_test.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,124 @@ | |||||||
|  | package sessionresolver | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"errors" | ||||||
|  | 	"strings" | ||||||
|  | 	"testing" | ||||||
|  | 
 | ||||||
|  | 	"github.com/apex/log" | ||||||
|  | 	"github.com/ooni/probe-cli/v3/internal/engine/netx/bytecounter" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func TestDefaultByteCounter(t *testing.T) { | ||||||
|  | 	reso := &Resolver{} | ||||||
|  | 	bc := reso.byteCounter() | ||||||
|  | 	if bc == nil { | ||||||
|  | 		t.Fatal("expected non-nil byte counter") | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestDefaultLogger(t *testing.T) { | ||||||
|  | 	logger := &log.Logger{} | ||||||
|  | 	reso := &Resolver{Logger: logger} | ||||||
|  | 	lo := reso.logger() | ||||||
|  | 	if lo != logger { | ||||||
|  | 		t.Fatal("expected another logger here counter") | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestGetResolverHTTPSStandard(t *testing.T) { | ||||||
|  | 	bc := bytecounter.New() | ||||||
|  | 	URL := "https://dns.google" | ||||||
|  | 	re := &FakeResolver{} | ||||||
|  | 	cmk := &fakeDNSClientMaker{reso: re} | ||||||
|  | 	reso := &Resolver{dnsClientMaker: cmk, ByteCounter: bc} | ||||||
|  | 	out, err := reso.getresolver(URL) | ||||||
|  | 	if err != nil { | ||||||
|  | 		t.Fatal(err) | ||||||
|  | 	} | ||||||
|  | 	if out != re { | ||||||
|  | 		t.Fatal("not the result we expected") | ||||||
|  | 	} | ||||||
|  | 	o2, err := reso.getresolver(URL) | ||||||
|  | 	if err != nil { | ||||||
|  | 		t.Fatal(err) | ||||||
|  | 	} | ||||||
|  | 	if out != o2 { | ||||||
|  | 		t.Fatal("not the result we expected") | ||||||
|  | 	} | ||||||
|  | 	reso.closeall() | ||||||
|  | 	if re.Closed != true { | ||||||
|  | 		t.Fatal("was not closed") | ||||||
|  | 	} | ||||||
|  | 	if cmk.savedURL != URL { | ||||||
|  | 		t.Fatal("not the URL we expected") | ||||||
|  | 	} | ||||||
|  | 	if cmk.savedConfig.ByteCounter != bc { | ||||||
|  | 		t.Fatal("unexpected ByteCounter") | ||||||
|  | 	} | ||||||
|  | 	if cmk.savedConfig.BogonIsError != true { | ||||||
|  | 		t.Fatal("unexpected BogonIsError") | ||||||
|  | 	} | ||||||
|  | 	if cmk.savedConfig.HTTP3Enabled != false { | ||||||
|  | 		t.Fatal("unexpected HTTP3Enabled") | ||||||
|  | 	} | ||||||
|  | 	if cmk.savedConfig.Logger != log.Log { | ||||||
|  | 		t.Fatal("unexpected Log") | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestGetResolverHTTP3(t *testing.T) { | ||||||
|  | 	bc := bytecounter.New() | ||||||
|  | 	URL := "http3://dns.google" | ||||||
|  | 	re := &FakeResolver{} | ||||||
|  | 	cmk := &fakeDNSClientMaker{reso: re} | ||||||
|  | 	reso := &Resolver{dnsClientMaker: cmk, ByteCounter: bc} | ||||||
|  | 	out, err := reso.getresolver(URL) | ||||||
|  | 	if err != nil { | ||||||
|  | 		t.Fatal(err) | ||||||
|  | 	} | ||||||
|  | 	if out != re { | ||||||
|  | 		t.Fatal("not the result we expected") | ||||||
|  | 	} | ||||||
|  | 	o2, err := reso.getresolver(URL) | ||||||
|  | 	if err != nil { | ||||||
|  | 		t.Fatal(err) | ||||||
|  | 	} | ||||||
|  | 	if out != o2 { | ||||||
|  | 		t.Fatal("not the result we expected") | ||||||
|  | 	} | ||||||
|  | 	reso.closeall() | ||||||
|  | 	if re.Closed != true { | ||||||
|  | 		t.Fatal("was not closed") | ||||||
|  | 	} | ||||||
|  | 	if cmk.savedURL != strings.Replace(URL, "http3://", "https://", 1) { | ||||||
|  | 		t.Fatal("not the URL we expected") | ||||||
|  | 	} | ||||||
|  | 	if cmk.savedConfig.ByteCounter != bc { | ||||||
|  | 		t.Fatal("unexpected ByteCounter") | ||||||
|  | 	} | ||||||
|  | 	if cmk.savedConfig.BogonIsError != true { | ||||||
|  | 		t.Fatal("unexpected BogonIsError") | ||||||
|  | 	} | ||||||
|  | 	if cmk.savedConfig.HTTP3Enabled != true { | ||||||
|  | 		t.Fatal("unexpected HTTP3Enabled") | ||||||
|  | 	} | ||||||
|  | 	if cmk.savedConfig.Logger != log.Log { | ||||||
|  | 		t.Fatal("unexpected Log") | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestGetResolverInvalidURL(t *testing.T) { | ||||||
|  | 	bc := bytecounter.New() | ||||||
|  | 	URL := "http3://dns.google" | ||||||
|  | 	errMocked := errors.New("mocked error") | ||||||
|  | 	cmk := &fakeDNSClientMaker{err: errMocked} | ||||||
|  | 	reso := &Resolver{dnsClientMaker: cmk, ByteCounter: bc} | ||||||
|  | 	out, err := reso.getresolver(URL) | ||||||
|  | 	if !errors.Is(err, errMocked) { | ||||||
|  | 		t.Fatal("not the error we expected", err) | ||||||
|  | 	} | ||||||
|  | 	if out != nil { | ||||||
|  | 		t.Fatal("not the result we expected") | ||||||
|  | 	} | ||||||
|  | } | ||||||
| @ -1,85 +1,134 @@ | |||||||
| // Package sessionresolver contains the resolver used by the session. This | // Package sessionresolver contains the resolver used by the session. This | ||||||
| // resolver uses Powerdns DoH by default and falls back on the system | // resolver will try to figure out which is the best service for running | ||||||
| // provided resolver if Powerdns DoH is not working. | // domain name resolutions and will consistently use it. | ||||||
|  | // | ||||||
|  | // Occasionally this code will also swap the best resolver with other | ||||||
|  | // ~good resolvers to give them a chance to perform. | ||||||
|  | // | ||||||
|  | // The penalty/reward mechanism is strongly derivative, so the code should | ||||||
|  | // adapt ~quickly to changing network conditions. Occasionally, we will | ||||||
|  | // have longer resolutions when trying out other resolvers. | ||||||
|  | // | ||||||
|  | // At the beginning we randomize the known resolvers so that we do not | ||||||
|  | // have any preferential ordering. The initial resolutions may be slower | ||||||
|  | // if there are many issues with resolvers. | ||||||
|  | // | ||||||
|  | // The system resolver is given the lowest priority at the beginning | ||||||
|  | // but it will of course be the most popular resolver if anything else | ||||||
|  | // is failing us. (We will still occasionally probe for other working | ||||||
|  | // resolvers and increase their score on success.) | ||||||
| package sessionresolver | package sessionresolver | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"errors" | ||||||
| 	"fmt" | 	"fmt" | ||||||
|  | 	"math/rand" | ||||||
|  | 	"sync" | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
| 	"github.com/ooni/probe-cli/v3/internal/engine/atomicx" | 	"github.com/ooni/probe-cli/v3/internal/engine/internal/multierror" | ||||||
| 	"github.com/ooni/probe-cli/v3/internal/engine/netx" | 	"github.com/ooni/probe-cli/v3/internal/engine/netx/bytecounter" | ||||||
| 	"github.com/ooni/probe-cli/v3/internal/engine/runtimex" | 	"github.com/ooni/probe-cli/v3/internal/engine/runtimex" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| // Resolver is the session resolver. | // Resolver is the session resolver. You should create an instance of | ||||||
|  | // this structure and use it in session.go. | ||||||
| type Resolver struct { | type Resolver struct { | ||||||
| 	Primary         netx.DNSClient | 	ByteCounter    *bytecounter.Counter // optional | ||||||
| 	PrimaryFailure  *atomicx.Int64 | 	KVStore        KVStore              // optional | ||||||
| 	PrimaryQuery    *atomicx.Int64 | 	Logger         Logger               // optional | ||||||
| 	Fallback        netx.DNSClient | 	codec          codec | ||||||
| 	FallbackFailure *atomicx.Int64 | 	dnsClientMaker dnsclientmaker | ||||||
| 	FallbackQuery   *atomicx.Int64 | 	mu             sync.Mutex | ||||||
|  | 	once           sync.Once | ||||||
|  | 	res            map[string]childResolver | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // New creates a new session resolver. | // CloseIdleConnections closes the idle connections, if any. This | ||||||
| func New(config netx.Config) *Resolver { | // function is guaranteed to be idempotent. | ||||||
| 	primary, err := netx.NewDNSClientWithOverrides(config, |  | ||||||
| 		"https://cloudflare.com/dns-query", "dns.cloudflare.com", "", "") |  | ||||||
| 	runtimex.PanicOnError(err, "cannot create dns over https resolver") |  | ||||||
| 	fallback, err := netx.NewDNSClient(config, "system:///") |  | ||||||
| 	runtimex.PanicOnError(err, "cannot create system resolver") |  | ||||||
| 	return &Resolver{ |  | ||||||
| 		Primary:         primary, |  | ||||||
| 		PrimaryFailure:  atomicx.NewInt64(), |  | ||||||
| 		PrimaryQuery:    atomicx.NewInt64(), |  | ||||||
| 		Fallback:        fallback, |  | ||||||
| 		FallbackFailure: atomicx.NewInt64(), |  | ||||||
| 		FallbackQuery:   atomicx.NewInt64(), |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // CloseIdleConnections closes the idle connections, if any |  | ||||||
| func (r *Resolver) CloseIdleConnections() { | func (r *Resolver) CloseIdleConnections() { | ||||||
| 	r.Primary.CloseIdleConnections() | 	r.once.Do(r.closeall) | ||||||
| 	r.Fallback.CloseIdleConnections() |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // Stats returns stats about the session resolver. | // Stats returns stats about the session resolver. | ||||||
| func (r *Resolver) Stats() string { | func (r *Resolver) Stats() string { | ||||||
| 	return fmt.Sprintf("sessionresolver: failure rate: primary: %d/%d; fallback: %d/%d", | 	data, err := json.Marshal(r.readstatedefault()) | ||||||
| 		r.PrimaryFailure.Load(), r.PrimaryQuery.Load(), | 	runtimex.PanicOnError(err, "json.Marshal should not fail here") | ||||||
| 		r.FallbackFailure.Load(), r.FallbackQuery.Load()) | 	return fmt.Sprintf("sessionresolver: %s", string(data)) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // LookupHost implements Resolver.LookupHost | // ErrLookupHost indicates that LookupHost failed. | ||||||
|  | var ErrLookupHost = errors.New("sessionresolver: LookupHost failed") | ||||||
|  | 
 | ||||||
|  | // LookupHost implements Resolver.LookupHost. This function returns a | ||||||
|  | // multierror.Union error on failure, so you can see individual errors | ||||||
|  | // and get a better picture of what's been going wrong. | ||||||
| func (r *Resolver) LookupHost(ctx context.Context, hostname string) ([]string, error) { | func (r *Resolver) LookupHost(ctx context.Context, hostname string) ([]string, error) { | ||||||
| 	// Algorithm similar to Firefox TRR2 mode. See: | 	state := r.readstatedefault() | ||||||
| 	// https://wiki.mozilla.org/Trusted_Recursive_Resolver#DNS-over-HTTPS_Prefs_in_Firefox | 	r.maybeConfusion(state, time.Now().UnixNano()) | ||||||
| 	// We use a higher timeout than Firefox's timeout (1.5s) to be on the safe side | 	defer r.writestate(state) | ||||||
| 	// and therefore see to use DoH more often. | 	me := multierror.New(ErrLookupHost) | ||||||
| 	r.PrimaryQuery.Add(1) | 	for _, e := range state { | ||||||
| 	trr2, cancel := context.WithTimeout(ctx, 4*time.Second) | 		addrs, err := r.lookupHost(ctx, e, hostname) | ||||||
| 	defer cancel() | 		if err == nil { | ||||||
| 	addrs, err := r.Primary.LookupHost(trr2, hostname) | 			return addrs, nil | ||||||
| 	if err != nil { |  | ||||||
| 		r.PrimaryFailure.Add(1) |  | ||||||
| 		r.FallbackQuery.Add(1) |  | ||||||
| 		addrs, err = r.Fallback.LookupHost(ctx, hostname) |  | ||||||
| 		if err != nil { |  | ||||||
| 			r.FallbackFailure.Add(1) |  | ||||||
| 		} | 		} | ||||||
|  | 		me.Add(&errwrapper{error: err, URL: e.URL}) | ||||||
| 	} | 	} | ||||||
| 	return addrs, err | 	return nil, me | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // Network implements Resolver.Network | func (r *Resolver) lookupHost(ctx context.Context, ri *resolverinfo, hostname string) ([]string, error) { | ||||||
|  | 	const ewma = 0.9 // the last sample is very important | ||||||
|  | 	re, err := r.getresolver(ri.URL) | ||||||
|  | 	if err != nil { | ||||||
|  | 		r.logger().Warnf("sessionresolver: getresolver: %s", err.Error()) | ||||||
|  | 		ri.Score = 0 // this is a hard error | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	addrs, err := r.timeLimitedLookup(ctx, re, hostname) | ||||||
|  | 	if err == nil { | ||||||
|  | 		r.logger().Infof("sessionresolver: %s... %v", ri.URL, nil) | ||||||
|  | 		ri.Score = ewma*1.0 + (1-ewma)*ri.Score // increase score | ||||||
|  | 		return addrs, nil | ||||||
|  | 	} | ||||||
|  | 	r.logger().Warnf("sessionresolver: %s... %s", ri.URL, err.Error()) | ||||||
|  | 	ri.Score = ewma*0.0 + (1-ewma)*ri.Score // decrease score | ||||||
|  | 	return nil, err | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // maybeConfusion will rearrange the  first elements of the vector | ||||||
|  | // with low probability, so giving other resolvers a chance | ||||||
|  | // to run and show that they are also viable. We do not fully | ||||||
|  | // reorder the vector because that could lead to long runtimes. | ||||||
|  | // | ||||||
|  | // The return value is only meaningful for testing. | ||||||
|  | func (r *Resolver) maybeConfusion(state []*resolverinfo, seed int64) int { | ||||||
|  | 	rng := rand.New(rand.NewSource(seed)) | ||||||
|  | 	const confusion = 0.3 | ||||||
|  | 	if rng.Float64() >= confusion { | ||||||
|  | 		return -1 | ||||||
|  | 	} | ||||||
|  | 	switch len(state) { | ||||||
|  | 	case 0, 1: // nothing to do | ||||||
|  | 		return 0 | ||||||
|  | 	case 2: | ||||||
|  | 		state[0], state[1] = state[1], state[0] | ||||||
|  | 		return 2 | ||||||
|  | 	default: | ||||||
|  | 		state[0], state[2] = state[2], state[0] | ||||||
|  | 		return 3 | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Network implements Resolver.Network. | ||||||
| func (r *Resolver) Network() string { | func (r *Resolver) Network() string { | ||||||
| 	return "sessionresolver" | 	return "sessionresolver" | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // Address implements Resolver.Address | // Address implements Resolver.Address. | ||||||
| func (r *Resolver) Address() string { | func (r *Resolver) Address() string { | ||||||
| 	return "" | 	return "" | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,31 +1,249 @@ | |||||||
| package sessionresolver_test | package sessionresolver | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
|  | 	"errors" | ||||||
|  | 	"net" | ||||||
| 	"strings" | 	"strings" | ||||||
| 	"testing" | 	"testing" | ||||||
| 
 | 
 | ||||||
| 	"github.com/ooni/probe-cli/v3/internal/engine/internal/sessionresolver" | 	"github.com/google/go-cmp/cmp" | ||||||
| 	"github.com/ooni/probe-cli/v3/internal/engine/netx" | 	"github.com/ooni/probe-cli/v3/internal/engine/internal/multierror" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| func TestFallbackWorks(t *testing.T) { | func TestNetworkWorks(t *testing.T) { | ||||||
| 	reso := sessionresolver.New(netx.Config{}) | 	reso := &Resolver{} | ||||||
| 	defer reso.CloseIdleConnections() |  | ||||||
| 	if reso.Network() != "sessionresolver" { | 	if reso.Network() != "sessionresolver" { | ||||||
| 		t.Fatal("unexpected Network") | 		t.Fatal("unexpected value returned by Network") | ||||||
| 	} | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestAddressWorks(t *testing.T) { | ||||||
|  | 	reso := &Resolver{} | ||||||
| 	if reso.Address() != "" { | 	if reso.Address() != "" { | ||||||
| 		t.Fatal("unexpected Address") | 		t.Fatal("unexpected value returned by Address") | ||||||
| 	} | 	} | ||||||
| 	addrs, err := reso.LookupHost(context.Background(), "antani.ooni.nu") | } | ||||||
| 	if err == nil || !strings.HasSuffix(err.Error(), "no such host") { | 
 | ||||||
| 		t.Fatal("not the error we expected") | func TestTypicalUsageWithFailure(t *testing.T) { | ||||||
|  | 	ctx, cancel := context.WithCancel(context.Background()) | ||||||
|  | 	cancel() // fail immediately | ||||||
|  | 	reso := &Resolver{} | ||||||
|  | 	addrs, err := reso.LookupHost(ctx, "ooni.org") | ||||||
|  | 	if !errors.Is(err, ErrLookupHost) { | ||||||
|  | 		t.Fatal("not the error we expected", err) | ||||||
|  | 	} | ||||||
|  | 	var me *multierror.Union | ||||||
|  | 	if !errors.As(err, &me) { | ||||||
|  | 		t.Fatal("cannot convert error") | ||||||
|  | 	} | ||||||
|  | 	for _, child := range me.Children { | ||||||
|  | 		// net.DNSError does not include the underlying error | ||||||
|  | 		// but just a string representing the error. This | ||||||
|  | 		// means that we need to go down hunting what's the | ||||||
|  | 		// real error that occurred and use more verbose code. | ||||||
|  | 		{ | ||||||
|  | 			var errWrapper *errwrapper | ||||||
|  | 			if !errors.As(child, &errWrapper) { | ||||||
|  | 				t.Fatal("not an instance of errwrapper") | ||||||
|  | 			} | ||||||
|  | 			var dnsError *net.DNSError | ||||||
|  | 			if errors.As(errWrapper.error, &dnsError) { | ||||||
|  | 				if !strings.HasSuffix(dnsError.Err, "operation was canceled") { | ||||||
|  | 					t.Fatal("not the error we expected", dnsError.Err) | ||||||
|  | 				} | ||||||
|  | 				continue | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		// otherwise just unwrap and check whether it's | ||||||
|  | 		// a real context.Canceled error. | ||||||
|  | 		if !errors.Is(child, context.Canceled) { | ||||||
|  | 			t.Fatal("unexpected sub-error", child) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	if addrs != nil { | ||||||
|  | 		t.Fatal("expected nil here") | ||||||
|  | 	} | ||||||
|  | 	if len(reso.res) < 1 { | ||||||
|  | 		t.Fatal("expected to see some resolvers here") | ||||||
|  | 	} | ||||||
|  | 	if reso.Stats() == "" { | ||||||
|  | 		t.Fatal("expected to see some string returned by stats") | ||||||
|  | 	} | ||||||
|  | 	reso.CloseIdleConnections() | ||||||
|  | 	if len(reso.res) != 0 { | ||||||
|  | 		t.Fatal("expected to see no resolvers after CloseIdleConnections") | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestTypicalUsageWithSuccess(t *testing.T) { | ||||||
|  | 	expected := []string{"8.8.8.8", "8.8.4.4"} | ||||||
|  | 	ctx := context.Background() | ||||||
|  | 	reso := &Resolver{ | ||||||
|  | 		dnsClientMaker: &fakeDNSClientMaker{ | ||||||
|  | 			reso: &FakeResolver{Data: expected}, | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 	addrs, err := reso.LookupHost(ctx, "dns.google") | ||||||
|  | 	if err != nil { | ||||||
|  | 		t.Fatal(err) | ||||||
|  | 	} | ||||||
|  | 	if diff := cmp.Diff(expected, addrs); diff != "" { | ||||||
|  | 		t.Fatal(diff) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestLittleLLookupHostWithInvalidURL(t *testing.T) { | ||||||
|  | 	reso := &Resolver{} | ||||||
|  | 	ctx := context.Background() | ||||||
|  | 	ri := &resolverinfo{URL: "\t\t\t", Score: 0.99} | ||||||
|  | 	addrs, err := reso.lookupHost(ctx, ri, "ooni.org") | ||||||
|  | 	if err == nil || !strings.HasSuffix(err.Error(), "invalid control character in URL") { | ||||||
|  | 		t.Fatal("not the error we expected", err) | ||||||
| 	} | 	} | ||||||
| 	if addrs != nil { | 	if addrs != nil { | ||||||
| 		t.Fatal("expected nil addrs here") | 		t.Fatal("expected nil addrs here") | ||||||
| 	} | 	} | ||||||
| 	if reso.PrimaryFailure.Load() != 1 || reso.FallbackFailure.Load() != 1 { | 	if ri.Score != 0 { | ||||||
| 		t.Fatal("not the counters we expected to see here") | 		t.Fatal("unexpected ri.Score", ri.Score) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestLittleLLookupHostWithSuccess(t *testing.T) { | ||||||
|  | 	expected := []string{"8.8.8.8", "8.8.4.4"} | ||||||
|  | 	reso := &Resolver{ | ||||||
|  | 		dnsClientMaker: &fakeDNSClientMaker{ | ||||||
|  | 			reso: &FakeResolver{Data: expected}, | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 	ctx := context.Background() | ||||||
|  | 	ri := &resolverinfo{URL: "dot://dns-nonexistent.ooni.org", Score: 0.1} | ||||||
|  | 	addrs, err := reso.lookupHost(ctx, ri, "dns.google") | ||||||
|  | 	if err != nil { | ||||||
|  | 		t.Fatal(err) | ||||||
|  | 	} | ||||||
|  | 	if diff := cmp.Diff(expected, addrs); diff != "" { | ||||||
|  | 		t.Fatal(diff) | ||||||
|  | 	} | ||||||
|  | 	if ri.Score < 0.88 || ri.Score > 0.92 { | ||||||
|  | 		t.Fatal("unexpected score", ri.Score) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestLittleLLookupHostWithFailure(t *testing.T) { | ||||||
|  | 	errMocked := errors.New("mocked error") | ||||||
|  | 	reso := &Resolver{ | ||||||
|  | 		dnsClientMaker: &fakeDNSClientMaker{ | ||||||
|  | 			reso: &FakeResolver{Err: errMocked}, | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 	ctx := context.Background() | ||||||
|  | 	ri := &resolverinfo{URL: "dot://dns-nonexistent.ooni.org", Score: 0.95} | ||||||
|  | 	addrs, err := reso.lookupHost(ctx, ri, "dns.google") | ||||||
|  | 	if !errors.Is(err, errMocked) { | ||||||
|  | 		t.Fatal("not the error we expected", err) | ||||||
|  | 	} | ||||||
|  | 	if addrs != nil { | ||||||
|  | 		t.Fatal("expected nil addrs here") | ||||||
|  | 	} | ||||||
|  | 	if ri.Score < 0.094 || ri.Score > 0.096 { | ||||||
|  | 		t.Fatal("unexpected score", ri.Score) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestMaybeConfusionNoConfusion(t *testing.T) { | ||||||
|  | 	reso := &Resolver{} | ||||||
|  | 	rv := reso.maybeConfusion(nil, 0) | ||||||
|  | 	if rv != -1 { | ||||||
|  | 		t.Fatal("unexpected return value", rv) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestMaybeConfusionNoArray(t *testing.T) { | ||||||
|  | 	reso := &Resolver{} | ||||||
|  | 	rv := reso.maybeConfusion(nil, 11) | ||||||
|  | 	if rv != 0 { | ||||||
|  | 		t.Fatal("unexpected return value", rv) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestMaybeConfusionSingleEntry(t *testing.T) { | ||||||
|  | 	reso := &Resolver{} | ||||||
|  | 	state := []*resolverinfo{{}} | ||||||
|  | 	rv := reso.maybeConfusion(state, 11) | ||||||
|  | 	if rv != 0 { | ||||||
|  | 		t.Fatal("unexpected return value", rv) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestMaybeConfusionTwoEntries(t *testing.T) { | ||||||
|  | 	reso := &Resolver{} | ||||||
|  | 	state := []*resolverinfo{{ | ||||||
|  | 		Score: 0.8, | ||||||
|  | 		URL:   "https://dns.google/dns-query", | ||||||
|  | 	}, { | ||||||
|  | 		Score: 0.4, | ||||||
|  | 		URL:   "http3://dns.google/dns-query", | ||||||
|  | 	}} | ||||||
|  | 	rv := reso.maybeConfusion(state, 11) | ||||||
|  | 	if rv != 2 { | ||||||
|  | 		t.Fatal("unexpected return value", rv) | ||||||
|  | 	} | ||||||
|  | 	if state[0].Score != 0.4 { | ||||||
|  | 		t.Fatal("unexpected state[0].Score") | ||||||
|  | 	} | ||||||
|  | 	if state[0].URL != "http3://dns.google/dns-query" { | ||||||
|  | 		t.Fatal("unexpected state[0].URL") | ||||||
|  | 	} | ||||||
|  | 	if state[1].Score != 0.8 { | ||||||
|  | 		t.Fatal("unexpected state[1].Score") | ||||||
|  | 	} | ||||||
|  | 	if state[1].URL != "https://dns.google/dns-query" { | ||||||
|  | 		t.Fatal("unexpected state[1].URL") | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestMaybeConfusionManyEntries(t *testing.T) { | ||||||
|  | 	reso := &Resolver{} | ||||||
|  | 	state := []*resolverinfo{{ | ||||||
|  | 		Score: 0.8, | ||||||
|  | 		URL:   "https://dns.google/dns-query", | ||||||
|  | 	}, { | ||||||
|  | 		Score: 0.4, | ||||||
|  | 		URL:   "http3://dns.google/dns-query", | ||||||
|  | 	}, { | ||||||
|  | 		Score: 0.1, | ||||||
|  | 		URL:   "system:///", | ||||||
|  | 	}, { | ||||||
|  | 		Score: 0.01, | ||||||
|  | 		URL:   "dot://dns.google", | ||||||
|  | 	}} | ||||||
|  | 	rv := reso.maybeConfusion(state, 11) | ||||||
|  | 	if rv != 3 { | ||||||
|  | 		t.Fatal("unexpected return value", rv) | ||||||
|  | 	} | ||||||
|  | 	if state[0].Score != 0.1 { | ||||||
|  | 		t.Fatal("unexpected state[0].Score") | ||||||
|  | 	} | ||||||
|  | 	if state[0].URL != "system:///" { | ||||||
|  | 		t.Fatal("unexpected state[0].URL") | ||||||
|  | 	} | ||||||
|  | 	if state[1].Score != 0.4 { | ||||||
|  | 		t.Fatal("unexpected state[1].Score") | ||||||
|  | 	} | ||||||
|  | 	if state[1].URL != "http3://dns.google/dns-query" { | ||||||
|  | 		t.Fatal("unexpected state[1].URL") | ||||||
|  | 	} | ||||||
|  | 	if state[2].Score != 0.8 { | ||||||
|  | 		t.Fatal("unexpected state[2].Score") | ||||||
|  | 	} | ||||||
|  | 	if state[2].URL != "https://dns.google/dns-query" { | ||||||
|  | 		t.Fatal("unexpected state[2].URL") | ||||||
|  | 	} | ||||||
|  | 	if state[3].Score != 0.01 { | ||||||
|  | 		t.Fatal("unexpected state[3].Score") | ||||||
|  | 	} | ||||||
|  | 	if state[3].URL != "dot://dns.google" { | ||||||
|  | 		t.Fatal("unexpected state[3].URL") | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | |||||||
							
								
								
									
										93
									
								
								internal/engine/internal/sessionresolver/state.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								internal/engine/internal/sessionresolver/state.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,93 @@ | |||||||
|  | package sessionresolver | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"errors" | ||||||
|  | 	"sort" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // storekey is the key used by the key value store to store | ||||||
|  | // the state required by this package. | ||||||
|  | const storekey = "sessionresolver.state" | ||||||
|  | 
 | ||||||
|  | // resolverinfo contains info about a resolver. | ||||||
|  | type resolverinfo struct { | ||||||
|  | 	// URL is the URL of a resolver. | ||||||
|  | 	URL string | ||||||
|  | 
 | ||||||
|  | 	// Score is the score of a resolver. | ||||||
|  | 	Score float64 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // readstate reads the resolver state from disk | ||||||
|  | func (r *Resolver) readstate() ([]*resolverinfo, error) { | ||||||
|  | 	data, err := r.kvstore().Get(storekey) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	var ri []*resolverinfo | ||||||
|  | 	if err := r.getCodec().Decode(data, &ri); err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	return ri, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // errNoEntries indicates that no entry remained after we pruned | ||||||
|  | // all the available entries in readstateandprune. | ||||||
|  | var errNoEntries = errors.New("sessionresolver: no available entries") | ||||||
|  | 
 | ||||||
|  | // readstateandprune reads the state from disk and removes all the | ||||||
|  | // entries that we don't actually support. | ||||||
|  | func (r *Resolver) readstateandprune() ([]*resolverinfo, error) { | ||||||
|  | 	ri, err := r.readstate() | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	var out []*resolverinfo | ||||||
|  | 	for _, e := range ri { | ||||||
|  | 		if _, found := allbyurl[e.URL]; !found { | ||||||
|  | 			continue // we don't support this specific entry | ||||||
|  | 		} | ||||||
|  | 		out = append(out, e) | ||||||
|  | 	} | ||||||
|  | 	if len(out) <= 0 { | ||||||
|  | 		return nil, errNoEntries | ||||||
|  | 	} | ||||||
|  | 	return out, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // sortstate sorts the state by descending score | ||||||
|  | func sortstate(ri []*resolverinfo) { | ||||||
|  | 	sort.SliceStable(ri, func(i, j int) bool { | ||||||
|  | 		return ri[i].Score >= ri[j].Score | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // readstatedefault reads the state from disk and merges the state | ||||||
|  | // so that all supported entries are represented. | ||||||
|  | func (r *Resolver) readstatedefault() []*resolverinfo { | ||||||
|  | 	ri, _ := r.readstateandprune() | ||||||
|  | 	here := make(map[string]bool) | ||||||
|  | 	for _, e := range ri { | ||||||
|  | 		here[e.URL] = true // record what we already have | ||||||
|  | 	} | ||||||
|  | 	for _, e := range allmakers { | ||||||
|  | 		if _, found := here[e.url]; found { | ||||||
|  | 			continue // already here so no need to add | ||||||
|  | 		} | ||||||
|  | 		ri = append(ri, &resolverinfo{ | ||||||
|  | 			URL:   e.url, | ||||||
|  | 			Score: e.score, | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  | 	sortstate(ri) | ||||||
|  | 	return ri | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // writestate writes the state on the kvstore. | ||||||
|  | func (r *Resolver) writestate(ri []*resolverinfo) error { | ||||||
|  | 	data, err := r.getCodec().Encode(ri) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	return r.kvstore().Set(storekey, data) | ||||||
|  | } | ||||||
							
								
								
									
										120
									
								
								internal/engine/internal/sessionresolver/state_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								internal/engine/internal/sessionresolver/state_test.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,120 @@ | |||||||
|  | package sessionresolver | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"errors" | ||||||
|  | 	"testing" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func TestReadStateNothingInKVStore(t *testing.T) { | ||||||
|  | 	reso := &Resolver{KVStore: &memkvstore{}} | ||||||
|  | 	out, err := reso.readstate() | ||||||
|  | 	if !errors.Is(err, errMemkvstoreNotFound) { | ||||||
|  | 		t.Fatal("not the error we expected", err) | ||||||
|  | 	} | ||||||
|  | 	if out != nil { | ||||||
|  | 		t.Fatal("expected nil here") | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestReadStateDecodeError(t *testing.T) { | ||||||
|  | 	errMocked := errors.New("mocked error") | ||||||
|  | 	reso := &Resolver{ | ||||||
|  | 		KVStore: &memkvstore{}, | ||||||
|  | 		codec:   &FakeCodec{DecodeErr: errMocked}, | ||||||
|  | 	} | ||||||
|  | 	if err := reso.KVStore.Set(storekey, []byte(`[]`)); err != nil { | ||||||
|  | 		t.Fatal(err) | ||||||
|  | 	} | ||||||
|  | 	out, err := reso.readstate() | ||||||
|  | 	if !errors.Is(err, errMocked) { | ||||||
|  | 		t.Fatal("not the error we expected", err) | ||||||
|  | 	} | ||||||
|  | 	if out != nil { | ||||||
|  | 		t.Fatal("expected nil here") | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestReadStateAndPruneReadStateError(t *testing.T) { | ||||||
|  | 	reso := &Resolver{KVStore: &memkvstore{}} | ||||||
|  | 	out, err := reso.readstateandprune() | ||||||
|  | 	if !errors.Is(err, errMemkvstoreNotFound) { | ||||||
|  | 		t.Fatal("not the error we expected", err) | ||||||
|  | 	} | ||||||
|  | 	if out != nil { | ||||||
|  | 		t.Fatal("expected nil here") | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestReadStateAndPruneWithUnsupportedEntries(t *testing.T) { | ||||||
|  | 	reso := &Resolver{KVStore: &memkvstore{}} | ||||||
|  | 	var in []*resolverinfo | ||||||
|  | 	in = append(in, &resolverinfo{}) | ||||||
|  | 	if err := reso.writestate(in); err != nil { | ||||||
|  | 		t.Fatal(err) | ||||||
|  | 	} | ||||||
|  | 	out, err := reso.readstateandprune() | ||||||
|  | 	if !errors.Is(err, errNoEntries) { | ||||||
|  | 		t.Fatal("not the error we expected", err) | ||||||
|  | 	} | ||||||
|  | 	if out != nil { | ||||||
|  | 		t.Fatal("expected nil here") | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestReadStateDefaultWithMissingEntries(t *testing.T) { | ||||||
|  | 	reso := &Resolver{KVStore: &memkvstore{}} | ||||||
|  | 	// let us simulate that we have just one entry here | ||||||
|  | 	existingURL := "https://dns.google/dns-query" | ||||||
|  | 	existingScore := 0.88 | ||||||
|  | 	var in []*resolverinfo | ||||||
|  | 	in = append(in, &resolverinfo{ | ||||||
|  | 		URL:   existingURL, | ||||||
|  | 		Score: existingScore, | ||||||
|  | 	}) | ||||||
|  | 	if err := reso.writestate(in); err != nil { | ||||||
|  | 		t.Fatal(err) | ||||||
|  | 	} | ||||||
|  | 	// let us seee what we read | ||||||
|  | 	out := reso.readstatedefault() | ||||||
|  | 	if len(out) < 1 { | ||||||
|  | 		t.Fatal("expected non-empty output") | ||||||
|  | 	} | ||||||
|  | 	keys := make(map[string]bool) | ||||||
|  | 	var found bool | ||||||
|  | 	for _, e := range out { | ||||||
|  | 		keys[e.URL] = true | ||||||
|  | 		if e.URL == existingURL { | ||||||
|  | 			if e.Score != existingScore { | ||||||
|  | 				t.Fatal("the score is not what we expected") | ||||||
|  | 			} | ||||||
|  | 			found = true | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	if !found { | ||||||
|  | 		t.Fatal("did not found the pre-loaded URL") | ||||||
|  | 	} | ||||||
|  | 	for k := range allbyurl { | ||||||
|  | 		if _, found := keys[k]; !found { | ||||||
|  | 			t.Fatal("missing key", k) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestWriteStateCannotSerialize(t *testing.T) { | ||||||
|  | 	errMocked := errors.New("mocked error") | ||||||
|  | 	reso := &Resolver{ | ||||||
|  | 		codec: &FakeCodec{ | ||||||
|  | 			EncodeErr: errMocked, | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | 	existingURL := "https://dns.google/dns-query" | ||||||
|  | 	existingScore := 0.88 | ||||||
|  | 	var in []*resolverinfo | ||||||
|  | 	in = append(in, &resolverinfo{ | ||||||
|  | 		URL:   existingURL, | ||||||
|  | 		Score: existingScore, | ||||||
|  | 	}) | ||||||
|  | 	if err := reso.writestate(in); !errors.Is(err, errMocked) { | ||||||
|  | 		t.Fatal("not the error we expected", err) | ||||||
|  | 	} | ||||||
|  | } | ||||||
| @ -109,7 +109,11 @@ func NewSession(config SessionConfig) (*Session, error) { | |||||||
| 		BogonIsError: true, | 		BogonIsError: true, | ||||||
| 		Logger:       sess.logger, | 		Logger:       sess.logger, | ||||||
| 	} | 	} | ||||||
| 	sess.resolver = sessionresolver.New(httpConfig) | 	sess.resolver = &sessionresolver.Resolver{ | ||||||
|  | 		ByteCounter: sess.byteCounter, | ||||||
|  | 		KVStore:     config.KVStore, | ||||||
|  | 		Logger:      sess.logger, | ||||||
|  | 	} | ||||||
| 	httpConfig.FullResolver = sess.resolver | 	httpConfig.FullResolver = sess.resolver | ||||||
| 	httpConfig.ProxyURL = config.ProxyURL // no need to proxy the resolver | 	httpConfig.ProxyURL = config.ProxyURL // no need to proxy the resolver | ||||||
| 	sess.httpDefaultTransport = netx.NewHTTPTransport(httpConfig) | 	sess.httpDefaultTransport = netx.NewHTTPTransport(httpConfig) | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user