diff --git a/x/go.mod b/x/go.mod index f777d403..962c61dd 100644 --- a/x/go.mod +++ b/x/go.mod @@ -3,7 +3,7 @@ module github.com/Jigsaw-Code/outline-sdk/x go 1.20 require ( - github.com/Jigsaw-Code/outline-sdk v0.0.11-0.20231215164346-91aaff6b855f + github.com/Jigsaw-Code/outline-sdk v0.0.11-0.20231215170417-3f452458d791 github.com/miekg/dns v1.1.57 github.com/songgao/water v0.0.0-20190725173103-fd331bda3f4b github.com/stretchr/testify v1.8.2 diff --git a/x/go.sum b/x/go.sum index 51efb20e..071db098 100644 --- a/x/go.sum +++ b/x/go.sum @@ -2,6 +2,8 @@ github.com/Jigsaw-Code/outline-sdk v0.0.11-0.20231212000605-eab30ec091c7 h1:3obj github.com/Jigsaw-Code/outline-sdk v0.0.11-0.20231212000605-eab30ec091c7/go.mod h1:C0XDPcOFq8ho9n5j0OWr1NkVL/8VVRdN5lTOgolptMg= github.com/Jigsaw-Code/outline-sdk v0.0.11-0.20231215164346-91aaff6b855f h1:4SOzehZzIGy5rZcOTroRMZIJ3rY2AJVVIbVXIwQdCEg= github.com/Jigsaw-Code/outline-sdk v0.0.11-0.20231215164346-91aaff6b855f/go.mod h1:nvQicUPpq1XvOme5Vp9vaohqkjn36LG3lXQm07mENWk= +github.com/Jigsaw-Code/outline-sdk v0.0.11-0.20231215170417-3f452458d791 h1:L/vWIQm9PjK//fb+XzwBpbuZHnXHTCH0oHa6urPmGG4= +github.com/Jigsaw-Code/outline-sdk v0.0.11-0.20231215170417-3f452458d791/go.mod h1:nvQicUPpq1XvOme5Vp9vaohqkjn36LG3lXQm07mENWk= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/x/smart/stream_dialer.go b/x/smart/stream_dialer.go index 15dd9a60..79b4375d 100644 --- a/x/smart/stream_dialer.go +++ b/x/smart/stream_dialer.go @@ -25,7 +25,6 @@ import ( "math/rand" "net" "net/url" - "strings" "sync" "time" "unicode" @@ -37,7 +36,6 @@ import ( ) // TODO: -// - Use dns.dnsmessage insted of miekg // - Add DNS caching // - Parallelize TLS // - Add debug logging to proxy handler @@ -67,154 +65,7 @@ import ( // To test one strategy: // go run ./x/examples/smart-proxy -v -localAddr=localhost:1080 --transport="" --domain www.rferl.org --config=<(echo '{"dns": [{"https": {"name": "doh.sb"}}]}') -// canonicalName returns the domain name in canonical form. A name in canonical -// form is lowercase and fully qualified. Only US-ASCII letters are affected. See -// Section 6.2 in RFC 4034. -func canonicalName(s string) string { - return strings.Map(func(r rune) rune { - if r >= 'A' && r <= 'Z' { - r += 'a' - 'A' - } - return r - }, s) -} - -type cacheEntry struct { - domain string - ips []net.IP - expire time.Time -} - -// cacheResolver is a very simple resolver. -// It doesn't use the response TTL and doesn't cache empty answers. -// It also doesn't dedup duplicate in-flight requests. -type cacheResolver struct { - resolver dns.Resolver - cache []cacheEntry -} - -var _ dns.Resolver = (*cacheResolver)(nil) - -func (r *cacheResolver) removeExpired() { - now := time.Now() - last := 0 - for _, entry := range r.cache { - if entry.expire.After(now) { - r.cache[last] = entry - last++ - } - } - r.cache = r.cache[:last] -} - -func (r *cacheResolver) moveToFront(index int) { - entry := r.cache[index] - copy(r.cache[1:], r.cache[:index]) - r.cache[0] = entry -} - -func (r *cacheResolver) LookupIP(ctx context.Context, domain string) ([]net.IP, error) { - r.removeExpired() - canonicalDomain := canonicalName(domain) - for ei, entry := range r.cache { - if entry.domain == canonicalDomain { - r.moveToFront(ei) - return entry.ips, nil - } - } - ips, err := r.resolver.LookupIP(ctx, domain) - if err != nil { - return nil, err - } - newSize := len(r.cache) + 1 - if newSize > cap(r.cache) { - newSize = cap(r.cache) - } - r.cache = r.cache[:newSize] - copy(r.cache[1:], r.cache[:newSize-1]) - r.cache[0] = cacheEntry{domain: canonicalDomain, ips: ips, expire: time.Now().Add(60 * time.Second)} - return ips, nil -} - -type dnsResolver struct { - rt dns.RoundTripper -} - -func newQuestion(domain string, qtype dnsmessage.Type) (*dnsmessage.Question, error) { - name, err := dnsmessage.NewName(domain) - if err != nil { - return nil, fmt.Errorf("cannot parse domain name: %w", err) - } - return &dnsmessage.Question{ - Name: name, - Type: qtype, - Class: dnsmessage.ClassINET, - }, nil -} - -func (rt dnsResolver) LookupIP(ctx context.Context, domain string) ([]net.IP, error) { - ip := net.ParseIP(domain) - if ip != nil { - return []net.IP{ip}, nil - } - domain = dns.MakeFullyQualified(domain) - var wg sync.WaitGroup - var returnErr error - ips := []net.IP{} - // TODO: re-enable IPv6. This doesn't work when the base dialer is Shadowsocks. - // wg.Add(1) - // go func() { - // defer wg.Done() - // var reqAAAA miekgdns.Msg - // reqAAAA.SetQuestion(domain, miekgdns.TypeAAAA) - // response, err := rt.rt.RoundTripMsg(ctx, &reqAAAA) - // if err != nil { - // returnErr = err - // return - // } - // for _, answer := range response.Answer { - // if answer.Header().Rrtype != miekgdns.TypeAAAA { - // continue - // } - // if rr, ok := answer.(*miekgdns.AAAA); ok { - // ips = append(ips, rr.AAAA) - // } - // } - // }() - q, err := newQuestion(domain, dnsmessage.TypeA) - if err != nil { - return nil, err - } - response, err := rt.rt.RoundTrip(ctx, *q) - wg.Wait() - if err != nil { - returnErr = errors.Join(returnErr, err) - } - if returnErr != nil { - return nil, returnErr - } - for _, answer := range response.Answers { - if answer.Header.Type != dnsmessage.TypeA { - continue - } - if rr, ok := answer.Body.(*dnsmessage.AResource); ok { - ips = append(ips, net.IP(rr.A[:])) - } - } - if len(ips) == 0 { - return nil, errors.New("no IPs found") - } - return ips, nil -} - -type funcStreamDialer func(ctx context.Context, raddr string) (transport.StreamConn, error) - -var _ transport.StreamDialer = (funcStreamDialer)(nil) - -func (d funcStreamDialer) Dial(ctx context.Context, raddr string) (transport.StreamConn, error) { - return d(ctx, raddr) -} - +// mixCase randomizes the case of the domain letters. func mixCase(domain string) string { var mixed []rune for _, r := range domain { @@ -262,7 +113,7 @@ func fingerprint(pd transport.PacketDialer, sd transport.StreamDialer, testDomai } } - q, err := newQuestion(testDomain, dnsmessage.TypeA) + q, err := dns.NewQuestion(testDomain, dnsmessage.TypeA) if err != nil { log.Fatalf("failed to parse domain name: %v", err) } @@ -419,7 +270,7 @@ func (f *StrategyFinder) testDNSClient(ctx context.Context, dnsRT dns.RoundTripp requestDomain := mixCase(testDomain) - q, err := newQuestion(requestDomain, dnsmessage.TypeA) + q, err := dns.NewQuestion(requestDomain, dnsmessage.TypeA) if err != nil { return nil, fmt.Errorf("failed to create question: %v", err) } @@ -429,10 +280,10 @@ func (f *StrategyFinder) testDNSClient(ctx context.Context, dnsRT dns.RoundTripp } ips, err := evaluateAResponse(*response, requestDomain) if err != nil { - return ips, err + return ips, fmt.Errorf("failed A test: %w", err) } - q, err = newQuestion(requestDomain, dnsmessage.TypeCNAME) + q, err = dns.NewQuestion(requestDomain, dnsmessage.TypeCNAME) if err != nil { return nil, fmt.Errorf("failed to create question: %v", err) } @@ -441,7 +292,10 @@ func (f *StrategyFinder) testDNSClient(ctx context.Context, dnsRT dns.RoundTripp return nil, fmt.Errorf("request for CNAME query failed: %w", err) } err = evaluateCNAMEResponse(*response, requestDomain) - return ips, err + if err != nil { + return nil, fmt.Errorf("failed CNAME test: %w", err) + } + return ips, nil } type httpsEntryJSON struct { @@ -622,7 +476,7 @@ func (f *StrategyFinder) findDNS(testDomain string, dnsConfig []dnsEntryJSON) (d } f.log(", status=ok ✅\n") if result.RT != nil { - return &cacheResolver{resolver: &dnsResolver{result.RT}, cache: make([]cacheEntry, 0, 100)}, nil + return dns.NewCacheResolver(&dns.RoundTripResolver{result.RT}, 100), nil } else { return nil, nil } @@ -679,6 +533,9 @@ func (f *StrategyFinder) findTLS(testDomain string, baseDialer transport.StreamD return nil, errors.New("could not find TLS strategy") } +// NewDialer uses the config in configBytes to search for a strategy that unblocks the testDomain, returning a dialer with the found strategy. +// It returns an error if no strategy was found that unblocks the testDomain. +// The testDomain must be a domain with a TLS service running on port 443. func (f *StrategyFinder) NewDialer(ctx context.Context, testDomain string, configBytes []byte) (transport.StreamDialer, error) { var parsedConfig configJSON err := json.Unmarshal(configBytes, &parsedConfig)