From 0b02d88fae0501ee48afb0b283993a5cecfa36ae Mon Sep 17 00:00:00 2001 From: Vinicius Fortuna Date: Fri, 19 Jan 2024 11:17:54 -0500 Subject: [PATCH] Create Smart Proxy --- x/examples/smart-proxy/config.json | 115 ++++ x/examples/smart-proxy/config_broken.json | 18 + x/examples/smart-proxy/main.go | 131 +++++ x/internal/dnsextra/cache.go | 117 ++++ x/internal/dnsextra/dialer.go | 152 +++++ x/mobileproxy/README.md | 116 +++- x/mobileproxy/mobileproxy.go | 66 +++ x/smart/stream_dialer.go | 664 ++++++++++++++++++++++ 8 files changed, 1377 insertions(+), 2 deletions(-) create mode 100644 x/examples/smart-proxy/config.json create mode 100644 x/examples/smart-proxy/config_broken.json create mode 100644 x/examples/smart-proxy/main.go create mode 100644 x/internal/dnsextra/cache.go create mode 100644 x/internal/dnsextra/dialer.go create mode 100644 x/smart/stream_dialer.go diff --git a/x/examples/smart-proxy/config.json b/x/examples/smart-proxy/config.json new file mode 100644 index 00000000..31c5ee5a --- /dev/null +++ b/x/examples/smart-proxy/config.json @@ -0,0 +1,115 @@ +{ + "dns": [ + {"system": {}}, + + {"https": {"name": "2620:fe::fe"}, "//": "Quad9"}, + {"https": {"name": "9.9.9.9"}}, + {"https": {"name": "149.112.112.112"}}, + + {"https": {"name": "2001:4860:4860::8888"}, "//": "Google"}, + {"https": {"name": "8.8.8.8"}}, + {"https": {"name": "2001:4860:4860::8844"}}, + {"https": {"name": "8.8.4.4"}}, + + {"https": {"name": "2606:4700:4700::1111"}, "//": "Cloudflare"}, + {"https": {"name": "1.1.1.1"}}, + {"https": {"name": "2606:4700:4700::1001"}}, + {"https": {"name": "1.0.0.1"}}, + {"https": {"name": "cloudflare-dns.com.", "address": "cloudflare.net."}}, + + {"https": {"name": "2620:119:35::35"}, "//": "OpenDNS"}, + {"https": {"name": "208.67.220.220"}}, + {"https": {"name": "2620:119:53::53"}}, + {"https": {"name": "208.67.222.222"}}, + + {"https": {"name": "2001:67c:930::1"}, "//": "Wikimedia DNS"}, + {"https": {"name": "185.71.138.138"}}, + + {"https": {"name": "doh.dns.sb", "address": "cloudflare.net:443"}, "//": "DNS.SB"}, + + + {"tls": {"name": "2620:fe::fe"}, "//": "Quad9"}, + {"tls": {"name": "9.9.9.9"}}, + {"tls": {"name": "149.112.112.112"}}, + + {"tls": {"name": "2001:4860:4860::8888"}, "//": "Google"}, + {"tls": {"name": "8.8.8.8"}}, + {"tls": {"name": "2001:4860:4860::8844"}}, + {"tls": {"name": "8.8.4.4"}}, + + {"tls": {"name": "2606:4700:4700::1111"}, "//": "Cloudflare"}, + {"tls": {"name": "1.1.1.1"}}, + {"tls": {"name": "2606:4700:4700::1001"}}, + {"tls": {"name": "1.0.0.1"}}, + + {"tls": {"name": "2620:119:35::35"}, "//": "OpenDNS"}, + {"tls": {"name": "208.67.220.220"}}, + {"tls": {"name": "2620:119:53::53"}}, + {"tls": {"name": "208.67.222.222"}}, + + {"tls": {"name": "2001:67c:930::1"}, "//": "Wikimedia DNS"}, + {"tls": {"name": "185.71.138.138"}}, + + + {"tcp": {"address": "2620:fe::fe"}, "//": "Quad9"}, + {"tcp": {"address": "9.9.9.9"}}, + {"tcp": {"address": "149.112.112.112"}}, + {"tcp": {"address": "[2620:fe::fe]:9953"}}, + {"tcp": {"address": "9.9.9.9:9953"}}, + {"tcp": {"address": "149.112.112.112:9953"}}, + + {"tcp": {"address": "2001:4860:4860::8888"}, "//": "Google"}, + {"tcp": {"address": "8.8.8.8"}}, + {"tcp": {"address": "2001:4860:4860::8844"}}, + {"tcp": {"address": "8.8.4.4"}}, + + {"tcp": {"address": "2606:4700:4700::1111"}, "//": "Cloudflare"}, + {"tcp": {"address": "1.1.1.1"}}, + {"tcp": {"address": "2606:4700:4700::1001"}}, + {"tcp": {"address": "1.0.0.1"}}, + + {"tcp": {"address": "2620:119:35::35"}, "//": "OpenDNS"}, + {"tcp": {"address": "208.67.220.220"}}, + {"tcp": {"address": "2620:119:53::53"}}, + {"tcp": {"address": "208.67.222.222"}}, + {"tcp": {"address": "[2620:119:35::35]:443"}}, + {"tcp": {"address": "208.67.220.220:443"}}, + {"tcp": {"address": "[2620:119:35::35]:5353"}}, + {"tcp": {"address": "208.67.220.220:5353"}}, + + + {"udp": {"address": "2620:fe::fe"}, "//": "Quad9"}, + {"udp": {"address": "9.9.9.9"}}, + {"udp": {"address": "149.112.112.112"}}, + {"udp": {"address": "[2620:fe::fe]:9953"}}, + {"udp": {"address": "9.9.9.9:9953"}}, + {"udp": {"address": "149.112.112.112:9953"}}, + + {"udp": {"address": "2001:4860:4860::8888"}, "//": "Google"}, + {"udp": {"address": "8.8.8.8"}}, + {"udp": {"address": "2001:4860:4860::8844"}}, + {"udp": {"address": "8.8.4.4"}}, + + {"udp": {"address": "2606:4700:4700::1111"}, "//": "Cloudflare"}, + {"udp": {"address": "1.1.1.1"}}, + {"udp": {"address": "2606:4700:4700::1001"}}, + {"udp": {"address": "1.0.0.1"}}, + + {"udp": {"address": "2620:119:35::35"}, "//": "OpenDNS"}, + {"udp": {"address": "208.67.220.220"}}, + {"udp": {"address": "2620:119:53::53"}}, + {"udp": {"address": "208.67.222.222"}}, + {"udp": {"address": "[2620:119:35::35]:443"}}, + {"udp": {"address": "208.67.220.220:443"}}, + {"udp": {"address": "[2620:119:35::35]:5353"}}, + {"udp": {"address": "208.67.220.220:5353"}} + ], + + "tls": [ + "", + "split:1", + "split:2", + "split:5", + "tlsfrag:1" + ] +} diff --git a/x/examples/smart-proxy/config_broken.json b/x/examples/smart-proxy/config_broken.json new file mode 100644 index 00000000..55a7c7ec --- /dev/null +++ b/x/examples/smart-proxy/config_broken.json @@ -0,0 +1,18 @@ +{ + "dns": [ + {"udp": {"address": "china.cn"}}, + {"udp": {"address": "ns1.tic.ir"}}, + {"tcp": {"address": "ns1.tic.ir"}}, + {"udp": {"address": "tmcell.tm"}}, + {"tls": {"name": "captive-portal.badssl.com", "address": "captive-portal.badssl.com:443"}}, + {"https": {"name": "mitm-software.badssl.com"}} + ], + + "tls": [ + "", + "split:1", + "split:2", + "split:5", + "tlsfrag:1" + ] +} diff --git a/x/examples/smart-proxy/main.go b/x/examples/smart-proxy/main.go new file mode 100644 index 00000000..cc078303 --- /dev/null +++ b/x/examples/smart-proxy/main.go @@ -0,0 +1,131 @@ +// Copyright 2023 Jigsaw Operations LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "flag" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "os/signal" + "time" + + "github.com/Jigsaw-Code/outline-sdk/transport" + "github.com/Jigsaw-Code/outline-sdk/x/config" + "github.com/Jigsaw-Code/outline-sdk/x/httpproxy" + "github.com/Jigsaw-Code/outline-sdk/x/smart" +) + +var debugLog log.Logger = *log.New(io.Discard, "", 0) + +type stringArrayFlagValue []string + +func (v *stringArrayFlagValue) String() string { + return fmt.Sprint(*v) +} + +func (v *stringArrayFlagValue) Set(value string) error { + *v = append(*v, value) + return nil +} + +func main() { + verboseFlag := flag.Bool("v", false, "Enable debug output") + addrFlag := flag.String("localAddr", "localhost:1080", "Local proxy address") + configFlag := flag.String("config", "config.json", "Address of the config file") + transportFlag := flag.String("transport", "", "The base transport for the connections") + var domainsFlag stringArrayFlagValue + flag.Var(&domainsFlag, "domain", "The test domains to find strategies.") + + flag.Parse() + if *verboseFlag { + debugLog = *log.New(os.Stderr, "", log.LstdFlags|log.Lmicroseconds|log.Lshortfile) + } + + if len(domainsFlag) == 0 { + log.Fatal("Must specify flag --domain") + } + + if *configFlag == "" { + log.Fatal("Must specify flag --config") + } + + finderConfig, err := os.ReadFile(*configFlag) + if err != nil { + log.Fatalf("Could not read config: %v", err) + } + + packetDialer, err := config.NewPacketDialer(*transportFlag) + if err != nil { + log.Fatalf("Could not create packet dialer: %v", err) + } + streamDialer, err := config.NewStreamDialer(*transportFlag) + if err != nil { + log.Fatalf("Could not create stream dialer: %v", err) + } + + finder := smart.StrategyFinder{ + LogWriter: debugLog.Writer(), + TestTimeout: 5 * time.Second, + StreamDialer: streamDialer, + PacketDialer: packetDialer, + } + + fmt.Println("Finding strategy") + dialer, err := finder.NewDialer(context.Background(), domainsFlag, finderConfig) + if err != nil { + log.Fatalf("Failed to find dialer: %v", err) + } + logDialer := transport.FuncStreamDialer(func(ctx context.Context, address string) (transport.StreamConn, error) { + conn, err := dialer.DialStream(ctx, address) + if err != nil { + debugLog.Printf("Failed to dial %v: %v\n", address, err) + } + return conn, err + }) + + listener, err := net.Listen("tcp", *addrFlag) + if err != nil { + log.Fatalf("Could not listen on address %v: %v", *addrFlag, err) + } + defer listener.Close() + fmt.Printf("Proxy listening on %v\n", listener.Addr().String()) + + server := http.Server{ + Handler: httpproxy.NewProxyHandler(logDialer), + ErrorLog: &debugLog, + } + go func() { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + log.Fatalf("Error running web server: %v", err) + } + }() + + // Wait for interrupt signal to stop the proxy. + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt) + <-sig + fmt.Print("Shutting down") + // Gracefully shut down the server, with a 5s timeout. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + log.Fatalf("Failed to shutdown gracefully: %v", err) + } +} diff --git a/x/internal/dnsextra/cache.go b/x/internal/dnsextra/cache.go new file mode 100644 index 00000000..47624010 --- /dev/null +++ b/x/internal/dnsextra/cache.go @@ -0,0 +1,117 @@ +// Copyright 2023 Jigsaw Operations LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dnsextra + +import ( + "context" + "strings" + "time" + + "github.com/Jigsaw-Code/outline-sdk/dns" + "golang.org/x/net/dns/dnsmessage" +) + +// 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 { + key string + msg *dnsmessage.Message + expire time.Time +} + +// cacheResolver is a very simple caching [RoundTripper]. +// 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 NewCacheResolver(resolver dns.Resolver, numEntries int) dns.Resolver { + return &cacheResolver{resolver: resolver, cache: make([]cacheEntry, numEntries)} +} + +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 makeCacheKey(q dnsmessage.Question) string { + domainKey := canonicalName(q.Name.String()) + return strings.Join([]string{domainKey, q.Type.String(), q.Class.String()}, "|") +} + +func (r *cacheResolver) searchCache(key string) *dnsmessage.Message { + for ei, entry := range r.cache { + if entry.key == key { + r.moveToFront(ei) + // TODO: update TTLs + // TODO: make names match + return entry.msg + } + } + return nil +} + +func (r *cacheResolver) addToCache(key string, msg *dnsmessage.Message) { + 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]) + // TODO: copy and normalize names + r.cache[0] = cacheEntry{key: key, msg: msg, expire: time.Now().Add(60 * time.Second)} +} + +func (r *cacheResolver) Query(ctx context.Context, q dnsmessage.Question) (*dnsmessage.Message, error) { + r.removeExpired() + cacheKey := makeCacheKey(q) + if msg := r.searchCache(cacheKey); msg != nil { + return msg, nil + } + msg, err := r.resolver.Query(ctx, q) + if err != nil { + // TODO: cache NXDOMAIN. See https://datatracker.ietf.org/doc/html/rfc2308. + return nil, err + } + r.addToCache(cacheKey, msg) + return msg, nil +} diff --git a/x/internal/dnsextra/dialer.go b/x/internal/dnsextra/dialer.go new file mode 100644 index 00000000..cd216211 --- /dev/null +++ b/x/internal/dnsextra/dialer.go @@ -0,0 +1,152 @@ +// Copyright 2023 Jigsaw Operations LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dnsextra + +import ( + "context" + "errors" + "fmt" + "net" + "time" + + "github.com/Jigsaw-Code/outline-sdk/dns" + "github.com/Jigsaw-Code/outline-sdk/transport" + "golang.org/x/net/dns/dnsmessage" +) + +type resolverStreamDialer struct { + resolver dns.Resolver + dialer transport.StreamDialer +} + +var _ transport.StreamDialer = (*resolverStreamDialer)(nil) + +// Returns a [context.Context] that is already done. +func newDoneContext() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx +} + +func (d *resolverStreamDialer) lookupIPv4(ctx context.Context, domain string) ([]net.IP, error) { + ips := []net.IP{} + q, err := dns.NewQuestion(domain, dnsmessage.TypeA) + if err != nil { + return nil, err + } + response, err := d.resolver.Query(ctx, *q) + if err != nil { + return nil, err + } + if response.RCode != dnsmessage.RCodeSuccess { + return nil, fmt.Errorf("got %v (%d)", response.RCode.String(), response.RCode) + } + 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 +} + +// MakeFullyQualified makes the domain fully-qualified, ending on a dot ("."). +// This is useful in domain resolution to avoid ambiguity with local domains +// and domain search. +func MakeFullyQualified(domain string) string { + if len(domain) > 0 && domain[len(domain)-1] == '.' { + return domain + } + return domain + "." +} + +func (d *resolverStreamDialer) DialStream(ctx context.Context, addr string) (transport.StreamConn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("failed to parse address: %w", err) + } + var ips []net.IP + ip := net.ParseIP(host) + if ip != nil { + ips = []net.IP{ip} + } else { + // TODO: Implement standard Happy Eyeballs v2. + // Need to properly sort addresses. + // We don't do domain search. + fqdn := MakeFullyQualified(host) + ips, err = d.lookupIPv4(ctx, fqdn) + if err != nil { + return nil, fmt.Errorf("failed to lookup IPv4 ips: %w", err) + } + } + type dialResult struct { + Conn transport.StreamConn + Err error + } + // Communicates the result of each dial. + resultChan := make(chan dialResult) + // Indicates to attempts that the search is done, so they don't get stuck. + searchCtx, searchDone := context.WithCancel(context.Background()) + defer searchDone() + // Used to space out each attempt. The initial value is done because there's no wait needed. + waitCtx := newDoneContext() + // Next entry to start dialing. + next := 0 + // How many connection attempts are not done. + toTry := len(ips) + var dialErr error + for toTry > 0 { + if next == len(ips) { + waitCtx = searchCtx + } + select { + case <-waitCtx.Done(): + // Start a new attempt. + ip := ips[next] + next++ + var waitDone context.CancelFunc + waitCtx, waitDone = context.WithTimeout(searchCtx, 250*time.Millisecond) + go func(ip net.IP, waitDone context.CancelFunc) { + defer waitDone() + conn, err := d.dialer.DialStream(ctx, net.JoinHostPort(ip.String(), port)) + select { + case <-searchCtx.Done(): + if conn != nil { + conn.Close() + } + case resultChan <- dialResult{Conn: conn, Err: err}: + } + }(ip, waitDone) + + case result := <-resultChan: + toTry-- + if result.Err != nil { + dialErr = errors.Join(dialErr, result.Err) + continue + } + return result.Conn, nil + } + } + return nil, dialErr +} + +func NewStreamDialer(resolver dns.Resolver, dialer transport.StreamDialer) transport.StreamDialer { + return &resolverStreamDialer{resolver, dialer} +} diff --git a/x/mobileproxy/README.md b/x/mobileproxy/README.md index 2065f38a..31cb82e2 100644 --- a/x/mobileproxy/README.md +++ b/x/mobileproxy/README.md @@ -47,6 +47,7 @@ The header file below is an example of the Objective-C interface that Go Mobile @class MobileproxyProxy; +@class MobileproxyStringList; /** * Proxy enables you to get the actual address bound by the server and stop the service when no longer needed. @@ -76,12 +77,40 @@ The function takes a timeoutSeconds number instead of a [time.Duration] so it's - (void)stop:(long)timeoutSeconds; @end +/** + * StringList allows us to pass a list of strings to the Go Mobile functions, since Go Mobiule doesn't +support slices as parameters. + */ +@interface MobileproxyStringList : NSObject { +} +@property(strong, readonly) _Nonnull id _ref; + +- (nonnull instancetype)initWithRef:(_Nonnull id)ref; +- (nonnull instancetype)init; +/** + * Append adds the string value to the end of the list. + */ +- (void)append:(NSString* _Nullable)value; +@end + +/** + * NewListFromLines creates a StringList by splitting the input string on new lines. + */ +FOUNDATION_EXPORT MobileproxyStringList* _Nullable MobileproxyNewListFromLines(NSString* _Nullable lines); + /** * RunProxy runs a local web proxy that listens on localAddress, and uses the transportConfig to create a [transport.StreamDialer] that is used to connect to the requested destination. */ FOUNDATION_EXPORT MobileproxyProxy* _Nullable MobileproxyRunProxy(NSString* _Nullable localAddress, NSString* _Nullable transportConfig, NSError* _Nullable* _Nullable error); +/** + * RunSmartProxy will run a local proxy that automatically selects a DNS and TLS strategy to use. +The local proxy will listen on localAddress. It will use testDomain to find a strategy that works. +The strategies to search are given in the searchConfig. + */ +FOUNDATION_EXPORT MobileproxyProxy* _Nullable MobileproxyRunSmartProxy(NSString* _Nullable localAddress, MobileproxyStringList* _Nullable testDomains, NSString* _Nullable searchConfig, NSError* _Nullable* _Nullable error); + #endif ``` @@ -121,11 +150,21 @@ public abstract class Mobileproxy { + /** + * NewListFromLines creates a StringList by splitting the input string on new lines. + */ + public static native StringList newListFromLines(String lines); /** * RunProxy runs a local web proxy that listens on localAddress, and uses the transportConfig to create a [transport.StreamDialer] that is used to connect to the requested destination. */ public static native Proxy runProxy(String localAddress, String transportConfig) throws Exception; + /** + * RunSmartProxy will run a local proxy that automatically selects a DNS and TLS strategy to use. + The local proxy will listen on localAddress. It will use testDomain to find a strategy that works. + The strategies to search are given in the searchConfig. + */ + public static native Proxy runSmartProxy(String localAddress, StringList testDomains, String searchConfig) throws Exception; } ``` @@ -197,13 +236,69 @@ public final class Proxy implements Seq.Proxy { } ``` +`StringList.java`: + +```java +// Code generated by gobind. DO NOT EDIT. + +// Java class mobileproxy.StringList is a proxy for talking to a Go program. +// +// autogenerated by gobind -lang=java github.com/Jigsaw-Code/outline-sdk/x/mobileproxy +package mobileproxy; + +import go.Seq; + +/** + * StringList allows us to pass a list of strings to the Go Mobile functions, since Go Mobiule doesn't +support slices as parameters. + */ +public final class StringList implements Seq.Proxy { + static { Mobileproxy.touch(); } + + private final int refnum; + + @Override public final int incRefnum() { + Seq.incGoRef(refnum, this); + return refnum; + } + + StringList(int refnum) { this.refnum = refnum; Seq.trackGoRef(refnum, this); } + + public StringList() { this.refnum = __New(); Seq.trackGoRef(refnum, this); } + + private static native int __New(); + + /** + * Append adds the string value to the end of the list. + */ + public native void append(String value); + @Override public boolean equals(Object o) { + if (o == null || !(o instanceof StringList)) { + return false; + } + StringList that = (StringList)o; + return true; + } + + @Override public int hashCode() { + return java.util.Arrays.hashCode(new Object[] {}); + } + + @Override public String toString() { + StringBuilder b = new StringBuilder(); + b.append("StringList").append("{"); + return b.append("}").toString(); + } +} +``` + ## Add the library to your mobile project To add the library to your mobile project, see Go Mobile's [Building and deploying to iOS](https://github.com/golang/go/wiki/Mobile#building-and-deploying-to-ios-1) and [Building and deploying to Android](https://github.com/golang/go/wiki/Mobile#building-and-deploying-to-android-1). -## Use the library +## Using the basic local proxy forwarder You need to call the `RunProxy` function passing the local address to use, and the transport configuration. @@ -217,6 +312,23 @@ val proxy = mobileproxy.runProxy("localhost:0", "split:3") proxy.stop() ``` +## Using the smart local proxy forwarder ("Smart Proxy") + +The Smart Proxy can automatically try multiple strategies to unblock access to the test domains you specify. +You need to specify a strategy config in JSON format ([example](../examples/smart-proxy/config.json)). + +On Android, the Kotlin code would look like this: +```kotlin +// Use port zero to let the system pick an open port for you. +val testDomains = mobileproxy.newListFromLines("www.youtube.com\ni.ytimg.com") +val strategiesConfig = "..." // Config JSON. +val proxy = mobileproxy.runSmartProxy("localhost:0", testDomains, strategies) +// Configure your networking library using proxy.host() and proxy.port() or proxy.address(). +// ... +// Stop running the proxy. +proxy.stop() +``` + ## Configure your HTTP client or networking library You need to configure your networking library to use the local proxy. How you do it depends on the networking library you are using. @@ -265,7 +377,7 @@ We are working on instructions on how use the local proxy in a Webview. On Android, you will likely have to implement [WebViewClient.shouldInterceptRequest](https://developer.android.com/reference/android/webkit/WebViewClient#shouldInterceptRequest(android.webkit.WebView,%20android.webkit.WebResourceRequest)) to fulfill requests using an HTTP client that uses the local proxy. -On iOS, you will have to use [NWParameters.PrivacyContext.proxyConfigurations](https://developer.apple.com/documentation/network/nwparameters/privacycontext/4156642-proxyconfigurations). It is iOS 17.0+ and MacOS 14.0+ only. As a fallback you can force encrypted DNS in iOS 14+ via [NWParameters.PrivacyContext.requireEncryptedNameResolution(_:fallbackResolver:)](https://developer.apple.com/documentation/network/nwparameters/privacycontext/3548851-requireencryptednameresolution). +On iOS, we are still looking for ideas. There's [WKWebViewConfiguration.setURLSchemeHandler](https://developer.apple.com/documentation/webkit/wkwebviewconfiguration/2875766-seturlschemehandler), but the documentation says it can't be used to intercept HTTPS. If you know how to use a proxy with the WKWebView, please let us know! ## Clean up diff --git a/x/mobileproxy/mobileproxy.go b/x/mobileproxy/mobileproxy.go index 3f89648a..c396d96e 100644 --- a/x/mobileproxy/mobileproxy.go +++ b/x/mobileproxy/mobileproxy.go @@ -24,11 +24,15 @@ import ( "log" "net" "net/http" + "os" "strconv" + "strings" "time" + "github.com/Jigsaw-Code/outline-sdk/transport" "github.com/Jigsaw-Code/outline-sdk/x/config" "github.com/Jigsaw-Code/outline-sdk/x/httpproxy" + "github.com/Jigsaw-Code/outline-sdk/x/smart" ) // Proxy enables you to get the actual address bound by the server and stop the service when no longer needed. @@ -90,3 +94,65 @@ func RunProxy(localAddress string, transportConfig string) (*Proxy, error) { } return &Proxy{host: host, port: port, server: server}, nil } + +// NewListFromLines creates a StringList by splitting the input string on new lines. +func NewListFromLines(lines string) *StringList { + return &StringList{list: strings.Split(lines, "\n")} +} + +// StringList allows us to pass a list of strings to the Go Mobile functions, since Go Mobiule doesn't +// support slices as parameters. +type StringList struct { + list []string +} + +// Append adds the string value to the end of the list. +func (l *StringList) Append(value string) { + l.list = append(l.list, value) +} + +// RunSmartProxy will run a local proxy that automatically selects a DNS and TLS strategy to use. +// The local proxy will listen on localAddress. It will use testDomain to find a strategy that works. +// The strategies to search are given in the searchConfig. +func RunSmartProxy(localAddress string, testDomains *StringList, searchConfig string) (*Proxy, error) { + // TODO: inject the base dialer for tests. + logWriter := os.Stderr + finder := smart.StrategyFinder{ + LogWriter: logWriter, + TestTimeout: 5 * time.Second, + StreamDialer: &transport.TCPDialer{}, + PacketDialer: &transport.UDPDialer{}, + } + + fmt.Println("Finding strategy") + testDomainsSlice := append(make([]string, 0, len(testDomains.list)), testDomains.list...) + dialer, err := finder.NewDialer(context.Background(), testDomainsSlice, []byte(searchConfig)) + if err != nil { + return nil, fmt.Errorf("failed to find dialer: %v", err) + } + logDialer := transport.FuncStreamDialer(func(ctx context.Context, address string) (transport.StreamConn, error) { + conn, err := dialer.DialStream(ctx, address) + if err != nil { + fmt.Fprintf(logWriter, "Failed to dial %v: %v\n", address, err) + } + return conn, err + }) + + listener, err := net.Listen("tcp", localAddress) + if err != nil { + return nil, fmt.Errorf("could not listen on address %v: %v", localAddress, err) + } + + server := &http.Server{Handler: httpproxy.NewProxyHandler(logDialer)} + go server.Serve(listener) + + host, portStr, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + return nil, fmt.Errorf("could not parse proxy address '%v': %v", listener.Addr().String(), err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + return nil, fmt.Errorf("could not parse proxy port '%v': %v", portStr, err) + } + return &Proxy{host: host, port: port, server: server}, nil +} diff --git a/x/smart/stream_dialer.go b/x/smart/stream_dialer.go new file mode 100644 index 00000000..c1f35fee --- /dev/null +++ b/x/smart/stream_dialer.go @@ -0,0 +1,664 @@ +// Copyright 2023 Jigsaw Operations LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package smart + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "math/rand" + "net" + "net/url" + "sync" + "time" + "unicode" + + "github.com/Jigsaw-Code/outline-sdk/dns" + "github.com/Jigsaw-Code/outline-sdk/transport" + "github.com/Jigsaw-Code/outline-sdk/x/config" + "github.com/Jigsaw-Code/outline-sdk/x/internal/dnsextra" + "golang.org/x/net/dns/dnsmessage" +) + +// TODO: +// - Add DNS caching +// - Parallelize TLS +// - Add debug logging to proxy handler +// - Figure out what to do for IPv6. +// - We should auto detect if underlying dialer supports it. +// - We need to make the ResolverDialer smarter +// - Improve plaintext DNS search +// - Use SOA or NS query. Need to account for CNAMEs. +// - Use injection fingerprint +// - Use TLS validator. Successful TLS, but cert not for domain is clear sign. (if SNI specified) +// - Downgrade, not drop, case not matched +// - Investigate why TLS is succeeding for www.rferl.org @ 188.43.20.67 +// - Also, is fake SNI working? + +// IP validation +// - Check against hardcoded ground truth (IPs, PTR record) +// - Check against encrypted answers +// - Try TLS connection. May need fragmentation +// +// Dialer: +// - check cache +// - resolve A and AAAA, save to cache +// - Go resolution: https://cs.opensource.google/go/go/+/master:src/net/dnsclient_unix.go;l=612;drc=6146a73d279d73b6138191929d2f1fad22188f51 +// - Go Happy Eyeballs (V1): https://cs.opensource.google/go/go/+/master:src/net/dial.go;l=455;drc=1fde99cd6eff725f5cc13748a43b4aef3de557c8 +// - Do basic fallback on dial: https://cs.opensource.google/go/go/+/master:src/net/addrselect.go + +// 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"}}]}') + +// mixCase randomizes the case of the domain letters. +func mixCase(domain string) string { + var mixed []rune + for _, r := range domain { + if rand.Intn(2) == 0 { + mixed = append(mixed, unicode.ToLower(r)) + } else { + mixed = append(mixed, unicode.ToUpper(r)) + } + } + return string(mixed) +} + +func getARootNameserver() (string, error) { + nsList, err := net.LookupNS(".") + if err != nil { + return "", fmt.Errorf("could not get list of root nameservers: %v", err) + } + if len(nsList) == 0 { + return "", fmt.Errorf("empty list of root nameservers") + } + return nsList[0].Host, nil +} + +func fingerprint(pd transport.PacketDialer, sd transport.StreamDialer, testDomain string) { + rootNS, err := getARootNameserver() + if err != nil { + log.Fatalf("Failed to find root nameserver: %v", err) + } + + allNSIPs, err := net.LookupIP(rootNS) + if err != nil { + log.Fatalf("Failed to resolve root nameserver: %v", err) + } + ips := []net.IP{} + for _, ip := range allNSIPs { + if ip.To4() != nil { + ips = append(ips, ip) + break + } + } + for _, ip := range allNSIPs { + if ip.To16() != nil { + ips = append(ips, ip) + break + } + } + + q, err := dns.NewQuestion(testDomain, dnsmessage.TypeA) + if err != nil { + log.Fatalf("failed to parse domain name: %v", err) + } + for _, rootNSIP := range ips { + resolvedNS := net.JoinHostPort(rootNSIP.String(), "53") + for _, proto := range []string{"udp", "tcp"} { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var resolver dns.Resolver + switch proto { + case "tcp": + resolver = dns.NewTCPResolver(sd, resolvedNS) + default: + resolver = dns.NewUDPResolver(pd, resolvedNS) + } + + response, err := resolver.Query(ctx, *q) + fmt.Printf("%v:%v", proto, resolvedNS) + if err != nil { + fmt.Printf("; status=error: %v\n", err) + continue + } + if len(response.Answers) > 0 { + fmt.Printf("; status=unexpected answer (injected): %v ⚠️\n", response.Answers) + // TODO: use RCODE, CNAME and IPs as blocking fingerprint. + continue + } + if response.RCode != dnsmessage.RCodeSuccess { + fmt.Printf("; status=unexpected rcode (injected): %v ⚠️\n", response.Answers) + // TODO: use RCODE, CNAME and IPs as blocking fingerprint. + continue + } + fmt.Print("; status=ok (no injection) ✓\n") + } + } +} + +func evaluateNetResolver(ctx context.Context, resolver *net.Resolver, testDomain string) ([]net.IP, error) { + requestDomain := mixCase(testDomain) + _, err := resolver.LookupCNAME(ctx, requestDomain) + if err != nil { + return nil, fmt.Errorf("could not get cname: %w", err) + } + ips, err := resolver.LookupIP(ctx, "ip", requestDomain) + if err != nil { + return nil, fmt.Errorf("failed to lookup IPs: %w", err) + } + if len(ips) == 0 { + return nil, fmt.Errorf("no ip answer") + } + for _, ip := range ips { + if ip.IsLoopback() { + return nil, fmt.Errorf("localhost ip: %v", ip) // -1 + } + if ip.IsPrivate() { + return nil, fmt.Errorf("private ip: %v", ip) // -1 + } + if ip.IsUnspecified() { + return nil, fmt.Errorf("zero ip: %v", ip) // -1 + } + // TODO: consider validating the IPs: fingerprint, hardcoded ground truth, trusted response, TLS connection. + } + return ips, nil +} + +func evaluateAddressResponse(response dnsmessage.Message, requestDomain string) ([]net.IP, error) { + if response.RCode != dnsmessage.RCodeSuccess { + return nil, fmt.Errorf("rcode is not success: %v", response.RCode) + } + var ips []net.IP + if len(response.Answers) == 0 { + return ips, errors.New("no answers") // -1 + } + for _, answer := range response.Answers { + if answer.Header.Type != dnsmessage.TypeA && answer.Header.Type != dnsmessage.TypeAAAA { + continue + } + var ip net.IP + switch rr := answer.Body.(type) { + case *dnsmessage.AResource: + ip = net.IP(rr.A[:]) + case *dnsmessage.AAAAResource: + ip = net.IP(rr.AAAA[:]) + default: + continue + } + if ip.IsLoopback() { + return nil, fmt.Errorf("localhost ip: %v", ip) // -1 + } + if ip.IsPrivate() { + return nil, fmt.Errorf("private ip: %v", ip) // -1 + } + if ip.IsUnspecified() { + return nil, fmt.Errorf("zero ip: %v", ip) // -1 + } + ips = append(ips, ip) + } + if len(ips) == 0 { + return ips, fmt.Errorf("no ip answer: %v", response.Answers) // -1 + } + // All popular recursive resolvers we tested maintain the domain case of the request. + // Note that this is not the case of authoritative resolvers. Some of them will return + // a fully normalized domain name, or normalize part of it. + if response.Answers[0].Header.Name.String() != requestDomain { + return ips, fmt.Errorf("domain mismatch: got %v, expected %v", response.Answers[0].Header.Name, requestDomain) // -0.5 or +0.5 if match + } + return ips, nil +} + +func evaluateCNAMEResponse(response dnsmessage.Message, requestDomain string) error { + if response.RCode != dnsmessage.RCodeSuccess { + return fmt.Errorf("rcode is not success: %v", response.RCode) + } + if len(response.Answers) == 0 { + var numSOA int + for _, answer := range response.Authorities { + if _, ok := answer.Body.(*dnsmessage.SOAResource); ok { + numSOA++ + } + } + if numSOA != 1 { + return fmt.Errorf("SOA records is %v, expected 1", numSOA) + } + return nil + } + var cname string + for _, answer := range response.Answers { + if answer.Header.Type != dnsmessage.TypeCNAME { + return fmt.Errorf("bad answer type: %v", answer.Header.Type) + } + if rr, ok := answer.Body.(*dnsmessage.CNAMEResource); ok { + if cname != "" { + return fmt.Errorf("found too many CNAMEs: %v %v", cname, rr.CNAME) + } + cname = rr.CNAME.String() + } + } + if cname == "" { + return fmt.Errorf("no CNAME in answers") + } + return nil +} + +type StrategyFinder struct { + TestTimeout time.Duration + LogWriter io.Writer + StreamDialer transport.StreamDialer + PacketDialer transport.PacketDialer + logMu sync.Mutex +} + +func (f *StrategyFinder) log(format string, a ...any) { + if f.LogWriter != nil { + f.logMu.Lock() + defer f.logMu.Unlock() + fmt.Fprintf(f.LogWriter, format, a...) + } +} + +func (f *StrategyFinder) testDNSClient(baseCtx context.Context, resolver dns.Resolver, testDomain string) ([]net.IP, error) { + // We special case the system resolver, since we can't get a dns.RoundTripper. + if resolver == nil { + ctx, cancel := context.WithTimeout(baseCtx, f.TestTimeout) + defer cancel() + return evaluateNetResolver(ctx, new(net.Resolver), testDomain) + } + + requestDomain := mixCase(testDomain) + + q, err := dns.NewQuestion(requestDomain, dnsmessage.TypeA) + if err != nil { + return nil, fmt.Errorf("failed to create question: %v", err) + } + ctxA, cancelA := context.WithTimeout(baseCtx, f.TestTimeout) + defer cancelA() + response, err := resolver.Query(ctxA, *q) + if err != nil { + return nil, fmt.Errorf("request for A query failed: %w", err) + } + ips, err := evaluateAddressResponse(*response, requestDomain) + if err != nil { + return ips, fmt.Errorf("failed A test: %w", err) + } + // TODO(fortuna): Consider testing whether we can establish a TCP connection to ip:443. + + q, err = dns.NewQuestion(requestDomain, dnsmessage.TypeCNAME) + if err != nil { + return nil, fmt.Errorf("failed to create question: %v", err) + } + ctxCNAME, cancelCNAME := context.WithTimeout(baseCtx, f.TestTimeout) + defer cancelCNAME() + response, err = resolver.Query(ctxCNAME, *q) + if err != nil { + return nil, fmt.Errorf("request for CNAME query failed: %w", err) + } + err = evaluateCNAMEResponse(*response, requestDomain) + if err != nil { + return nil, fmt.Errorf("failed CNAME test: %w", err) + } + return ips, nil +} + +type httpsEntryJSON struct { + Name string `json:"name,omitempty"` + Address string `json:"address,omitempty"` +} + +type tlsEntryJSON struct { + Name string `json:"name,omitempty"` + Address string `json:"address,omitempty"` +} + +type udpEntryJSON struct { + Address string `json:"address,omitempty"` +} + +type tcpEntryJSON struct { + Address string `json:"address,omitempty"` +} + +type dnsEntryJSON struct { + System *struct{} `json:"system,omitempty"` + HTTPS *httpsEntryJSON `json:"https,omitempty"` + TLS *tlsEntryJSON `json:"tls,omitempty"` + UDP *udpEntryJSON `json:"udp,omitempty"` + TCP *tcpEntryJSON `json:"tcp,omitempty"` +} + +type configJSON struct { + DNS []dnsEntryJSON `json:"dns,omitempty"` + TLS []string `json:"tls,omitempty"` +} + +func (f *StrategyFinder) newDNSResolverFromEntry(entry dnsEntryJSON) (dns.Resolver, error) { + if entry.System != nil { + return nil, nil + } else if cfg := entry.HTTPS; cfg != nil { + if cfg.Name == "" { + return nil, fmt.Errorf("https entry has empty server name") + } + serverAddr := cfg.Address + if serverAddr == "" { + serverAddr = cfg.Name + } + _, port, err := net.SplitHostPort(serverAddr) + if err != nil { + serverAddr = net.JoinHostPort(serverAddr, "443") + port = "443" + } + dohURL := url.URL{Scheme: "https", Host: net.JoinHostPort(cfg.Name, port), Path: "/dns-query"} + return dns.NewHTTPSResolver(f.StreamDialer, serverAddr, dohURL.String()), nil + } else if cfg := entry.TLS; cfg != nil { + if cfg.Name == "" { + return nil, fmt.Errorf("tls entry has empty server name") + } + serverAddr := cfg.Address + if serverAddr == "" { + serverAddr = cfg.Name + } + _, _, err := net.SplitHostPort(serverAddr) + if err != nil { + serverAddr = net.JoinHostPort(serverAddr, "853") + } + return dns.NewTLSResolver(f.StreamDialer, serverAddr, cfg.Name), nil + } else if cfg := entry.TCP; cfg != nil { + if cfg.Address == "" { + return nil, fmt.Errorf("tcp entry has empty server address") + } + host, port, err := net.SplitHostPort(cfg.Address) + if err != nil { + host = cfg.Address + port = "53" + } + serverAddr := net.JoinHostPort(host, port) + return dns.NewTCPResolver(f.StreamDialer, serverAddr), nil + } else if cfg := entry.UDP; cfg != nil { + if cfg.Address == "" { + return nil, fmt.Errorf("udp entry has empty server address") + } + host, port, err := net.SplitHostPort(cfg.Address) + if err != nil { + host = cfg.Address + port = "53" + } + serverAddr := net.JoinHostPort(host, port) + return dns.NewUDPResolver(f.PacketDialer, serverAddr), nil + } else { + return nil, errors.New("invalid DNS entry") + } +} + +type resolverEntry struct { + ID string + Resolver dns.Resolver +} + +func (f *StrategyFinder) dnsConfigToRoundTrippers(dnsConfig []dnsEntryJSON) ([]resolverEntry, error) { + if len(dnsConfig) == 0 { + return nil, errors.New("no DNS config entry") + } + rts := make([]resolverEntry, 0, len(dnsConfig)) + for ei, entry := range dnsConfig { + idBytes, err := json.Marshal(entry) + if err != nil { + return nil, fmt.Errorf("cannot serialize entry %v: %w", ei, err) + } + id := string(idBytes) + resolver, err := f.newDNSResolverFromEntry(entry) + if err != nil { + return nil, fmt.Errorf("failed to process entry %v: %w", ei, err) + } + rts = append(rts, resolverEntry{ID: id, Resolver: resolver}) + } + return rts, nil +} + +// Returns a [context.Context] that is already done. +func newDoneContext() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx +} + +func (f *StrategyFinder) findDNS(testDomains []string, dnsConfig []dnsEntryJSON) (dns.Resolver, error) { + resolvers, err := f.dnsConfigToRoundTrippers(dnsConfig) + if err != nil { + return nil, err + } + type testResult struct { + ID string + Resolver dns.Resolver + Err error + } + // Communicates the result of each test. + resultChan := make(chan testResult) + // Indicates to tests that the search is done, so they don't get stuck writing to the results channel that will no longer be read. + searchCtx, searchDone := context.WithCancel(context.Background()) + defer searchDone() + // Used to space out each test. The initial value is done because there's no wait needed. + waitCtx := newDoneContext() + // Next entry to start testing. + nextResolver := 0 + // How many test entries are not done. + resolversToTest := len(resolvers) + for resolversToTest > 0 { + if nextResolver == len(resolvers) { + // No more tests to start. Make sure the select doesn't trigger on waitCtx. + waitCtx = searchCtx + } + select { + case <-waitCtx.Done(): + // Start a new test. + entry := resolvers[nextResolver] + nextResolver++ + var waitDone context.CancelFunc + waitCtx, waitDone = context.WithTimeout(searchCtx, 250*time.Millisecond) + go func(entry resolverEntry, testDone context.CancelFunc) { + defer testDone() + for _, testDomain := range testDomains { + select { + case <-searchCtx.Done(): + return + default: + } + f.log("🏃 run dns: %v (domain: %v)\n", entry.ID, testDomain) + startTime := time.Now() + ips, err := f.testDNSClient(searchCtx, entry.Resolver, testDomain) + duration := time.Since(startTime) + status := "ok ✅" + if err != nil { + status = fmt.Sprintf("%v ❌", err) + } + f.log("🏁 got dns: %v (domain: %v), duration=%v, ips=%v, status=%v\n", entry.ID, testDomain, duration, ips, status) + if err != nil { + select { + case <-searchCtx.Done(): + return + case resultChan <- testResult{ID: entry.ID, Resolver: entry.Resolver, Err: err}: + return + } + } + } + select { + case <-searchCtx.Done(): + case resultChan <- testResult{ID: entry.ID, Resolver: entry.Resolver, Err: nil}: + } + }(entry, waitDone) + + case result := <-resultChan: + resolversToTest-- + // Process the result of a test. + if result.Err != nil { + continue + } + f.log("✅ selected resolver %v\n", result.ID) + // Tested all domains on this resolver. Return + if result.Resolver != nil { + return dnsextra.NewCacheResolver(result.Resolver, 100), nil + } else { + return nil, nil + } + } + } + return nil, errors.New("could not find working resolver") +} + +func (f *StrategyFinder) findTLS(testDomains []string, baseDialer transport.StreamDialer, tlsConfig []string) (transport.StreamDialer, error) { + if len(tlsConfig) == 0 { + return nil, errors.New("config for TLS is empty. Please specify at least one transport") + } + for _, transportCfg := range tlsConfig { + for di, testDomain := range testDomains { + testAddr := net.JoinHostPort(testDomain, "443") + f.log(" tls=%v (domain: %v)", transportCfg, testDomain) + + tlsDialer, err := config.WrapStreamDialer(baseDialer, transportCfg) + if err != nil { + f.log("; wrap_error=%v ❌\n", err) + break + } + ctx, cancel := context.WithTimeout(context.Background(), f.TestTimeout) + defer cancel() + testConn, err := tlsDialer.DialStream(ctx, testAddr) + if err != nil { + f.log("; dial_error=%v ❌\n", err) + break + } + tlsConn := tls.Client(testConn, &tls.Config{ServerName: testDomain}) + err = tlsConn.HandshakeContext(ctx) + tlsConn.Close() + if err != nil { + f.log("; handshake=%v ❌\n", err) + break + } + f.log("; status=ok ✅\n") + if di+1 < len(testDomains) { + // More domains to test + continue + } + return transport.FuncStreamDialer(func(ctx context.Context, raddr string) (transport.StreamConn, error) { + _, portStr, err := net.SplitHostPort(raddr) + if err != nil { + return nil, fmt.Errorf("failed to parse address: %w", err) + } + portNum, err := net.DefaultResolver.LookupPort(ctx, "tcp", portStr) + if err != nil { + return nil, fmt.Errorf("could not resolve port: %w", err) + } + selectedDialer := baseDialer + if portNum == 443 || portNum == 853 { + selectedDialer = tlsDialer + } + return selectedDialer.DialStream(ctx, raddr) + }), nil + } + } + return nil, errors.New("could not find TLS strategy") +} + +// NewDialer uses the config in configBytes to search for a strategy that unblocks all of the testDomains, returning a dialer with the found strategy. +// It returns an error if no strategy was found that unblocks the testDomains. +// The testDomains must be domains with a TLS service running on port 443. +func (f *StrategyFinder) NewDialer(ctx context.Context, testDomains []string, configBytes []byte) (transport.StreamDialer, error) { + var parsedConfig configJSON + err := json.Unmarshal(configBytes, &parsedConfig) + if err != nil { + return nil, fmt.Errorf("failed to parse config: %v", err) + } + + // Make domain fully-qualified to prevent confusing domain search. + testDomains = append(make([]string, 0, len(testDomains)), testDomains...) + for di, domain := range testDomains { + testDomains[di] = dnsextra.MakeFullyQualified(domain) + } + + dnsRT, err := f.findDNS(testDomains, parsedConfig.DNS) + if err != nil { + return nil, err + } + var dnsDialer transport.StreamDialer + if dnsRT == nil { + if _, ok := f.StreamDialer.(*transport.TCPDialer); !ok { + return nil, fmt.Errorf("cannot use system resolver with base dialer of type %T", f.StreamDialer) + } + dnsDialer = f.StreamDialer + } else { + dnsDialer = dnsextra.NewStreamDialer(dnsRT, f.StreamDialer) + } + + if len(parsedConfig.TLS) == 0 { + return dnsDialer, nil + } + return f.findTLS(testDomains, dnsDialer, parsedConfig.TLS) +} + +/* + Scoring: + - Priority ordering: system, HTTPS, TLS, unencrypted + - Retriable error: -5, hard evidence: -10 + - IsPrivate: -5 + - Validated: +10 + + - For system resolver: base score 2 + - Test [testDomain NS]. If error: -5 + - For each UDP resolver: base score 0 + - Test [testDomain NS]. If error -5 or non-NS answer: -10 + - Test techniques: mix case + - For each TCP resolver: base score 0 + - Test TCP connection. If it fails, likely blocked by IP or port (-5) + - Test [testDomain NS]. If error -5 or non-NS answer: -10 + - Test techniques: mix case, split + - For each TLS resolvers: base score 1 + - Test TCP connection. If it fails, likely blocked by IP (-5) + - Test TLS connection. If it fails, likely blocked by SNI (-10) + - Test techniques: domain fronting, tcp split, tlsrecordfrag + - Try changing case + + Hostmap should not go through scoring at first. We shouldn't use it if not needed. Also, it only helps A/AAAA, it doesn't work with NS, SOA, etc. + + Lookup (domain A/AAAA) at root resolver (doesn’t apply to DoH and system resolver) + no error: 0 + error: -1 + has answer: -1 + + Lookup (domain, NS) (breaks with hostmap) + expected answer: +1 + unknown answer: 0 + bad answer or no answer: -1 + Lookup (domain, CNAME) (breaks with hostmap) + expected answer: +1 + unknown answer: 0 + Lookup IP, then reverse IP. A and AAAA + expected domain: +1 + unknown domain or no answer: 0 + Is Private? + public: 0 + private: -1 +*/ + +// TODO: +// Add recursive resolver. +// Save RTT for sorting. +// What to do about clustering IPs for a resolver? +// Go over list of public resolvers, restricted to working categories. +// Perhaps make a score function? +// If no working category, try alternative ports. +// Define DNS strategy object. Or perhaps Client with debug info.